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:
195
k-tv-frontend/app/(main)/guide/page.tsx
Normal file
195
k-tv-frontend/app/(main)/guide/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user