Two separate costs, both measured on musicdolphin (Pi 4, 1920x1080 kiosk), where the app was burning ~70% of a core with nothing happening on screen. Per-frame work. The ambient canvas repaints a full-screen gradient plus a particle field every frame, and `usePlaybackClock` pushes a React setState per animation frame into both PlayView and PlayerBar for the whole length of a track. `?pi=1` (lib/ lowPower.ts) makes those cheaper rather than switching them off: the canvas paints a quarter of the pixels at 30fps with a bubble cap, and the clock renders ten times a second - a progress bar advances one pixel every few hundred ms and its label has one-second resolution, so nothing on screen can tell. Only the effects with no cheap version actually go: the backdrop-filter glass blur and the decorative CSS loops. Also drops a redundant full-canvas clearRect that the opaque gradient always covered. Search. normalize() runs a Unicode NFD decomposition, and albumMatches/songMatches called it on every album title and every track title on every keystroke - 4969 of them for a track search, whose answer cannot change until the library does. buildSearchIndex does it once per library payload; a keystroke is now String.includes over strings that already exist. On the real library that is 33ms -> 3.4ms for an eight-letter track query on a laptop, and this runs on a Pi. The same index partitions albums by shelf and pre-sorts each shelf's categories, which App and BrowseView were deriving separately from the same data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.1 KiB
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:
cd ../python-backend
python -m musicmouse --config ./config.yml --no-hardware # real audio
python -m musicmouse --config ./config.yml --simulate # fake player
Then:
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
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.
src/lib/search.ts builds its normalized search keys once per library payload
(buildSearchIndex) rather than per keystroke. It matters more than it sounds: a real
collection is 343 albums and 4969 tracks, and normalize() does a Unicode NFD
decomposition, so track search used to mean five thousand of those before a single
comparison - 4 ms per keystroke on a laptop, and this runs on a Pi. The same build also
partitions albums by shelf and pre-sorts each shelf's categories, which App and
BrowseView were otherwise deriving separately from the same data.
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 - see the note on buildSearchIndex below. |
src/lib/lowPower.ts |
The ?pi=1 profile: what gets cheaper, and by how much. |
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.
The Raspberry Pi profile
?pi=1 loads the same app with the two per-frame costs cut down. It exists for
musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen, where the app was spending
about 70% of a core with nothing happening.
What it changes, all of it in src/lib/lowPower.ts:
| Full | ?pi=1 |
|
|---|---|---|
| Ambient canvas backing store | 1:1 with the screen | half scale, capped at 1280 px |
| Ambient canvas frame rate | display refresh | 30 fps |
| Bubbles alive at once | unlimited | 60 |
| Progress-bar re-renders | every animation frame | 10 per second |
backdrop-filter glass blur |
on | off - plain translucent instead |
| Decorative CSS animation loops | on | off |
The principle is make the expensive thing cheaper before switching it off. The canvas
is the app's whole look, so it stays and paints a quarter of the pixels half as often -
on a soft gradient behind content that is close to invisible. Only the effects with no
cheap version go: a real backdrop blur, and the decorative loops that carry no
information. The aquarium pets stay on even here; they are the reward the typing game
pays out, and a handful of transform writes is not what is slow.
The flag is read once at module load from the URL and nowhere else - no persistence, no
auto-detection. That is what makes "is this the profile or the hardware?" answerable by
editing the address bar. The kiosk gets it from pi_kiosk_url in the ansible repo.
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. Athttp://<mouse>:8080on 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.