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",
|
"dayjs": "^1.11.10",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"formik": "^2.4.5",
|
"formik": "^2.4.5",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^25.1.2",
|
"i18next": "^25.1.2",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"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 { isDirectMedia, isHlsUrl, toYouTubeEmbed } from '../helpers/publicMedia';
|
||||||
import BaseIcon from './BaseIcon';
|
import BaseIcon from './BaseIcon';
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
Hls?: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PublicMediaPlayerProps = {
|
type PublicMediaPlayerProps = {
|
||||||
title: string;
|
title: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@ -21,45 +15,19 @@ type PublicMediaPlayerProps = {
|
|||||||
externalLabel?: string;
|
externalLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HLS_SCRIPT_ID = 'public-media-hls-script';
|
let hlsLibraryPromise: Promise<any> | null = null;
|
||||||
const HLS_SCRIPT_SRC = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js';
|
|
||||||
|
|
||||||
function loadHlsLibrary() {
|
function loadHlsLibrary() {
|
||||||
return new Promise<any>((resolve, reject) => {
|
if (!hlsLibraryPromise) {
|
||||||
if (typeof window === 'undefined') {
|
hlsLibraryPromise = import('hls.js')
|
||||||
reject(new Error('HLS can only be loaded in the browser.'));
|
.then((module) => module.default || module)
|
||||||
return;
|
.catch((error) => {
|
||||||
}
|
hlsLibraryPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (window.Hls) {
|
return hlsLibraryPromise;
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PublicMediaPlayer({
|
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) {
|
export function getLivePlaybackUrls(stream: any) {
|
||||||
const originalUrl = stream?.original_stream_url || '';
|
const originalUrl = stream?.original_stream_url || '';
|
||||||
const streamUrl = stream?.stream_url || '';
|
const streamUrl = stream?.stream_url || '';
|
||||||
@ -136,6 +161,23 @@ export function getPrimaryPlaybackType(episode: any) {
|
|||||||
return 'video';
|
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> = {
|
const statusStyles: Record<string, string> = {
|
||||||
live: 'border border-red-500/20 bg-red-500/10 text-red-300',
|
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',
|
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 type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import PublicNowPlayingDock from '../components/PublicNowPlayingDock';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { formatMediaDate, getPublicStatusClass, humanizeMediaKind } from '../helpers/publicMedia';
|
import { buildPublicStreamHref, formatMediaDate, getPublicStatusClass, getPublicStreamQueryId, humanizeMediaKind, resolvePreferredPublicStream } from '../helpers/publicMedia';
|
||||||
|
|
||||||
const contentPillars = [
|
const contentPillars = [
|
||||||
{
|
{
|
||||||
@ -60,7 +62,12 @@ const fallbackTickerItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const router = useRouter();
|
||||||
const [tickerStreams, setTickerStreams] = useState<any[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@ -90,6 +97,85 @@ export default function HomePage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tickerItems = useMemo(() => (tickerStreams.length ? tickerStreams : fallbackTickerItems), [tickerStreams]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -121,7 +207,7 @@ export default function HomePage() {
|
|||||||
<a href='#programming' className='transition hover:text-white'>Programming</a>
|
<a href='#programming' className='transition hover:text-white'>Programming</a>
|
||||||
<a href='#workflow' className='transition hover:text-white'>Workflow</a>
|
<a href='#workflow' className='transition hover:text-white'>Workflow</a>
|
||||||
<a href='#portal' className='transition hover:text-white'>Portal</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
|
Watch live
|
||||||
</Link>
|
</Link>
|
||||||
<Link href='/login' className='rounded-full border border-white/15 px-4 py-2 transition hover:border-cyan-300 hover:text-white'>
|
<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-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'>
|
<div className='live-ticker-track flex min-w-max items-center gap-3 pr-3'>
|
||||||
{[...tickerItems, ...tickerItems].map((item, index) => (
|
{[...tickerItems, ...tickerItems].map((item, index) => {
|
||||||
<Link
|
const isSelected = item.id === activeTickerStream?.id;
|
||||||
key={`${item.id || item.title}-${index}`}
|
|
||||||
href='/watch/live'
|
return (
|
||||||
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'
|
<button
|
||||||
>
|
key={`${item.id || item.title}-${index}`}
|
||||||
<span className='inline-flex items-center gap-2'>
|
type='button'
|
||||||
<span className='h-2.5 w-2.5 rounded-full bg-red-400 animate-pulse' />
|
onClick={() => handleTickerSelect(item)}
|
||||||
<span className={`rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${getPublicStatusClass(item.status)}`}>
|
aria-pressed={isSelected}
|
||||||
{item.status}
|
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>
|
<span className='font-medium text-white'>{item.title}</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 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>
|
||||||
<span className='text-xs text-slate-400'>{formatMediaDate(item.starts_at)}</span>
|
</button>
|
||||||
</Link>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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.
|
a clear route into the admin interface, and a control room for browsing shows, episodes, and live streams in one place.
|
||||||
</p>
|
</p>
|
||||||
<div className='mt-8 flex flex-wrap gap-3'>
|
<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='/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' />
|
<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>
|
</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='mt-10 grid gap-4 sm:grid-cols-3'>
|
||||||
<div className='rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur'>
|
<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>
|
<p className='text-sm uppercase tracking-[0.24em] text-slate-400'>Experience</p>
|
||||||
@ -322,7 +459,7 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-wrap gap-3'>
|
<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='/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='/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' />
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
{isNowPlayingVisible && activeTickerStream ? (
|
||||||
|
<PublicNowPlayingDock
|
||||||
|
stream={activeTickerStream}
|
||||||
|
isMinimized={isNowPlayingMinimized}
|
||||||
|
liveRoomHref={liveRoomHref}
|
||||||
|
onToggleMinimized={() => setIsNowPlayingMinimized((current) => !current)}
|
||||||
|
onClose={() => {
|
||||||
|
setHasDismissedNowPlaying(true);
|
||||||
|
setIsNowPlayingVisible(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
.live-ticker-track {
|
.live-ticker-track {
|
||||||
animation: liveTicker 34s linear infinite;
|
animation: liveTicker 34s linear infinite;
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
getPrimaryPlaybackUrl,
|
getPrimaryPlaybackUrl,
|
||||||
getPublicStatusClass,
|
getPublicStatusClass,
|
||||||
humanizeMediaKind,
|
humanizeMediaKind,
|
||||||
|
pickPreferredLiveStream,
|
||||||
} from '../../helpers/publicMedia';
|
} from '../../helpers/publicMedia';
|
||||||
import LayoutGuest from '../../layouts/Guest';
|
import LayoutGuest from '../../layouts/Guest';
|
||||||
|
|
||||||
@ -71,14 +72,7 @@ export default function WatchHomePage() {
|
|||||||
|
|
||||||
const featuredStream = useMemo(() => {
|
const featuredStream = useMemo(() => {
|
||||||
const items = data.liveStreams || [];
|
const items = data.liveStreams || [];
|
||||||
return (
|
return pickPreferredLiveStream(items);
|
||||||
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
|
|
||||||
);
|
|
||||||
}, [data.liveStreams]);
|
}, [data.liveStreams]);
|
||||||
|
|
||||||
const featuredStreamPlayback = useMemo(() => getLivePlaybackUrls(featuredStream), [featuredStream]);
|
const featuredStreamPlayback = useMemo(() => getLivePlaybackUrls(featuredStream), [featuredStream]);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { mdiBroadcast, mdiRadioTower, mdiTelevisionClassic } from '@mdi/js';
|
import { mdiBroadcast, mdiRadioTower, mdiTelevisionClassic } from '@mdi/js';
|
||||||
import BaseButton from '../../components/BaseButton';
|
import BaseButton from '../../components/BaseButton';
|
||||||
@ -8,10 +9,11 @@ import BaseIcon from '../../components/BaseIcon';
|
|||||||
import LoadingSpinner from '../../components/LoadingSpinner';
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
import PublicMediaPlayer from '../../components/PublicMediaPlayer';
|
import PublicMediaPlayer from '../../components/PublicMediaPlayer';
|
||||||
import { getPageTitle } from '../../config';
|
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';
|
import LayoutGuest from '../../layouts/Guest';
|
||||||
|
|
||||||
export default function PublicLivePage() {
|
export default function PublicLivePage() {
|
||||||
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [streams, setStreams] = useState<any[]>([]);
|
const [streams, setStreams] = useState<any[]>([]);
|
||||||
@ -35,16 +37,8 @@ export default function PublicLivePage() {
|
|||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
const rows = response.data?.rows || [];
|
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);
|
setStreams(rows);
|
||||||
setSelectedStreamId(preferredStream?.id || '');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load public live streams:', error);
|
console.error('Failed to load public live streams:', error);
|
||||||
if (isMounted) {
|
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(() => {
|
const selectedStream = useMemo(() => {
|
||||||
return streams.find((item) => item.id === selectedStreamId) || streams[0] || null;
|
return resolvePreferredPublicStream(streams, selectedStreamId) || null;
|
||||||
}, [selectedStreamId, streams]);
|
}, [selectedStreamId, streams]);
|
||||||
|
|
||||||
const playback = useMemo(() => getLivePlaybackUrls(selectedStream), [selectedStream]);
|
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