Commit Graph

108 Commits

Author SHA1 Message Date
d465a74cde Figures: several albums per figure, picked on the LED ring
A figure folder that holds album folders instead of audio can play any of them,
up to six, in folder order (depth-first, alphabetical). Each is its own album,
titled "Fuchs - <folder>" and of the figure's kind. Putting the figure down plays
the album it played last, or the first, straight away; another figure placed in
between forgets the position in that album, not the album itself.

Meanwhile the ring offers all of them: one section per album from a fixed palette,
edges shifting towards the neighbours' hue, the selected one bright and wobbling.
The knob moves the selection one section per detent and wraps; pressing plays the
selected album, and left alone for 8 s the picker fades into the figure's colours
and the music carries on. While open, the device hands the knob to the picker
(`capture_knob`): turns do not change the volume and the press is not published to
Home Assistant. A tick per step and a confirm "pling" go with it.

The firmware only draws: one new ring effect, LED_WHEEL_EFFECT_SEGMENT_SELECT (23),
re-sent on every detent with the section to glide from. The look is tunable through
a temporary `general.figure_selection` config section until the right values
become constants.

Every UI sound - the screen's blubb, the parents' timer, the picker - now follows
the music volume with a floor, times a new parent-mode "Effekt-Lautstärke"
(`general.effects_volume`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:29:18 +02:00
a55cc4f67f Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer
Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big
analog watch over whatever page is open, built from the Delphin Uhr mockup
without its swimming dolphin. The overlay stays mounted and is only hidden, so it
appears at once. Beside the watch, only while they run:

- The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25)
  that starts `bus_lead_minutes` (60) before. School days skip weekends and the
  school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily
  from openholidaysapi.org and cached under the library cache. A badge on the
  clock says whether there is school today, and why not.
- The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time).
  It lives in the backend, so a parent's phone can start it for the kiosk. When it
  runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the
  device; it clears after a minute, or at the next key, tap or remote press.

Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button
next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay
bookmarked. The timer comes first there, the device settings below it.

Unmapped IR buttons are logged at debug level, to find out what irserver calls a
new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:45:45 +02:00
30065bb9e2 Design: the "Delphin Uhr" clock mockup, and the updated design bundle
Delphin Uhr.dc.html is the reference for the kiosk's clock overlay. The bundle
now also carries it, its bus and dolphin-with-watch images, and the dinosaur
skin's artwork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:44:32 +02:00
1254790374 Volume: quieter/louder buttons that take the remote's own step
Next to the five volume bars, a − and a + button move the volume by
`volume_increment` device units, the same step as the IR remote's volume keys and
the rotary encoder, via a new `{steps}` form of POST /api/volume. The bars still
jump to a percentage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:44:07 +02:00
c74cb09324 Browse: no vertical scrollbar on the root shelves
The three shelf rows fit the screen, and each scrolls sideways on its own, so the
page around them no longer scrolls vertically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:43:06 +02:00
077c4e819f Screen: switch off by itself when nothing is going on
With `general.screen.off_command` set, the monitor switches off once nothing has
played and nobody has done anything for `auto_off_minutes` (15 by default, 0 for
never, editable in parent mode). "Doing anything" is any intent a person could
have caused: a remote key, a web command, or a key press or tap in the kiosk,
which the page reports at most every 30s via POST /api/screen/activity. The first
such intent after an auto-off runs `on_command`, so the remote wakes the screen
too. The auto-off itself is silent; switches a person asks for still blubb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:42:59 +02:00
459599d2ba Number keys: the keyboard's digits work like the remote's; a key can hold one song
The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.

A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:41:34 +02:00
92305bece0 Screen switch: a blubb on every switch, and Del on the kiosk keyboard
ScreenService plays a synthesised two-bubble "blubb" (musicmouse.blubb) after each
switch, through aplay on the music's ALSA device at the player's volume: rising for
on, falling for off, going by the last word the toggle command prints. Nothing plays
with simulated audio or at volume 0. Built from samples rather than shipped as a
file, like the front-end's WebAudio blips, so its loudness follows the volume.

