2.0
This commit is contained in:
parent
bfc0c7768a
commit
3ea6b2dc42
12121
frontend/package-lock.json
generated
Normal file
12121
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
118
frontend/src/components/PublicNowPlayingDock.tsx
Normal file
118
frontend/src/components/PublicNowPlayingDock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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]);
|
||||
|
||||
1483
frontend/yarn.lock
1483
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user