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

3
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo

120
web/README.md Normal file
View File

@@ -0,0 +1,120 @@
# Musik Delphin — the web front-end
Browse the whole music collection and play any of it from a browser: the six figurines
are only five of the twenty-odd albums on the shelf, and everything else was previously
unreachable without swapping files around on disk.
Built from `../claude-design/Dolphin Beats.dc.html`, which is a finished interaction and
visual spec rather than a sketch. The keyboard model, the layout, the colours and the
German copy are ported from it; what changed is that a real player sits behind it.
## Running it
The backend serves the API. Either of these will do - `--simulate` needs no audio device
and no libVLC at all, which makes it the easier one to develop against:
```sh
cd ../python-backend
python -m musicmouse --config ./config.yml --no-hardware # real audio
python -m musicmouse --config ./config.yml --simulate # fake player
```
Then:
```sh
npm install
npm run dev # http://localhost:5173, /api and the websocket proxied to :8080
```
For the device, `npm run build` writes `dist/`, and `general.web.static_dir` in
`config.yml` points at it so one process serves both.
## Checks
```sh
npm run test # vitest over src/lib
npx tsc --noEmit # strict, noUncheckedIndexedAccess
```
Only the pure modules are tested - the search, the keyboard machine and the time
formatting. That is where the behaviour lives; the components are mostly layout.
## How it is put together
The split that matters: **what is playing** comes from the backend, **what you are
looking at** lives in the browser. The mockup kept both in one state object, but a real
player has other front-ends - the buttons on the mouse, a figurine on the reader, Home
Assistant - and this UI has to follow them rather than assume it is in charge. So
`isPlaying`, `volume`, the position and the current album all arrive over the websocket,
and only `search / mode / filter / category / selIndex / view / openAlbumId / showHelp`
are local.
| Path | What it is |
|---|---|
| `src/lib/keyboard.ts` | The key map, as a pure function from a keypress to a list of actions. Testable without a DOM. |
| `src/lib/search.ts` | Filtering and the three-level browse hierarchy. The whole library is in memory, so this is instant. |
| `src/lib/covers.ts` | Generated cover art from the album's three colours, and the book-spine treatment. |
| `src/hooks/usePlayerState.ts` | The websocket, with reconnect-and-reseed. |
| `src/hooks/usePlaybackClock.ts` | Interpolates the 2 Hz position onto animation frames. |
| `src/components/` | Layout only. |
`src/api/types.ts` mirrors `musicmouse/services/web/schemas.py`; the two are hand-kept in
step.
## Keyboard
The device is meant to be driven from a keyboard, so every screen is reachable without a
pointer. `F1` shows the same list in-app.
| Key | |
|---|---|
| `SPACE` | play / pause |
| `CTRL+H` / `CTRL+L` | previous / next track |
| `CTRL+J` / `CTRL+K` | quieter / louder |
| `SHIFT+←` / `SHIFT+→` | seek 15 s |
| `A-Z`, `0-9` | search albums as you type |
| `?` | switch to searching individual tracks |
| `TAB` | cycle all / music / audiobooks |
| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing |
| `ENTER` | play the selection |
| `ESC` | back out one layer at a time |
| `/` | back to browsing |
| `F1` | this list |
`SHIFT+arrows` is the one addition to the mockup: plain arrows were already taken, and
seeking is the one thing a real player can do that the mockup could not.
## Parent mode
`?parentMode=1` opens a settings panel - volume limits, the rotary step, button
brightness, and a button to re-read the library. Saving writes the device's
`config.yml`.
It is hidden from the child, not protected from them: there is no authentication on the
endpoints behind it. See `../python-backend/README.md`.
## Installing it on a phone
It is a PWA: `public/manifest.webmanifest`, icons generated from the mascot, and a small
service worker in `public/sw.js`. On iOS, Share → *Zum Home-Bildschirm*; it then opens
full-screen with no Safari chrome, because iOS reads the `apple-mobile-web-app-*` meta
tags in `index.html` rather than the manifest.
Two things worth knowing:
- **The service worker only registers over HTTPS or on `localhost`.** At
`http://<mouse>:8080` on the LAN, iOS will still install the icon and run it
standalone - that part is the meta tags - but there is no offline caching. Put the
mouse behind a TLS-terminating proxy if you want that.
- **The worker deliberately caches very little**: the app shell, the font and cover art.
Not `/api/state`, not the library, not anything else live. A player that shows stale
state is worse than one that says it cannot reach the mouse.
Nunito is self-hosted in `public/fonts/` (one variable file per subset, ~74 kB) rather
than pulled from Google. The mouse may have no internet, and a cached shell rendering in
a fallback face looks broken.
## Not implemented
The mockup's second page, "Mein Zimmer" (Hue lights, a roller shutter and scenes), is
not built. Nothing here depends on it.

38
web/index.html Normal file
View File

