124 lines
4.5 KiB
TypeScript
124 lines
4.5 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) => ({
|
|
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<string | null> => {
|
|
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,
|
|
});
|
|
}
|