Web frontend

This commit is contained in:
2026-08-27 12:32:20 +02:00
parent d44c24ec97
commit edb6e5e027
97 changed files with 9535 additions and 195 deletions

43
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,43 @@
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
import type { Album, PlayerState, Settings } 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),
});
}
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) }),
};
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;

65
web/src/api/types.ts Normal file
View File

@@ -0,0 +1,65 @@
/** The shapes the backend serves. Mirrors `musicmouse/services/web/schemas.py`. */
export type AlbumKind = "music" | "book";
export interface TrackAnalysis {
tempo: number | null;
energy: number | null;
valence: number | null;
brightness: number | null;
beats: boolean;
}
export interface Track {
title: string;
/** Seconds, read from the file's tags at scan time. */
duration: number;
analysis: TrackAnalysis | null;
}
export interface Album {
id: string;
section: string;
kind: AlbumKind;
title: string;
artist: string;
series: string | null;
figure: string | null;
/** Series for audiobooks, artist for music. What the browse view groups by. */
category: string;
/** Three `#rrggbb`, taken from the cover art or synthesised: primary, secondary, accent. */
colors: string[];
has_cover: boolean;
duration: number;
tracks: Track[];
}
export interface PlayerState {
playing: boolean;
album_id: string | null;
album_title: string | null;
artist: string | null;
kind: AlbumKind | null;
track_index: number;
track_title: string | null;
track_count: number;
position: number;
duration: number;
/** Percent, 0..100. The device's configured range never leaves the backend. */
volume: number;
active_figure: string | null;
connected: { firmware: boolean; mqtt: boolean };
}
export interface Settings {
min_volume: number;
max_volume: number;
initial_volume: number;
volume_increment: number;
button_leds_brightness: number;
}
export type ServerMessage =
| { type: "state"; state: PlayerState }
| { type: "position"; position: number; duration: number }
| { type: "library" };