@@ -0,0 +1,38 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<!-- viewport-fit=cover so the sea reaches under the iPhone's rounded corners;
user-scalable=no because a pinch-zoom on a big pink play button is a misfire. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no"
/>
<title>Musik Delphin</title>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0d2f3c" />
<meta name="description" content="Musik und Hörbücher für die MusicMouse" />
<!-- iOS does not read the manifest for home-screen installs; these are what it uses. -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Musik" />
<!-- black-translucent lets the page's own gradient run behind the status bar. -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/icon-192.png" type="image/png" />
<link rel="stylesheet" href="/fonts/nunito.css" />
<link
rel="preload"
href="/fonts/nunito-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2333
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
web/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "musicmouse-web",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"check": "tsc -b --noEmit false --emitDeclarationOnly false && eslint . || tsc -b",
"test": "vitest run"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.9.0",
"vite": "^7.1.0",
"vitest": "^3.2.0"
},
"allowScripts": {
"esbuild@0.28.2": true
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
/* Nunito, self-hosted: one variable file per subset covers 500-900.
Google Fonts would be a network round trip the mouse may not have, and a
cached app shell rendered in the fallback face looks broken. latin-ext is
what carries "Hörbücher" and "Käpt'n"; the Cyrillic and Vietnamese subsets
Google also offers are dropped, since nothing in this UI can produce them. */
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 500 900;
font-display: swap;
src: url("/fonts/nunito-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 500 900;
font-display: swap;
src: url("/fonts/nunito-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

BIN
web/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
web/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -0,0 +1,22 @@
{
"name": "Musik Delphin",
"short_name": "Musik",
"description": "Musik und Hörbücher für die MusicMouse",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0d2f3c",
"theme_color": "#0d2f3c",
"lang": "de",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

79
web/public/sw.js Normal file
View File

@@ -0,0 +1,79 @@
// A deliberately small service worker: it exists so iOS treats this as an installable
// app and so the shell survives a flaky wifi moment. It does NOT try to make the player
// work offline - the audio, the library and the state all live on the mouse, and a
// cached answer for any of those would be a lie.
const SHELL = "musikdelphin-shell-v1";
const COVERS = "musikdelphin-covers-v1";
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL)
.then((cache) =>
cache.addAll([
"/",
"/manifest.webmanifest",
"/dolphin-mascot.png",
"/fonts/nunito.css",
"/fonts/nunito-latin.woff2",
"/fonts/nunito-latin-ext.woff2",
]),
)
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((names) =>
Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Cover art is immutable per album id and is the only heavy thing here.
if (url.pathname.startsWith("/api/albums/")) {
event.respondWith(
caches.open(COVERS).then(async (cache) => {
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}),
);
return;
}
// Everything else under /api is live state. Never cache it, never serve it stale.
if (url.pathname.startsWith("/api/")) return;
// The shell: network first so a rebuilt frontend is picked up on the next load,
// falling back to the cache when the mouse is off or out of range.
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(SHELL).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(async () => {
const hit = await caches.match(request);
// A client-side app: any in-scope navigation resolves to the one document.
return hit ?? (await caches.match("/")) ?? Response.error();
}),
);
});

322
web/src/App.tsx Normal file
View File

@@ -0,0 +1,322 @@
/** Owns the browse state, mounts the keyboard handler, and switches between views.
*
* The split that matters: everything about *what is playing* comes from the backend
* over the websocket, and everything about *what you are looking at* lives here. The
* mockup kept both in one object; a real player has other front-ends - the buttons on
* the mouse, a figure on the reader, Home Assistant - and this UI has to follow them.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api/client";
import type { Album } from "./api/types";
import { AlbumModal } from "./components/AlbumModal";
import { AppHeader } from "./components/AppHeader";
import { BrowseView } from "./components/BrowseView";
import { Bubbles } from "./components/Bubbles";
import { HelpOverlay } from "./components/HelpOverlay";
import { ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar";
import { PlayView } from "./components/PlayView";
import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary";
import { usePlaybackClock } from "./hooks/usePlaybackClock";
import { usePlayerState } from "./hooks/usePlayerState";
import type { Action, UiState } from "./lib/keyboard";
import { handleKey, initialUiState } from "./lib/keyboard";
import { playPop } from "./lib/pop";
import type { Filter, Results, SongHit } from "./lib/search";
import { results as computeResults } from "./lib/search";
/** Volume when un-muting, matching the mockup. */
const UNMUTE_PERCENT = 60;
export function App() {
const [ui, setUi] = useState<UiState>(initialUiState);
const library = useLibrary();
const connection = usePlayerState(library.reload);
const [gridRef, columns] = useGridColumns();
const [parentMode, setParentMode] = useState(
() => new URLSearchParams(location.search).get("parentMode") === "1",
);
const state = connection.state;
const position = usePlaybackClock(state?.position ?? 0, state?.playing ?? false);
useEffect(() => {
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
}, [columns]);
const results: Results = useMemo(
() =>
computeResults({
albums: library.albums,
search: ui.search,
mode: ui.mode,
filter: ui.filter,
category: ui.category,
}),
[library.albums, ui.search, ui.mode, ui.filter, ui.category],
);
const byId = useMemo(
() => new Map(library.albums.map((album) => [album.id, album])),
[library.albums],
);
const currentAlbum: Album | null = state?.album_id ? (byId.get(state.album_id) ?? null) : null;
const openAlbum: Album | null = ui.openAlbumId ? (byId.get(ui.openAlbumId) ?? null) : null;
const play = useCallback((albumId: string, trackIndex: number) => {
playPop(300);
setUi((previous) => ({ ...previous, view: "play", openAlbumId: null }));
void api.play(albumId, trackIndex);
}, []);
const setVolume = useCallback(
(percent: number) => {
const clamped = Math.max(0, Math.min(100, percent));
// Optimistic, so the bars move on the keypress rather than a frame later.
connection.optimistic({ volume: clamped });
void api.setVolume(clamped);
},
[connection],
);
const toggle = useCallback(() => {
if (!state) return;
if (state.album_id === null) {
// Nothing loaded yet: space starts whatever is on screen, as in the mockup.
const first = results.albums[0] ?? library.albums[0];
if (first) play(first.id, 0);
return;
}
connection.optimistic({ playing: !state.playing });
void (state.playing ? api.pause() : api.resume());
}, [connection, library.albums, play, results.albums, state]);
/** The one place a keyboard action, a click or a tap all end up. */
const run = useCallback(
(actions: Action[]) => {
for (const action of actions) {
switch (action.type) {
case "ui":
setUi((previous) => ({ ...previous, ...action.patch }));
break;
case "play":
play(action.albumId, action.trackIndex);
break;
case "toggle":
toggle();
break;
case "next":
playPop(260);
void api.next();
break;
case "previous":
playPop(260);
void api.previous();
break;
case "volume":
setVolume((state?.volume ?? 0) + action.delta);
break;
case "seek":
if (state?.duration) {
void api.seek(Math.max(0, Math.min(state.duration, position + action.delta)));
}
break;
case "pop":
playPop(action.freq);
break;
}
}
},
[play, position, setVolume, state, toggle],
);
// Held in a ref so the listener is installed once rather than on every state change.
const latest = useRef({ ui, results, run });
latest.current = { ui, results, run };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
// Parent mode has real text fields; typing a title into one must not start a song.
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
const current = latest.current;
const actions = handleKey(event, current.ui, current.results);
if (!actions.length) return;
event.preventDefault();
current.run(actions);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const onFilter = (filter: Filter) => {
playPop(380);
setUi((previous) => ({
...previous,
filter,
selIndex: 0,
view: "browse",
category: null,
}));
};
const onCategory = (key: string | null) => {
if (key) playPop(440);
setUi((previous) => ({ ...previous, category: key, selIndex: 0 }));
};
const onOpenAlbum = (album: Album, navIndex: number) => {
playPop(420);
setUi((previous) => ({ ...previous, openAlbumId: album.id, selIndex: navIndex }));
};
const onPlaySong = (hit: SongHit) => play(hit.album.id, hit.index);
const onSeek = (target: number) => void api.seek(target);
const onMute = () => setVolume(state && state.volume > 0 ? 0 : UNMUTE_PERCENT);
return (
<div className="stage">
<Bubbles />
{ui.view === "browse" && state && (
<div
style={{
position: "relative",
zIndex: 1,
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<AppHeader status={<ConnectionDot online={connection.online} state={state} />} />
<BrowseView
results={results}
filter={ui.filter}
mode={ui.mode}
search={ui.search}
category={ui.category}
selIndex={ui.selIndex}
currentAlbumId={state.album_id}
gridRef={gridRef}
onFilter={onFilter}
onCategory={onCategory}
onOpenAlbum={onOpenAlbum}
onPlaySong={onPlaySong}
/>
</div>
)}
{ui.view === "play" && state && (
<PlayView
state={state}
album={currentAlbum}
position={position}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}
onSeek={onSeek}
onVolume={setVolume}
onMute={onMute}
onBrowse={() => setUi((previous) => ({ ...previous, view: "browse" }))}
/>
)}
{openAlbum && state && (
<AlbumModal
album={openAlbum}
currentAlbumId={state.album_id}
currentTrackIndex={state.track_index}
onClose={() => setUi((previous) => ({ ...previous, openAlbumId: null }))}
onPlay={(trackIndex) => play(openAlbum.id, trackIndex)}
/>
)}
<button
onClick={() => setUi((previous) => ({ ...previous, showHelp: !previous.showHelp }))}
title="Zaubertasten (F1)"
style={{
position: "absolute",
top: 18,
right: 24,
zIndex: 3,
width: 44,
height: 44,
background: "oklch(97% 0.01 210 / .95)",
border: "none",
borderRadius: 999,
boxShadow: "0 6px 18px oklch(15% 0.05 210 / .4)",
fontSize: 20,
fontWeight: 900,
color: "var(--ink)",
cursor: "pointer",
}}
>
?
</button>
{ui.showHelp && (
<HelpOverlay onClose={() => setUi((previous) => ({ ...previous, showHelp: false }))} />
)}
{ui.view === "browse" && state && (
<PlayerBar
state={state}
album={currentAlbum}
position={position}
onToggle={toggle}
onNext={() => run([{ type: "next" }])}
onPrevious={() => run([{ type: "previous" }])}
onSeek={onSeek}
onVolume={setVolume}
onMute={onMute}
onOpenPlayView={() => setUi((previous) => ({ ...previous, view: "play" }))}
/>
)}
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
{(!state || library.loading) && <Splash error={library.error} />}
</div>
);
}
function ConnectionDot({ online, state }: { online: boolean; state: { active_figure: string | null } }) {
const label = !online ? "Keine Verbindung" : state.active_figure ? `🧸 ${state.active_figure}` : null;
if (!label) return null;
return (
<div
style={{
fontSize: 14,
fontWeight: 800,
color: online ? "var(--paper)" : "oklch(75% 0.16 30)",
background: "oklch(97% 0.01 210 / .16)",
padding: "6px 14px",
borderRadius: 999,
}}
>
{label}
</div>
);
}
function Splash({ error }: { error: string | null }) {
return (
<div
className="overlay"
style={{ zIndex: 10, background: "oklch(20% 0.045 210 / .92)", flexDirection: "column", gap: 18 }}
>
<img
src="/dolphin-mascot.png"
alt=""
style={{ width: 140, height: 140, objectFit: "contain", animation: "dolphinBob 1.6s ease-in-out infinite" }}
/>
<div style={{ fontSize: 22, fontWeight: 800, color: "var(--paper)" }}>
{error ? "Der Delphin ist nicht erreichbar …" : "Einen Moment …"}
</div>
</div>
);
}

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" };

View File

@@ -0,0 +1,189 @@
/** The album detail sheet: cover, metadata, and a numbered track list. */
import type { Album } from "../api/types";
import { isBook, unitLabel } from "../lib/covers";
import { clock } from "../lib/format";
import { Cover } from "./Cover";
interface Props {
album: Album;
currentAlbumId: string | null;
currentTrackIndex: number;
onClose: () => void;
onPlay: (trackIndex: number) => void;
}
export function AlbumModal({
album,
currentAlbumId,
currentTrackIndex,
onClose,
onPlay,
}: Props) {
const book = isBook(album);
return (
<div className="overlay" style={{ zIndex: 4, background: "oklch(15% 0.03 210 / .6)" }} onClick={onClose}>
<div
onClick={(event) => event.stopPropagation()}
style={{
background: "oklch(96% 0.012 210)",
borderRadius: 26,
padding: 26,
width: 640,
maxWidth: "100%",
maxHeight: "100%",
overflow: "auto",
boxShadow: "0 24px 60px oklch(10% 0.04 210 / .55)",
}}
>
<div style={{ display: "flex", gap: 20, alignItems: "flex-start" }}>
<div style={{ width: 150, flex: "none" }}>
<Cover album={album} size={150} radius={book ? "10px 18px 18px 10px" : "16px"} label />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 26,
fontWeight: 900,
color: "oklch(22% 0.03 210)",
lineHeight: 1.15,
}}
>
{album.title}
</div>
<div
style={{
fontSize: 15,
fontWeight: 700,
color: "oklch(45% 0.03 210 / .85)",
marginTop: 2,
}}
>
{album.artist}
</div>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(50% 0.03 210 / .7)",
marginTop: 2,
}}
>
{book ? "📖 Hörbuch" : "🎵 Musik"} · {unitLabel(album, album.tracks.length)} ·{" "}
{clock(album.duration)}
{album.figure && ` · 🧸 ${album.figure}`}
</div>
<button
onClick={() => onPlay(0)}
style={{
marginTop: 14,
border: "none",
cursor: "pointer",
background: "var(--accent)",
color: "#fff",
fontSize: 16,
fontWeight: 800,
padding: "12px 22px",
borderRadius: 999,
display: "flex",
alignItems: "center",
gap: 10,
boxShadow: "0 4px 14px oklch(70% 0.16 340 / .45)",
}}
>
<span
style={{
width: 14,
height: 16,
background: "#fff",
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
}}
/>
{book ? "Hörbuch abspielen" : "Alle Songs abspielen"}
</button>
</div>
<button
onClick={onClose}
aria-label="Schließen"
style={{
border: "none",
cursor: "pointer",
background: "oklch(88% 0.02 210)",
color: "var(--ink)",
fontSize: 18,
fontWeight: 900,
width: 40,
height: 40,
borderRadius: 999,
flex: "none",
}}
>
×
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 22 }}>
{album.tracks.map((track, index) => {
const current = currentAlbumId === album.id && currentTrackIndex === index;
return (
<button
key={index}
onClick={() => onPlay(index)}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "10px 14px",
borderRadius: 14,
cursor: "pointer",
border: "none",
font: "inherit",
textAlign: "left",
background: current
? "oklch(70% 0.16 340 / .16)"
: "oklch(30% 0.03 210 / .05)",
}}
>
<div
style={{
width: 30,
height: 30,
borderRadius: 999,
flex: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
font: "800 13px ui-monospace, Menlo, monospace",
background: current ? "var(--accent)" : "oklch(88% 0.02 210)",
color: current ? "#fff" : "oklch(35% 0.03 210)",
}}
>
{index + 1}
</div>
<div
style={{
flex: 1,
minWidth: 0,
fontSize: 16,
fontWeight: 800,
color: current ? "oklch(45% 0.16 340)" : "oklch(24% 0.03 210)",
}}
>
{track.title}
</div>
<div
style={{
font: "700 13px ui-monospace, Menlo, monospace",
color: "oklch(45% 0.03 210 / .7)",
}}
>
{clock(track.duration)}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
interface Props {
/** Shown next to the title when the mouse is reachable but the firmware is not. */
status?: ReactNode;
}
export function AppHeader({ status }: Props) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 16,
// The status bar is translucent in standalone mode, so make room for it.
padding: "calc(18px + env(safe-area-inset-top)) 32px 6px",
flex: "none",
}}
>
<img
src="/dolphin-mascot.png"
alt=""
style={{ width: 64, height: 64, objectFit: "contain" }}
/>
<div
style={{
fontSize: 34,
fontWeight: 900,
color: "var(--paper)",
textShadow: "0 3px 14px oklch(15% 0.05 210 / .5)",
}}
>
Musik Delphin
</div>
{status}
</div>
);
}

View File

