feat(guide): implement channel guide page with EPG and upcoming slots

feat(layout): add guide link to navigation
feat(tv): enable channel navigation via query parameter
This commit is contained in:
2026-03-12 03:29:52 +01:00
parent e5a9b99b14
commit 9559858075
3 changed files with 205 additions and 1 deletions

View File

@@ -0,0 +1,195 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Tv } from "lucide-react";
import { api } from "@/lib/api";
import { useChannels } from "@/hooks/use-channels";
import { useAuthContext } from "@/context/auth-context";
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtDuration(secs: number) {
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function slotLabel(slot: ScheduledSlotResponse) {
const { item } = slot;
if (item.content_type === "episode" && item.series_name) {
const ep = [
item.season_number != null ? `S${item.season_number}` : "",
item.episode_number != null ? `E${String(item.episode_number).padStart(2, "0")}` : "",
]
.filter(Boolean)
.join("");
return ep
? `${item.series_name} ${ep} ${item.title}`
: `${item.series_name} ${item.title}`;
}
return item.year ? `${item.title} (${item.year})` : item.title;
}
// ---------------------------------------------------------------------------
// ChannelRow — fetches its own EPG slice and renders current + upcoming
// ---------------------------------------------------------------------------
function ChannelRow({ channel }: { channel: ChannelResponse }) {
const { token } = useAuthContext();
const { data: slots, isError, isPending } = useQuery({
queryKey: ["guide-epg", channel.id],
queryFn: () => {
const now = new Date();
const from = now.toISOString();
const until = new Date(now.getTime() + 4 * 60 * 60 * 1000).toISOString();
return api.schedule.getEpg(channel.id, token ?? "", from, until);
},
refetchInterval: 30_000,
retry: false,
});
const now = Date.now();
const current = slots?.find(
(s) =>
new Date(s.start_at).getTime() <= now && now < new Date(s.end_at).getTime(),
);
const upcoming = slots?.filter((s) => new Date(s.start_at).getTime() > now).slice(0, 3) ?? [];
const progress = current
? (now - new Date(current.start_at).getTime()) /
(new Date(current.end_at).getTime() - new Date(current.start_at).getTime())
: 0;
const remaining = current
? Math.ceil((new Date(current.end_at).getTime() - now) / 60_000)
: 0;
return (
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
{/* Channel header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Tv className="size-3.5 shrink-0 text-zinc-600" />
<span className="truncate text-sm font-semibold text-zinc-100">{channel.name}</span>
{channel.description && (
<span className="hidden truncate text-xs text-zinc-600 sm:block">
{channel.description}
</span>
)}
</div>
<Link
href={`/tv?channel=${channel.id}`}
className="ml-4 shrink-0 rounded-md px-2.5 py-1 text-xs text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100"
>
Watch
</Link>
</div>
<div className="space-y-3 px-4 py-3">
{/* Currently airing */}
{current ? (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between gap-4">
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 rounded bg-red-600/20 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-red-400">
Now
</span>
<span className="truncate text-sm font-medium text-zinc-100">
{slotLabel(current)}
</span>
</div>
<span className="shrink-0 text-xs text-zinc-500">{remaining}m left</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-zinc-800">
<div
className="h-full rounded-full bg-red-600 transition-all duration-1000"
style={{ width: `${Math.round(progress * 100)}%` }}
/>
</div>
</div>
) : isPending ? (
<p className="text-xs italic text-zinc-600">Loading</p>
) : (
<p className="text-xs italic text-zinc-600">
{isError || !slots?.length
? "No signal — schedule may not be generated yet"
: "Nothing airing right now"}
</p>
)}
{/* Upcoming */}
{upcoming.length > 0 && (
<ul className="space-y-1.5 border-t border-zinc-800 pt-2.5">
{upcoming.map((slot) => (
<li key={slot.id} className="flex items-baseline justify-between gap-4">
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 font-mono text-[11px] text-zinc-500">
{fmtTime(slot.start_at)}
</span>
<span className="truncate text-xs text-zinc-400">{slotLabel(slot)}</span>
</div>
<span className="shrink-0 font-mono text-[11px] text-zinc-600">
{fmtDuration(slot.item.duration_secs)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function GuidePage() {
const { data: channels, isLoading } = useChannels();
const now = new Date();
const timeLabel = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const dateLabel = now.toLocaleDateString([], {
weekday: "long",
month: "long",
day: "numeric",
});
return (
<div className="mx-auto w-full max-w-4xl space-y-6 px-6 py-8">
<div className="flex items-baseline gap-3">
<h1 className="text-xl font-semibold text-zinc-100">Channel Guide</h1>
<span className="text-sm text-zinc-500">
{dateLabel} · {timeLabel}
</span>
</div>
{isLoading && <p className="text-sm text-zinc-600">Loading channels</p>}
{!isLoading && channels?.length === 0 && (
<p className="text-sm text-zinc-600">
No channels yet.{" "}
<Link href="/dashboard" className="text-zinc-400 underline">
Create one in the dashboard.
</Link>
</p>
)}
<div className="space-y-3">
{channels?.map((channel) => (
<ChannelRow key={channel.id} channel={channel} />
))}
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { NavAuth } from "./components/nav-auth";
const NAV_LINKS = [
{ href: "/tv", label: "TV" },
{ href: "/guide", label: "Guide" },
{ href: "/dashboard", label: "Dashboard" },
{ href: "/docs", label: "Docs" },
];

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
VideoPlayer,
@@ -37,12 +38,19 @@ const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
export default function TvPage() {
const { token } = useAuthContext();
const searchParams = useSearchParams();
// Channel list
const { data: channels, isLoading: isLoadingChannels } = useChannels();
// Channel navigation
// Channel navigation — seed from ?channel=<id> query param if present
const [channelIdx, setChannelIdx] = useState(0);
useEffect(() => {
const id = searchParams.get("channel");
if (!id || !channels) return;
const idx = channels.findIndex((c) => c.id === id);
if (idx !== -1) setChannelIdx(idx);
}, [channels, searchParams]);
const channel = channels?.[channelIdx];
// Overlay / idle state