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

529 lines
20 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 } 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 { 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() {
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<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);
// 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);
}, []);
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 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);
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(() => {
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;
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)) {
setChannelIdx(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, 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 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 (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}
/>
);
}
// 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>
{/* ── 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>
);
}