/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */ import type { Album, HaConfig, HaEntityState, LircConfig, PlayerState, RemoteMapping, RemoteSlotInput, Settings, TippenCurriculum, TippenProgress, TippenRunInput, TippenRunResult, TippenSettings, TrackDetail, } from "./types"; async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`/api${path}`, { headers: init?.body ? { "Content-Type": "application/json" } : undefined, ...init, }); if (!response.ok) { throw new Error(`${init?.method ?? "GET"} ${path} failed: ${response.status}`); } return response.status === 204 ? (undefined as T) : ((await response.json()) as T); } function post(path: string, body?: unknown): Promise { return request(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body), }); } /** GET, treating a 404 as an expected "not configured" rather than an error - unlike * `request()`, which throws on it. Anything else that isn't ok still throws. */ async function fetchOrNullOn404(path: string): Promise { const response = await fetch(`/api${path}`); if (response.status === 404) return null; if (!response.ok) throw new Error(`GET ${path} failed: ${response.status}`); return (await response.json()) as T; } /** GET, treating *any* non-ok response as "nothing to show" rather than an error - for * callers that poll and would rather fall back quietly than crash the poll loop. */ async function fetchOrNullOnError(path: string): Promise { const response = await fetch(`/api${path}`); if (!response.ok) return null; return (await response.json()) as T; } /** `null` means the room-control page isn't configured, not an error. */ const fetchHaConfig = (): Promise => fetchOrNullOn404("/ha"); /** `null` means the IR remote isn't configured, not an error. */ const fetchLircConfig = (): Promise => fetchOrNullOn404("/lirc"); /** `null` means the typing game isn't configured, not an error - the tab still shows, * just with a "not set up" placeholder instead of a lesson map. */ const fetchTippenCurriculum = (): Promise => fetchOrNullOn404("/tippen/curriculum"); /** `null` covers both "unknown to Home Assistant" and "Home Assistant unreachable * right now" (the backend answers the latter with a 502) - the room page treats a * device with no state the same way either way, rather than crashing on a poll. */ const fetchHaState = (entityId: string): Promise => fetchOrNullOnError(`/ha/states/${entityId}`); async function fetchHaStates(entityIds: string[]): Promise> { const results = await Promise.all(entityIds.map(fetchHaState)); const byId: Record = {}; results.forEach((state, index) => { if (state) byId[entityIds[index]!] = state; }); return byId; } /** `null` means "not analyzed" (the backend's expected 404 for this), not an error - * the ambient background just falls back to its un-analyzed baseline for that track. */ const fetchTrackDetail = (albumId: string, trackIndex: number): Promise => fetchOrNullOnError(`/tracks/${albumId}/${trackIndex}/analysis`); export const api = { library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums), state: () => request("/state"), refreshLibrary: () => post("/library/refresh"), play: (albumId: string, trackIndex = 0) => post("/play", { album_id: albumId, track_index: trackIndex }), resume: () => post("/resume"), pause: () => post("/pause"), next: () => post("/next"), previous: () => post("/previous"), seek: (position: number) => post("/seek", { position }), setVolume: (percent: number) => post("/volume", { percent }), nudgeVolume: (deltaPercent: number) => post("/volume", { delta_percent: deltaPercent }), settings: () => request("/settings"), saveSettings: (settings: Settings) => request("/settings", { method: "PUT", body: JSON.stringify(settings) }), haConfig: fetchHaConfig, haStates: fetchHaStates, haCallService: (domain: string, service: string, body: Record) => post(`/ha/services/${domain}/${service}`, body), trackDetail: fetchTrackDetail, lircConfig: fetchLircConfig, remoteMapping: () => request("/remote/mapping"), saveRemoteMapping: (slots: Record) => request("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }), tippenCurriculum: fetchTippenCurriculum, tippenProgress: () => request("/tippen/progress"), saveTippenSettings: (settings: TippenSettings) => request("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }), recordTippenRun: (body: TippenRunInput) => request("/tippen/runs", { method: "POST", body: JSON.stringify(body) }), }; /** Cover URL. `thumb` is the small file for cards and rows; only the play view needs * `full`. With a `version` (`Album.cover_v`) the URL names one exact set of bytes and the * server marks it immutable, so the browser never asks for it twice; without one it * revalidates on every use. */ export function coverUrl( albumId: string, { size = "full", version }: { size?: "thumb" | "full"; version?: number } = {}, ): string { const params: string[] = []; if (size === "thumb") params.push("size=thumb"); if (version) params.push(`v=${version}`); return `/api/albums/${albumId}/cover${params.length ? `?${params.join("&")}` : ""}`; } /** Largest `Cover` `size` (CSS px) that is served the thumbnail; the play view's 340 gets * the full file. Keep in step with `THUMB_COVER_PX` in the backend's `library/cache.py`. */ export const THUMB_MAX_SIZE = 220;