This commit is contained in:
Flatlogic Bot 2026-04-05 15:28:10 +00:00
parent bfc0c7768a
commit 3ea6b2dc42
9 changed files with 13847 additions and 220 deletions

12121
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,7 @@
"dayjs": "^1.11.10",
"file-saver": "^2.0.5",
"formik": "^2.4.5",
"hls.js": "^1.6.15",
"html2canvas": "^1.4.1",
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",

View File

@ -4,12 +4,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { isDirectMedia, isHlsUrl, toYouTubeEmbed } from '../helpers/publicMedia';
import BaseIcon from './BaseIcon';
declare global {
interface Window {
Hls?: any;
}
}
type PublicMediaPlayerProps = {
title: string;
url?: string;
@ -21,45 +15,19 @@ type PublicMediaPlayerProps = {
externalLabel?: string;
};
const HLS_SCRIPT_ID = 'public-media-hls-script';
const HLS_SCRIPT_SRC = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js';
let hlsLibraryPromise: Promise<any> | null = null;
function loadHlsLibrary() {
return new Promise<any>((resolve, reject) => {
if (typeof window === 'undefined') {
reject(new Error('HLS can only be loaded in the browser.'));
return;
}
if (!hlsLibraryPromise) {
hlsLibraryPromise = import('hls.js')
.then((module) => module.default || module)
.catch((error) => {
hlsLibraryPromise = null;
throw error;
});
}
if (window.Hls) {
resolve(window.Hls);
return;
}
const existingScript = document.getElementById(HLS_SCRIPT_ID) as HTMLScriptElement | null;
if (existingScript) {
if (existingScript.dataset.loaded === 'true' && window.Hls) {
resolve(window.Hls);
return;
}
existingScript.addEventListener('load', () => resolve(window.Hls));
existingScript.addEventListener('error', () => reject(new Error('Failed to load HLS library.')));
return;
}
const script = document.createElement('script');
script.id = HLS_SCRIPT_ID;
script.src = HLS_SCRIPT_SRC;
script.async = true;
script.onload = () => {
script.dataset.loaded = 'true';
resolve(window.Hls);
};
script.onerror = () => reject(new Error('Failed to load HLS library.'));
document.body.appendChild(script);
});
return hlsLibraryPromise;
}
export default function PublicMediaPlayer({

View File

@ -0,0 +1,118 @@
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiOpenInNew } from '@mdi/js';
import React, { useMemo } from 'react';
import { formatMediaDate, getLivePlaybackUrls, getPublicStatusClass, humanizeMediaKind } from '../helpers/publicMedia';
import BaseButton from './BaseButton';
import BaseIcon from './BaseIcon';
import PublicMediaPlayer from './PublicMediaPlayer';
type PublicNowPlayingDockProps = {
stream: any;
isMinimized?: boolean;
liveRoomHref?: string;
onToggleMinimized: () => void;
onClose: () => void;
};
export default function PublicNowPlayingDock({
stream,
isMinimized = false,
liveRoomHref = '/watch/live',
onToggleMinimized,
onClose,
}: PublicNowPlayingDockProps) {
const playback = useMemo(() => getLivePlaybackUrls(stream), [stream]);
if (!stream) return null;
return (
<div className='fixed inset-x-4 bottom-4 z-40 md:left-auto md:right-4 md:w-[420px]'>
<div className='overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,_rgba(8,17,34,0.96),_rgba(15,23,42,0.97),_rgba(30,41,59,0.95))] shadow-2xl shadow-cyan-950/40 backdrop-blur'>
<div className='border-b border-white/10 px-4 py-4 sm:px-5'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='flex flex-wrap items-center gap-2'>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getPublicStatusClass(stream.status)}`}>
{stream.status || 'live'}
</span>
<span className='rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] uppercase tracking-[0.18em] text-cyan-100'>
{humanizeMediaKind(stream.stream_type)}
</span>
</div>
<h3 className='mt-3 line-clamp-2 text-lg font-semibold text-white'>{stream.title || 'Now playing'}</h3>
<p className='mt-1 text-xs text-slate-400'>
{stream.category?.name || 'Live program'} {formatMediaDate(stream.starts_at)}
</p>
</div>
<div className='flex items-center gap-2'>
<button
type='button'
onClick={onToggleMinimized}
className='inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-200 transition hover:border-cyan-300/40 hover:text-white'
aria-label={isMinimized ? 'Expand now playing dock' : 'Minimize now playing dock'}
>
<BaseIcon path={isMinimized ? mdiChevronUp : mdiChevronDown} size={20} />
</button>
<button
type='button'
onClick={onClose}
className='inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-slate-200 transition hover:border-rose-300/40 hover:text-white'
aria-label='Close now playing dock'
>
<BaseIcon path={mdiClose} size={20} />
</button>
</div>
</div>
</div>
{!isMinimized ? (
<div className='space-y-4 p-4 sm:p-5'>
<PublicMediaPlayer
title={stream.title || 'Now playing'}
url={playback.primaryUrl}
fallbackUrl={playback.fallbackUrl}
type={stream.stream_type}
posterUrl={stream.coverImageUrl}
externalMessage='This stream opens in a separate player or external broadcast window.'
externalLabel='Open source'
emptyMessage='No playback source is available for this stream yet.'
/>
<div className='flex flex-wrap gap-2 text-xs text-slate-300'>
{stream.host?.name ? (
<span className='rounded-full border border-white/10 bg-white/5 px-3 py-1.5'>Host: {stream.host.name}</span>
) : null}
{stream.is_demo_stream ? (
<span className='rounded-full border border-amber-400/20 bg-amber-400/10 px-3 py-1.5 text-amber-100'>Demo source enabled</span>
) : null}
{stream.playback_note ? (
<span className='rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1.5 text-cyan-100'>{stream.playback_note}</span>
) : null}
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href={liveRoomHref} color='info' label='Open full live room' className='shadow-lg shadow-cyan-500/20' />
{playback.externalUrl ? (
<BaseButton
href={playback.externalUrl}
target='_blank'
color='whiteDark'
outline
icon={mdiOpenInNew}
label='Open source'
className='border-white/20 bg-transparent text-white hover:bg-white/10'
/>
) : null}
</div>
</div>
) : (
<div className='flex items-center justify-between gap-3 px-4 py-3 text-sm text-slate-300 sm:px-5'>
<span className='line-clamp-1'>{stream.description || 'Mini player minimized. Re-open to continue watching or listening.'}</span>
<BaseButton href={liveRoomHref} color='whiteDark' outline label='Live room' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
</div>
)}
</div>
</div>
);
}

View File

@ -92,6 +92,31 @@ export function isDirectMedia(url?: string | null, type?: string | null) {
);
}
export function getPublicStreamQueryId(value?: string | string[] | null) {
if (Array.isArray(value)) {
return value[0] || '';
}
return typeof value === 'string' ? value : '';
}
export function buildPublicStreamHref(path: string, streamId?: string | null) {
if (!streamId) return path;
const searchParams = new URLSearchParams({ stream: streamId });
return `${path}?${searchParams.toString()}`;
}
export function findStreamById(items: any[] = [], streamId?: string | null) {
const list = Array.isArray(items) ? items : [];
if (!streamId) {
return null;
}
return list.find((item: any) => item?.id === streamId) || null;
}
export function getLivePlaybackUrls(stream: any) {
const originalUrl = stream?.original_stream_url || '';
const streamUrl = stream?.stream_url || '';
@ -136,6 +161,23 @@ export function getPrimaryPlaybackType(episode: any) {
return 'video';
}
export function pickPreferredLiveStream(items: any[] = []) {
const list = Array.isArray(items) ? items : [];
return (
list.find((item: any) => item?.status === 'live' && item?.stream_type === 'video') ||
list.find((item: any) => item?.stream_type === 'video') ||
list.find((item: any) => item?.status === 'live') ||
list.find((item: any) => item?.is_featured) ||
list[0] ||
null
);
}
export function resolvePreferredPublicStream(items: any[] = [], streamId?: string | null) {
return findStreamById(items, streamId) || pickPreferredLiveStream(items);
}
const statusStyles: Record<string, string> = {
live: 'border border-red-500/20 bg-red-500/10 text-red-300',
scheduled: 'border border-amber-500/20 bg-amber-500/10 text-amber-200',

View File

@ -2,11 +2,13 @@ import React, { useEffect, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import BaseButton from '../components/BaseButton';
import PublicNowPlayingDock from '../components/PublicNowPlayingDock';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { formatMediaDate, getPublicStatusClass, humanizeMediaKind } from '../helpers/publicMedia';
import { buildPublicStreamHref, formatMediaDate, getPublicStatusClass, getPublicStreamQueryId, humanizeMediaKind, resolvePreferredPublicStream } from '../helpers/publicMedia';
const contentPillars = [
{
@ -60,7 +62,12 @@ const fallbackTickerItems = [
];
export default function HomePage() {
const router = useRouter();
const [tickerStreams, setTickerStreams] = useState<any[]>([]);
const [selectedTickerStreamId, setSelectedTickerStreamId] = useState('');
const [isNowPlayingVisible, setIsNowPlayingVisible] = useState(false);
const [isNowPlayingMinimized, setIsNowPlayingMinimized] = useState(false);
const [hasDismissedNowPlaying, setHasDismissedNowPlaying] = useState(false);
useEffect(() => {
let isMounted = true;
@ -90,6 +97,85 @@ export default function HomePage() {
}, []);
const tickerItems = useMemo(() => (tickerStreams.length ? tickerStreams : fallbackTickerItems), [tickerStreams]);
const queryStreamId = useMemo(() => getPublicStreamQueryId(router.query.stream), [router.query.stream]);
useEffect(() => {
const preferredStream = resolvePreferredPublicStream(tickerItems, queryStreamId);
if (!preferredStream) {
return;
}
const nextSelectedStreamId = preferredStream.id || '';
if (nextSelectedStreamId !== selectedTickerStreamId) {
setSelectedTickerStreamId(nextSelectedStreamId);
}
}, [queryStreamId, selectedTickerStreamId, tickerItems]);
const activeTickerStream = useMemo(() => {
return resolvePreferredPublicStream(tickerItems, selectedTickerStreamId);
}, [selectedTickerStreamId, tickerItems]);
const selectedLiveRoomStreamId = useMemo(() => {
if (!activeTickerStream?.id) {
return '';
}
return tickerStreams.some((item) => item.id === activeTickerStream.id) ? activeTickerStream.id : '';
}, [activeTickerStream, tickerStreams]);
const liveRoomHref = useMemo(() => buildPublicStreamHref('/watch/live', selectedLiveRoomStreamId), [selectedLiveRoomStreamId]);
useEffect(() => {
if (!router.isReady) {
return;
}
const currentQueryStreamId = getPublicStreamQueryId(router.query.stream);
if (selectedLiveRoomStreamId && currentQueryStreamId !== selectedLiveRoomStreamId) {
router.replace(
{
pathname: router.pathname,
query: { ...router.query, stream: selectedLiveRoomStreamId },
},
undefined,
{ shallow: true, scroll: false },
);
return;
}
if (!selectedLiveRoomStreamId && currentQueryStreamId) {
const nextQuery = { ...router.query };
delete nextQuery.stream;
router.replace(
{
pathname: router.pathname,
query: nextQuery,
},
undefined,
{ shallow: true, scroll: false },
);
}
}, [router, selectedLiveRoomStreamId]);
useEffect(() => {
if (!tickerStreams.length || hasDismissedNowPlaying || isNowPlayingVisible || activeTickerStream?.status !== 'live') {
return;
}
setIsNowPlayingVisible(true);
setIsNowPlayingMinimized(false);
}, [activeTickerStream, hasDismissedNowPlaying, isNowPlayingVisible, tickerStreams.length]);
const handleTickerSelect = (stream: any) => {
setSelectedTickerStreamId(stream?.id || '');
setHasDismissedNowPlaying(false);
setIsNowPlayingVisible(true);
setIsNowPlayingMinimized(false);
};
return (
<>
@ -121,7 +207,7 @@ export default function HomePage() {
<a href='#programming' className='transition hover:text-white'>Programming</a>
<a href='#workflow' className='transition hover:text-white'>Workflow</a>
<a href='#portal' className='transition hover:text-white'>Portal</a>
<Link href='/watch/live' className='rounded-full border border-white/15 px-4 py-2 transition hover:border-cyan-300 hover:text-white'>
<Link href={liveRoomHref} className='rounded-full border border-white/15 px-4 py-2 transition hover:border-cyan-300 hover:text-white'>
Watch live
</Link>
<Link href='/login' className='rounded-full border border-white/15 px-4 py-2 transition hover:border-cyan-300 hover:text-white'>
@ -135,23 +221,33 @@ export default function HomePage() {
<div className='live-ticker-wrap mt-5 overflow-hidden rounded-full border border-white/10 bg-white/5 px-3 py-3 backdrop-blur'>
<div className='live-ticker-track flex min-w-max items-center gap-3 pr-3'>
{[...tickerItems, ...tickerItems].map((item, index) => (
<Link
key={`${item.id || item.title}-${index}`}
href='/watch/live'
className='inline-flex shrink-0 items-center gap-3 rounded-full border border-white/10 bg-slate-950/70 px-4 py-2 text-sm text-slate-200 transition hover:border-cyan-300/40 hover:text-white'
>
<span className='inline-flex items-center gap-2'>
<span className='h-2.5 w-2.5 rounded-full bg-red-400 animate-pulse' />
<span className={`rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getPublicStatusClass(item.status)}`}>
{item.status}
{[...tickerItems, ...tickerItems].map((item, index) => {
const isSelected = item.id === activeTickerStream?.id;
return (
<button
key={`${item.id || item.title}-${index}`}
type='button'
onClick={() => handleTickerSelect(item)}
aria-pressed={isSelected}
className={`inline-flex shrink-0 items-center gap-3 rounded-full border px-4 py-2 text-sm transition ${
isSelected
? 'border-cyan-300/60 bg-cyan-300/15 text-white'
: 'border-white/10 bg-slate-950/70 text-slate-200 hover:border-cyan-300/40 hover:text-white'
}`}
>
<span className='inline-flex items-center gap-2'>
<span className='h-2.5 w-2.5 rounded-full bg-red-400 animate-pulse' />
<span className={`rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getPublicStatusClass(item.status)}`}>
{item.status}
</span>
</span>
</span>
<span className='font-medium text-white'>{item.title}</span>
<span className='text-xs uppercase tracking-[0.18em] text-cyan-200/80'>{humanizeMediaKind(item.stream_type)}</span>
<span className='text-xs text-slate-400'>{formatMediaDate(item.starts_at)}</span>
</Link>
))}
<span className='font-medium text-white'>{item.title}</span>
<span className='text-xs uppercase tracking-[0.18em] text-cyan-200/80'>{humanizeMediaKind(item.stream_type)}</span>
<span className='text-xs text-slate-400'>{formatMediaDate(item.starts_at)}</span>
</button>
);
})}
</div>
</div>
@ -169,10 +265,51 @@ export default function HomePage() {
a clear route into the admin interface, and a control room for browsing shows, episodes, and live streams in one place.
</p>
<div className='mt-8 flex flex-wrap gap-3'>
<BaseButton href='/watch/live' color='info' label='Watch live now' className='shadow-lg shadow-cyan-500/20' />
<BaseButton href={liveRoomHref} color='info' label='Watch live now' className='shadow-lg shadow-cyan-500/20' />
{activeTickerStream ? (
<BaseButton
color='whiteDark'
outline
label={isNowPlayingVisible ? 'Resume mini player' : 'Quick play selected stream'}
onClick={() => {
setIsNowPlayingVisible(true);
setIsNowPlayingMinimized(false);
}}
className='border-white/20 bg-white/5 text-white hover:bg-white/10'
/>
) : null}
<BaseButton href='/dashboard' color='whiteDark' outline label='Open admin interface' className='border-white/20 bg-white/5 text-white hover:bg-white/10' />
<BaseButton href='/media-center' color='whiteDark' outline label='Enter media control room' className='border-white/20 bg-white/5 text-white hover:bg-white/10' />
</div>
{activeTickerStream ? (
<div className='mt-6 rounded-[26px] border border-white/10 bg-white/5 p-5 backdrop-blur'>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-cyan-200/80'>Selected from live ticker</p>
<h2 className='mt-2 text-2xl font-semibold text-white'>{activeTickerStream.title}</h2>
<div className='mt-3 flex flex-wrap items-center gap-2 text-sm text-slate-300'>
<span className={`rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getPublicStatusClass(activeTickerStream.status)}`}>
{activeTickerStream.status}
</span>
<span>{humanizeMediaKind(activeTickerStream.stream_type)}</span>
<span className='text-slate-500'></span>
<span>{formatMediaDate(activeTickerStream.starts_at)}</span>
</div>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton
color='info'
label={isNowPlayingVisible ? 'Open now playing' : 'Play in mini player'}
onClick={() => {
setIsNowPlayingVisible(true);
setIsNowPlayingMinimized(false);
}}
/>
<BaseButton href={liveRoomHref} color='whiteDark' outline label='Open live room' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
</div>
</div>
</div>
) : null}
<div className='mt-10 grid gap-4 sm:grid-cols-3'>
<div className='rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur'>
<p className='text-sm uppercase tracking-[0.24em] text-slate-400'>Experience</p>
@ -322,7 +459,7 @@ export default function HomePage() {
</p>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/watch/live' color='info' label='Open live room' />
<BaseButton href={liveRoomHref} color='info' label='Open live room' />
<BaseButton href='/watch' color='whiteDark' outline label='Open public watch hub' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/login' color='whiteDark' outline label='Login' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
<BaseButton href='/dashboard' color='whiteDark' outline label='Open admin interface' className='border-white/20 bg-transparent text-white hover:bg-white/10' />
@ -332,6 +469,18 @@ export default function HomePage() {
</div>
</section>
</main>
{isNowPlayingVisible && activeTickerStream ? (
<PublicNowPlayingDock
stream={activeTickerStream}
isMinimized={isNowPlayingMinimized}
liveRoomHref={liveRoomHref}
onToggleMinimized={() => setIsNowPlayingMinimized((current) => !current)}
onClose={() => {
setHasDismissedNowPlaying(true);
setIsNowPlayingVisible(false);
}}
/>
) : null}
<style jsx>{`
.live-ticker-track {
animation: liveTicker 34s linear infinite;

View File

@ -15,6 +15,7 @@ import {
getPrimaryPlaybackUrl,
getPublicStatusClass,
humanizeMediaKind,
pickPreferredLiveStream,
} from '../../helpers/publicMedia';
import LayoutGuest from '../../layouts/Guest';
@ -71,14 +72,7 @@ export default function WatchHomePage() {
const featuredStream = useMemo(() => {
const items = data.liveStreams || [];
return (
items.find((item: any) => item.status === 'live' && item.stream_type === 'video') ||
items.find((item: any) => item.stream_type === 'video') ||
items.find((item: any) => item.status === 'live') ||
items.find((item: any) => item.is_featured) ||
items[0] ||
null
);
return pickPreferredLiveStream(items);
}, [data.liveStreams]);
const featuredStreamPlayback = useMemo(() => getLivePlaybackUrls(featuredStream), [featuredStream]);

View File

@ -1,6 +1,7 @@
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import { mdiBroadcast, mdiRadioTower, mdiTelevisionClassic } from '@mdi/js';
import BaseButton from '../../components/BaseButton';
@ -8,10 +9,11 @@ import BaseIcon from '../../components/BaseIcon';
import LoadingSpinner from '../../components/LoadingSpinner';
import PublicMediaPlayer from '../../components/PublicMediaPlayer';
import { getPageTitle } from '../../config';
import { formatMediaDate, getLivePlaybackUrls, getPublicStatusClass, humanizeMediaKind } from '../../helpers/publicMedia';
import { formatMediaDate, getLivePlaybackUrls, getPublicStatusClass, getPublicStreamQueryId, humanizeMediaKind, resolvePreferredPublicStream } from '../../helpers/publicMedia';
import LayoutGuest from '../../layouts/Guest';
export default function PublicLivePage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [streams, setStreams] = useState<any[]>([]);
@ -35,16 +37,8 @@ export default function PublicLivePage() {
if (!isMounted) return;
const rows = response.data?.rows || [];
const preferredStream =
rows.find((item: any) => item.status === 'live' && item.stream_type === 'video') ||
rows.find((item: any) => item.stream_type === 'video') ||
rows.find((item: any) => item.status === 'live') ||
rows.find((item: any) => item.is_featured) ||
rows[0] ||
null;
setStreams(rows);
setSelectedStreamId(preferredStream?.id || '');
} catch (error) {
console.error('Failed to load public live streams:', error);
if (isMounted) {
@ -64,8 +58,45 @@ export default function PublicLivePage() {
};
}, []);
const queryStreamId = useMemo(() => getPublicStreamQueryId(router.query.stream), [router.query.stream]);
useEffect(() => {
const preferredStream = resolvePreferredPublicStream(streams, queryStreamId);
if (!preferredStream) {
return;
}
const nextSelectedStreamId = preferredStream.id || '';
if (nextSelectedStreamId !== selectedStreamId) {
setSelectedStreamId(nextSelectedStreamId);
}
}, [queryStreamId, selectedStreamId, streams]);
useEffect(() => {
if (!router.isReady || !selectedStreamId) {
return;
}
const currentQueryStreamId = getPublicStreamQueryId(router.query.stream);
if (currentQueryStreamId === selectedStreamId) {
return;
}
router.replace(
{
pathname: router.pathname,
query: { ...router.query, stream: selectedStreamId },
},
undefined,
{ shallow: true, scroll: false },
);
}, [router, selectedStreamId]);
const selectedStream = useMemo(() => {
return streams.find((item) => item.id === selectedStreamId) || streams[0] || null;
return resolvePreferredPublicStream(streams, selectedStreamId) || null;
}, [selectedStreamId, streams]);
const playback = useMemo(() => getLivePlaybackUrls(selectedStream), [selectedStream]);

File diff suppressed because it is too large Load Diff