Files
k-tv/k-tv-frontend/app/(main)/tv/page.tsx

588 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (
<Suspense>
<TvPageContent />
</Suspense>
);
}
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<ReturnType<typeof setTimeout> | null>(null);
// Video ref — used to resume playback if autoplay was blocked on load
const videoRef = useRef<HTMLVideoElement>(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<SubtitleTrack[]>([]);
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(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}, []);
// 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<ReturnType<typeof setTimeout> | null>(null);
// Touch-swipe state
const touchStartY = useRef<number | null>(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 <NoSignal variant="loading" message="Tuning in…" />;
}
if (!channels || channels.length === 0) {
return (
<NoSignal
variant="no-signal"
message="No channels configured. Visit the Dashboard to create one."
/>
);
}
if (isLoadingBroadcast) {
return <NoSignal variant="loading" message="Tuning in…" />;
}
if (!hasBroadcast) {
return (
<NoSignal
variant="no-signal"
message="Nothing is scheduled right now. Check back later."
/>
);
}
if (streamError) {
return (
<NoSignal variant="error" message="Stream failed to load.">
<button
onClick={handleRetry}
className="mt-2 rounded-md border border-zinc-700 bg-zinc-800/80 px-4 py-2 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100"
>
Retry
</button>
</NoSignal>
);
}
if (videoEnded) {
return <NoSignal variant="loading" message="Up next, stay tuned…" />;
}
if (streamUrl) {
return (
<VideoPlayer
ref={videoRef}
src={streamUrl}
className="absolute inset-0 h-full w-full"
initialOffset={broadcast ? calcOffsetSecs(broadcast.slot.start_at) : 0}
subtitleTrack={activeSubtitleTrack}
onSubtitleTracksChange={setSubtitleTracks}
onStreamError={handleStreamError}
onEnded={handleVideoEnded}
onNeedsInteraction={() => setNeedsInteraction(true)}
/>
);
}
// Broadcast exists but stream URL resolving — show loading until ready
return <NoSignal variant="loading" message="Loading stream…" />;
};
// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------
return (
<div
className="relative flex flex-1 overflow-hidden bg-black"
style={{ cursor: showOverlays ? "default" : "none" }}
onMouseMove={resetIdle}
onClick={resetIdle}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* ── Base layer ─────────────────────────────────────────────── */}
<div className="absolute inset-0">{renderBase()}</div>
{/* ── Autoplay blocked prompt ─────────────────────────────────── */}
{needsInteraction && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className="rounded-xl bg-black/70 px-8 py-5 text-center backdrop-blur-sm">
<p className="text-sm font-medium text-zinc-200">Click or move the mouse to play</p>
</div>
</div>
)}
{/* ── Overlays — only when channels available ─────────────────── */}
{channel && (
<>
<div
className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between transition-opacity duration-300"
style={{ opacity: showOverlays ? 1 : 0 }}
>
{/* Top-right: subtitle picker + guide toggle */}
<div className="flex justify-end gap-2 p-4">
{subtitleTracks.length > 0 && (
<div className="pointer-events-auto relative">
<button
className="rounded-md bg-black/50 px-3 py-1.5 text-xs backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
style={{
color: activeSubtitleTrack !== -1 ? "white" : undefined,
borderBottom:
activeSubtitleTrack !== -1
? "2px solid white"
: "2px solid transparent",
}}
onClick={() => setShowSubtitlePicker((s) => !s)}
>
CC
</button>
{showSubtitlePicker && (
<div className="absolute right-0 top-9 z-30 min-w-[10rem] overflow-hidden rounded-md border border-zinc-700 bg-zinc-900/95 py-1 shadow-xl backdrop-blur">
<button
className={`w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-700 ${activeSubtitleTrack === -1 ? "text-white" : "text-zinc-400"}`}
onClick={() => {
setActiveSubtitleTrack(-1);
setShowSubtitlePicker(false);
}}
>
Off
</button>
{subtitleTracks.map((track) => (
<button
key={track.id}
className={`w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-zinc-700 ${activeSubtitleTrack === track.id ? "text-white" : "text-zinc-400"}`}
onClick={() => {
setActiveSubtitleTrack(track.id);
setShowSubtitlePicker(false);
}}
>
{track.name || track.lang || `Track ${track.id + 1}`}
</button>
))}
</div>
)}
</div>
)}
{/* Volume control */}
<div className="pointer-events-auto relative">
<button
className="rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={() => setShowVolumeSlider((s) => !s)}
title="Volume"
>
<VolumeIcon className="h-4 w-4" />
</button>
{showVolumeSlider && (
<div className="absolute right-0 top-9 z-30 w-36 rounded-lg border border-zinc-700 bg-zinc-900/95 p-3 shadow-xl backdrop-blur">
<input
type="range"
min={0}
max={100}
value={isMuted ? 0 : Math.round(volume * 100)}
onChange={(e) => {
const v = Number(e.target.value) / 100;
setVolume(v);
setIsMuted(v === 0);
}}
className="w-full accent-white"
/>
<div className="mt-1.5 flex items-center justify-between">
<button
onClick={toggleMute}
className="text-[10px] text-zinc-500 hover:text-zinc-300"
>
{isMuted ? "Unmute [M]" : "Mute [M]"}
</button>
<span className="font-mono text-[10px] text-zinc-500">
{isMuted ? "0" : Math.round(volume * 100)}%
</span>
</div>
</div>
)}
</div>
<button
className="pointer-events-auto rounded-md bg-black/50 p-1.5 text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleFullscreen}
title={isFullscreen ? "Exit fullscreen [F]" : "Fullscreen [F]"}
>
{isFullscreen
? <Minimize2 className="h-4 w-4" />
: <Maximize2 className="h-4 w-4" />}
</button>
<button
className="pointer-events-auto rounded-md bg-black/50 px-3 py-1.5 text-xs text-zinc-400 backdrop-blur transition-colors hover:bg-black/70 hover:text-white"
onClick={toggleSchedule}
>
{showSchedule ? "Hide guide" : "Guide [G]"}
</button>
</div>
{/* Bottom: banner + info row */}
<div className="flex flex-col gap-3 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-5 pt-20">
{showBanner && nextSlot && (
<UpNextBanner
nextShowTitle={nextSlot.item.title}
minutesUntil={minutesUntil(nextSlot.start_at)}
nextShowStartTime={fmtTime(nextSlot.start_at)}
/>
)}
<div className="flex items-end justify-between gap-4">
{hasBroadcast ? (
<ChannelInfo
channelNumber={channelIdx + 1}
channelName={channel.name}
item={broadcast.slot.item}
showStartTime={fmtTime(broadcast.slot.start_at)}
showEndTime={fmtTime(broadcast.slot.end_at)}
progress={progress}
/>
) : (
/* Minimal channel badge when no broadcast */
<div className="rounded-lg bg-black/60 px-4 py-3 backdrop-blur-md">
<div className="flex items-center gap-2">
<span className="flex h-7 min-w-9 items-center justify-center rounded bg-white px-1.5 font-mono text-xs font-bold text-black">
{channelIdx + 1}
</span>
<span className="text-sm font-medium text-zinc-300">
{channel.name}
</span>
</div>
</div>
)}
<div className="pointer-events-auto">
<ChannelControls
channelNumber={channelIdx + 1}
channelName={channel.name}
onPrevChannel={prevChannel}
onNextChannel={nextChannel}
/>
</div>
</div>
</div>
</div>
{/* Channel number input overlay */}
{channelInput && (
<div className="pointer-events-none absolute left-1/2 top-1/2 z-30 -translate-x-1/2 -translate-y-1/2 rounded-xl bg-black/80 px-8 py-5 text-center backdrop-blur">
<p className="mb-1 text-[10px] uppercase tracking-widest text-zinc-500">Channel</p>
<p className="font-mono text-5xl font-bold text-white">{channelInput}</p>
</div>
)}
{/* Schedule overlay — outside the fading div so it has its own visibility */}
{showOverlays && showSchedule && (
<div className="absolute bottom-4 right-4 top-14 z-20 w-80">
<ScheduleOverlay
channelName={channel.name}
slots={scheduleSlots}
/>
</div>
)}
</>
)}
</div>
);
}