230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { Tv } from "lucide-react";
|
||
import { api, ApiRequestError } 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, isLoaded } = useAuthContext();
|
||
|
||
const {
|
||
data: slots,
|
||
isError,
|
||
error,
|
||
isPending,
|
||
isFetching,
|
||
} = useQuery({
|
||
queryKey: ["guide-epg", channel.id, token],
|
||
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);
|
||
},
|
||
enabled: isLoaded,
|
||
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-primary/20 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary-foreground">
|
||
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-primary transition-all duration-1000"
|
||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : isPending && isFetching ? (
|
||
<p className="text-xs italic text-zinc-600">Loading…</p>
|
||
) : isError &&
|
||
error instanceof ApiRequestError &&
|
||
error.status === 401 ? (
|
||
<p className="text-xs italic text-zinc-600">
|
||
Sign in to view this channel
|
||
</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>
|
||
);
|
||
}
|