286 lines
9.5 KiB
TypeScript
286 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
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,
|
|
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);
|
|
|
|
// 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,
|
|
);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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,
|
|
);
|
|
}, []);
|
|
|
|
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]);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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 (streamUrl) {
|
|
return (
|
|
<VideoPlayer
|
|
src={streamUrl}
|
|
className="absolute inset-0 h-full w-full"
|
|
initialOffset={broadcast?.offset_secs}
|
|
/>
|
|
);
|
|
}
|
|
// Broadcast exists but stream URL resolving — show no-signal 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}
|
|
showTitle={broadcast.slot.item.title}
|
|
showStartTime={fmtTime(broadcast.slot.start_at)}
|
|
showEndTime={fmtTime(broadcast.slot.end_at)}
|
|
progress={progress}
|
|
description={broadcast.slot.item.description ?? undefined}
|
|
/>
|
|
) : (
|
|
/* 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>
|
|
);
|
|
}
|