- Added package.json with dependencies and scripts for development, build, and linting. - Created postcss.config.mjs for Tailwind CSS integration. - Added SVG assets for UI components including file, globe, next, vercel, and window icons. - Configured TypeScript with tsconfig.json for strict type checking and module resolution.
247 lines
8.5 KiB
TypeScript
247 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import {
|
|
VideoPlayer,
|
|
ChannelInfo,
|
|
ChannelControls,
|
|
ScheduleOverlay,
|
|
UpNextBanner,
|
|
NoSignal,
|
|
} from "./components";
|
|
import type { ScheduleSlot } from "./components";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock data — replace with TanStack Query hooks once the API is ready
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface MockChannel {
|
|
number: number;
|
|
name: string;
|
|
src?: string;
|
|
schedule: ScheduleSlot[];
|
|
current: {
|
|
title: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
progress: number;
|
|
description?: string;
|
|
};
|
|
next: {
|
|
title: string;
|
|
startTime: string;
|
|
minutesUntil: number;
|
|
};
|
|
}
|
|
|
|
const MOCK_CHANNELS: MockChannel[] = [
|
|
{
|
|
number: 1,
|
|
name: "Cinema Classic",
|
|
schedule: [
|
|
{ id: "c1-1", title: "The Maltese Falcon", startTime: "17:00", endTime: "18:45" },
|
|
{ id: "c1-2", title: "Casablanca", startTime: "18:45", endTime: "20:30", isCurrent: true },
|
|
{ id: "c1-3", title: "Sunset Boulevard", startTime: "20:30", endTime: "22:15" },
|
|
{ id: "c1-4", title: "Rear Window", startTime: "22:15", endTime: "00:00" },
|
|
],
|
|
current: {
|
|
title: "Casablanca",
|
|
startTime: "18:45",
|
|
endTime: "20:30",
|
|
progress: 72,
|
|
description:
|
|
"A cynical American expatriate struggles to decide whether or not he should help his former lover and her fugitive husband escape French Morocco.",
|
|
},
|
|
next: { title: "Sunset Boulevard", startTime: "20:30", minutesUntil: 23 },
|
|
},
|
|
{
|
|
number: 2,
|
|
name: "Nature & Wild",
|
|
schedule: [
|
|
{ id: "c2-1", title: "Planet Earth II", startTime: "19:00", endTime: "20:00", isCurrent: true },
|
|
{ id: "c2-2", title: "Blue Planet", startTime: "20:00", endTime: "21:00" },
|
|
{ id: "c2-3", title: "Africa", startTime: "21:00", endTime: "22:00" },
|
|
],
|
|
current: {
|
|
title: "Planet Earth II",
|
|
startTime: "19:00",
|
|
endTime: "20:00",
|
|
progress: 85,
|
|
description:
|
|
"David Attenborough explores the world's most iconic landscapes and the remarkable animals that inhabit them.",
|
|
},
|
|
next: { title: "Blue Planet", startTime: "20:00", minutesUntil: 9 },
|
|
},
|
|
{
|
|
number: 3,
|
|
name: "Sci-Fi Zone",
|
|
schedule: [
|
|
{ id: "c3-1", title: "2001: A Space Odyssey", startTime: "19:30", endTime: "22:10", isCurrent: true },
|
|
{ id: "c3-2", title: "Blade Runner", startTime: "22:10", endTime: "00:17" },
|
|
],
|
|
current: {
|
|
title: "2001: A Space Odyssey",
|
|
startTime: "19:30",
|
|
endTime: "22:10",
|
|
progress: 40,
|
|
description:
|
|
"After discovering a mysterious artifact, mankind sets off on a quest to find its origins with help from intelligent supercomputer H.A.L. 9000.",
|
|
},
|
|
next: { title: "Blade Runner", startTime: "22:10", minutesUntil: 96 },
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const IDLE_TIMEOUT_MS = 3500;
|
|
const BANNER_THRESHOLD = 80; // show "up next" banner when progress ≥ this
|
|
|
|
export default function TvPage() {
|
|
const [channelIdx, setChannelIdx] = useState(0);
|
|
const [showOverlays, setShowOverlays] = useState(true);
|
|
const [showSchedule, setShowSchedule] = useState(false);
|
|
const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const channel = MOCK_CHANNELS[channelIdx];
|
|
const showBanner = channel.current.progress >= BANNER_THRESHOLD;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Idle detection — hide overlays after inactivity
|
|
// ------------------------------------------------------------------
|
|
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 prevChannel = useCallback(() => {
|
|
setChannelIdx((i) => (i - 1 + MOCK_CHANNELS.length) % MOCK_CHANNELS.length);
|
|
resetIdle();
|
|
}, [resetIdle]);
|
|
|
|
const nextChannel = useCallback(() => {
|
|
setChannelIdx((i) => (i + 1) % MOCK_CHANNELS.length);
|
|
resetIdle();
|
|
}, [resetIdle]);
|
|
|
|
const toggleSchedule = useCallback(() => {
|
|
setShowSchedule((s) => !s);
|
|
resetIdle();
|
|
}, [resetIdle]);
|
|
|
|
// ------------------------------------------------------------------
|
|
// Keyboard shortcuts
|
|
// ------------------------------------------------------------------
|
|
useEffect(() => {
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
// Don't steal input from focused form elements
|
|
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
|
|
// ------------------------------------------------------------------
|
|
return (
|
|
<div
|
|
className="relative flex flex-1 overflow-hidden bg-black"
|
|
style={{ cursor: showOverlays ? "default" : "none" }}
|
|
onMouseMove={resetIdle}
|
|
onClick={resetIdle}
|
|
>
|
|
{/* ── Base layer ─────────────────────────────────────────────── */}
|
|
{channel.src ? (
|
|
<VideoPlayer src={channel.src} className="absolute inset-0 h-full w-full" />
|
|
) : (
|
|
<div className="absolute inset-0">
|
|
<NoSignal variant="no-signal" />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Overlays ───────────────────────────────────────────────── */}
|
|
<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 && (
|
|
<UpNextBanner
|
|
nextShowTitle={channel.next.title}
|
|
minutesUntil={channel.next.minutesUntil}
|
|
nextShowStartTime={channel.next.startTime}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex items-end justify-between gap-4">
|
|
<ChannelInfo
|
|
channelNumber={channel.number}
|
|
channelName={channel.name}
|
|
showTitle={channel.current.title}
|
|
showStartTime={channel.current.startTime}
|
|
showEndTime={channel.current.endTime}
|
|
progress={channel.current.progress}
|
|
description={channel.current.description}
|
|
/>
|
|
<div className="pointer-events-auto">
|
|
<ChannelControls
|
|
channelNumber={channel.number}
|
|
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={channel.schedule} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|