@@ -0,0 +1,475 @@
/** The browse screen: filter pills, the search pill, and whichever of the three result
* lists applies. Selection indices run flat across songs, then categories, then albums,
* which is what lets one pair of arrow keys walk the whole page. */
import { useEffect, useRef } from "react";
import type { Album } from "../api/types";
import {
albumLine,
aspectOfKind,
cardBackground,
cardShadow,
isBook,
unitLabel,
} from "../lib/covers";
import { clock } from "../lib/format";
import type { Filter, Results, SongHit } from "../lib/search";
import { Cover } from "./Cover";
interface Props {
results: Results;
filter: Filter;
mode: "albums" | "tracks";
search: string;
category: string | null;
selIndex: number;
currentAlbumId: string | null;
gridRef: (element: HTMLElement | null) => void;
onFilter: (filter: Filter) => void;
onCategory: (key: string | null) => void;
onOpenAlbum: (album: Album, navIndex: number) => void;
onPlaySong: (hit: SongHit) => void;
}
const FILTERS: Array<[Filter, string]> = [
["all", "Alles"],
["music", "🎵 Musik"],
["book", "📖 Hörbücher"],
];
export function BrowseView({
results,
filter,
mode,
search,
category,
selIndex,
currentAlbumId,
gridRef,
onFilter,
onCategory,
onOpenAlbum,
onPlaySong,
}: Props) {
const scroller = useRef<HTMLDivElement | null>(null);
const { songs, categories, albums, total } = results;
const selected = total ? Math.min(selIndex, total - 1) : -1;
// Keep the selection on screen as the arrow keys walk past the fold.
useEffect(() => {
const box = scroller.current;
const element = box?.querySelector<HTMLElement>(`[data-nav-index="${selected}"]`);
if (!box || !element) return;
const top = element.offsetTop - box.offsetTop;
const bottom = top + element.offsetHeight;
const pad = 24;
if (top - pad < box.scrollTop) box.scrollTop = Math.max(0, top - pad);
else if (bottom + pad > box.scrollTop + box.clientHeight) {
box.scrollTop = bottom + pad - box.clientHeight;
}
}, [selected]);
const showSearchBar = search.length > 0 || mode === "tracks";
const countLabel =
mode === "tracks"
? `${songs.length} Titel gefunden`
: albums.length
? `${albums.length} Album${albums.length === 1 ? "" : "en"} gefunden`
: "nichts gefunden";
const sectionLabel =
filter === "book" ? "Hörbücher" : filter === "music" ? "Alben" : "Alben & Hörbücher";
const categoryLabel =
filter === "book" ? "Figuren" : filter === "music" ? "Künstler" : "Figuren & Künstler";
return (
<>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
padding: "4px 32px 6px",
flex: "none",
}}
>
{FILTERS.map(([value, label]) => (
<button
key={value}
className="pill"
data-active={filter === value}
onClick={() => onFilter(value)}
>
{label}
</button>
))}
</div>
{showSearchBar && (
<div
style={{
margin: "2px 32px 6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
flex: "none",
}}
>
<div
style={{
background: "var(--paper)",
color: "var(--ink)",
fontWeight: 800,
fontSize: 20,
padding: "10px 20px",
borderRadius: 999,
boxShadow: "0 4px 14px var(--shadow)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
fontSize: 14,
fontWeight: 800,
background: "var(--ink)",
color: "#fff",
padding: "4px 10px",
borderRadius: 999,
}}
>
{mode === "tracks" ? "♪ Titel" : "🔎 Alben"}
</span>
<span>{search ? `"${search}"` : "tippe …"}</span>
</div>
<div style={{ color: "oklch(90% 0.02 210 / .85)", fontWeight: 700, fontSize: 14 }}>
{countLabel}
</div>
</div>
)}
<div
ref={scroller}
style={{ flex: 1, overflow: "auto", minHeight: 0, padding: "14px 32px 250px" }}
>
{songs.length > 0 && (
<div style={{ marginBottom: 30 }}>
<SectionTitle>
Titel <Muted>{songs.length} Treffer</Muted>
</SectionTitle>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
maxWidth: 760,
margin: "0 auto",
}}
>
{songs.map((hit, index) => (
<button
key={`${hit.album.id}-${hit.index}`}
data-nav-index={index}
onClick={() => onPlaySong(hit)}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "10px 16px",
borderRadius: 14,
cursor: "pointer",
border: "none",
textAlign: "left",
font: "inherit",
background: `oklch(97% 0.01 210 / ${
currentAlbumId === hit.album.id ? ".22" : ".10"
})`,
outline:
selected === index ? "4px solid var(--paper)" : undefined,
outlineOffset: 2,
}}
>
<div style={{ height: 40, flex: "none", display: "flex" }}>
<Cover
album={hit.album}
size={40}
fit="height"
radius={isBook(hit.album) ? "5px 10px 10px 5px" : "10px"}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 800, color: "var(--paper)" }}>
{hit.title}
</div>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .65)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{albumLine(hit.album)}
</div>
</div>
<div
style={{
font: "700 13px ui-monospace, Menlo, monospace",
color: "oklch(88% 0.02 210 / .6)",
}}
>
{clock(hit.duration)}
</div>
</button>
))}
</div>
</div>
)}
{categories.length > 0 && (
<div>
<SectionTitle centered>{categoryLabel}</SectionTitle>
<div className="grid" ref={gridRef}>
{categories.map((entry, index) => {
const navIndex = songs.length + index;
const books = entry.albums.filter(isBook).length;
const allBooks = books === entry.albums.length;
const mostlyBooks = books * 2 > entry.albums.length;
const shown = entry.albums.slice(0, 4);
return (
<button
key={entry.key}
className="card"
data-nav-index={navIndex}
data-selected={selected === navIndex}
onClick={() => onCategory(entry.key)}
style={{
background: allBooks
? "oklch(93% 0.055 88 / .7)"
: "oklch(95% 0.015 210 / .66)",
borderRadius: allBooks ? "6px 18px 18px 6px" : "16px",
boxShadow: "0 6px 18px var(--shadow)",
}}
>
<div
style={{
display: "grid",
// A category of one gets one full-bleed cover rather than a
// 2x2 grid with three empty holes in it.
// Always a full 2x2 for more than one, so every cell has the
// same shape as the tile and the covers fill it exactly. Two
// albums in a single row would each be twice as wide as their
// cell and spill out of it.
gridTemplateColumns: shown.length === 1 ? "1fr" : "1fr 1fr",
gridTemplateRows: shown.length === 1 ? "1fr" : "1fr 1fr",
gap: 4,
padding: 8,
// The tile takes the shape of what it holds, so a shelf of
// audiobooks is visibly taller than a shelf of albums.
aspectRatio: aspectOfKind(mostlyBooks ? "book" : "music"),
}}
>
{shown.map((album) => (
<div
key={album.id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<Cover
album={album}
size={shown.length === 1 ? 160 : 70}
fit="height"
radius="4px"
label={shown.length === 1}
/>
</div>
))}
</div>
<div style={{ padding: "4px 12px 14px" }}>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
}}
>
{entry.key}
</div>
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "oklch(40% 0.17 340)",
marginTop: 5,
}}
>
{entry.albums.length}{" "}
{allBooks
? entry.albums.length === 1
? "Hörbuch"
: "Hörbücher"
: entry.albums.length === 1
? "Album"
: "Alben"}
</div>
</div>
</button>
);
})}
</div>
</div>
)}
{albums.length > 0 && (
<div>
{category !== null && !search && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
marginBottom: 14,
}}
>
<button
className="pill"
data-active="true"
onClick={() => onCategory(null)}
>
Alle
</button>
<div style={{ fontSize: 22, fontWeight: 900, color: "var(--paper)" }}>
{category}
</div>
</div>
)}
{search.length > 0 && <SectionTitle centered>{sectionLabel}</SectionTitle>}
<div className="grid" ref={gridRef}>
{albums.map((album, index) => {
const navIndex = songs.length + categories.length + index;
return (
<button
key={album.id}
className="card"
data-nav-index={navIndex}
data-selected={selected === navIndex}
data-current={album.id === currentAlbumId}
onClick={() => onOpenAlbum(album, navIndex)}
style={{
background: cardBackground(album),
borderRadius: isBook(album) ? "6px 18px 18px 6px" : "16px",
boxShadow: cardShadow(album),
}}
>
<Cover album={album} size={180} radius="0" label />
<div
style={{
padding: "10px 12px 14px",
paddingRight: isBook(album) ? 22 : 12,
}}
>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "oklch(22% 0.03 210)",
lineHeight: 1.2,
}}
>
{album.title}
</div>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "oklch(30% 0.03 210)",
marginTop: 2,
}}
>
{album.artist}
</div>
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "oklch(40% 0.17 340)",
marginTop: 6,
}}
>
{unitLabel(album, album.tracks.length)}
{album.figure && " · 🧸"}
</div>
</div>
</button>
);
})}
</div>
</div>
)}
{total === 0 && (
<div
style={{ textAlign: "center", marginTop: 60, color: "oklch(92% 0.02 210 / .8)" }}
>
<img
src="/dolphin-mascot.png"
alt=""
style={{ width: 120, height: 120, objectFit: "contain", opacity: 0.9 }}
/>
<div style={{ fontSize: 22, fontWeight: 800, marginTop: 10 }}>
Nichts gefunden probier andere Buchstaben!
</div>
</div>
)}
</div>
</>
);
}
function SectionTitle({
children,
centered,
}: {
children: React.ReactNode;
centered?: boolean;
}) {
return (
<div
style={{
fontSize: 19,
fontWeight: 900,
color: "var(--paper)",
marginBottom: 12,
textAlign: centered ? "center" : "left",
display: centered ? "block" : "flex",
alignItems: "baseline",
justifyContent: "center",
gap: 10,
}}
>
{children}
</div>
);
}
function Muted({ children }: { children: React.ReactNode }) {
return (
<span style={{ fontSize: 13, fontWeight: 700, color: "oklch(88% 0.02 210 / .7)" }}>
{children}
</span>
);
}

View File