Del maps to a new "screen" action on every page, which App.tsx sends to the new
POST /api/screen/toggle only when the page is served from localhost - the kiosk on
the device - so Del on a tablet does not switch off a screen in another room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:00:37 +02:00
2d0caeb732 IR remote: power key switches the screen; count irserver's repeats
KEY_POWER emits ToggleScreenRequested, which a new ScreenService turns into the
optional general.screen.toggle_command (musicdolphin: ansible's kiosk-screen). It is
a service, not a reaction, so the subprocess runs in its own task instead of holding
the bus; a press while one is still running is dropped, and failures, hangs and a
missing command are only logged.

irserver reports every line of a held button as repeat 00, so a slightly long press
on Next skipped several tracks and Power would switch the screen off and on again.
A line of the same button within 0.25s of the previous one now counts as a repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:13:22 +02:00
92644e9519 IR remote: accept irserver's lowercase button and remote names
irserver (musicdolphin's IRTrans daemon) serves lircd's network protocol on port 8765,
but sends "key_play hauppauge" where lircd sends "KEY_PLAY Hauppauge", so every press
was dropped. Uppercase button names when parsing and compare the remote name
case-insensitively.

irserver also always reports repeat 00, so a held transport button is not debounced
there; volume ramps as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:57:50 +02:00
f67f84ded3 Phone layout: make the whole document reach the bottom in the home-screen app
Sizing the stage from the screen height was not enough on iOS 26: with the layout
viewport a status bar short, WebKit clips at the short viewport and draws nothing
below the document, so the dock still floated over a dark strip. In standalone mode
`<html>` now takes the larger of `100vh` and the screen height too, and the stage
follows it.

Coming back from the background, or a relaunch restoring the page, is when iOS is most
likely to hand back a short viewport and it does not always fire `resize`, so the
height is also re-applied on `pageshow` and `visibilitychange`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:29:50 +02:00
34656a8517 Phone layout: fill the screen in the home-screen app, drop the title row
In the iOS home-screen app the layout viewport - `100dvh`, `innerHeight`, even a
`position: fixed; inset: 0` box - can come back one status bar short, which left the
dock floating that far above the bottom of the screen with an empty strip below it.
In standalone mode the stage now takes its height from `screen` instead (swapped for
landscape, since iOS reports it in portrait), via a `--full-height` variable that
lib/fullHeight.ts sets only there; the keyboard inset is measured against the same
height. A Safari tab, Android and the kiosk are unchanged.

The mascot and app title above the group switch only cost a row on a phone, so they
are gone; the connection/figure pill moves above the group switch, centred, and only
shows while it has something to say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:40:21 +02:00
1da2274076 Pi profile: no glass blur, keep the drift; consolidate the perf knobs
With the ambient canvas off under ?pi=1, the backdrop blur no longer buys anything:
it paid for itself by giving each panel a compositing layer above the animating
canvas, and with nothing animating underneath it was just cost - enough to make the
browse rows visibly sluggish on the device. So the Pi profile now drops every backdrop
blur (`glassBlur`, which the music app and the typing game share), and turns the
drift and the moving mascot back on: plain CSS transform loops the compositor runs by
itself, which measured as noise.

The knob set is consolidated to match: one glass toggle instead of panel and card
blur, one shadows toggle for cards and tiles, the typing game's drift and view
transitions folded into the shared ones, and the backing-store cap fixed by profile
rather than a setting. The parent dialog groups them into Darstellung / Abspielen /
System and hides the canvas tuning while the canvas is off. Stored settings move to
musicmouse.perf.v2, since v1 drafts held the old preset in full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:19:06 +02:00
2a629faf50 Web frontend: switchable dolphin/dinosaur skins
Two kids, two looks: ?theme=dolphin or ?theme=dinosaur picks one, and the
choice is remembered in the browser so the installed home-screen app keeps it.
Everything that differs per child lives in one Skin object per file under
web/src/lib/skins/; layout and feature toggles in lib/theme.ts stay shared.
The dolphin renders pixel-identical to before.

The hardcoded sea hues (210 music, 175 typing game, 340 accent) are now CSS
variables written once at startup, and the Gecko-friendly sRGB gradient stops
come from the skin. The dinosaur is jungle green with a lava-orange accent,
its own mascot, icons, manifest and browser colour.

Nothing underwater is left in the dinosaur skin: leaves instead of bubbles
(play-view canvas, room page, typing game), a walking mascot, lava blobs in
the bubble game, a hatching egg instead of the treasure chest, dino pets that
walk or fly, a prehistoric speed ladder with the T-Rex as the hero rung, and
renamed games and light colours. Pets and ladder rungs are keyed by the
backend's existing ids, used as slots, so the backend and save format are
unchanged. Pets show as emoji until drawings exist.

python-backend/.gitignore: ignore tippen-curriculum-dino.yml, the dino
device's personal curriculum, like the original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:41 +02:00
21d65cad9e Web frontend: phone header glass and full-height stage
On an iPhone the sticky group switch sat on a fixed teal fade, which was the
wrong colour as soon as the ambience took on another album's tint (brown for
Conni). It is now a neutral frosted blur that fades out, so it suits whatever
is playing, and it reaches up under the status bar so scrolled content never
shows behind the clock.

After the search keyboard had been up, 100dvh in the home-screen app came back
a status bar short and the white page showed as a strip along the bottom. On
phones the stage is pinned to the viewport edges instead, with the sea colour
behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:45:52 +02:00
6981890be0 Web frontend: hide app title and row titles, regroup help sheet, drop Ctrl+d/u
- New SHOW_APP_TITLE switch (off): the mascot + title header goes, the status pill
  stays on a slim row of its own.
- SHOW_ROW_TITLES off; with only icons, the shelf icon moves into a column left of
  the tiles so an icon-only shelf is no taller than one without.
- Help sheet regrouped into sections in a compact two-column grid, and reachable
  from a small keyboard button (not on phones or the typing page).
- Ctrl+d/Ctrl+u half-page jumps removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:53:06 +02:00
5af5c54c62 Backfill podcast covers for episodes already on disk
A podcast's episode cover was only fetched right after the feed sync downloaded that
episode, so anything placed by hand (or downloaded before the og:image fallback
existed) never got one - in Wissen macht Ah only the three newest episodes had their
own art. The sync now fetches the cover of every on-disk episode in the feed window
that lacks a sidecar image. One that can't be found or fetched is recorded in
.missing-covers.json and left alone for the same 7-day backoff as failed downloads,
so a feed without per-episode art doesn't cost a page fetch per episode every poll.

The scanner's per-episode fingerprint now includes the sidecar cover, so one added
after the episode was first scanned invalidates its cached entry instead of being
ignored until the index is rebuilt. Episodes without a sidecar fingerprint exactly
as before, so existing caches stay valid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:53:06 +02:00
0f08e1d16c Web frontend: touch layout for phones
Phones got the kiosk's keyboard-first layout squeezed down: shelves cut off
by the tab rail and a header taking a fifth of the screen. They now get
their own layout, chosen by a media query (narrow, or short landscape on a
touch screen); tablets and desktop are unchanged.

- Pick a group (Musik/Hörbücher/Podcasts), then a category, then scroll
  through a grid of smaller albums.
- A floating glass dock: mini player, Musik/Zimmer tabs (no typing tab - a
  phone has no keyboard for it) and a search button that turns into a real
  search field, raised above the on-screen keyboard.
- Album sheet and room page get compact phone variants.

Browse state stays in the shared UiState, so resizing or rotating keeps
your place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:33:26 +02:00
61104d1e25 sync-library.sh: run the closing report under sh, not the remote login shell
The report was an ssh command string, which the remote user's login shell runs - and
on the devices root's is fish, which rejects `if ...; then`. The copy itself finished,
but the script then exited 127 at the very end, so a successful sync looked failed.
Feed the report to `sh -s` on stdin instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:22:21 +02:00
683cda35cb Add design references, editor settings and todo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
2db4368fc9 Web frontend: perf panel, cover warmup, incremental browse rendering
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
9fd106b757 Backend: cover cache and web API changes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:42:33 +02:00
794126bfdb Port "Mein Zimmer" - the Home Assistant room panel
The second of the web front-end's three pages. Lights get a toggle, five brightness
steps and six colours; shutters get four presets, up/stop/down and a window that
reflects the position Home Assistant reports. Its own violet hue rather than the
player's teal, so the two pages read as siblings rather than one bleeding into the
other - the same reason room.css has its own tokens.

The backend keeps Home Assistant's URL and token and relays the calls, so this client
learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha
means Home Assistant is not configured, which is a normal answer: the tab simply does
not appear.

Polled, not subscribed, and only while the page is on screen. HA's own auth-and-
subscribe websocket is more machinery than a room panel needs, and a lamp's state is
not worth a request every 2.5 seconds when nobody is looking at it - this device has a
music player to stay out of the way of. A tap patches the entity locally and records
when; a poll that was already in flight when the patch landed is dropped for that
entity rather than putting the lamp back to "off" until the next one catches up. That
reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried
over for the same reason.

Nothing on a shutter card animates locally. A real cover reports its own movement and
position, and a client-side guess at where it will end up is precisely what would
fight with that - the design mockup animated it because it had no backend to ask.

Two additions beyond a straight port, both because a kiosk has no pointer:

CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a
front-end whose README claims every screen is reachable from the keyboard. Plain TAB
was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere
else.

--page opens directly on a page, which a kiosk may well want and which is the only way
in with neither keyboard nor pointer.

Verified against the real Home Assistant: the shutter reported open and the lamp off,
and both drew that way. Nothing was switched - those entities are hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 01:02:18 +02:00
ec9d617ad8 Vendor Nunito as a TTF so the device renders in the right face
Slint cannot read woff2, which is the only form web/public/fonts/ carries, and Raspbian
has no fonts-nunito to install instead - so the device was going to fall back to
DejaVu Sans, which is not what any of this was drawn in. The upstream variable TTF
(SIL OFL, weights 200-1000) is small enough to vendor and covers every weight the
design uses, including the 800/900 the headings lean on.

SLINT_DEFAULT_FONT takes one file and makes it the primary font. shell.nix now points
at this copy rather than at nixpkgs' own, so development and the device render from the
identical file, and the binary falls back to finding it beside the executable when
neither the dev shell nor the device's launcher has set the variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:45:37 +02:00
6ccb3e458f Add a native Slint front-end for the music player
The React UI is sluggish on the kiosk and the reasons are browser-shaped: a compositor
deciding what gets its own layer, a requestAnimationFrame loop repainting the viewport,
and a JPEG decode per album card per cold start. The last three commits chased that
through the service worker and the cover pipeline. This is the other direction - the
same backend, the same layout, the same German copy, without a browser.

Browse and play only. The typing game, the room-lights page, parent mode and the
IR-remote assignment stay web-only, and nothing here touches python-backend.

Four things carry the performance claim, in rough order of how much they should matter:

Covers are downscaled once, ever. The backend serves one size - 640px on the long edge,
no ?size= - and a grid card is 180. src/covers.rs fetches each cover once on a worker
thread, resizes it, and writes a JPEG thumbnail to ~/.cache/musicmouse-slint/covers/.
Two tiers and only two, which is what makes the cache worth keeping: a per-widget pixel
size would give each album a dozen near-identical files and a fresh decode for each. The
cache is dropped when the websocket announces a rescan, because that is the one moment
the backend rewrites the art behind an unchanged cover URL - the trap sw.js had to learn
about the hard way.

The grid is virtualized. Rust hands the UI the album list pre-chunked into rows and the
view puts those in a ListView, which instantiates only what is on screen. A flat list of
660 cards gives it no rows to skip, hence the chunking. Same purpose as
content-visibility: auto on .grid > .card.

The progress bar animates between the 2 Hz pushes rather than running a clock, so
interpolation costs a property evaluation per frame on the render side and there is no
equivalent of usePlaybackClock. It is suppressed for the frame a track changes on, so a
new track jumps instead of sliding across two unrelated positions.

Nothing on screen animates by itself. The ambient canvas is not ported, for the reason
lib/lowPower.ts already gives: a loop repainting the viewport is a floor you cannot get
under while it runs at all.

The device must not use FemtoVG. On Mesa V3D it draws every runtime-loaded Image as
solid black (slint-ui/slint#11785, open), which here means every album cover; Skia and
the software renderer are unaffected. So `kiosk` is linuxkms + Skia and FemtoVG stays
the default only for desktop development. Both profiles are verified to build; the kiosk
binary links libinput/libgbm/libdrm and no X11 or Wayland at all.

lib/search.ts, lib/keyboard.ts and lib/format.ts were already pure functions with their
own tests, so they port across as pure Rust with theirs: 43 tests, no window required.
The .slint files are layout only - nothing in them formats a number or picks a word.

Verified against the real 660-album library rather than a fixture. tools/headless-shots.sh
renders the UI inside a nested headless compositor and grabs a frame per screen, which
is how that was checked on a machine whose session was locked; a Slint window needs a
real compositor, and under bare Xvfb nothing maps at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:26:09 +02:00
83d16e0058 Take the service worker off the cover path, and cut cover art to 384px
Search was still stalling for seconds a keystroke. A trace across five keystrokes said
the renderer main thread was blocked for 9.4 s in a single task with *zero* V8 samples
inside it - no JavaScript ran at all. What ran instead, on the worker pool during those
same 9.4 s: ImageDecodeTask 7.6 s, RasterTask 1.7 s, and only 229 ms of actual "Decode
Image". The main thread was waiting on cover bytes, not computing anything.

Two causes, and the first one was mine. The stale-while-revalidate handler added earlier
today made every cover do a Cache Storage read *plus* a network fetch *plus* a cache
write, all serialised through one worker thread. Measured with
Network.setBypassServiceWorker: worst keystroke 5580 ms through the worker against
461 ms without it. So covers no longer go through the worker at all. That costs nothing
here: this worker only registers on localhost or over HTTPS, which on this setup is the
kiosk on the device itself, where the backend is the same machine and a cache lookup is
strictly more work than asking for the file. The shell caching, which is what makes it
installable, stays.

The second is that decode cost goes with pixel count, and 640 px was headroom for a
tablet at devicePixelRatio 2 that nobody had asked for. A browse grid paints a card
132 px wide; the largest any screen asks for is 340. At 384 px, typing "conni" over 343
albums, keydown to painted, three runs:

  before   3735,  270, 5580,  88,  57 ms
  after     873,   75,   17,  24,  26 ms

`shrink_cover` never scales art up, so dropping the limit cannot be applied by
re-reading the cache - only by going back to the original art. _INDEX_VERSION 6 does
that: the index is discarded and every album is scanned again through store_cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:31:57 +02:00
ebab03b700 Cache every cover, not only the ones pulled out of tags
The downscaling landed and the device kept serving 1920px art, because _cover_for has
two branches and only one of them went through the cache. An album with a cover.jpg
already sitting beside its audio - which most of this library has - had its `cover`
point straight at that file, so FileResponse served whatever the internet had given it.
The cache directory was full of tidy 640px files that half the albums never used.

Both branches now store through the cache, in _cover_for and in _cover_for_episode
(sidecar and shared-folder art alike). The undownscaled bytes still come back alongside
the path, because colour extraction wants the real thing.

That re-points `cover` for every album, but only for albums that are actually rescanned,
and the scanner reuses anything whose fingerprint is unchanged - so _INDEX_VERSION goes
to 5. An index from 4 is discarded and rebuilt, which is what that mechanism is for and
is cheap by design.

Two tests asserted `cover == <the library file>`, which is precisely the behaviour being
changed. The episode one was also checking something real - that an episode's own
sidecar art beats the show's shared cover - and both covers now live under their own
album id, so it reads the colour back out of the stored file instead of comparing paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:57:00 +02:00
3fea56d4e1 Stop cover art going stale in two caches at once
Shrinking the covers changed nothing on the device, twice over, because two layers were
independently serving the old bytes and each had the same wrong premise written into its
comment: that a cover is "content-addressed by album id". The id addresses which album
the art belongs to. The bytes behind the URL change whenever the art is reprocessed.

The backend sent `public, max-age=604800`, so every browser that had loaded the page in
the previous week kept decoding the 3000px original out of its own disk cache without
asking. It now sends `no-cache`, which does not mean "do not store" but "revalidate
before reusing" - the file stays cached and the usual answer is a 304.

The service worker cached covers cache-first on a URL that never changes, which means a
client could serve a stale cover for ever; bumping the cache name fixed today's covers
and would have had to be done again for the next batch. It now does
stale-while-revalidate: the cached copy is returned immediately, so a grid of album art
still never waits on the network, and a fresh copy is fetched behind the page for next
time. Neither layer needs a version bump when art is reprocessed again.

The revalidation is off the paint path entirely, which is what makes `no-cache`
affordable here: the service worker answers first, the network call happens behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:43:56 +02:00
e1c10f5408 Retire the service worker's cache of the old, huge cover art
A DevTools trace recorded on musicdolphin put the whole question beyond argument:

  ImageDecodeTask      29,939 ms  x20   (~1.5 s each)
  Decode LazyPixelRef   3,924 ms  x20
  Decode Image          3,530 ms  x22
  Paint                    99.7 ms
  Layout                   77.3 ms
  all app JavaScript      <400 ms  across 22 seconds

Image decoding is not the largest cost on that page, it is very nearly the only one.

The covers should already have been small - the backend downscales to 640 px now - but
the page was still decoding them at 1920, 1600, 1400 px, with deliveryType
"cache-storage" on every one. The service worker caches cover art cache-first, keyed on
URL, on the premise that it is "immutable per album id". The id did not change; the
bytes did. So every client that had ever loaded a cover kept serving the 3000 px
original from its own disk and never asked the backend for the new one.

Renaming the cache is the retirement mechanism the worker already has - `activate`
deletes every cache that is not one of the two current names - so COVERS becomes v2,
with a note saying that changing how covers are produced means bumping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:29:37 +02:00
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
3cdad1e714 Stop rendering album cards that are off screen, and split card blur from panel blur
Searching a single letter over a real library puts 340 cards in one grid, each with a
cover image, a shadow and a backdrop blur, all of them live whether or not they are on
screen. On musicdolphin that saturated the renderer badly enough that a DevTools
Runtime.evaluate could not be scheduled on the main thread inside 30 seconds, which is
a fair description of what "sluggish" felt like.

content-visibility: auto on the grid's cards is the browser's own answer: off-screen
cards skip layout, paint and compositing and come back as they scroll near the
viewport. The grid's tracks are sized by minmax(180px, 1fr) rather than by card
content, so skipping that content cannot move the columns; contain-intrinsic-size
supplies the block-axis guess and `auto` remembers each card's real size after its
first render, so scroll height and offsetTop - which BrowseView's keep-the-selection-
on-screen effect reads - stay honest. Not behind ?pi=1: there is no visual difference
to trade away.

SHOW_CARD_BLUR separates the repeated frosted surfaces (every card, every list row)
from the handful of panels wrapped around them, because the two behave nothing alike
and lumping them together is what made the first Pi profile five times slower. A panel
is one live backdrop copy that buys its whole subtree a compositing layer; a card is
one of three hundred sitting directly over the animated canvas. ?pi=1 now drops the
card blur and keeps the panel blur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:12:26 +02:00
2514ecbc35 Measure the Pi profile on the Pi, and keep only what helped
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>
2026-09-20 13:44:05 +02:00
cc3db44c4e Add a ?pi=1 profile, and stop re-deriving search keys per keystroke
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>
2026-09-20 13:15:23 +02:00
a7fb56c9fe Target the Python that Raspberry Pi OS ships
Requiring 3.13 meant the device needed an interpreter the distribution does not
have, which is what dragged uv in, and uv then had to be matched to the Pi's
32-bit userland by hand and to build Pillow from source because no armv7 wheel
exists for a 3.13 ABI. Dropping to 3.11 removes all of that: apt provides the
interpreter and piwheels has prebuilt armhf wheels for the native dependencies.

The 3.13-only syntax was shallow - PEP 695 throughout, which converts back
mechanically:

  type X = Y               ->  X: TypeAlias = Y
  type Handler[E: Event]   ->  E = TypeVar("E", bound=Event) plus a plain alias,
                               which is generic anyway because it carries a TypeVar
  def f[T: Bound](...)     ->  a module-level TypeVar

Also drop the one @override (3.12, and static-only), and stop the lirc test
harness calling Server.close_clients(), which is 3.13: the scripted handler now
releases its connection when asked, which is what that call was there to force.

Verified on 3.11.14 - 485 passed, mypy strict clean - and still 496 passed on
the 3.14 dev venv, which additionally has the analysis extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:20:59 +02:00
fa3d92189c Keep only the newest 50 episodes of each podcast
A show that has published for years is unbounded: GEOlino Spezial alone is 358
episodes and 5.8 GB, and the device it syncs onto is a 30 GB SD card that also
holds the rest of the library. Nothing stopped the 6-hourly poll from eventually
filling it.

Cap each show's folder at general.podcast_episode_limit (default 50, null to
keep everything), pruning the oldest past that after each sync pass.

The same limit caps what is downloaded, and it has to be one number for both.
Prune to the newest N but keep fetching everything the feed offers, and every
poll would re-download exactly the episodes the previous one deleted - forever,
at full size, since missing_episodes() decides purely from what is on disk.
There is a test for that specific loop.

Pruning only touches files named the way this module names them
(YYYYMMDD - Title.ext), so feed.txt, folder.jpg, the failed-download record and
anything placed by hand are all left alone; a parse that fails means "not ours",
not "delete it". An episode's sidecar cover goes with it. A pass that only
deleted still reports a change, because the library needs the rescan just as
much as it does after a download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:40:53 +02:00
dd14f5d901 Add a script to seed a device's library and analysis cache
Copying the library to a Pi is one rsync; copying the cache with it is not,
because its three parts travel differently. covers/ is keyed by the album
folder's path relative to the library root, so it survives the move. analysis/
is keyed by name:size:mtime, so it survives only if mtimes do - hence -a
everywhere, and a comment saying why. index.json holds absolute source paths and
must not be copied at all: scan_library() reuses a cached album on a matching
(name, size, mtime) fingerprint without re-checking the path, so copying it
leaves every album pointing at the dev machine and playback fails on files that
are present. It is the cheap part, and the device rebuilds it on first scan
while reusing everything expensive.

Syncs shelf by shelf rather than the whole directory, which is what makes
--delete safe: config.yml, tippen-curriculum.yml, tippen-progress.json and the
cache all live in the same directory on the device and have no counterpart here.
Preflight checks ssh, sizes the transfer and refuses to fill the SD card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:58:50 +02:00
274085b92e Stop tracking the library cache
python-backend/.gitignore lists /.musicmouse-cache, but index.json and seven
cover JPGs were committed before that ignore existed, so the ignore never
applied to them. index.json is a 2 MB machine-specific blob holding 6606
absolute /home/martin/... paths, rewritten on every app start - it showed up as
modified in git status permanently, and a deploy checkout would have shipped a
dev machine's index to the device.

The files stay on disk; only the tracking goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:26:45 +02:00
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
e6f6b15cc7 Make the lesson map the floor screen of the typing tab
The aquarium was a separate landing screen in front of the map, showing the pets
collected so far and a "Weiter üben" button. But the pets already swim behind
every screen in the tab, so the screen mostly restated what was visible anyway,
at the cost of one extra step between opening the tab and typing.

The map absorbs what was worth keeping: the "Weiter üben" button now floats over
it, and the pets, streak and best animal move into the header. One Escape from
the map leaves the tab entirely, where it used to go back a screen first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:08:11 +02:00
f3f7082fb8 Draw locked covers as artwork instead of a question mark
A reward-gated album showed a bare ❓ over the generated gradient, which reads as
"something is broken" rather than "something is waiting". It is now a proper
placeholder cover, with a separate one for audiobooks and podcasts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:19 +02:00
36e93be79b Let a lesson list review keys beside the two it introduces
A lesson's `keys` was capped at two outright, which made it impossible to write
"K, with the four keys before it still in play". The cap now counts only what is
*new*; keys already taught can be listed freely, and the spotlight falls on the
new ones alone, so a lesson titled "K" drills K instead of spreading itself
evenly over all five keys it names. A round that introduces nothing new still
spotlights its whole list - that is the "mixed" replay, unchanged.

Also documents `unlocks:` paths as relative to the library root. Absolute and
"~" paths still resolve, so an existing file keeps working, but a relative path
survives moving the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:19 +02:00
b6342cc117 Drop the pearls currency
Pearls were earned every run and spent on nothing: the aquarium fills up by
finishing worlds, not by paying for it. A counter that only ever goes up is one
more stat competing for attention on the result sheet and the home screen, and
one more field to carry through the run payload, the progress file and both test
suites.

Stars and the animal ladder already say how a run went, so nothing is lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:08 +02:00
fd6283718c Analyse tracks in a pool of worker processes
A first pass over an unanalysed library is hours of librosa, and there was no
reason for a desktop to spend them one core at a time. Analysis now runs in a
process pool sized by `general.library.analysis_workers`, defaulting to one per
core bar one (capped at 8) when the key is absent.

Two consequences worth knowing before touching this: whatever an `Analyzer`
returns has to be picklable, and an analyzer that keeps state on its own
instance - a test double counting calls - only behaves as written with
`analysis_workers=1`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:04:01 +02:00
75b0eed080 Remove the Quallenalarm (jellyfish) typing minigame
Letters-round lessons only had bubbles and jellyfish as eligible arcade
modes; with jellyfish gone, bubbles is now always used instead of the
two alternating - no lessons need to be dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:38:19 +02:00
a69db98241 Use a plain text note glyph for the music tab, not the emoji
Setting color explicitly wasn't enough: the 🎵 emoji has its own
baked-in colour (a muted grey-blue on at least one real platform) and
simply ignores the CSS color property, unlike a plain Unicode symbol.
Swapped to the bare eighth-note character (♪), which renders as text
and finally responds to the color already set on the button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:29:18 +02:00
e243862769 Fix barely-visible music-note tab icon
The glass-bar redesign dropped the explicit icon color, so inactive
icons fell back to the browser's default text color - fine for icons
that render as full-color emoji, but the music note apparently renders
as a plain glyph on at least one platform, making it nearly invisible
against the bar's near-white glass background. Set it explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:21:34 +02:00
b8f9f6d537 Give the tab rail its own glass bar and reserve space for it
The three tab buttons now share one frosted glass pill instead of each
floating separately, and BrowseView/RoomView reserve TAB_RAIL_CLEARANCE
of right-side padding so a full-width row or grid never renders under
it - verified against the real library, where dense rows (e.g. Conni's
82 audiobooks) previously butted right up against the rail.

Also gitignore the real (non-.example) tippen-curriculum.yml and
tippen-progress.json, matching config.yml's own privacy treatment,
since the curriculum now contains real reward paths into a personal
library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:16:42 +02:00
57ead93497 Apply lock state to category-tile cover previews too
Verified the whole reward-lock feature against a real library with an
actual browser (Playwright + headless Chromium): the album-grid cards
already showed the question-mark placeholder for a locked album, but
its 2x2 preview thumbnail in the category-tile view (the screen before
drilling into an artist/category) still rendered the real cover art,
since that Cover call never received the locked prop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:37:57 +02:00
7f5e2733c2 Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player /
smarthome / typing), replacing the old single room-toggle corner button with a
vertical icon tab rail. Curriculum and progress now come from the backend
(musicmouse/tippen/*) instead of a build-time YAML import and localStorage.

Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question
mark for locked albums/tracks with a hint on what unlocks them, and
ResultSheet gets a new unlock-animation block alongside the existing
lesson-unlock and aquarium-creature celebrations.

CSS from the two apps is merged carefully: identical rules (bubble/card/
key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is,
while rules that bake in each app's own hue are kept separate under a
`tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so
neither app's look bleeds into the other's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:25:42 +02:00
f7a5d24d8d Add backend support for the typing game: curriculum, progress, and reward unlocks
Moves the typing app's lesson plan and progress from client-side YAML/localStorage
into the backend, and adds a reward system that ties passing a lesson to unlocking
part of the music library. Lock state is always recomputed live from curriculum x
progress x the live library, never persisted separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:03:45 +02:00