"use client"; import { useState, useEffect, useCallback, useRef, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { VideoPlayer, ChannelInfo, ChannelControls, ScheduleOverlay, UpNextBanner, NoSignal, } from "./components"; import type { SubtitleTrack } from "./components/video-player"; import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react"; 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() { return ( ); } function TvPageContent() { const { token } = useAuthContext(); const router = useRouter(); const searchParams = useSearchParams(); // Channel list const { data: channels, isLoading: isLoadingChannels } = useChannels(); // URL is the single source of truth for the active channel. // channelIdx is derived — never stored in state. const channelId = searchParams.get("channel"); const channelIdx = channels && channelId ? Math.max(0, channels.findIndex((c) => c.id === channelId)) : 0; const channel = channels?.[channelIdx]; // Write a channel switch back to the URL so keyboard, buttons, and // guide links all stay in sync and the page is bookmarkable/refreshable. const switchChannel = useCallback((idx: number, list = channels) => { const target = list?.[idx]; if (!target) return; router.replace(`/tv?channel=${target.id}`, { scroll: false }); }, [channels, router]); // 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); // When the video ends, show no-signal until the next broadcast is detected. const [videoEnded, setVideoEnded] = useState(false); // Autoplay blocked by browser — cleared on first interaction via resetIdle const [needsInteraction, setNeedsInteraction] = useState(false); // Subtitles const [subtitleTracks, setSubtitleTracks] = useState([]); const [activeSubtitleTrack, setActiveSubtitleTrack] = useState(-1); const [showSubtitlePicker, setShowSubtitlePicker] = useState(false); // Fullscreen const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { const handler = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener("fullscreenchange", handler); return () => document.removeEventListener("fullscreenchange", handler); }, []); // iOS Safari: track fullscreen state via webkit video element events. // Re-run when streamUrl changes so we catch the video element after it mounts. useEffect(() => { const video = videoRef.current; if (!video) return; const onBegin = () => setIsFullscreen(true); const onEnd = () => setIsFullscreen(false); video.addEventListener("webkitbeginfullscreen", onBegin); video.addEventListener("webkitendfullscreen", onEnd); return () => { video.removeEventListener("webkitbeginfullscreen", onBegin); video.removeEventListener("webkitendfullscreen", onEnd); }; }, [streamUrl]); // Hide the shared nav bar in fullscreen; reveal it when overlays are shown // (mouse move / key press). Classes are cleaned up on unmount. useEffect(() => { document.body.classList.toggle("tv-fullscreen", isFullscreen); document.body.classList.toggle("tv-overlays", isFullscreen && showOverlays); return () => document.body.classList.remove("tv-fullscreen", "tv-overlays"); }, [isFullscreen, showOverlays]); const toggleFullscreen = useCallback(() => { // Standard Fullscreen API (Chrome, Firefox, Safari desktop) if (document.fullscreenEnabled) { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(() => {}); } else { document.exitFullscreen().catch(() => {}); } return; } // iOS Safari: requestFullscreen is not supported. Fall back to the video // element's proprietary webkit API instead. const video = videoRef.current as HTMLVideoElement & { webkitEnterFullscreen?: () => void; webkitExitFullscreen?: () => void; webkitDisplayingFullscreen?: boolean; }; if (!video?.webkitEnterFullscreen) return; if (video.webkitDisplayingFullscreen) { video.webkitExitFullscreen?.(); } else { video.webkitEnterFullscreen(); } }, []); // Volume control const [volume, setVolume] = useState(1); // 0.0 – 1.0 const [isMuted, setIsMuted] = useState(false); const [showVolumeSlider, setShowVolumeSlider] = useState(false); useEffect(() => { if (!videoRef.current) return; videoRef.current.muted = isMuted; videoRef.current.volume = volume; }, [isMuted, volume]); const toggleMute = useCallback(() => setIsMuted((m) => !m), []); const VolumeIcon = isMuted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2; // Channel jump by number (e.g. press "1","4" → jump to ch 14 after 1.5 s) const [channelInput, setChannelInput] = useState(""); const channelInputTimer = useRef | null>(null); // Touch-swipe state const touchStartY = useRef(null); 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 transient states when a new slot is detected useEffect(() => { setStreamError(false); setVideoEnded(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); setNeedsInteraction(false); if (idleTimer.current) clearTimeout(idleTimer.current); idleTimer.current = setTimeout(() => { setShowOverlays(false); setShowVolumeSlider(false); setShowSubtitlePicker(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(() => { switchChannel((channelIdx - 1 + Math.max(channelCount, 1)) % Math.max(channelCount, 1)); resetIdle(); }, [channelIdx, channelCount, switchChannel, resetIdle]); const nextChannel = useCallback(() => { switchChannel((channelIdx + 1) % Math.max(channelCount, 1)); resetIdle(); }, [channelIdx, channelCount, switchChannel, 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; case "f": case "F": toggleFullscreen(); break; case "m": case "M": toggleMute(); break; default: { if (e.key >= "0" && e.key <= "9") { setChannelInput((prev) => { const next = prev + e.key; if (channelInputTimer.current) clearTimeout(channelInputTimer.current); channelInputTimer.current = setTimeout(() => { const num = parseInt(next, 10); if (num >= 1 && num <= Math.max(channelCount, 1)) { switchChannel(num - 1); resetIdle(); } setChannelInput(""); }, 1500); return next; }); } } } }; window.addEventListener("keydown", handleKey); return () => { window.removeEventListener("keydown", handleKey); if (channelInputTimer.current) clearTimeout(channelInputTimer.current); }; }, [nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute, channelCount, switchChannel, resetIdle]); // ------------------------------------------------------------------ // Touch swipe (swipe up = next channel, swipe down = prev channel) // ------------------------------------------------------------------ const handleTouchStart = useCallback((e: React.TouchEvent) => { touchStartY.current = e.touches[0].clientY; resetIdle(); }, [resetIdle]); const handleTouchEnd = useCallback((e: React.TouchEvent) => { if (touchStartY.current === null) return; const dy = touchStartY.current - e.changedTouches[0].clientY; touchStartY.current = null; if (Math.abs(dy) > 60) { if (dy > 0) nextChannel(); else prevChannel(); } }, [nextChannel, prevChannel]); // ------------------------------------------------------------------ // Stream error recovery // ------------------------------------------------------------------ const handleStreamError = useCallback(() => { setStreamError(true); }, []); const handleVideoEnded = useCallback(() => { setVideoEnded(true); // Immediately poll for the next broadcast instead of waiting up to 30 s. queryClient.invalidateQueries({ queryKey: ["broadcast", channel?.id] }); }, [queryClient, channel?.id]); 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 ( Retry ); } if (videoEnded) { return ; } if (streamUrl) { return ( setNeedsInteraction(true)} /> ); } // Broadcast exists but stream URL resolving — show loading until ready return ; }; // ------------------------------------------------------------------ // Render // ------------------------------------------------------------------ return ( {/* ── Base layer ─────────────────────────────────────────────── */} {renderBase()} {/* ── Autoplay blocked prompt ─────────────────────────────────── */} {needsInteraction && ( Click or move the mouse to play )} {/* ── Overlays — only when channels available ─────────────────── */} {channel && ( <> {/* Top-right: subtitle picker + guide toggle */} {subtitleTracks.length > 0 && ( setShowSubtitlePicker((s) => !s)} > CC {showSubtitlePicker && ( { setActiveSubtitleTrack(-1); setShowSubtitlePicker(false); }} > Off {subtitleTracks.map((track) => ( { setActiveSubtitleTrack(track.id); setShowSubtitlePicker(false); }} > {track.name || track.lang || `Track ${track.id + 1}`} ))} )} )} {/* Volume control */} setShowVolumeSlider((s) => !s)} title="Volume" > {showVolumeSlider && ( { const v = Number(e.target.value) / 100; setVolume(v); setIsMuted(v === 0); }} className="w-full accent-white" /> {isMuted ? "Unmute [M]" : "Mute [M]"} {isMuted ? "0" : Math.round(volume * 100)}% )} {isFullscreen ? : } {showSchedule ? "Hide guide" : "Guide [G]"} {/* Bottom: banner + info row */} {showBanner && nextSlot && ( )} {hasBroadcast ? ( ) : ( /* Minimal channel badge when no broadcast */ {channelIdx + 1} {channel.name} )} {/* Channel number input overlay */} {channelInput && ( Channel {channelInput} )} {/* Schedule overlay — outside the fading div so it has its own visibility */} {showOverlays && showSchedule && ( )} > )} ); }
Click or move the mouse to play
Channel
{channelInput}