From 95598580758a79ab77b3aeccf297dfacfb3ec663 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 12 Mar 2026 03:29:52 +0100 Subject: [PATCH] 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 --- k-tv-frontend/app/(main)/guide/page.tsx | 195 ++++++++++++++++++++++++ k-tv-frontend/app/(main)/layout.tsx | 1 + k-tv-frontend/app/(main)/tv/page.tsx | 10 +- 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 k-tv-frontend/app/(main)/guide/page.tsx diff --git a/k-tv-frontend/app/(main)/guide/page.tsx b/k-tv-frontend/app/(main)/guide/page.tsx new file mode 100644 index 0000000..5639ecb --- /dev/null +++ b/k-tv-frontend/app/(main)/guide/page.tsx @@ -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 ( +
+ {/* Channel header */} +
+
+ + {channel.name} + {channel.description && ( + + — {channel.description} + + )} +
+ + Watch → + +
+ +
+ {/* Currently airing */} + {current ? ( +
+
+
+ + Now + + + {slotLabel(current)} + +
+ {remaining}m left +
+
+
+
+
+ ) : isPending ? ( +

Loading…

+ ) : ( +

+ {isError || !slots?.length + ? "No signal — schedule may not be generated yet" + : "Nothing airing right now"} +

+ )} + + {/* Upcoming */} + {upcoming.length > 0 && ( +
    + {upcoming.map((slot) => ( +
  • +
    + + {fmtTime(slot.start_at)} + + {slotLabel(slot)} +
    + + {fmtDuration(slot.item.duration_secs)} + +
  • + ))} +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( +
+
+

Channel Guide

+ + {dateLabel} · {timeLabel} + +
+ + {isLoading &&

Loading channels…

} + + {!isLoading && channels?.length === 0 && ( +

+ No channels yet.{" "} + + Create one in the dashboard. + +

+ )} + +
+ {channels?.map((channel) => ( + + ))} +
+
+ ); +} diff --git a/k-tv-frontend/app/(main)/layout.tsx b/k-tv-frontend/app/(main)/layout.tsx index b0ebae0..ed46e0d 100644 --- a/k-tv-frontend/app/(main)/layout.tsx +++ b/k-tv-frontend/app/(main)/layout.tsx @@ -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" }, ]; diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx index ecfc205..d17894b 100644 --- a/k-tv-frontend/app/(main)/tv/page.tsx +++ b/k-tv-frontend/app/(main)/tv/page.tsx @@ -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= 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