Files
musicmouse/web/src/api/client.ts
Martin Bauer a55cc4f67f Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer
Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big
analog watch over whatever page is open, built from the Delphin Uhr mockup
without its swimming dolphin. The overlay stays mounted and is only hidden, so it
appears at once. Beside the watch, only while they run:

- The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25)
  that starts `bus_lead_minutes` (60) before. School days skip weekends and the
  school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily
  from openholidaysapi.org and cached under the library cache. A badge on the
  clock says whether there is school today, and why not.
- The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time).
  It lives in the backend, so a parent's phone can start it for the kiosk. When it
  runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the
  device; it clears after a minute, or at the next key, tap or remote press.

Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button
next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay
bookmarked. The timer comes first there, the device settings below it.

Unmapped IR buttons are logged at debug level, to find out what irserver calls a
new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:45:45 +02:00

152 lines
6.5 KiB
TypeScript

/** 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<T>(path: string, init?: RequestInit): Promise<T> {
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<void> {
return request<void>(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<T>(path: string): Promise<T | null> {
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<T>(path: string): Promise<T | null> {
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<HaConfig | null> => fetchOrNullOn404<HaConfig>("/ha");
/** `null` means the IR remote isn't configured, not an error. */
const fetchLircConfig = (): Promise<LircConfig | null> => fetchOrNullOn404<LircConfig>("/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<TippenCurriculum | null> =>
fetchOrNullOn404<TippenCurriculum>("/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<HaEntityState | null> =>
fetchOrNullOnError<HaEntityState>(`/ha/states/${entityId}`);
async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEntityState>> {
const results = await Promise.all(entityIds.map(fetchHaState));
const byId: Record<string, HaEntityState> = {};
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<TrackDetail | null> =>
fetchOrNullOnError<TrackDetail>(`/tracks/${albumId}/${trackIndex}/analysis`);
export const api = {
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
state: () => request<PlayerState>("/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 }),
/** One step of the IR remote's volume keys per unit, in the device's own increment. */
stepVolume: (steps: number) => post("/volume", { steps }),
toggleScreen: () => post("/screen/toggle"),
/** Someone is at the kiosk: holds off the screen's idle auto-off. */
screenActivity: () => post("/screen/activity"),
/** The parents' countdown: minutes from now, or until a time of day ("HH:MM"). */
setTimer: (timer: { minutes: number } | { until: string }) => post("/timer", timer),
clearTimer: () => request<void>("/timer", { method: "DELETE" }),
settings: () => request<Settings>("/settings"),
saveSettings: (settings: Settings) =>
request<Settings>("/settings", { method: "PUT", body: JSON.stringify(settings) }),
haConfig: fetchHaConfig,
haStates: fetchHaStates,
haCallService: (domain: string, service: string, body: Record<string, unknown>) =>
post(`/ha/services/${domain}/${service}`, body),
trackDetail: fetchTrackDetail,
lircConfig: fetchLircConfig,
pressRemote: (digit: string) => post(`/remote/press/${digit}`),
remoteMapping: () => request<RemoteMapping>("/remote/mapping"),
saveRemoteMapping: (slots: Record<string, RemoteSlotInput>) =>
request<RemoteMapping>("/remote/mapping", { method: "PUT", body: JSON.stringify({ slots }) }),
tippenCurriculum: fetchTippenCurriculum,
tippenProgress: () => request<TippenProgress>("/tippen/progress"),
saveTippenSettings: (settings: TippenSettings) =>
request<TippenSettings>("/tippen/settings", { method: "PUT", body: JSON.stringify(settings) }),
recordTippenRun: (body: TippenRunInput) =>
request<TippenRunResult>("/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;