@@ -0,0 +1,32 @@
/** The ambient bubble field. Positions are fixed so they do not reshuffle on render. */
const BUBBLES: Array<[left: number, size: number, alpha: number, seconds: number, delay: number]> =
[
[5, 14, 0.5, 9, 0], [15, 22, 0.4, 12, 2], [28, 10, 0.5, 7, 1], [42, 18, 0.45, 10, 4],
[58, 12, 0.5, 8, 3], [70, 26, 0.35, 13, 5], [82, 16, 0.5, 9.5, 1.5], [92, 10, 0.5, 6.5, 2.5],
[2, 8, 0.45, 7.5, 3.5], [9, 18, 0.3, 14, 6], [21, 12, 0.5, 8.5, 5.5], [34, 24, 0.32, 12.5, 1.2],
[38, 9, 0.5, 6.8, 4.6], [48, 14, 0.42, 10.5, 0.6], [53, 20, 0.3, 13.5, 7], [63, 8, 0.5, 7.2, 2.2],
[66, 16, 0.38, 11.5, 5.2], [76, 11, 0.48, 9.2, 6.4], [87, 22, 0.28, 15, 3.2],
[96, 14, 0.42, 10.8, 8], [45, 7, 0.5, 6.2, 7.6],
];
export function Bubbles() {
return (
<>
{BUBBLES.map(([left, size, alpha, seconds, delay], index) => (
<div
key={index}
className="bubble"
style={{
left: `${left}%`,
width: size,
height: size,
background: `oklch(90% 0.02 210 / ${alpha})`,
animationDuration: `${seconds}s`,
animationDelay: `${delay}s`,
}}
/>
))}
</>
);
}

View File

