"use client"; import { useState, useEffect, useCallback, useRef, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { ChannelInfo, ChannelControls, ScheduleOverlay, UpNextBanner, ChannelPasswordModal, TopControlBar, LogoWatermark, AutoplayPrompt, ChannelNumberOverlay, TvBaseLayer, } from "./components"; import { useAuthContext } from "@/context/auth-context"; import { useChannels, useCurrentBroadcast, useEpg } from "@/hooks/use-channels"; import { useCast } from "@/hooks/use-cast"; import { useStreamUrl, fmtTime, calcProgress, minutesUntil, toScheduleSlots, findNextSlot, } from "@/hooks/use-tv"; import { useFullscreen } from "@/hooks/use-fullscreen"; import { useIdle } from "@/hooks/use-idle"; import { useVolume } from "@/hooks/use-volume"; import { useQuality, QUALITY_OPTIONS } from "@/hooks/use-quality"; import { useSubtitlePicker } from "@/hooks/use-subtitles"; import { useChannelInput } from "@/hooks/use-channel-input"; import { useChannelPasswords } from "@/hooks/use-channel-passwords"; import { useTvKeyboard } from "@/hooks/use-tv-keyboard"; const IDLE_TIMEOUT_MS = 3500; const BANNER_THRESHOLD = 80; export default function TvPage() { return ( ); } function TvPageContent() { const { token } = useAuthContext(); const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); const { data: channels, isLoading: isLoadingChannels } = useChannels(); const channelId = searchParams.get("channel"); const channelIdx = channels && channelId ? Math.max(0, channels.findIndex((c) => c.id === channelId)) : 0; const channel = channels?.[channelIdx]; const switchChannel = useCallback( (idx: number, list = channels) => { const target = list?.[idx]; if (!target) return; router.replace(`/tv?channel=${target.id}`, { scroll: false }); }, [channels, router], ); const [showSchedule, setShowSchedule] = useState(false); const [showStats, setShowStats] = useState(false); const [streamError, setStreamError] = useState(false); const [videoEnded, setVideoEnded] = useState(false); const videoRef = useRef(null); const touchStartY = useRef(null); const { castAvailable, isCasting, castDeviceName, requestCast, stopCasting } = useCast(); // passwords: channelPassword depends only on channelId (from localStorage), safe to call before broadcast const passwords = useChannelPasswords(channel?.id); const { data: broadcast, isLoading: isLoadingBroadcast, error: broadcastError, } = useCurrentBroadcast(channel?.id ?? "", passwords.channelPassword); // blockPassword derived from broadcast.slot.id after broadcast loads const blockPassword = passwords.getBlockPassword(broadcast?.slot.id); const { data: epgSlots } = useEpg( channel?.id ?? "", undefined, undefined, passwords.channelPassword, ); const quality = useQuality(channel?.id, broadcast?.slot.id); const volume = useVolume(videoRef, isCasting); const subtitles = useSubtitlePicker(channelIdx, broadcast?.slot.id); const { data: streamUrl, error: streamUrlError } = useStreamUrl( channel?.id, token, broadcast?.slot.id, passwords.channelPassword, blockPassword, quality.quality, ); const channelCount = channels?.length ?? 0; // useIdle after volume/subtitles/quality so onIdle can reference their stable setters const { showOverlays, needsInteraction, setNeedsInteraction, resetIdle } = useIdle( IDLE_TIMEOUT_MS, videoRef, useCallback(() => { volume.setShowSlider(false); subtitles.setShowSubtitlePicker(false); quality.setShowQualityPicker(false); // setters from useState are stable — empty deps is intentional // eslint-disable-next-line react-hooks/exhaustive-deps }, []), ); // useFullscreen after useIdle so we have showOverlays const { isFullscreen, toggleFullscreen } = useFullscreen( videoRef, showOverlays, streamUrl, ); 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]); const { channelInput, handleDigit } = useChannelInput( channelCount, switchChannel, resetIdle, ); useTvKeyboard({ nextChannel, prevChannel, toggleSchedule, toggleFullscreen, toggleMute: volume.toggleMute, setShowStats, subtitleTracks: subtitles.subtitleTracks, setActiveSubtitleTrack: subtitles.setActiveSubtitleTrack, handleDigit, }); // Password modal triggers useEffect(() => { if ((broadcastError as Error)?.message === "password_required") { passwords.setShowChannelPasswordModal(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [broadcastError]); useEffect(() => { if ((streamUrlError as Error)?.message === "password_required") { passwords.setShowBlockPasswordModal(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [streamUrlError]); // Clear transient states when slot changes useEffect(() => { setStreamError(false); setVideoEnded(false); }, [broadcast?.slot.id]); // Tick for progress bar (every 30 s) const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick((n) => n + 1), 30_000); return () => clearInterval(id); }, []); // Derived 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; const handleStreamError = useCallback(() => setStreamError(true), []); const handleVideoEnded = useCallback(() => { setVideoEnded(true); queryClient.invalidateQueries({ queryKey: ["broadcast", channel?.id] }); }, [queryClient, channel?.id]); const handleRetry = useCallback(() => { queryClient.invalidateQueries({ queryKey: ["stream-url", channel?.id, broadcast?.slot.id], }); setStreamError(false); }, [queryClient, channel?.id, broadcast?.slot.id]); 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], ); return (
{/* Base layer */}
0} broadcastError={broadcastError} isLoadingBroadcast={isLoadingBroadcast} hasBroadcast={hasBroadcast} streamUrlError={streamUrlError} streamError={streamError} videoEnded={videoEnded} streamUrl={streamUrl} broadcast={broadcast} activeSubtitleTrack={subtitles.activeSubtitleTrack} isMuted={volume.isMuted} showStats={showStats} videoRef={videoRef} onSubtitleTracksChange={subtitles.setSubtitleTracks} onStreamError={handleStreamError} onEnded={handleVideoEnded} onNeedsInteraction={() => setNeedsInteraction(true)} onRetry={handleRetry} />
{/* Logo watermark */} {channel?.logo && ( )} {/* Password modals */} {passwords.showChannelPasswordModal && ( passwords.setShowChannelPasswordModal(false)} /> )} {passwords.showBlockPasswordModal && ( passwords.submitBlockPassword( broadcast?.slot.id ?? "", channel?.id, pw, ) } onCancel={() => passwords.setShowBlockPasswordModal(false)} /> )} {/* Autoplay blocked prompt */} {needsInteraction && } {/* Overlays */} {channel && ( <>
subtitles.setShowSubtitlePicker((s) => !s) } onChangeSubtitleTrack={(id) => { subtitles.setActiveSubtitleTrack(id); subtitles.setShowSubtitlePicker(false); }} volume={volume.volume} isMuted={volume.isMuted} VolumeIcon={volume.VolumeIcon} showVolumeSlider={volume.showSlider} onToggleVolumeSlider={() => volume.setShowSlider((s) => !s)} onToggleMute={volume.toggleMute} onVolumeChange={(v) => { volume.setVolume(v); volume.setIsMuted(v === 0); }} isFullscreen={isFullscreen} onToggleFullscreen={toggleFullscreen} castAvailable={castAvailable} isCasting={isCasting} castDeviceName={castDeviceName} streamUrl={streamUrl} onRequestCast={requestCast} onStopCasting={stopCasting} quality={quality.quality} qualityOptions={QUALITY_OPTIONS} showQualityPicker={quality.showQualityPicker} onToggleQualityPicker={() => quality.setShowQualityPicker((s) => !s) } onChangeQuality={quality.changeQuality} showStats={showStats} onToggleStats={() => setShowStats((v) => !v)} showSchedule={showSchedule} onToggleSchedule={toggleSchedule} />
{showBanner && nextSlot && ( )}
{hasBroadcast ? ( ) : (
{channelIdx + 1} {channel.name}
)}
{isCasting && castDeviceName && (
Casting to {castDeviceName}
)} {showOverlays && showSchedule && (
)} )}
); }