Files
musicmouse/web
Martin Bauer f3337975c2 Celebrate a media unlock with a treasure chest that opens
The reward pipeline worked end to end already, but it had nothing to show for
itself: the unlock was one more line at the bottom of the result sheet, a 52px
thumbnail with the generic 520ms pop, below the stars, the stats, the progress
bar, the animal ladder and three other badges. The sound was `playFanfare`, the
same chirp used for a new lesson and a new aquarium pet. It fired correctly and
was impossible to notice.

This is the only reward that reaches outside the game, so it now gets the whole
screen. A chest drops in shut and rattles, the lid swings open on a burst of
light and confetti, the cover art rises out of it, and the tune is a real melody
- two seconds landing on a held major chord - rather than another blip. It is
dismissed by hand, so she can look at what she won for as long as she likes.

On the map, the 15px 🎁 becomes a drawn chest, shut while the reward is unwon
and open with the cover inside once it has been. It is rendered as a sibling of
the lesson node rather than a child, because a locked node is dimmed to 45% and
the chest that most needs to be bright is the one three worlds away.

One real bug behind the missing badge state: `progressFromApi` dropped the
`earned` flag the backend already sends, so the map could not tell a claimed
reward from an unclaimed one. Added, with a test that names it - a hand-written
field mapping loses fields without failing a type check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:10:11 +02:00
..
2026-08-27 12:32:20 +02:00
2026-08-27 12:32:20 +02:00
2026-08-27 12:32:20 +02:00
2026-08-27 12:32:20 +02:00
2026-08-27 12:32:20 +02:00

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.

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.