# 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. `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`.** At `http://: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.