"use client"; 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 — HLS playlist from the Playout Service // --------------------------------------------------------------------------- const PLAYOUT_URL = process.env.NEXT_PUBLIC_PLAYOUT_URL ?? "http://localhost:9090"; export function useStreamUrl(channelId: string | undefined) { if (!channelId) return { data: null, isLoading: false, error: null }; return { data: `${PLAYOUT_URL}/playout/${channelId}/playlist.m3u8`, isLoading: false, error: null, }; }