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

327 lines
11 KiB
TypeScript

"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 { 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);
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]);
// ------------------------------------------------------------------
// 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),
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;
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [nextChannel, prevChannel, toggleSchedule]);
// ------------------------------------------------------------------
// 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}
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}
>
{/* ── 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: guide toggle */}
<div className="flex justify-end p-4">
<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>
{/* 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>
);
}