"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, ChannelPasswordModal, } 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); // Access control — persisted per channel in localStorage const [channelPasswords, setChannelPasswords] = useState>(() => { try { return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}"); } catch { return {}; } }); const [blockPasswords, setBlockPasswords] = useState>(() => { try { return JSON.parse(localStorage.getItem("block_passwords") ?? "{}"); } catch { return {}; } }); const [showChannelPasswordModal, setShowChannelPasswordModal] = useState(false); const [showBlockPasswordModal, setShowBlockPasswordModal] = useState(false); const channelPassword = channel ? channelPasswords[channel.id] : undefined; // 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); }, []); // 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, error: broadcastError } = useCurrentBroadcast(channel?.id ?? "", channelPassword); const blockPassword = broadcast?.slot.id ? blockPasswords[broadcast.slot.id] : undefined; const { data: epgSlots } = useEpg(channel?.id ?? "", undefined, undefined, channelPassword); const { data: streamUrl, error: streamUrlError } = useStreamUrl( channel?.id, token, broadcast?.slot.id, channelPassword, blockPassword, ); // 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]); // Show channel password modal when broadcast returns password_required useEffect(() => { if ((broadcastError as Error)?.message === "password_required") { setShowChannelPasswordModal(true); } }, [broadcastError]); // Show block password modal when stream URL fetch returns password_required useEffect(() => { if ((streamUrlError as Error)?.message === "password_required") { setShowBlockPasswordModal(true); } }, [streamUrlError]); // 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]); const submitChannelPassword = useCallback((password: string) => { if (!channel) return; const next = { ...channelPasswords, [channel.id]: password }; setChannelPasswords(next); try { localStorage.setItem("channel_passwords", JSON.stringify(next)); } catch {} setShowChannelPasswordModal(false); queryClient.invalidateQueries({ queryKey: ["broadcast", channel.id] }); }, [channel, channelPasswords, queryClient]); const submitBlockPassword = useCallback((password: string) => { if (!broadcast?.slot.id) return; const next = { ...blockPasswords, [broadcast.slot.id]: password }; setBlockPasswords(next); try { localStorage.setItem("block_passwords", JSON.stringify(next)); } catch {} setShowBlockPasswordModal(false); queryClient.invalidateQueries({ queryKey: ["stream-url", channel?.id, broadcast.slot.id] }); }, [broadcast?.slot.id, blockPasswords, channel?.id, queryClient]); // ------------------------------------------------------------------ // Render helpers // ------------------------------------------------------------------ const renderBase = () => { if (isLoadingChannels) { return ; } if (!channels || channels.length === 0) { return ( ); } // Channel-level access errors (not password — those show a modal) const broadcastErrMsg = (broadcastError as Error)?.message; if (broadcastErrMsg === "auth_required") { return ; } if (broadcastErrMsg && broadcastError && (broadcastError as { status?: number }).status === 403) { return ; } if (isLoadingBroadcast) { return ; } if (!hasBroadcast) { return ( ); } // Block-level access errors (not password — those show a modal overlay) const streamErrMsg = (streamUrlError as Error)?.message; if (streamErrMsg === "auth_required") { return ; } if (streamUrlError && (streamUrlError as { status?: number }).status === 403) { 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()} {/* ── Channel password modal ──────────────────────────────────── */} {showChannelPasswordModal && ( setShowChannelPasswordModal(false)} /> )} {/* ── Block password modal ────────────────────────────────────── */} {showBlockPasswordModal && ( setShowBlockPasswordModal(false)} /> )} {/* ── 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}