"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { VideoPlayer, ChannelInfo, ChannelControls, ScheduleOverlay, UpNextBanner, NoSignal, } from "./components"; import type { SubtitleTrack } from "./components/video-player"; import { useAuthContext } from "@/context/auth-context"; import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels"; import { useStreamUrl, fmtTime, calcProgress, calcOffsetSecs, minutesUntil, toScheduleSlots, findNextSlot, } from "@/hooks/use-tv"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const IDLE_TIMEOUT_MS = 3500; const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this % // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- export default function TvPage() { const { token } = useAuthContext(); // Channel list const { data: channels, isLoading: isLoadingChannels } = useChannels(); // Channel navigation const [channelIdx, setChannelIdx] = useState(0); const channel = channels?.[channelIdx]; // Overlay / idle state const [showOverlays, setShowOverlays] = useState(true); const [showSchedule, setShowSchedule] = useState(false); const idleTimer = useRef | null>(null); // Video ref — used to resume playback if autoplay was blocked on load const videoRef = useRef(null); // Stream error recovery const [streamError, setStreamError] = useState(false); // Subtitles const [subtitleTracks, setSubtitleTracks] = useState([]); const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1); const [showSubtitlePicker, setShowSubtitlePicker] = useState(false); const queryClient = useQueryClient(); // Tick for live progress calculation (every 30 s is fine for the progress bar) const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick((n) => n + 1), 30_000); return () => clearInterval(id); }, []); // Per-channel data const { data: broadcast, isLoading: isLoadingBroadcast } = useCurrentBroadcast(channel?.id ?? ""); const { data: epgSlots } = useEpg(channel?.id ?? ""); const { data: streamUrl } = useStreamUrl(channel?.id, token, broadcast?.slot.id); // Clear stream error when the slot changes (next item started) useEffect(() => { setStreamError(false); }, [broadcast?.slot.id]); // Reset subtitle state when channel or slot changes useEffect(() => { setSubtitleTracks([]); setActiveSubtitleTrack(-1); setShowSubtitlePicker(false); }, [channelIdx, broadcast?.slot.id]); // ------------------------------------------------------------------ // Derived display values // ------------------------------------------------------------------ const hasBroadcast = !!broadcast; const progress = hasBroadcast ? calcProgress(broadcast.slot.start_at, broadcast.slot.item.duration_secs) : 0; const scheduleSlots = toScheduleSlots(epgSlots ?? [], broadcast?.slot.id); const nextSlot = findNextSlot(epgSlots ?? [], broadcast?.slot.id); const showBanner = hasBroadcast && progress >= BANNER_THRESHOLD && !!nextSlot; // ------------------------------------------------------------------ // Idle detection // ------------------------------------------------------------------ const resetIdle = useCallback(() => { setShowOverlays(true); if (idleTimer.current) clearTimeout(idleTimer.current); idleTimer.current = setTimeout( () => setShowOverlays(false), IDLE_TIMEOUT_MS, ); // Resume playback if autoplay was blocked (e.g. on page refresh with no prior interaction) videoRef.current?.play().catch(() => {}); }, []); useEffect(() => { resetIdle(); return () => { if (idleTimer.current) clearTimeout(idleTimer.current); }; }, [resetIdle]); // ------------------------------------------------------------------ // Channel switching // ------------------------------------------------------------------ const channelCount = channels?.length ?? 0; const prevChannel = useCallback(() => { setChannelIdx((i) => (i - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1)); resetIdle(); }, [channelCount, resetIdle]); const nextChannel = useCallback(() => { setChannelIdx((i) => (i + 1) % Math.max(channelCount, 1)); resetIdle(); }, [channelCount, resetIdle]); const toggleSchedule = useCallback(() => { setShowSchedule((s) => !s); resetIdle(); }, [resetIdle]); // ------------------------------------------------------------------ // Keyboard shortcuts // ------------------------------------------------------------------ useEffect(() => { const handleKey = (e: KeyboardEvent) => { if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement ) return; switch (e.key) { case "ArrowUp": case "PageUp": e.preventDefault(); nextChannel(); break; case "ArrowDown": case "PageDown": e.preventDefault(); prevChannel(); break; case "g": case "G": toggleSchedule(); break; } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, [nextChannel, prevChannel, toggleSchedule]); // ------------------------------------------------------------------ // Stream error recovery // ------------------------------------------------------------------ const handleStreamError = useCallback(() => { setStreamError(true); }, []); const handleRetry = useCallback(() => { // Bust the cached stream URL so it refetches with the current offset queryClient.invalidateQueries({ queryKey: ["stream-url", channel?.id, broadcast?.slot.id], }); setStreamError(false); }, [queryClient, channel?.id, broadcast?.slot.id]); // ------------------------------------------------------------------ // Render helpers // ------------------------------------------------------------------ const renderBase = () => { if (isLoadingChannels) { return ; } if (!channels || channels.length === 0) { return ( ); } if (isLoadingBroadcast) { return ; } if (!hasBroadcast) { return ( ); } if (streamError) { return ( ); } if (streamUrl) { return ( ); } // Broadcast exists but stream URL resolving — show loading until ready return ; }; // ------------------------------------------------------------------ // Render // ------------------------------------------------------------------ return (
{/* ── Base layer ─────────────────────────────────────────────── */}
{renderBase()}
{/* ── Overlays — only when channels available ─────────────────── */} {channel && ( <>
{/* Top-right: subtitle picker + guide toggle */}
{subtitleTracks.length > 0 && (
{showSubtitlePicker && (
{subtitleTracks.map((track) => ( ))}
)}
)}
{/* Bottom: banner + info row */}
{showBanner && nextSlot && ( )}
{hasBroadcast ? ( ) : ( /* Minimal channel badge when no broadcast */
{channelIdx + 1} {channel.name}
)}
{/* Schedule overlay — outside the fading div so it has its own visibility */} {showOverlays && showSchedule && (
)} )}
); }