Files
k-tv/k-tv-frontend/hooks/use-tv.ts

162 lines
5.9 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import type { ScheduleSlot } from "@/app/(main)/tv/components";
import type { ScheduledSlotResponse } from "@/lib/types";
// ---------------------------------------------------------------------------
// Pure transformation utilities
// ---------------------------------------------------------------------------
/** Format an ISO-8601 string to "HH:MM" in the user's local timezone. */
export function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
/** Progress percentage through a slot based on wall-clock time. */
export function calcProgress(startAt: string, durationSecs: number): number {
if (durationSecs <= 0) return 0;
const elapsedSecs = (Date.now() - new Date(startAt).getTime()) / 1000;
return Math.min(100, Math.max(0, Math.round((elapsedSecs / durationSecs) * 100)));
}
/**
* Seconds elapsed since a slot started, computed from the current wall clock.
* Use this instead of `broadcast.offset_secs` when the broadcast may be cached —
* start_at is a fixed timestamp so the offset is always accurate regardless of
* when the broadcast response was fetched.
*/
export function calcOffsetSecs(startAt: string): number {
return Math.max(0, (Date.now() - new Date(startAt).getTime()) / 1000);
}
/** Minutes until a future timestamp (rounded, minimum 0). */
export function minutesUntil(iso: string): number {
return Math.max(0, Math.round((new Date(iso).getTime() - Date.now()) / 60_000));
}
/**
* Map EPG slots to the shape expected by ScheduleOverlay.
* Marks the slot matching currentSlotId as current.
*/
export function toScheduleSlots(
slots: ScheduledSlotResponse[],
currentSlotId?: string,
): ScheduleSlot[] {
return slots.map((slot) => {
const item = slot.item;
const isEpisode = item.content_type === "episode";
// Headline: series name for episodes (fall back to episode title), film title otherwise
const title = isEpisode && item.series_name ? item.series_name : item.title;
// Subtitle: episode identifier + title, or year for films
let subtitle: string | null = null;
if (isEpisode) {
const epParts: string[] = [];
if (item.season_number != null) epParts.push(`S${item.season_number}`);
if (item.episode_number != null) epParts.push(`E${item.episode_number}`);
const epLabel = epParts.join(" · ");
subtitle = item.series_name
? [epLabel, item.title].filter(Boolean).join(" · ")
: epLabel || null;
} else if (item.year) {
subtitle = String(item.year);
}
const durationMins = Math.round(
(new Date(slot.end_at).getTime() - new Date(slot.start_at).getTime()) / 60_000,
);
return {
id: slot.id,
title,
subtitle,
durationMins,
startTime: fmtTime(slot.start_at),
endTime: fmtTime(slot.end_at),
isCurrent: slot.id === currentSlotId,
};
});
}
/**
* Find the slot immediately after the current one.
* Returns null if current slot is last or not found.
*/
export function findNextSlot(
slots: ScheduledSlotResponse[],
currentSlotId?: string,
): ScheduledSlotResponse | null {
if (!currentSlotId || slots.length === 0) return null;
const idx = slots.findIndex((s) => s.id === currentSlotId);
if (idx === -1 || idx === slots.length - 1) return null;
return slots[idx + 1];
}
// ---------------------------------------------------------------------------
// useStreamUrl — resolves the 307 stream redirect via a Next.js API route
// ---------------------------------------------------------------------------
/**
* Resolves the live stream URL for a channel, starting at the correct
* broadcast offset so refresh doesn't replay from the beginning.
*
* The backend's GET /channels/:id/stream endpoint returns a 307 redirect to
* the Jellyfin stream URL. Since browsers can't read redirect Location headers
* from fetch(), we proxy through /api/stream/[channelId] (a Next.js route that
* runs server-side) and return the final URL as JSON.
*
* slotId is included in the query key so the URL is re-fetched automatically
* when the current item changes (the next scheduled item starts playing).
* Within the same slot, the URL stays stable — no mid-item restarts.
*
* Returns null when the channel is in a gap (no-signal / 204).
*/
/**
* Resolves the stream URL for the current slot, with StartTimeTicks set so
* Jellyfin begins transcoding at the correct broadcast offset.
*
* slotId is in the query key: the URL refetches when the item changes (new
* slot), but stays stable while the same slot is playing — no mid-item
* restarts. offsetSecs is captured once when the query first runs for a
* given slot, so 30-second broadcast refetches don't disturb playback.
*/
export function useStreamUrl(
channelId: string | undefined,
token: string | null,
slotId: string | undefined,
channelPassword?: string,
blockPassword?: string,
quality?: string,
) {
return useQuery({
queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword, quality],
queryFn: async (): Promise<string | null> => {
const params = new URLSearchParams();
if (token) params.set("token", token);
if (channelPassword) params.set("channel_password", channelPassword);
if (blockPassword) params.set("block_password", blockPassword);
if (quality) params.set("quality", quality);
const res = await fetch(`/api/stream/${channelId}?${params}`, {
cache: "no-store",
});
if (res.status === 204) return null;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg = body?.error ?? `Stream resolve failed: ${res.status}`;
throw new Error(msg);
}
const { url } = await res.json();
return url as string;
},
enabled: !!channelId && !!slotId,
staleTime: Infinity,
retry: false,
});
}