@@ -0,0 +1,90 @@
/** Album art, or a generated stand-in built from the album's own three colours.
*
* The gradient sits underneath the image rather than instead of it, so a cover that is
* still loading - or an album that has none - never shows a hole.
*
* The shape comes from the media type and from nowhere else: square for an album,
* taller than wide for an audiobook. Callers pick which axis is fixed, never the ratio.
*/
import { coverUrl } from "../api/client";
import type { Album } from "../api/types";
import { aspectOf, coverBackground, isBook } from "../lib/covers";
interface Props {
album: Album;
/** Rendered size in px along the fixed axis; drives the stripe width so the generated
* pattern scales with the art. */
size: number;
/** Which axis the parent constrains. `height` keeps rows and grids even when a book
* and an album sit side by side. */
fit?: "width" | "height";
radius?: string;
className?: string;
style?: React.CSSProperties;
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
label?: boolean;
}
export function Cover({ album, size, fit = "width", radius, className, style, label }: Props) {
const book = isBook(album);
const defaultRadius = book ? "6px 18px 18px 6px" : "16px";
const box: React.CSSProperties =
fit === "height"
? { height: "100%", width: "auto", aspectRatio: aspectOf(album) }
: { width: "100%", aspectRatio: aspectOf(album) };
return (
<div
className={className}
style={{
position: "relative",
flex: "none",
borderRadius: radius ?? defaultRadius,
overflow: "hidden",
background: coverBackground(album, Math.max(6, Math.round(size / 13))),
...box,
...style,
}}
>
{!album.has_cover && label && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: book ? "10% 14% 10% 20%" : "10%",
textAlign: "center",
fontSize: Math.max(11, Math.round(size / 11)),
fontWeight: 900,
lineHeight: 1.15,
color: "oklch(99% 0 0 / .92)",
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
overflow: "hidden",
}}
>
{album.title}
</div>
)}
{album.has_cover && (
<img
src={coverUrl(album.id)}
alt=""
loading="lazy"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
// Books keep a sliver of spine showing on the right, so the shelf metaphor
// survives contact with real square artwork.
clipPath: book ? "inset(0 5% 0 0)" : undefined,
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
/** The Zaubertasten sheet (F1). Mirrors lib/keyboard.ts - if a binding changes there,
* it changes here. */
const KEYS: Array<[caps: string[], label: string]> = [
[["LEER"], "Play / Pause"],
[["CTRL+H", "CTRL+L"], "Song zurück / vor"],
[["CTRL+J", "CTRL+K"], "Leiser / lauter"],
[["⇧←", "⇧→"], "15 Sekunden zurück / vor"],
[["A-Z"], "Album oder Hörbuch suchen"],
[["?"], "Einzelne Titel suchen"],
[["F1"], "Diese Hilfe"],
[["TAB"], "Musik / Hörbücher / alles"],
[["← ↑ ↓ →"], "Auswahl bewegen"],
[["ENTER"], "Auswahl abspielen"],
[["ESC"], "Schließen / Suche löschen"],
];
export function HelpOverlay({ onClose }: { onClose: () => void }) {
return (
<div className="overlay" style={{ zIndex: 5 }} onClick={onClose}>
<div className="sheet" style={{ padding: "26px 30px", width: 330 }}>
<div
style={{
fontSize: 17,
fontWeight: 800,
color: "var(--ink)",
marginBottom: 14,
letterSpacing: 0.3,
}}
>
Zaubertasten
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{KEYS.map(([caps, label]) => (
<div key={label} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ display: "flex", gap: 4 }}>
{caps.map((cap) => (
<div key={cap} className="key-cap" style={{ minWidth: caps.length > 1 ? 0 : 56 }}>
{cap}
</div>
))}
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: "oklch(35% 0.03 210)" }}>
{label}
</div>
</div>
))}
</div>
<div
style={{
marginTop: 16,
fontSize: 12,
fontWeight: 600,
color: "oklch(50% 0.03 210 / .8)",
textAlign: "center",
}}
>
Tippe irgendwohin, um zu schließen
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,167 @@
/** Parent mode: reachable only via `?parentMode=1`, never from the child-facing UI.
*
* This hides the settings, it does not protect them - there is no login, and the
* endpoints behind it are as open as the rest of the API. That matches the device's
* threat model (a box on a home network) and is stated in the README rather than
* implied.
*/
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { Settings } from "../api/types";
const FIELDS: Array<[keyof Settings, string, string, number, number, number]> = [
["min_volume", "Minimale Lautstärke", "Untergrenze am Gerät (0-200)", 0, 200, 1],
["max_volume", "Maximale Lautstärke", "Was 100 % in der Kinder-Ansicht bedeutet", 0, 200, 1],
["initial_volume", "Start-Lautstärke", "Beim Einschalten", 0, 200, 1],
["volume_increment", "Schritt am Drehknopf", "Pro Rasterung", 1, 100, 1],
["button_leds_brightness", "Tastenbeleuchtung", "0 bis 1", 0, 1, 0.05],
];
export function ParentPanel({ onClose }: { onClose: () => void }) {
const [settings, setSettings] = useState<Settings | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api
.settings()
.then(setSettings)
.catch((cause: unknown) => setStatus(String(cause)));
}, []);
const save = async () => {
if (!settings) return;
setBusy(true);
setStatus(null);
try {
setSettings(await api.saveSettings(settings));
setStatus("Gespeichert.");
} catch {
// The backend rejects an inverted range or an out-of-band start volume.
setStatus("Nicht gespeichert — bitte die Werte prüfen (min ≤ Start ≤ max).");
} finally {
setBusy(false);
}
};
const rescan = async () => {
setStatus("Bibliothek wird eingelesen …");
await api.refreshLibrary();
setStatus("Einlesen gestartet. Neue Titel erscheinen gleich von selbst.");
};
return (
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 210 / .75)" }}>
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
🔧 Eltern-Einstellungen
</div>
<button
onClick={onClose}
aria-label="Schließen"
style={{
border: "none",
cursor: "pointer",
background: "oklch(88% 0.02 210)",
color: "var(--ink)",
fontSize: 18,
fontWeight: 900,
width: 36,
height: 36,
borderRadius: 999,
}}
>
×
</button>
</div>
{settings === null ? (
<div style={{ marginTop: 18, color: "oklch(45% 0.03 210)" }}>Wird geladen </div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
{FIELDS.map(([key, label, hint, min, max, step]) => (
<label key={key} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 14, fontWeight: 800, color: "var(--ink)" }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: "oklch(50% 0.03 210)" }}>
{hint}
</span>
<input
type="number"
min={min}
max={max}
step={step}
value={settings[key]}
onChange={(event) =>
setSettings({ ...settings, [key]: Number(event.target.value) })
}
style={{
padding: "8px 12px",
borderRadius: 10,
border: "2px solid oklch(85% 0.02 210)",
fontSize: 16,
fontWeight: 700,
}}
/>
</label>
))}
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
<button
onClick={() => void save()}
disabled={busy}
style={{
border: "none",
cursor: "pointer",
background: "var(--accent)",
color: "#fff",
fontSize: 15,
fontWeight: 800,
padding: "11px 20px",
borderRadius: 999,
}}
>
Speichern
</button>
<button
onClick={() => void rescan()}
style={{
border: "none",
cursor: "pointer",
background: "oklch(88% 0.02 210)",
color: "var(--ink)",
fontSize: 15,
fontWeight: 800,
padding: "11px 20px",
borderRadius: 999,
}}
>
Bibliothek neu einlesen
</button>
</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "oklch(50% 0.03 210)",
lineHeight: 1.5,
}}
>
Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
Obergrenze gilt sofort, ohne Neustart.
</div>
</div>
)}
{status && (
<div style={{ marginTop: 14, fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>
{status}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
/** The full-screen now-playing view. */
import type { Album, PlayerState } from "../api/types";
import { albumLine, isBook } from "../lib/covers";
import { clock, remainingInAlbum } from "../lib/format";
import { Cover } from "./Cover";
import { ProgressBar } from "./ProgressBar";
import { Transport } from "./Transport";
import { VolumeBars } from "./VolumeBars";
interface Props {
state: PlayerState;
album: Album | null;
position: number;
onToggle: () => void;
onNext: () => void;
onPrevious: () => void;
onSeek: (position: number) => void;
onVolume: (percent: number) => void;
onMute: () => void;
onBrowse: () => void;
}
export function PlayView({
state,
album,
position,
onToggle,
onNext,
onPrevious,
onSeek,
onVolume,
onMute,
onBrowse,
}: Props) {
const book = album ? isBook(album) : false;
const remaining = album
? remainingInAlbum(
album.tracks.map((track) => track.duration),
state.track_index,
position,
)
: 0;
return (
<div style={{ position: "relative", zIndex: 1, height: "100%", minHeight: 0, overflow: "auto" }}>
<div
style={{
minHeight: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "clamp(8px, 1.8vh, 26px)",
padding: "clamp(14px, 3.5vh, 40px) 40px",
}}
>
<img
src="/dolphin-mascot.png"
alt=""
style={{
position: "absolute",
top: "14%",
left: 0,
width: 140,
height: 140,
objectFit: "contain",
zIndex: 0,
pointerEvents: "none",
opacity: 0.92,
animation: "dolphinSwim 62s linear infinite",
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45))",
}}
/>
<div
style={{
height: "min(340px, 32vh)",
flex: "none",
position: "relative",
zIndex: 1,
display: "flex",
filter: "drop-shadow(0 20px 40px oklch(10% 0.05 210 / .55))",
}}
>
{album ? (
<Cover
album={album}
size={340}
fit="height"
radius={book ? "18px 34px 34px 18px" : "28px"}
label
/>
) : (
<div
style={{
height: "100%",
aspectRatio: 1,
borderRadius: 28,
background: "oklch(35% 0.03 210)",
}}
/>
)}
</div>
<div style={{ textAlign: "center", maxWidth: 760, position: "relative", zIndex: 1 }}>
<div
style={{
fontSize: "clamp(28px, 4.6vh, 46px)",
fontWeight: 900,
color: "var(--paper)",
lineHeight: 1.1,
textWrap: "pretty",
}}
>
{state.track_title ?? "Wähl ein Album!"}
</div>
<div
style={{
fontSize: 20,
fontWeight: 700,
color: "oklch(88% 0.02 210 / .8)",
marginTop: 8,
}}
>
{album ? albumLine(album) : "Tippen oder klicken"}
</div>
{album && (
<div
style={{
fontSize: 15,
fontWeight: 800,
color: "var(--accent-dim)",
marginTop: 6,
}}
>
{book ? "Kapitel" : "Song"} {state.track_index + 1} von {state.track_count}
</div>
)}
</div>
<div style={{ width: "min(680px, 90%)", position: "relative", zIndex: 1 }}>
<ProgressBar
position={position}
duration={state.duration}
onSeek={onSeek}
height={14}
interactive
/>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginTop: 8,
fontSize: 14,
fontWeight: 800,
color: "oklch(88% 0.02 210 / .8)",
}}
>
<span style={{ fontFamily: "ui-monospace, Menlo, monospace" }}>
{state.duration ? `${clock(state.duration - position)}` : ""}
</span>
<span>
{album ? `Noch ${clock(remaining)} ${book ? "im Hörbuch" : "im Album"}` : ""}
</span>
</div>
</div>
<div style={{ position: "relative", zIndex: 1 }}>
<Transport
playing={state.playing}
onToggle={onToggle}
onNext={onNext}
onPrevious={onPrevious}
size={72}
gap={22}
/>
</div>
<div style={{ position: "relative", zIndex: 1 }}>
<VolumeBars
volume={state.volume}
onChange={onVolume}
onMute={onMute}
barWidth={18}
barHeight="clamp(26px, 4.6vh, 44px)"
gap={5}
iconSize={24}
/>
</div>
<button
onClick={onBrowse}
style={{
position: "absolute",
top: 28,
left: 32,
border: "none",
cursor: "pointer",
background: "oklch(97% 0.01 210 / .95)",
borderRadius: 999,
padding: "11px 20px",
fontSize: 14,
fontWeight: 800,
color: "var(--ink)",
display: "flex",
alignItems: "center",
gap: 8,
boxShadow: "0 8px 24px oklch(15% 0.05 210 / .4)",
}}
>
<span
style={{
font: "800 13px ui-monospace, monospace",
background: "var(--ink)",
color: "#fff",
padding: "2px 8px",
borderRadius: 6,
}}
>
/
</span>
Zurück zur Suche
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
/** The bar along the bottom of the browse screen. */
import type { Album, PlayerState } from "../api/types";
import { albumLine, isBook } from "../lib/covers";
import { Cover } from "./Cover";
import { ProgressBar } from "./ProgressBar";
import { Transport } from "./Transport";
import { VolumeBars } from "./VolumeBars";
interface Props {
state: PlayerState;
album: Album | null;
position: number;
onToggle: () => void;
onNext: () => void;
onPrevious: () => void;
onSeek: (position: number) => void;
onVolume: (percent: number) => void;
onMute: () => void;
onOpenPlayView: () => void;
}
export function PlayerBar({
state,
album,
position,
onToggle,
onNext,
onPrevious,
onSeek,
onVolume,
onMute,
onOpenPlayView,
}: Props) {
return (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 2,
background: "oklch(18% 0.05 210 / .96)",
backdropFilter: "blur(6px)",
padding: "16px clamp(12px, 3vw, 32px)",
// Clear of the iPhone home indicator when installed to the home screen.
paddingBottom: "calc(16px + env(safe-area-inset-bottom))",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "clamp(10px, 1.6vw, 22px)",
borderTop: "3px solid oklch(70% 0.16 340 / .5)",
}}
>
<img
src="/dolphin-mascot.png"
alt=""
style={{
width: "clamp(56px, 7vw, 84px)",
height: "clamp(56px, 7vw, 84px)",
objectFit: "contain",
flex: "0 1 auto",
minWidth: 0,
animation: state.playing ? "dolphinBob 1.6s ease-in-out infinite" : "none",
filter: "drop-shadow(0 4px 12px oklch(10% 0.05 210 / .5))",
}}
/>
<button
onClick={onOpenPlayView}
aria-label="Große Ansicht"
style={{
height: 56,
width: album ? "auto" : 56,
flex: "none",
border: "none",
padding: 0,
borderRadius: 12,
overflow: "hidden",
display: "flex",
cursor: album ? "pointer" : "default",
background: album ? "none" : "oklch(35% 0.03 210)",
}}
>
{album && <Cover album={album} size={56} fit="height" radius="10px" />}
</button>
<div style={{ flex: "0 1 220px", minWidth: 0, overflow: "hidden" }}>
<div
style={{
fontSize: 16,
fontWeight: 800,
color: "var(--paper)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{state.track_title ?? "Wähl ein Album!"}
</div>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "oklch(85% 0.02 210 / .75)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{album ? albumLine(album) : "Tippen oder klicken"}
</div>
{album && (
<div
style={{
fontSize: 12,
fontWeight: 800,
color: "var(--accent-dim)",
marginTop: 1,
}}
>
{isBook(album) ? "Kapitel" : "Song"} {state.track_index + 1} von{" "}
{state.track_count}
</div>
)}
</div>
<div style={{ flex: "1 1 auto", maxWidth: 360, minWidth: 90 }}>
<ProgressBar
position={position}
duration={state.duration}
onSeek={onSeek}
height={10}
interactive
/>
</div>
<Transport
playing={state.playing}
onToggle={onToggle}
onNext={onNext}
onPrevious={onPrevious}
size={48}
gap={10}
/>
<VolumeBars
volume={state.volume}
onChange={onVolume}
onMute={onMute}
barWidth={11}
barHeight="30px"
gap={3}
iconSize={18}
/>
</div>
);
}

View File

@@ -0,0 +1,70 @@
/** The progress bar, and the one thing the mockup could not do: scrubbing. */
import { useCallback, useRef, useState } from "react";
interface Props {
position: number;
duration: number;
onSeek: (position: number) => void;
height: number;
interactive?: boolean;
}
export function ProgressBar({ position, duration, onSeek, height, interactive }: Props) {
const track = useRef<HTMLDivElement | null>(null);
const [dragging, setDragging] = useState<number | null>(null);
const positionAt = useCallback(
(clientX: number): number => {
const box = track.current?.getBoundingClientRect();
if (!box || !duration) return 0;
const ratio = Math.min(1, Math.max(0, (clientX - box.left) / box.width));
return ratio * duration;
},
[duration],
);
const shown = dragging ?? position;
const percent = duration > 0 ? Math.min(100, (shown / duration) * 100) : 0;
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!interactive || !duration) return;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(positionAt(event.clientX));
};
return (
<div
ref={track}
onPointerDown={onPointerDown}
onPointerMove={(event) => {
if (dragging !== null) setDragging(positionAt(event.clientX));
}}
onPointerUp={(event) => {
if (dragging === null) return;
const target = positionAt(event.clientX);
setDragging(null);
onSeek(target);
}}
style={{
height,
borderRadius: 999,
background: "oklch(30% 0.03 210 / .6)",
overflow: "hidden",
cursor: interactive && duration ? "pointer" : "default",
touchAction: "none",
}}
>
<div
style={{
height: "100%",
borderRadius: 999,
background: "var(--accent)",
width: `${percent}%`,
// No easing while dragging, or the fill lags the finger.
transition: dragging === null ? "width .25s linear" : "none",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,94 @@
/** The three transport buttons, at two sizes. The glyphs are CSS shapes, as in the
* mockup - no icon font to load on a device that boots offline. */
interface Props {
playing: boolean;
onToggle: () => void;
onNext: () => void;
onPrevious: () => void;
/** Side length of the skip buttons; the play button scales from it. */
size: number;
gap: number;
}
export function Transport({ playing, onToggle, onNext, onPrevious, size, gap }: Props) {
const glyph = Math.round(size * 0.33);
const bar = Math.max(4, Math.round(size * 0.085));
const playSize = Math.round(size * 1.26);
const playBar = Math.max(6, Math.round(playSize * 0.1));
const playGlyph = Math.round(playSize * 0.37);
return (
<div style={{ display: "flex", alignItems: "center", gap }}>
<button
className="round"
onClick={onPrevious}
aria-label="Vorheriger Titel"
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
>
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
<span
style={{
width: glyph,
height: glyph,
background: "#fff",
clipPath: "polygon(100% 0%, 100% 100%, 0% 50%)",
}}
/>
</span>
</button>
<button
className="round"
onClick={onToggle}
aria-label={playing ? "Pause" : "Abspielen"}
style={{
width: playSize,
height: playSize,
background: "var(--accent)",
boxShadow: "0 6px 20px oklch(70% 0.16 340 / .5)",
}}
>
{playing ? (
<span style={{ display: "flex", gap: playBar * 0.8 }}>
<span
style={{ width: playBar, height: playGlyph, borderRadius: 2, background: "#fff" }}
/>
<span
style={{ width: playBar, height: playGlyph, borderRadius: 2, background: "#fff" }}
/>
</span>
) : (
<span
style={{
width: playGlyph,
height: playGlyph,
background: "#fff",
clipPath: "polygon(6% 0%, 100% 50%, 6% 100%)",
}}
/>
)}
</button>
<button
className="round"
onClick={onNext}
aria-label="Nächster Titel"
style={{ width: size, height: size, background: "oklch(30% 0.04 210)" }}
>
<span style={{ display: "flex", alignItems: "center", gap: 2 }}>
<span
style={{
width: glyph,
height: glyph,
background: "#fff",
clipPath: "polygon(0% 0%, 0% 100%, 100% 50%)",
}}
/>
<span style={{ width: bar, height: glyph, borderRadius: 1, background: "#fff" }} />
</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,62 @@
/** Five bars, not a slider. The device's real range never reaches the browser: 100 %
* here means whatever ceiling a parent configured. */
interface Props {
/** 0..100. */
volume: number;
onChange: (percent: number) => void;
onMute: () => void;
barWidth: number;
barHeight: string;
gap: number;
iconSize: number;
}
export function VolumeBars({
volume,
onChange,
onMute,
barWidth,
barHeight,
gap,
iconSize,
}: Props) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button
onClick={onMute}
title="Stumm"
style={{
border: "none",
background: "none",
padding: 0,
cursor: "pointer",
fontSize: iconSize,
lineHeight: 1,
}}
>
{volume === 0 ? "🔇" : "🔊"}
</button>
<div style={{ display: "flex", gap }}>
{[20, 40, 60, 80, 100].map((level) => (
<button
key={level}
onClick={() => onChange(level)}
title={`Lautstärke ${level} %`}
aria-label={`Lautstärke ${level} Prozent`}
style={{
width: barWidth,
height: barHeight,
borderRadius: 5,
border: "none",
padding: 0,
cursor: "pointer",
background:
volume >= level ? "var(--accent)" : "oklch(88% 0.02 210 / .4)",
}}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
/** How many columns the album grid actually has.
Arrow-key navigation has to move a whole row at a time, and the grid is
`repeat(auto-fill, minmax(180px, 1fr))` - so the count is whatever the browser worked
out, not something the code can assume.
*/
import { useCallback, useEffect, useRef, useState } from "react";
export function useGridColumns(): [(element: HTMLElement | null) => void, number] {
const [columns, setColumns] = useState(4);
const element = useRef<HTMLElement | null>(null);
const measure = useCallback(() => {
const grid = element.current;
if (!grid) return;
const count = getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length;
if (count) setColumns((previous) => (count === previous ? previous : count));
}, []);
const ref = useCallback(
(node: HTMLElement | null) => {
element.current = node;
if (node) requestAnimationFrame(measure);
},
[measure],
);
useEffect(() => {
const observer = new ResizeObserver(measure);
if (element.current) observer.observe(element.current);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [measure]);
return [ref, columns];
}

View File

@@ -0,0 +1,38 @@
/** The whole album index, fetched once.
At roughly 90 kB for a real collection this is cheaper than a search endpoint and makes
type-to-search instant, which is what the keyboard-first design needs.
*/
import { useCallback, useEffect, useState } from "react";
import { api } from "../api/client";
import type { Album } from "../api/types";
export interface Library {
albums: Album[];
loading: boolean;
error: string | null;
reload: () => void;
}
export function useLibrary(): Library {
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(() => {
api
.library()
.then((loaded) => {
setAlbums(loaded);
setError(null);
})
.catch((cause: unknown) => setError(String(cause)))
.finally(() => setLoading(false));
}, []);
useEffect(reload, [reload]);
return { albums, loading, error, reload };
}

View File

@@ -0,0 +1,33 @@
/** A smooth playback position, from a position that only arrives twice a second.
Driving a progress bar straight off the websocket visibly steps. This interpolates
between frames on every animation frame and re-seeds whenever a real one lands, which
also gives beat-synced animation the frame-accurate clock it will need later.
*/
import { useEffect, useRef, useState } from "react";
export function usePlaybackClock(position: number, playing: boolean): number {
const [interpolated, setInterpolated] = useState(position);
const anchor = useRef({ position, at: performance.now() });
// A new server frame is the truth; restart the interpolation from it.
useEffect(() => {
anchor.current = { position, at: performance.now() };
setInterpolated(position);
}, [position]);
useEffect(() => {
if (!playing) return;
let frame = 0;
const tick = () => {
const { position: base, at } = anchor.current;
setInterpolated(base + (performance.now() - at) / 1000);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [playing]);
return playing ? interpolated : position;
}

View File

@@ -0,0 +1,82 @@
/** The live connection to the backend.
State arrives over a websocket that only pushes: every command goes out as a plain
POST. A reconnect reseeds from `GET /api/state` rather than assuming the UI's copy is
still right, and `onLibraryChanged` fires when a rescan has finished.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import type { PlayerState, ServerMessage } from "../api/types";
const RECONNECT_DELAY_MS = 1500;
export interface Connection {
state: PlayerState | null;
online: boolean;
/** Apply a change locally so a keypress feels instant; the next frame reconciles. */
optimistic: (patch: Partial<PlayerState>) => void;
}
export function usePlayerState(onLibraryChanged: () => void): Connection {
const [state, setState] = useState<PlayerState | null>(null);
const [online, setOnline] = useState(false);
// Kept in a ref so reconnecting never re-runs the effect that owns the socket.
const libraryChanged = useRef(onLibraryChanged);
libraryChanged.current = onLibraryChanged;
useEffect(() => {
let socket: WebSocket | null = null;
let retry: ReturnType<typeof setTimeout> | undefined;
let closed = false;
const connect = () => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${location.host}/api/ws`);
socket.onopen = () => {
setOnline(true);
// The server sends a snapshot on connect, but a reconnect may have missed
// changes in between, so ask for the truth as well.
void api.state().then(setState).catch(() => undefined);
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data as string) as ServerMessage;
if (message.type === "state") {
setState(message.state);
} else if (message.type === "position") {
setState((previous) =>
previous
? { ...previous, position: message.position, duration: message.duration }
: previous,
);
} else if (message.type === "library") {
libraryChanged.current();
}
};
const reconnect = () => {
setOnline(false);
if (!closed) retry = setTimeout(connect, RECONNECT_DELAY_MS);
};
socket.onclose = reconnect;
socket.onerror = () => socket?.close();
};
connect();
return () => {
closed = true;
clearTimeout(retry);
socket?.close();
};
}, []);
const optimistic = useCallback((patch: Partial<PlayerState>) => {
setState((previous) => (previous ? { ...previous, ...patch } : previous));
}, []);
return { state, online, optimistic };
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { clock, remainingInAlbum } from "../format";
describe("clock", () => {
it("formats minutes and seconds", () => {
expect(clock(0)).toBe("0:00");
expect(clock(9)).toBe("0:09");
expect(clock(200.5)).toBe("3:21");
});
it("grows an hours field for a long audiobook", () => {
expect(clock(3661)).toBe("1:01:01");
});
it("never shows negative time", () => {
expect(clock(-5)).toBe("0:00");
});
});
describe("remainingInAlbum", () => {
it("counts the rest of this track plus every track after it", () => {
expect(remainingInAlbum([60, 30, 10], 0, 20)).toBe(80);
expect(remainingInAlbum([60, 30, 10], 2, 0)).toBe(10);
});
it("copes with a position past the end of the track", () => {
expect(remainingInAlbum([60, 30], 0, 999)).toBe(30);
});
it("returns zero for an empty album", () => {
expect(remainingInAlbum([], 0, 0)).toBe(0);
});
});

View File

@@ -0,0 +1,224 @@
import { describe, expect, it } from "vitest";
import type { Album } from "../../api/types";
import { handleKey, initialUiState, selectionAt, type UiState } from "../keyboard";
import { normalize, results as computeResults } from "../search";
function album(id: string, over: Partial<Album> = {}): Album {
return {
id,
section: "Musik",
kind: "music",
title: `Album ${id}`,
artist: "Kinderparty",
series: null,
figure: null,
category: "Kinderparty",
colors: ["#111111", "#222222", "#333333"],
has_cover: false,
duration: 120,
tracks: [
{ title: `Lied ${id}`, duration: 60, analysis: null },
{ title: "Zweites Lied", duration: 60, analysis: null },
],
...over,
};
}
const ALBUMS: Album[] = [
album("a"),
album("b", { title: "Hörbuch Conni", artist: "Conni", kind: "book", series: "Conni", category: "Conni" }),
album("c", { artist: "Rolf", category: "Rolf" }),
];
const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) => ({
key: k,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...over,
});
const resultsFor = (ui: UiState) =>
computeResults({
albums: ALBUMS,
search: ui.search,
mode: ui.mode,
filter: ui.filter,
category: ui.category,
});
const press = (k: string, ui: UiState = initialUiState, over = {}) =>
handleKey(key(k, over), ui, resultsFor(ui));
describe("normalize", () => {
it("strips case, punctuation and diacritics so a child's letters match", () => {
expect(normalize("Hörbücher, Folge 2!")).toBe("horbucherfolge2");
expect(normalize("Käpt'n Krabbe")).toBe("kaptnkrabbe");
});
});
describe("search", () => {
it("shows categories with no query, albums once there is one", () => {
expect(resultsFor(initialUiState).categories).toHaveLength(3);
expect(resultsFor(initialUiState).albums).toHaveLength(0);
const searching = { ...initialUiState, search: "conni" };
expect(resultsFor(searching).albums.map((a) => a.id)).toEqual(["b"]);
});
it("filters books and music apart", () => {
const books = resultsFor({ ...initialUiState, filter: "book", search: "a" });
expect(books.albums.every((a) => a.kind === "book")).toBe(true);
});
it("matches title and artist together", () => {
expect(resultsFor({ ...initialUiState, search: "rolf" }).albums.map((a) => a.id)).toEqual(["c"]);
});
});
describe("keyboard", () => {
it("types letters into the search", () => {
expect(press("k")).toEqual([
{ type: "ui", patch: { search: "k", view: "browse", selIndex: 0 } },
]);
});
it("accepts umlauts, which the mockup's a-z0-9 test rejected", () => {
expect(press("ö")).toHaveLength(1);
});
it("ignores keys that mean nothing, so the browser keeps its own", () => {
expect(press("F5")).toEqual([]);
expect(press("r", { ...initialUiState }, { ctrlKey: true })).toEqual([]);
});
it("maps the ctrl chords to transport and volume", () => {
expect(press("l", initialUiState, { ctrlKey: true })).toEqual([{ type: "next" }]);
expect(press("h", initialUiState, { ctrlKey: true })).toEqual([{ type: "previous" }]);
expect(press("k", initialUiState, { ctrlKey: true })).toEqual([
{ type: "volume", delta: 10 },
]);
expect(press("j", initialUiState, { ctrlKey: true })).toEqual([
{ type: "volume", delta: -10 },
]);
});
it("cycles the filter with TAB", () => {
expect(press("Tab")).toContainEqual({
type: "ui",
patch: { filter: "music", selIndex: 0, view: "browse", category: null },
});
expect(press("Tab", { ...initialUiState, filter: "book" })).toContainEqual({
type: "ui",
patch: { filter: "all", selIndex: 0, view: "browse", category: null },
});
});
it("gives arrows to navigation while browsing and to transport while playing", () => {
expect(press("ArrowRight")).toEqual([{ type: "ui", patch: { selIndex: 1 } }]);
const playing: UiState = { ...initialUiState, view: "play" };
expect(press("ArrowRight", playing)).toEqual([{ type: "next" }]);
expect(press("ArrowUp", playing)).toEqual([{ type: "volume", delta: 10 }]);
expect(press("ArrowDown", playing)).toEqual([{ type: "volume", delta: -10 }]);
});
it("seeks with shift+arrows in either view", () => {
expect(press("ArrowRight", initialUiState, { shiftKey: true })).toEqual([
{ type: "seek", delta: 15 },
]);
expect(press("ArrowLeft", { ...initialUiState, view: "play" }, { shiftKey: true })).toEqual([
{ type: "seek", delta: -15 },
]);
});
it("moves a whole row at a time in the grid", () => {
const ui = { ...initialUiState, cols: 2, selIndex: 0 };
expect(handleKey(key("ArrowDown"), ui, resultsFor(ui))).toEqual([
{ type: "ui", patch: { selIndex: 2 } },
]);
});
it("clamps the selection to what is on screen", () => {
const ui = { ...initialUiState, selIndex: 2 };
expect(handleKey(key("ArrowRight"), ui, resultsFor(ui))).toEqual([
{ type: "ui", patch: { selIndex: 2 } },
]);
});
it("peels one layer at a time with ESC", () => {
const deep: UiState = {
...initialUiState,
showHelp: true,
openAlbumId: "a",
search: "x",
mode: "tracks",
category: "Conni",
};
expect(press("Escape", deep)).toEqual([
{ type: "ui", patch: { showHelp: false, openAlbumId: null } },
]);
const searching = { ...deep, showHelp: false, openAlbumId: null };
expect(press("Escape", searching)).toEqual([
{ type: "ui", patch: { search: "", selIndex: 0 } },
]);
const inTracks = { ...searching, search: "" };
expect(press("Escape", inTracks)).toEqual([
{ type: "ui", patch: { mode: "albums", selIndex: 0 } },
]);
const inCategory = { ...inTracks, mode: "albums" as const };
expect(press("Escape", inCategory)).toEqual([
{ type: "ui", patch: { category: null, selIndex: 0 } },
]);
});
it("does not swallow backspace when there is nothing to delete", () => {
expect(press("Backspace")).toEqual([]);
expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([
{ type: "ui", patch: { search: "a", selIndex: 0 } },
]);
});
it("switches to track search with ?", () => {
expect(press("?")).toContainEqual({
type: "ui",
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
});
});
it("plays the open album from the top on ENTER", () => {
expect(press("Enter", { ...initialUiState, openAlbumId: "b" })).toEqual([
{ type: "play", albumId: "b", trackIndex: 0 },
]);
});
});
describe("selectionAt", () => {
it("walks songs, then categories, then albums in one flat index space", () => {
const ui: UiState = { ...initialUiState, mode: "tracks", search: "zweites" };
const found = resultsFor(ui);
expect(found.songs.length).toBeGreaterThan(0);
expect(selectionAt(found, 0)).toEqual({
type: "play",
albumId: found.songs[0]!.album.id,
trackIndex: found.songs[0]!.index,
});
});
it("opens a category rather than playing it", () => {
const found = resultsFor(initialUiState);
expect(selectionAt(found, 0)).toEqual({
type: "ui",
patch: { category: found.categories[0]!.key, selIndex: 0 },
});
});
it("returns nothing when there is nothing to select", () => {
const ui: UiState = { ...initialUiState, search: "zzzznothing" };
expect(selectionAt(resultsFor(ui), 0)).toBeNull();
});
});

77
web/src/lib/covers.ts Normal file
View File

@@ -0,0 +1,77 @@
/** How an album is painted when there is no artwork, and how a book is made to look
* like a book.
*
* The mockup generated everything from a single per-album `hue`. The backend now sends
* three colours pulled out of the real cover art, so the generated fallback and the
* artwork agree - and so do the LED strips, which run the same primary colour.
*/
import type { Album, AlbumKind } from "../api/types";
export const isBook = (album: Album): boolean => album.kind === "book";
/** Shape is how you tell the two apart without reading anything: albums are square,
* audiobooks are taller than wide, everywhere they appear - grid, list, group preview,
* player bar, now-playing. Nothing else may set an aspect ratio on a cover. */
export const ALBUM_ASPECT = 1;
export const BOOK_ASPECT = 0.82;
export const aspectOfKind = (kind: AlbumKind): number =>
kind === "book" ? BOOK_ASPECT : ALBUM_ASPECT;
export const aspectOf = (album: Album): number => aspectOfKind(album.kind);
const colours = (album: Album): [string, string, string] => [
album.colors[0] ?? "#4a6fa5",
album.colors[1] ?? "#6a8fc5",
album.colors[2] ?? "#a5804a",
];
/** Diagonal two-tone stripes, the mockup's stand-in for a music cover. */
export function stripes(album: Album, width: number): string {
const [primary, secondary] = colours(album);
return (
`repeating-linear-gradient(135deg, ${primary} 0px, ${primary} ${width}px, ` +
`${secondary} ${width}px, ${secondary} ${width * 2}px)`
);
}
/** A book spine: page edges on the right, a darker board on the left. */
export function spine(album: Album): string {
const [primary, secondary] = colours(album);
return [
"linear-gradient(90deg, transparent 0 95%, oklch(97% 0.02 88) 95% 97.5%," +
" oklch(90% 0.03 88) 97.5% 100%)",
"linear-gradient(90deg, transparent 0 3.5%, oklch(98% 0 0 / .35) 3.5% 4.3%," +
" transparent 4.3% 7%, oklch(98% 0 0 / .35) 7% 7.8%, transparent 7.8%)",
`linear-gradient(90deg, ${secondary} 0 11%, ${primary} 11% 13%, transparent 13%)`,
`linear-gradient(155deg, ${primary} 0%, ${secondary} 100%)`,
].join(",");
}
export function coverBackground(album: Album, stripeWidth: number): string {
return isBook(album) ? spine(album) : stripes(album, stripeWidth);
}
/** The card's own tint: warm paper for books, cool glass for music. */
export const cardBackground = (album: Album): string =>
isBook(album) ? "oklch(93% 0.055 88 / .7)" : "oklch(95% 0.015 210 / .66)";
export const cardShadow = (album: Album): string =>
isBook(album)
? "inset -7px 0 0 oklch(88% 0.06 88 / .7), inset -11px 0 0 oklch(82% 0.06 88 / .7)," +
" 0 6px 18px oklch(15% 0.05 210 / .35)"
: "0 6px 18px oklch(15% 0.05 210 / .35)";
export const unitLabel = (album: Album, count: number): string =>
isBook(album)
? `${count} ${count === 1 ? "Kapitel" : "Kapitel"}`
: `${count} ${count === 1 ? "Song" : "Songs"}`;
/** "Album · Artist", unless a podcast makes those the same string. */
export const albumLine = (album: Album): string => {
const prefix = isBook(album) ? "📖 " : "";
return album.artist && album.artist !== album.title
? `${prefix}${album.title} · ${album.artist}`
: `${prefix}${album.title}`;
};

25
web/src/lib/format.ts Normal file
View File

@@ -0,0 +1,25 @@
/** Time formatting, ported from the design mockup. */
/** Seconds as `m:ss`, or `h:mm:ss` once an audiobook runs past the hour. */
export function clock(totalSeconds: number): string {
const total = Math.max(0, Math.round(totalSeconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return hours ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}
/** How much of the album is left after the current position. */
export function remainingInAlbum(
trackDurations: number[],
trackIndex: number,
position: number,
): number {
const current = trackDurations[trackIndex] ?? 0;
let rest = Math.max(0, current - position);
for (let i = trackIndex + 1; i < trackDurations.length; i++) {
rest += trackDurations[i] ?? 0;
}
return rest;
}

187
web/src/lib/keyboard.ts Normal file
View File

@@ -0,0 +1,187 @@
/** The keyboard state machine, ported from the design mockup.
Kept pure - it takes a key and the current view state and returns what should happen -
so the whole interaction model can be tested without a DOM, and so the component that
mounts it stays a thin adapter.
The mockup's key map is followed exactly, with one addition: SHIFT+arrows seek. Plain
arrows were already taken (selection while browsing, transport and volume while
playing), and seeking is the one thing a real player can do that the mockup could not.
*/
import type { Album } from "../api/types";
import type { Filter, Mode, Results } from "./search";
export interface UiState {
search: string;
mode: Mode;
filter: Filter;
category: string | null;
selIndex: number;
view: "browse" | "play";
openAlbumId: string | null;
showHelp: boolean;
cols: number;
}
export const initialUiState: UiState = {
search: "",
mode: "albums",
filter: "all",
category: null,
selIndex: 0,
view: "browse",
openAlbumId: null,
showHelp: false,
cols: 4,
};
export type Action =
| { type: "ui"; patch: Partial<UiState> }
| { type: "play"; albumId: string; trackIndex: number }
| { type: "toggle" }
| { type: "next" }
| { type: "previous" }
| { type: "volume"; delta: number }
| { type: "seek"; delta: number }
| { type: "pop"; freq: number };
/** Matches the mockup's `/^[a-zA-Z0-9]$/`, widened to the umlauts a German title needs. */
const SEARCHABLE = /^[\p{L}\p{N}]$/u;
const FILTER_ORDER: Filter[] = ["all", "music", "book"];
export const VOLUME_STEP = 10;
export const SEEK_STEP = 15;
/** What the flat selection index currently points at. */
export function selectionAt(results: Results, selIndex: number): Action | null {
const { songs, categories, albums } = results;
const index = Math.min(selIndex, results.total - 1);
if (index < 0) return null;
if (index < songs.length) {
const hit = songs[index];
return hit ? { type: "play", albumId: hit.album.id, trackIndex: hit.index } : null;
}
const afterSongs = index - songs.length;
if (afterSongs < categories.length) {
const category = categories[afterSongs];
return category ? { type: "ui", patch: { category: category.key, selIndex: 0 } } : null;
}
const album: Album | undefined = albums[afterSongs - categories.length];
return album ? { type: "play", albumId: album.id, trackIndex: 0 } : null;
}
function moveSelection(state: UiState, results: Results, dx: number, dy: number): Action[] {
if (!results.total) return [];
const cols = Math.max(1, state.cols);
let index = Math.min(state.selIndex, results.total - 1);
if (dx) index += dx;
if (dy) {
// Song hits are a single-column list; everything below them is a grid.
index += index < results.songs.length ? dy : dy * cols;
}
index = Math.max(0, Math.min(index, results.total - 1));
return [{ type: "ui", patch: { selIndex: index } }];
}
/** ESC peels one layer off at a time rather than dumping you back at the top. */
function escape(state: UiState): Action[] {
if (state.showHelp || state.openAlbumId !== null) {
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
}
if (state.search) return [{ type: "ui", patch: { search: "", selIndex: 0 } }];
if (state.mode === "tracks") return [{ type: "ui", patch: { mode: "albums", selIndex: 0 } }];
return [{ type: "ui", patch: { category: null, selIndex: 0 } }];
}
export interface KeyEvent {
key: string;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}
/**
* Translate one keypress. Returns the actions to run, or nothing when the key means
* nothing here - the caller only calls `preventDefault()` when something came back.
*/
export function handleKey(event: KeyEvent, state: UiState, results: Results): Action[] {
const { key } = event;
if (event.ctrlKey || event.metaKey) {
switch (key.toLowerCase()) {
case "l":
return [{ type: "next" }];
case "h":
return [{ type: "previous" }];
case "k":
return [{ type: "volume", delta: VOLUME_STEP }];
case "j":
return [{ type: "volume", delta: -VOLUME_STEP }];
default:
return [];
}
}
const browsing = state.view === "browse" && state.openAlbumId === null && !state.showHelp;
switch (key) {
case "Tab": {
const next = FILTER_ORDER[(FILTER_ORDER.indexOf(state.filter) + 1) % FILTER_ORDER.length]!;
return [
{ type: "pop", freq: 380 },
{ type: "ui", patch: { filter: next, selIndex: 0, view: "browse", category: null } },
];
}
case "/":
return [{ type: "ui", patch: { view: "browse" } }];
case " ":
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
case "ArrowRight":
if (event.shiftKey) return [{ type: "seek", delta: SEEK_STEP }];
return browsing ? moveSelection(state, results, 1, 0) : [{ type: "next" }];
case "ArrowLeft":
if (event.shiftKey) return [{ type: "seek", delta: -SEEK_STEP }];
return browsing ? moveSelection(state, results, -1, 0) : [{ type: "previous" }];
case "ArrowDown":
return browsing
? moveSelection(state, results, 0, 1)
: [{ type: "volume", delta: -VOLUME_STEP }];
case "ArrowUp":
return browsing
? moveSelection(state, results, 0, -1)
: [{ type: "volume", delta: VOLUME_STEP }];
case "?":
return [
{ type: "pop", freq: 460 },
{
type: "ui",
patch: { mode: "tracks", search: "", selIndex: 0, view: "browse", showHelp: false },
},
];
case "F1":
return [{ type: "ui", patch: { showHelp: !state.showHelp } }];
case "Escape":
return escape(state);
case "Backspace":
return state.search
? [{ type: "ui", patch: { search: state.search.slice(0, -1), selIndex: 0 } }]
: [];
case "Enter": {
if (state.openAlbumId !== null) {
return [{ type: "play", albumId: state.openAlbumId, trackIndex: 0 }];
}
const chosen = selectionAt(results, state.selIndex);
return chosen ? [chosen] : [];
}
default:
if (SEARCHABLE.test(key)) {
return [{ type: "ui", patch: { search: state.search + key, view: "browse", selIndex: 0 } }];
}
return [];
}
}

22
web/src/lib/pop.ts Normal file
View File

@@ -0,0 +1,22 @@
/** The mockup's click feedback: a short rising blip per interaction. */
let context: AudioContext | null = null;
export function playPop(frequency: number): void {
try {
context ??= new AudioContext();
const now = context.currentTime;
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(frequency, now);
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.8, now + 0.08);
gain.gain.setValueAtTime(0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
oscillator.connect(gain).connect(context.destination);
oscillator.start();
oscillator.stop(now + 0.16);
} catch {
// No audio context before the first user gesture, and on some browsers never.
}
}

114
web/src/lib/search.ts Normal file
View File

@@ -0,0 +1,114 @@
/** Browsing and searching the library, entirely in the browser.
The whole index arrives in one response, so type-to-search has no round trip and feels
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
*/
import type { Album } from "../api/types";
export type Filter = "all" | "music" | "book";
export type Mode = "albums" | "tracks";
export interface SongHit {
album: Album;
index: number;
title: string;
duration: number;
}
export interface Category {
key: string;
albums: Album[];
}
/** Diacritics and punctuation are noise when a child is hunting for letters. */
export function normalize(value: string): string {
return (value ?? "")
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]/g, "");
}
export function inFilter(album: Album, filter: Filter): boolean {
if (filter === "book") return album.kind === "book";
if (filter === "music") return album.kind === "music";
return true;
}
export function pool(albums: Album[], filter: Filter): Album[] {
return albums.filter((album) => inFilter(album, filter));
}
export interface BrowseQuery {
albums: Album[];
search: string;
mode: Mode;
filter: Filter;
category: string | null;
}
/** Which of the three lists the browse view is showing. */
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
if (query.mode === "tracks") return "tracks";
if (query.search || query.category) return "albums";
return "categories";
}
export function categoryMatches(query: BrowseQuery): Category[] {
if (listMode(query) !== "categories") return [];
const map = new Map<string, Category>();
for (const album of pool(query.albums, query.filter)) {
const key = album.category;
let entry = map.get(key);
if (!entry) {
entry = { key, albums: [] };
map.set(key, entry);
}
entry.albums.push(album);
}
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, "de"));
}
export function albumMatches(query: BrowseQuery): Album[] {
if (query.mode === "tracks") return [];
const needle = normalize(query.search);
let candidates = pool(query.albums, query.filter);
if (!needle && !query.category) return [];
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
if (!needle) return candidates;
return candidates.filter((a) => normalize(a.title + a.artist).includes(needle));
}
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
export const MAX_SONG_HITS = 40;
export function songMatches(query: BrowseQuery): SongHit[] {
if (query.mode !== "tracks") return [];
const needle = normalize(query.search);
const hits: SongHit[] = [];
for (const album of pool(query.albums, query.filter)) {
album.tracks.forEach((track, index) => {
if (!needle || normalize(track.title).includes(needle)) {
hits.push({ album, index, title: track.title, duration: track.duration });
}
});
if (hits.length >= MAX_SONG_HITS) break;
}
return hits.slice(0, MAX_SONG_HITS);
}
export interface Results {
songs: SongHit[];
categories: Category[];
albums: Album[];
/** Selection indices run flat across songs, then categories, then albums. */
total: number;
}
export function results(query: BrowseQuery): Results {
const songs = songMatches(query);
const categories = categoryMatches(query);
const albums = albumMatches(query);
return { songs, categories, albums, total: songs.length + categories.length + albums.length };
}

23
web/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/app.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
// Registered after load so it never competes with the first paint. It is what makes
// iOS offer "Zum Home-Bildschirm" as an app rather than a bookmark; see public/sw.js
// for what it does and deliberately does not cache.
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch(() => {
// A plain http:// origin that is not localhost cannot register one. The app
// works exactly the same, it just cannot be installed.
});
});
}

188
web/src/styles/app.css Normal file
View File

@@ -0,0 +1,188 @@
/* Ported from claude-design/Dolphin Beats.dc.html. The palette is oklch throughout;
the tokens below are the handful of values that repeat. */
:root {
--ink: oklch(30% 0.04 210);
--paper: oklch(97% 0.01 210);
--accent: oklch(70% 0.16 340);
--accent-dim: oklch(78% 0.14 340);
--sea-deep: oklch(20% 0.045 210);
--shadow: oklch(15% 0.05 210 / 0.35);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Nunito", system-ui, sans-serif;
overscroll-behavior: none;
/* Installed on a phone this is an app, not a document: no rubber-banding, no
long-press callout, no accidental text selection when a small hand misses a
button. Text inside the parent-mode inputs opts back in below. */
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
input,
textarea {
-webkit-user-select: text;
user-select: text;
}
button {
font-family: inherit;
}
@keyframes bubbleRise {
0% {
transform: translateY(0) scale(1);
opacity: 0.55;
}
100% {
transform: translateY(-120vh) scale(1.3);
opacity: 0;
}
}
@keyframes dolphinBob {
0%,
100% {
transform: translateY(0) rotate(-2deg);
}
50% {
transform: translateY(-10px) rotate(2deg);
}
}
@keyframes dolphinSwim {
0% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(1); }
15% { transform: translate(2vw, 2vh) rotate(4deg) scaleX(1); }
30% { transform: translate(24vw, 12vh) rotate(-3deg) scaleX(1); }
45% { transform: translate(46vw, 2vh) rotate(4deg) scaleX(1); }
60% { transform: translate(68vw, 12vh) rotate(-3deg) scaleX(1); }
70% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(1); }
74% { transform: translate(84vw, 6vh) rotate(0deg) scaleX(-1); }
85% { transform: translate(52vw, 18vh) rotate(-3deg) scaleX(-1); }
95% { transform: translate(16vw, 6vh) rotate(3deg) scaleX(-1); }
100% { transform: translate(-20vw, 10vh) rotate(-3deg) scaleX(-1); }
}
.stage {
position: relative;
width: 100%;
/* dvh follows the iOS toolbar as it collapses; vh is the fallback for older
browsers, where it would otherwise leave a strip of white at the bottom. */
height: 100vh;
height: 100dvh;
overflow: hidden;
background: linear-gradient(
180deg,
oklch(55% 0.07 210) 0%,
oklch(38% 0.06 210) 45%,
var(--sea-deep) 100%
);
}
.bubble {
position: absolute;
bottom: -40px;
border-radius: 50%;
animation-name: bubbleRise;
animation-timing-function: linear;
animation-iteration-count: infinite;
pointer-events: none;
}
.pill {
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 800;
padding: 9px 15px;
border-radius: 999px;
white-space: nowrap;
background: oklch(97% 0.01 210 / 0.16);
color: var(--paper);
}
.pill[data-active="true"] {
background: var(--paper);
color: oklch(28% 0.04 210);
box-shadow: 0 4px 14px var(--shadow);
}
.card {
backdrop-filter: blur(6px);
overflow: hidden;
cursor: pointer;
transition: transform 0.12s ease;
border: none;
padding: 0;
text-align: left;
font: inherit;
display: block;
width: 100%;
}
.card[data-selected="true"] {
outline: 5px solid var(--paper);
outline-offset: 3px;
transform: translateY(-3px);
}
.card[data-current="true"]:not([data-selected="true"]) {
outline: 4px solid var(--accent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
max-width: 1180px;
margin: 0 auto;
}
.round {
border-radius: 999px;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.key-cap {
font: 800 12px ui-monospace, Menlo, monospace;
background: var(--ink);
color: #fff;
padding: 5px 12px;
border-radius: 8px;
min-width: 56px;
text-align: center;
}
.sheet {
background: var(--paper);
border-radius: 24px;
box-shadow: 0 20px 50px oklch(10% 0.04 210 / 0.5);
}
.overlay {
position: absolute;
inset: 0;
background: oklch(15% 0.03 210 / 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
input[type="number"],
input[type="range"] {
font: inherit;
}

20
web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

16
web/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// In development the frontend runs on 5173 and the backend on 8081; everything under
// /api - including the websocket upgrade - is proxied there, so the browser sees one
// origin and there is no CORS to configure on either side.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": { target: "http://127.0.0.1:8081", changeOrigin: true, ws: true },
},
},
build: { outDir: "dist", emptyOutDir: true },
});