Files
musicmouse/web/README.md
Martin Bauer 0ef5a04cb9 Downscale cached cover art, and stop every animation loop under ?pi=1
Covers. Art out of an ID3 APIC frame is sized for a record sleeve: this library
averaged 3000x3000 and 580 kB per cover, 140 MB across 284 albums. The browser was
decoding nine megapixels - around 36 MB of bitmap - for every cover it painted, to show
it in a 185 px card, on a Pi with 2 GB of RAM. The largest any screen in this app asks
for is 340 px (the play view), so cache.store_cover now downscales to a 640 px long
edge, which leaves room for a tablet at devicePixelRatio 2 and cuts the decode about
twentyfold. Pillow was already a hard dependency, for colour extraction.

scan_library reuses an album whose fingerprint is unchanged without re-reading its
tags, so covers already on disk would never be rewritten - hence shrink_stored_covers(),
a pass at the top of a scan. Reading a JPEG's dimensions only parses its header, so
after the first run it costs one small read per album. Art already small enough is
returned byte-identical rather than re-encoded, so repeated scans cannot slowly grind
it down, and anything Pillow cannot read is passed through untouched: a cover that is
too big is a performance problem, a cover that is missing is a visible one.

Animation. Halving the ambient canvas to a quarter of the pixels at 30fps took it from
53.5% of a core to 25%, and 25% was still not good enough to use. A requestAnimationFrame
loop repainting the viewport is a floor you cannot get under while it runs at all, so
?pi=1 now switches it off outright rather than thinning it, along with the decorative
CSS loops, the view transitions, the typing game's bubbles and its next-key pulse. The
pets stay on screen but hold still, through the same path prefers-reduced-motion already
took - taking the animation away is the point, taking away what she earned is not.
.stage keeps its own static gradient, so there is still a sea behind everything.

Both blurs on the panels stay on. Dropping those measured five times worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:44:02 +02:00

7.5 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 nothing on screen animating itself. It exists for musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen.

Full ?pi=1
Ambient canvas (gradient + bubbles) on not rendered
Decorative CSS loops, view transitions on off
Typing game bubbles, next-key pulse on off
Typing game pets swimming placed, held still
backdrop-filter on cards and list rows on off
backdrop-filter on panels on on
Progress-bar re-renders every animation frame 10 per second

Two of those rows are worth knowing the reasons for, because both are the opposite of what they look like.

The panel blur stays on. Dropping it measured five times worse on the device - 118% of a core against 25%. backdrop-filter is what promotes each glass panel to its own compositing layer; without it the panels and everything under them collapse into one layer that repaints wholesale. The card blur goes because that is a different problem: three hundred of them in one grid, not one per panel.

The canvas goes away rather than getting cheaper. Half resolution and 30 fps took it from 53.5% of a core to 25.0%, and 25% was still not good enough to use. A requestAnimationFrame loop repainting the viewport is a floor you cannot get under while it runs at all. .stage keeps its own static CSS gradient, so there is still a sea behind everything - it just no longer moves or follows the track. AMBIENCE_QUALITY in src/lib/lowPower.ts is kept accurate for whoever turns the canvas back on.

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. 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.