142 lines
5.9 KiB
TypeScript
142 lines
5.9 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 }),
|
|
|
|
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,
|
|
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;
|