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 = [
|
const NAV_LINKS = [
|
||||||
{ href: "/tv", label: "TV" },
|
{ href: "/tv", label: "TV" },
|
||||||
|
{ href: "/guide", label: "Guide" },
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
{ href: "/docs", label: "Docs" },
|
{ href: "/docs", label: "Docs" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
VideoPlayer,
|
VideoPlayer,
|
||||||
@@ -37,12 +38,19 @@ const BANNER_THRESHOLD = 80; // show "up next" when progress ≥ this %
|
|||||||
|
|
||||||
export default function TvPage() {
|
export default function TvPage() {
|
||||||
const { token } = useAuthContext();
|
const { token } = useAuthContext();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
// Channel list
|
// Channel list
|
||||||
const { data: channels, isLoading: isLoadingChannels } = useChannels();
|
const { data: channels, isLoading: isLoadingChannels } = useChannels();
|
||||||
|
|
||||||
// Channel navigation
|
// Channel navigation — seed from ?channel=<id> query param if present
|
||||||
const [channelIdx, setChannelIdx] = useState(0);
|
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];
|
const channel = channels?.[channelIdx];
|
||||||
|
|
||||||
// Overlay / idle state
|
// Overlay / idle state
|
||||||
|
|||||||
Reference in New Issue
Block a user