The first cut of ?pi=1 switched off the two things that look most expensive - the backdrop-filter glass blur and the decorative CSS animation loops - and made the device slower, not faster: 53.5% of a core idle became 118%. Measured on musicdolphin, sum of the Firefox process tree, idle on the browse screen, 15s average: full app 53.5% + ambient canvas at half resolution 25.0% <- the whole win + 30fps cap on top of that 25.0% (no idle change) + decorative CSS animation loops off 24.6% (noise; left on) + backdrop-filter glass blur off 118.1% <- 5x worse The blur is what promotes each glass panel to its own compositing layer. Without it the animated canvas and the whole album grid above it collapse into one layer and every canvas frame repaints all of it. The most expensive-looking CSS in the app is what was keeping the rest of it cheap. SHOW_GLASS_BLUR and SHOW_DECORATIVE_ANIMATIONS therefore go back to unconditionally on, in both the music app and the typing game, with the numbers written down next to them so the next person does not repeat this. What is left is one real change - paint a quarter of the pixels - plus three caps that only bite while something is playing and so are not in the table above: 30fps on the canvas, 60 bubbles alive at once, and ten progress-bar re-renders a second. Those three are unmeasured; measuring them means playing audio. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
7.5 KiB
Markdown
165 lines
7.5 KiB
Markdown
# 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 ambient canvas painting a quarter of the pixels. It
|
|
exists for musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen - where Firefox
|
|
rasterizes 2D canvas in the content process, and the canvas repaints the whole screen
|
|
every frame whether or not anything is playing.
|
|
|
|
Everything in `src/lib/lowPower.ts` was measured on the device. Sum of the Firefox
|
|
process tree, idle on the browse screen, 15 s average:
|
|
|
|
| | idle CPU |
|
|
|---|---|
|
|
| full app | 53.5% |
|
|
| + ambient canvas at half resolution | **25.0%** |
|
|
| + 30 fps cap on top of that | 25.0% |
|
|
| + decorative CSS animation loops off | 24.6% |
|
|
| + `backdrop-filter` glass blur off | **118.1%** |
|
|
|
|
Two things to take from that table. The canvas resolution is the whole win, and
|
|
**turning off the backdrop blur makes it five times worse** - the blur is what promotes
|
|
each glass panel to its own compositing layer, so a canvas frame underneath repaints
|
|
only the canvas. Without it the canvas and the album grid above it share one layer and
|
|
every frame repaints all of it. The most expensive-looking CSS in the app is what keeps
|
|
the rest of it cheap; `SHOW_GLASS_BLUR` stays on everywhere, and so do the decorative
|
|
loops, which measured as noise.
|
|
|
|
So the profile is one real change plus three caps that only bite while something is
|
|
playing, and are therefore not in the table: 30 fps on the canvas, 60 bubbles alive at
|
|
once, and ten progress-bar re-renders a second instead of sixty (`usePlaybackClock`
|
|
pushes a React `setState` per animation frame, into both `PlayView` and `PlayerBar`).
|
|
|
|
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.
|