"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) => ({ id: slot.id, title: slot.item.title, 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, ) { return useQuery({ queryKey: ["stream-url", channelId, slotId], queryFn: async (): Promise => { const params = new URLSearchParams(); if (token) params.set("token", token); const res = await fetch(`/api/stream/${channelId}?${params}`, { cache: "no-store", }); if (res.status === 204) return null; if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`); const { url } = await res.json(); return url as string; }, enabled: !!channelId && !!slotId, staleTime: Infinity, retry: false, }); }