22 Commits

Author SHA1 Message Date
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
104 changed files with 14407 additions and 739 deletions

View File

@@ -123,3 +123,7 @@ reported at once.
and size, so **a change to how the scanner derives a title, artist or series is
invisible until the cache is invalidated** — bump `_INDEX_VERSION` in
`musicmouse/library/cache.py` when you touch that logic.
- Track analysis (librosa) runs in a pool of worker *processes* - see
`musicmouse/library/workers.py`. Anything an `Analyzer` returns therefore has to be
picklable, and an analyzer that records state in its own instance (a test double
counting calls) only behaves as written with `analysis_workers=1`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,12 @@ general:
# is rebuilt on the next start. Deleting it does throw away track analysis, which
# is expensive to recompute.
cache: .musicmouse-cache
# How many tracks the background analyzer may work on at once, each in its own
# worker process. Omitted means one per core bar one (capped at 8), which is what
# turns a first-time pass over a whole library from an overnight job into a coffee
# break on a desktop. Set it to 1 on a machine that has better things to do, or to
# a specific number to cap how much of it analysis may take.
# analysis_workers: 4
# Serial port the ESP32 firmware is on. A dropped link is retried, not fatal.
# Required - use "simulate" to run without the mouse attached, which is a complete
@@ -55,6 +61,19 @@ general:
# a half-finished .tmp - is ignored.
audio_extensions: [".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac"]
# How many episodes of each podcast show to keep, newest first. A show that has
# published for years grows without bound: GEOlino Spezial alone is 358 episodes and
# 5.8 GB, which will not sit next to the rest of a library on a Pi's SD card.
#
# The same number caps what gets downloaded, which is what makes the folder settle.
# Prune to the newest N but fetch everything the feed offers, and every poll would
# re-download the episodes the last one deleted.
#
# Lowering this DELETES the episodes that fall outside the window on the next poll,
# and an episode that has aged out of its feed cannot be fetched again. Use `null` to
# keep every episode and mind the free space yourself.
podcast_episode_limit: 50
# The web front-end. Omit the whole section to run without it.
#
# There is no authentication: this is a device on a home network. The settings panel

View File

@@ -19,7 +19,7 @@ import logging
import sys
from collections.abc import Awaitable, Callable, Coroutine
from pathlib import Path
from typing import Any
from typing import Any, TypeVar
import httpx2
@@ -32,7 +32,7 @@ from musicmouse.devices.mouse import MusicMouseDevice
from musicmouse.devices.null_transport import NullTransport
from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.devices.serial_link import SerialLink
from musicmouse.library import MusicLibrary
from musicmouse.library import MusicLibrary, default_worker_count
from musicmouse.library.analysis import build_analyzer
from musicmouse.reactions import register_all
from musicmouse.services.base import Service
@@ -283,11 +283,16 @@ def _build_player(bus: EventBus, general: GeneralConfig, *, clock: RealClock) ->
async def build_library(config: Config) -> MusicLibrary:
library_config = config.general.library
workers = library_config.analysis_workers
return await MusicLibrary.build(
library_config.root,
library_config.cache,
frozenset(config.general.audio_extensions),
analyzer=build_analyzer(),
# Unset in the config means "use the machine": a first pass over an unanalyzed
# library is hours of DSP, and there is no reason for a desktop to do it one
# core at a time. `MusicLibrary` itself defaults to 1 - see its docstring.
analysis_workers=default_worker_count() if workers is None else workers,
figure_kinds=config.figure_kinds,
)
@@ -319,7 +324,10 @@ def _build_app(
return app
def _service_of_type[T: Service](services: list[Service], kind: type[T]) -> T | None:
T = TypeVar("T", bound=Service)
def _service_of_type(services: list[Service], kind: type[T]) -> T | None:
return next((s for s in services if isinstance(s, kind)), None)
@@ -373,6 +381,7 @@ def _build_services(
on_change=lambda: app.rescan_library(
broadcast=web_service.hub.broadcast_library if web_service else None
),
episode_limit=app.config.general.podcast_episode_limit,
)
)

View File

@@ -16,7 +16,7 @@ import contextlib
import inspect
import logging
from collections.abc import Callable, Coroutine
from typing import Any
from typing import Any, TypeAlias, TypeVar
from musicmouse.events import Event
@@ -24,8 +24,12 @@ _log = logging.getLogger(__name__)
__all__ = ["EventBus", "Handler", "Unsubscribe"]
type Handler[E: Event] = Callable[[E], Coroutine[Any, Any, None] | None]
type Unsubscribe = Callable[[], None]
#: An alias carrying a TypeVar is generic on its own, so ``Handler[SomeEvent]`` still
#: parameterises it the way the PEP 695 form did.
E = TypeVar("E", bound=Event)
Handler: TypeAlias = Callable[[E], Coroutine[Any, Any, None] | None]
Unsubscribe: TypeAlias = Callable[[], None]
class EventBus:
@@ -63,7 +67,7 @@ class EventBus:
# --------------------------------------------------------------- subscription
def subscribe[E: Event](self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
def subscribe(self, event_type: type[E], handler: Handler[E]) -> Unsubscribe:
"""Register ``handler`` for ``event_type`` and any subclass of it."""
self._handlers.setdefault(event_type, []).append(handler)
self._resolved.clear()

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated, Any, Final, Literal, Self
from typing import Annotated, Any, Final, Literal, Self, TypeAlias
from pydantic import (
BaseModel,
@@ -26,6 +26,7 @@ from ruamel.yaml.error import YAMLError
from musicmouse.color import ColorRGBW, parse_color
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT
_log = logging.getLogger(__name__)
@@ -50,7 +51,7 @@ __all__ = [
]
#: Number keys on the IR remote, as lircd's ``BTN_0``..``BTN_9`` map to them.
type Digit = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
Digit: TypeAlias = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
@@ -172,6 +173,11 @@ class LibraryConfig(_Strict):
root: Path
#: Scan results, extracted cover art and track analysis. Relative to this file.
cache: Path = Path(".musicmouse-cache")
#: How many tracks background analysis may work on at once, each in its own worker
#: process. Omit for one per core bar one (see
#: :func:`musicmouse.library.workers.default_worker_count`); set it to 1 to keep
#: analysis to a single process on a machine that has other work to do.
analysis_workers: int | None = Field(default=None, ge=1)
@field_validator("root")
@classmethod
@@ -283,6 +289,14 @@ class GeneralConfig(_Strict):
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
#: How many episodes of each podcast show to keep, newest first. A show that has
#: published for years grows without bound and will eventually fill the device's SD
#: card. ``null`` keeps every episode, and minding the free space is then on you.
#:
#: Lowering this *deletes* the episodes that fall outside the window on the next
#: poll, and an episode that has aged out of its feed cannot be fetched again.
podcast_episode_limit: int | None = Field(default=DEFAULT_EPISODE_LIMIT, ge=1)
@property
def serial_simulated(self) -> bool:
return self.serial_port == SIMULATE

View File

@@ -15,7 +15,7 @@ from __future__ import annotations
import struct
from dataclasses import dataclass, replace
from enum import IntEnum
from typing import override
from typing import TypeAlias
from musicmouse.effects import (
EffectAlexaSwipeConfig,
@@ -162,7 +162,7 @@ class FirmwareLog:
text: str
type Decoded = InputEvent | FirmwareLog
Decoded: TypeAlias = InputEvent | FirmwareLog
# ------------------------------------------------------------------------- encoding
@@ -367,12 +367,11 @@ class SetButtonBrightness:
button: Button
brightness: float
@override
def __repr__(self) -> str:
return f"{self.button.slug} backlight <- {self.brightness:.2f}"
type HostCommand = SetEffect | SetButtonBrightness
HostCommand: TypeAlias = SetEffect | SetButtonBrightness
_ID_TO_EFFECT: dict[int, tuple[LedZone, type[LedEffect]]] = {
message: (zone, effect_cls)

View File

@@ -15,7 +15,7 @@ from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Literal
from typing import Literal, TypeAlias
from musicmouse.effects import LedEffect
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
@@ -53,7 +53,7 @@ __all__ = [
"VolumeChanged",
]
type EventSource = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
EventSource: TypeAlias = Literal["device", "player", "mqtt", "web", "lirc", "simulator", "system"]
@dataclass(frozen=True, slots=True, kw_only=True)

View File

@@ -8,14 +8,14 @@ plain immutable data.
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections import deque
from collections.abc import Awaitable, Callable, Collection, Mapping
from concurrent.futures import BrokenExecutor, Executor
from dataclasses import replace
from pathlib import Path
from typing import Final
from typing import Any, Final
from musicmouse.library.analysis import (
ANALYZER_VERSION,
@@ -29,6 +29,7 @@ from musicmouse.library.cache import Fingerprint, LibraryCache
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
from musicmouse.library.scanner import scan_library
from musicmouse.library.sections import SECTIONS, AlbumKind
from musicmouse.library.workers import analysis_pool, analyze_one, default_worker_count
from musicmouse.media import Playlist
_log = logging.getLogger(__name__)
@@ -45,6 +46,7 @@ __all__ = [
"NullAnalyzer",
"TrackCurves",
"album_id",
"default_worker_count",
"track_key",
]
@@ -69,19 +71,12 @@ _PUBLISH_BATCH_SIZE: Final = 25
_PROGRESS_INTERVAL_SECONDS: Final = 5.0
def _analyze_one(
analyzer: Analyzer, path: Path
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
"""Runs in a worker thread. Lowers this thread's own scheduling priority first.
On Linux, ``os.nice`` affects only the calling thread, not the whole process - so
this makes idle-time analysis yield CPU to anything else without touching threads
used for other work. Niceness only ever increases and clamps at the OS maximum
(19), so calling this repeatedly on a reused pool thread is harmless.
"""
with contextlib.suppress(OSError):
os.nice(1)
return analyzer.analyze(path)
def _discard(future: asyncio.Future[Any]) -> None:
"""Drop a result nobody is going to read, without leaving a warning behind."""
if not future.done():
future.cancel()
elif not future.cancelled():
future.exception()
class MusicLibrary:
@@ -94,12 +89,19 @@ class MusicLibrary:
extensions: frozenset[str],
*,
analyzer: Analyzer | None = None,
analysis_workers: int = 1,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> None:
self.root = root
self.cache = cache
self.extensions = extensions
self.analyzer: Analyzer = analyzer or NullAnalyzer()
#: How many tracks background analysis may work on at once. The default of 1
#: keeps the analyzer in this process, where an analyzer that holds state
#: still behaves as written; the app passes `default_worker_count()`, which is
#: what makes a first-time pass finish in hours rather than days on a desktop.
#: See `musicmouse.library.workers`.
self.analysis_workers = analysis_workers
#: What each figure holds. The only thing a folder name cannot say.
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
@@ -146,9 +148,7 @@ class MusicLibrary:
]
if not candidates:
return None
return max(
candidates, key=lambda album: album.tracks[0].path.name if album.tracks else ""
)
return max(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
def beats(self, identifier: str, index: int) -> BeatGrid | None:
album = self.get(identifier)
@@ -219,10 +219,18 @@ class MusicLibrary:
extensions: frozenset[str],
*,
analyzer: Analyzer | None = None,
analysis_workers: int = 1,
figure_kinds: Mapping[str, AlbumKind] | None = None,
) -> MusicLibrary:
cache = LibraryCache(cache_dir)
library = cls(root, cache, extensions, analyzer=analyzer, figure_kinds=figure_kinds)
library = cls(
root,
cache,
extensions,
analyzer=analyzer,
analysis_workers=analysis_workers,
figure_kinds=figure_kinds,
)
library._entries = await asyncio.to_thread(cache.load_index)
await library.refresh()
return library
@@ -261,64 +269,145 @@ class MusicLibrary:
is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None,
batch_size: int = _PUBLISH_BATCH_SIZE,
workers: int | None = None,
) -> int:
"""Run the analyzer over tracks of `kinds` that have no current result.
Restricted to music by default - see `_ANALYZED_KINDS`. Checked before every
track, `is_busy()` pauses the whole pass rather than one file: analysis must
never compete with audio decoding for CPU, and a children's player is idle most
of the day, so the pass simply resumes next time it is. A track the analyzer
fails on (corrupt file, DRM, zero length) is still recorded as attempted - with
every scalar left `None` - so it is never retried forever and the frontend falls
back to the un-analyzed baseline for it. Results are folded into the live index
and persisted every `batch_size` tracks, so a long first run is visible in open
browser tabs as it goes rather than only once it finishes.
Restricted to music by default - see `_ANALYZED_KINDS`. Up to `workers` tracks
are analyzed at once (`self.analysis_workers` when not given), each in its own
worker process - see `musicmouse.library.workers` for why processes and how the
machine is kept usable while they run. Checked before every track is handed out,
`is_busy()` pauses the whole pass rather than one file: analysis must never
compete with audio decoding for CPU, and a children's player is idle most of the
day, so the pass simply resumes next time it is. Tracks already in flight when
it goes busy are allowed to finish - their results are already paid for - and
the workers are then shut down for the duration rather than sitting idle with
a librosa apiece resident on a machine that is now playing music.
A track the analyzer fails on (corrupt file, DRM, zero length) is still recorded
as attempted - with every scalar left `None` - so it is never retried forever and
the frontend falls back to the un-analyzed baseline for it. A worker *process*
dying, though, says nothing about the track it was on, so that ends the pass
without recording anything: the next one picks the same tracks up again.
Results are folded into the live index and persisted every `batch_size` tracks,
so a long first run is visible in open browser tabs as it goes rather than only
once it finishes.
"""
analyzer = self.analyzer
if analyzer.version < ANALYZER_VERSION:
return 0
pending = deque(self._pending_tracks(kinds, analyzer.version))
if not pending:
return 0
workers = self.analysis_workers if workers is None else max(1, workers)
total = len(pending)
_log.info("Analyzing %d tracks with %d worker(s)", total, workers)
loop = asyncio.get_running_loop()
in_flight: dict[asyncio.Future[Any], tuple[str, Path]] = {}
done = 0
last_report = time.monotonic()
for album in self.albums:
if album.kind not in kinds:
continue
for track in album.tracks:
try:
while pending:
while is_busy():
# Waited out *between* pools, so a half-hour album is not played
# with a houseful of idle worker processes holding onto librosa.
await asyncio.sleep(_BUSY_POLL_SECONDS)
key = track_key(track.path)
cached = self.cache.load_analysis(key)
if cached is not None and cached.version >= analyzer.version:
continue
now = time.monotonic()
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
_log.info("Analyzing library: %d tracks done so far, now on %s", done, track.path)
last_report = now
try:
analysis, grid, curve = await asyncio.to_thread(
_analyze_one, analyzer, track.path
)
except Exception:
_log.warning(
"Analyzer raised on %s; marking it attempted so it is not retried forever",
track.path,
exc_info=True,
)
analysis, grid, curve = TrackAnalysis(version=analyzer.version), None, None
if grid is not None:
self.cache.store_beats(key, grid)
if curve is not None:
self.cache.store_curve(key, curve)
self.cache.store_analysis(key, analysis)
done += 1
if done % batch_size == 0:
await self._publish_analysis(kinds, on_batch)
with analysis_pool(workers) as pool:
try:
while in_flight or (pending and not is_busy()):
while pending and len(in_flight) < workers and not is_busy():
key, path = pending.popleft()
in_flight[self._submit(pool, loop, path)] = (key, path)
if not in_flight:
break # gone busy: drop the pool and wait above
finished, _ = await asyncio.wait(
in_flight, return_when=asyncio.FIRST_COMPLETED
)
for future in finished:
key, path = in_flight.pop(future)
self._store_result(key, path, future)
done += 1
if done % batch_size == 0:
await self._publish_analysis(kinds, on_batch)
now = time.monotonic()
if now - last_report >= _PROGRESS_INTERVAL_SECONDS:
_log.info("Analyzing library: %d/%d tracks done", done, total)
last_report = now
finally:
# Nothing is left to read these - without this, a pass that ends
# early (cancelled at shutdown, or a dead pool) leaves "exception
# was never retrieved" behind for every track still in flight.
for future in in_flight:
_discard(future)
in_flight.clear()
except BrokenExecutor:
_log.error(
"An analysis worker process died (out of memory?) after %d of %d tracks; "
"stopping this pass. The rest are retried on the next one.",
done,
total,
)
if done % batch_size:
await self._publish_analysis(kinds, on_batch)
if done:
_log.info("Analyzed %d tracks", done)
return done
def _pending_tracks(self, kinds: Collection[AlbumKind], version: int) -> list[tuple[str, Path]]:
"""The whole pass's worklist, as (cache key, path), worked out up front.
Up front rather than per track, because a pool has to have the next file ready
the moment a worker frees up, and because it is what lets the progress line say
"37/412". Deduplicated by cache key: the same file can sit in two albums, and
two workers analyzing it at once would be pure waste.
"""
worklist: list[tuple[str, Path]] = []
seen: set[str] = set()
for album in self.albums:
if album.kind not in kinds:
continue
for track in album.tracks:
key = track_key(track.path)
if key in seen:
continue
seen.add(key)
cached = self.cache.load_analysis(key)
if cached is not None and cached.version >= version:
continue
worklist.append((key, track.path))
return worklist
def _submit(
self, pool: Executor | None, loop: asyncio.AbstractEventLoop, path: Path
) -> asyncio.Future[Any]:
"""Start one track, in a worker process or - with no pool - on a thread here."""
if pool is None:
return asyncio.ensure_future(asyncio.to_thread(analyze_one, self.analyzer, path))
return loop.run_in_executor(pool, analyze_one, self.analyzer, path)
def _store_result(self, key: str, path: Path, future: asyncio.Future[Any]) -> None:
"""Persist one finished track. Re-raises only what ends the whole pass."""
error = future.exception()
if isinstance(error, BrokenExecutor):
raise error
if error is not None:
_log.warning(
"Analyzer raised on %s; marking it attempted so it is not retried forever",
path,
exc_info=error,
)
analysis, grid, curve = TrackAnalysis(version=self.analyzer.version), None, None
else:
analysis, grid, curve = future.result()
if grid is not None:
self.cache.store_beats(key, grid)
if curve is not None:
self.cache.store_curve(key, curve)
self.cache.store_analysis(key, analysis)
async def _publish_analysis(
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
) -> None:

View File

@@ -5,7 +5,13 @@ different amounts to produce::
<cache_dir>/
├── index.json cheap: tags and structure. Thrown away freely.
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame
├── covers/<album_id>.jpg medium: the album's art - out of an ID3 APIC
│ frame, or copied from a cover.jpg in the
│ folder - downscaled to MAX_COVER_PX on
│ the way in. An album's `cover` always
│ points here and never into the library,
│ so nothing can serve full-size art by
│ accident.
└── analysis/<track_key>.json expensive: minutes of DSP per track
analysis/<track_key>.beats.json
analysis/<track_key>.curve.json
@@ -18,12 +24,15 @@ folder or re-sorting a section then costs nothing.
from __future__ import annotations
import io
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from typing import Any, Final, cast
from PIL import Image, UnidentifiedImageError
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.models import Album, LibraryTrack
@@ -37,7 +46,16 @@ __all__ = ["Fingerprint", "LibraryCache"]
#: worked out - not just when the JSON shape does. A cached entry is reused whenever its
#: files are untouched, so otherwise a change to that logic is invisible until somebody
#: edits their music folder.
_INDEX_VERSION = 4
# 5: every album's `cover` now points into this cache, downscaled, including the ones
# that used to point straight at a `cover.jpg` in the library folder.
# 6: MAX_COVER_PX dropped from 640 to 384. This is what applies it. `shrink_cover` never
# scales art *up*, so a cover already written at some size is only ever rewritten
# smaller - which means a cache holding 640 px files cannot be brought to 384 px by
# re-reading them, only by going back to the original art. Discarding the index does
# exactly that: every album is scanned again and `store_cover` runs on the real art.
#
# Bumping this is cheap by design - see the module docstring.
_INDEX_VERSION = 6
@dataclass(frozen=True, slots=True)
@@ -86,9 +104,37 @@ class LibraryCache:
def store_cover(self, album_id: str, data: bytes) -> Path:
path = self.cover_path(album_id)
path.write_bytes(data)
path.write_bytes(shrink_cover(data))
return path
def shrink_stored_covers(self) -> int:
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
Needed because :func:`~musicmouse.library.scanner.scan_library` reuses an album
whose fingerprint is unchanged *without* re-reading its tags, so a cover written
by an older version would otherwise never be touched again. Reading a JPEG's
dimensions only parses its header, so once every file is within the limit this
costs one small read per album and nothing else.
Returns the number of files actually rewritten.
"""
rewritten = 0
for path in sorted(self.covers.glob("*.jpg")):
try:
with Image.open(path) as image:
oversized = max(image.size) > MAX_COVER_PX
except (OSError, UnidentifiedImageError):
continue
if not oversized:
continue
try:
shrunk = shrink_cover(path.read_bytes())
path.write_bytes(shrunk)
except OSError: # pragma: no cover - a cache we cannot write is not fatal
continue
rewritten += 1
return rewritten
# ------------------------------------------------------------------ analysis
def load_analysis(self, key: str) -> TrackAnalysis | None:
@@ -213,3 +259,54 @@ def _album_from_json(data: dict[str, Any]) -> Album:
for track in data["tracks"]
),
)
# ---------------------------------------------------------------------------- covers
#: Longest edge kept for cached album art, in pixels.
#:
#: The art that comes out of an ID3 APIC frame is sized for a record sleeve, not for a
#: screen: a real library here averaged 3000x3000 and 580 kB per cover, 140 MB for 284
#: albums. The browser was decoding nine megapixels - about 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.
#:
#: 340 is the largest any of this app's screens asks for (the play view; the browse grid
#: asks for 180 and the player bar for 56), so this only has to beat 340 to be lossless
#: where it shows.
#:
#: It was 640 first, for headroom on a tablet at devicePixelRatio 2, and that headroom
#: turned out to be expensive on the device that actually runs this. Measured on
#: musicdolphin, typing "conni" over a 343-album library, keydown to painted, worst
#: keystroke of three runs: 3.4-9.1 s at 640 px against 0.2 s at 384 px. Decode cost
#: goes with pixel count, and a browse grid paints a card 132 px wide.
#:
#: A tablet at devicePixelRatio 2 therefore gets a slightly soft cover on the play view
#: and nowhere else. That is the trade: a visible sharpness margin nobody asked for, for
#: a search box that answers a keystroke in a frame.
MAX_COVER_PX: Final = 384
#: Re-encode quality. At these dimensions the difference from 95 is invisible and the
#: file is a third of the size.
_COVER_JPEG_QUALITY: Final = 85
def shrink_cover(data: bytes) -> bytes:
"""Downscale cover art to :data:`MAX_COVER_PX` on its longest edge.
Art that is already small enough is returned untouched rather than re-encoded, so
repeated scans never degrade it. Anything Pillow cannot read is passed through
unchanged: a cover that is too big is a performance problem, a cover that is missing
is a visible one.
"""
try:
with Image.open(io.BytesIO(data)) as image:
if max(image.size) <= MAX_COVER_PX:
return data
# `thumbnail` keeps the aspect ratio and never scales up.
image = image.convert("RGB")
image.thumbnail((MAX_COVER_PX, MAX_COVER_PX), Image.Resampling.LANCZOS)
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=_COVER_JPEG_QUALITY, optimize=True)
return buffer.getvalue()
except (OSError, UnidentifiedImageError, ValueError):
return data

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import TypeAlias
from musicmouse.library.analysis import TrackAnalysis
from musicmouse.library.sections import AlbumKind
@@ -15,7 +16,7 @@ __all__ = ["Album", "AlbumColors", "LibraryTrack", "album_id", "track_key"]
#: Primary, secondary and accent as ``"#rrggbb"`` - the format
#: :func:`musicmouse.color.parse_color` already accepts, so the LED side needs no new
#: parsing and the frontend gets CSS colours for free.
type AlbumColors = tuple[str, str, str]
AlbumColors: TypeAlias = tuple[str, str, str]
def album_id(root: Path, folder: Path) -> str:

View File

@@ -12,6 +12,12 @@ a feed offers real per-episode artwork, it's saved alongside as a same-named sid
image, which ``scanner.py``'s ``_cover_for_episode`` picks up automatically. A
video-only enclosure (some shows publish no audio feed at all) is transcoded to audio
via the ``ffmpeg`` binary, which must be on ``PATH`` for those shows to sync.
A show folder is also kept to a fixed number of the newest episodes
(:data:`DEFAULT_EPISODE_LIMIT`), so a long-running feed cannot fill the device's disk.
The same limit caps what is downloaded, which is what stops the two halves fighting:
prune what is older than the newest N, download only the newest N, and the folder
settles instead of re-fetching every episode it just deleted.
Nothing here raises on bad input - an unreachable feed or a broken enclosure is logged
and skipped, not a crash, mirroring ``scanner.py``'s own rule.
"""
@@ -37,6 +43,7 @@ from musicmouse.library.sections import SECTIONS
_log = logging.getLogger(__name__)
__all__ = [
"DEFAULT_EPISODE_LIMIT",
"FEED_MARKER_NAME",
"AudioExtractionError",
"Episode",
@@ -46,7 +53,9 @@ __all__ = [
"episode_filename",
"find_feed_shows",
"missing_episodes",
"newest_episodes",
"parse_feed",
"prune_show",
"resolve_episode_cover",
"sync_all_shows",
"sync_show",
@@ -54,6 +63,15 @@ __all__ = [
FEED_MARKER_NAME: Final = "feed.txt"
#: How many episodes of one show to keep, newest first. A show that has published for
#: years is otherwise unbounded: GEOlino Spezial alone is 358 episodes and 5.8 GB, which
#: does not fit next to the rest of the library on a Pi's SD card.
#:
#: This caps downloads as well as deletions, and it has to be one number for both. Prune
#: to the newest N but download everything the feed offers, and every poll would
#: re-fetch the episodes the last one deleted, forever.
DEFAULT_EPISODE_LIMIT: Final = 50
#: Enclosure content-type -> file extension, for a URL whose own suffix is missing or
#: not a real extension (tracking-redirect URLs are common in the wild).
_EXTENSION_BY_TYPE: Final[dict[str, str]] = {
@@ -287,6 +305,80 @@ def missing_episodes(folder: Path, episodes: list[Episode]) -> list[tuple[Episod
return out
def newest_episodes(episodes: list[Episode], keep: int | None) -> list[Episode]:
"""The ``keep`` most recently published episodes, newest first.
Applied to the *feed* before anything is downloaded. Without it, capping the folder
would be pointless: the next poll would see every pruned episode as missing again.
"""
ordered = sorted(episodes, key=lambda episode: episode.published, reverse=True)
return ordered if keep is None else ordered[:keep]
def _episode_date(name: str) -> datetime | None:
"""The date out of a ``YYYYMMDD - Title.ext`` filename, or ``None`` if it has none.
Deliberately strict. A file that does not follow the convention is one this module
did not write - something dropped in by hand under another name - and it is left
alone rather than guessed at, because the alternative is deleting somebody's file
on a parse that happened to fail.
"""
stem = Path(name).stem
if len(stem) < 8 or not stem[:8].isdigit():
return None
try:
return datetime.strptime(stem[:8], "%Y%m%d").replace(tzinfo=UTC)
except ValueError:
return None
def prune_show(folder: Path, keep: int | None, extensions: frozenset[str] | None = None) -> int:
"""Delete all but the ``keep`` newest episodes in ``folder``. Returns how many went.
Only touches files named the way :func:`episode_filename` names them, so a
``feed.txt``, a ``folder.jpg``, the failed-download record and any hand-named file
are all safe. An episode's sidecar cover image goes with it - it shares the stem,
and leaving it behind would strand art for a track that no longer exists.
``keep`` of ``None`` disables pruning entirely.
"""
if keep is None:
return 0
audio_extensions = extensions if extensions is not None else _KNOWN_EXTENSIONS
dated: list[tuple[datetime, Path]] = []
for path in folder.iterdir():
if not path.is_file() or path.suffix.lower() not in audio_extensions:
continue
published = _episode_date(path.name)
if published is not None:
dated.append((published, path))
if len(dated) <= keep:
return 0
dated.sort(key=lambda item: (item[0], item[1].name), reverse=True)
removed = 0
for _published, path in dated[keep:]:
try:
path.unlink()
except OSError as exc:
_log.warning("Could not delete old episode %s: %s", path, exc)
continue
removed += 1
for image_extension in _IMAGE_EXTENSIONS:
sidecar = path.with_suffix(image_extension)
try:
sidecar.unlink(missing_ok=True)
except OSError as exc:
_log.debug("Could not delete cover %s: %s", sidecar, exc)
if removed:
_log.info(
"Pruned %d old episode(s) from %s, keeping the newest %d", removed, folder.name, keep
)
return removed
def _load_failed_downloads(folder: Path) -> dict[str, datetime]:
"""Filename -> when it last failed to download, for episodes ``sync_show`` should
leave alone until :data:`_RETRY_BACKOFF` has passed.
@@ -437,14 +529,28 @@ async def resolve_episode_cover(client: httpx2.AsyncClient, episode: Episode) ->
return _extract_og_image(response.text)
async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) -> bool:
"""Download every episode in ``feed_url`` that ``folder`` doesn't have yet.
async def sync_show(
client: httpx2.AsyncClient,
folder: Path,
feed_url: str,
*,
keep: int | None = DEFAULT_EPISODE_LIMIT,
) -> bool:
"""Bring ``folder`` up to date with the newest ``keep`` episodes of ``feed_url``.
Returns whether anything changed. Errors - an unreachable feed, a malformed one, a
single broken enclosure - are logged and swallowed here so one bad show never stops
the others or takes down the poll loop. An episode that fails is remembered and left
alone for :data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead
enclosure doesn't get hammered on every poll.
Downloads what is missing from that window and deletes what has fallen out of it.
Both halves use the same ``keep``, which is what makes the folder settle - see
:data:`DEFAULT_EPISODE_LIMIT`.
Returns whether anything changed, deletions included: a pruned folder needs a
rescan just as much as a downloaded episode does, or the library keeps offering
tracks whose files are gone.
Errors - an unreachable feed, a malformed one, a single broken enclosure - are
logged and swallowed here so one bad show never stops the others or takes down the
poll loop. An episode that fails is remembered and left alone for
:data:`_RETRY_BACKOFF` before it's attempted again, so a permanently dead enclosure
doesn't get hammered on every poll.
"""
try:
response = await client.get(feed_url, timeout=_HTTP_TIMEOUT, follow_redirects=True)
@@ -453,7 +559,7 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
_log.warning("Could not fetch podcast feed %s for %s: %s", feed_url, folder.name, exc)
return False
pending = missing_episodes(folder, parse_feed(response.content))
pending = missing_episodes(folder, newest_episodes(parse_feed(response.content), keep))
failed = _load_failed_downloads(folder)
now = datetime.now(UTC)
changed = False
@@ -490,13 +596,20 @@ async def sync_show(client: httpx2.AsyncClient, folder: Path, feed_url: str) ->
pending_filenames = {filename for _episode, filename in pending}
failed = {filename: when for filename, when in failed.items() if filename in pending_filenames}
_save_failed_downloads(folder, failed)
# After downloading, not before: an episode that just arrived is one of the newest
# and must be counted when deciding what falls off the end.
if prune_show(folder, keep):
changed = True
return changed
async def sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
async def sync_all_shows(
client: httpx2.AsyncClient, root: Path, *, keep: int | None = DEFAULT_EPISODE_LIMIT
) -> bool:
"""Poll every show with a feed marker under ``root``. Returns whether any changed."""
changed = False
for _section_name, folder, feed_url in find_feed_shows(root):
if await sync_show(client, folder, feed_url):
if await sync_show(client, folder, feed_url, keep=keep):
changed = True
return changed

View File

@@ -119,10 +119,20 @@ def _most_common(values: list[str]) -> str:
def _cover_for(
folder: Path, paths: list[Path], identifier: str, cache: LibraryCache
) -> tuple[Path | None, bytes | None]:
"""The album's art, as a path *into the cache* plus the original bytes.
Every branch goes through :meth:`LibraryCache.store_cover`, including the one that
finds a ``cover.jpg`` already sitting in the folder. Returning that file directly
would be the obvious thing and was the original behaviour, and it quietly undid the
downscaling: a folder cover is whatever the internet gave it, often 1920px or more,
and the browser then decoded all of it to fill a 185px card. The bytes come back
undownscaled either way, because `colors_from_cover` wants the real art.
"""
for name in _COVER_NAMES:
candidate = folder / name
if candidate.is_file():
return candidate, candidate.read_bytes()
art = candidate.read_bytes()
return cache.store_cover(identifier, art), art
for path in paths[:3]:
# Podcast feeds sometimes art only some episodes; a couple of tries is enough.
art = _embedded_art(path)
@@ -143,14 +153,16 @@ def _cover_for_episode(
for extension in _SIDECAR_COVER_EXTENSIONS:
candidate = path.with_suffix(extension)
if candidate.is_file():
return candidate, candidate.read_bytes()
art = candidate.read_bytes()
return cache.store_cover(identifier, art), art
art = _embedded_art(path)
if art is not None:
return cache.store_cover(identifier, art), art
for name in _COVER_NAMES:
candidate = folder / name
if candidate.is_file():
return candidate, candidate.read_bytes()
shared = candidate.read_bytes()
return cache.store_cover(identifier, shared), shared
return None, None
@@ -295,6 +307,13 @@ def scan_library(
figure name to what it holds, which is the one thing the folders cannot say.
"""
cache.prepare()
# Covers written before MAX_COVER_PX existed are still whatever size the tag held,
# and the reuse path below means an unchanged album never rewrites its own. One
# pass here catches them; after the first run every file is already small and this
# is 300-odd header reads.
shrunk = cache.shrink_stored_covers()
if shrunk:
_log.info("Downscaled %d oversized cover(s) in the cache", shrunk)
known = known or {}
out: dict[str, tuple[Album, Fingerprint]] = {}
last_report = time.monotonic()

View File

@@ -8,15 +8,15 @@ quirks are not understood yet.
from __future__ import annotations
from dataclasses import dataclass
from typing import Final, Literal
from typing import Final, Literal, TypeAlias
__all__ = ["SECTIONS", "AlbumKind", "ArtistSource", "Section", "TitleSource", "TrackOrder"]
type AlbumKind = Literal["music", "book"]
type TrackOrder = Literal["filename", "newest_first"]
type TitleSource = Literal["tags", "folder"]
type ArtistSource = Literal["tags", "folder"]
type AlbumUnit = Literal["folder", "episode"]
AlbumKind: TypeAlias = Literal["music", "book"]
TrackOrder: TypeAlias = Literal["filename", "newest_first"]
TitleSource: TypeAlias = Literal["tags", "folder"]
ArtistSource: TypeAlias = Literal["tags", "folder"]
AlbumUnit: TypeAlias = Literal["folder", "episode"]
@dataclass(frozen=True, slots=True)

View File

@@ -0,0 +1,143 @@
"""Where analysis actually burns CPU, and how it is kept from taking the machine over.
Analyzing one track is a few seconds of single-threaded DSP that the GIL will not let
another Python thread overlap with - librosa's work is numba-jitted and numpy glue, not
long C calls that release it. So a library-sized pass parallelizes across *processes*:
:func:`analysis_pool` hands :meth:`~musicmouse.library.MusicLibrary.analyze_pending` an
executor whose workers are separate interpreters, and a 12-core desktop chews through a
first-time scan roughly an order of magnitude faster than the Raspberry Pi this also
has to stay polite on.
Polite means three things, all of them set here rather than at the call site:
* **One core stays free** (:func:`default_worker_count`), so the audio thread and the
web server never have to fight a full house of analyzers for a timeslice.
* **Workers run niced**, so even the cores they do own yield to playback instantly.
* **Each worker stays single-threaded** - numpy's BLAS and numba would each happily
start one thread per core *inside* every worker, and N x N threads on a 4-core Pi is
slower than N, not faster.
Whatever an analyzer returns has to survive the trip back from a worker process, which
is what keeps :class:`~musicmouse.library.analysis.TrackAnalysis` and friends plain
frozen dataclasses of floats. An analyzer that records state in its own instance -
a test double counting calls, say - only sees that state in the worker, so such an
analyzer must be run with ``workers=1``, where everything stays in this process on a
thread.
"""
from __future__ import annotations
import contextlib
import logging
import multiprocessing
import os
from collections.abc import Iterator
from concurrent.futures import Executor, ProcessPoolExecutor
from pathlib import Path
from typing import Final
from musicmouse.library.analysis import Analyzer, BeatGrid, TrackAnalysis, TrackCurves
_log = logging.getLogger(__name__)
__all__ = ["analysis_pool", "analyze_one", "default_worker_count"]
#: Ceiling on the automatic worker count. Every worker is a fresh interpreter with its
#: own librosa, numpy and a decoded track in memory - a few hundred MB each - so on a
#: big machine the limit that bites first is RAM, not cores. An explicit
#: ``analysis_workers`` in the config overrides this; the default stays conservative.
_MAX_AUTO_WORKERS: Final = 8
#: How much worse than everything else analysis schedules. Niceness clamps at the OS
#: maximum (19) and only ever increases, so re-applying it to a reused worker is
#: harmless.
_NICENESS: Final = 5
#: Forced into every worker *before* it imports numpy, which reads these once at import
#: time. Without them each worker opens its own BLAS thread pool sized for the whole
#: machine and the pool oversubscribes every core several times over.
_SINGLE_THREADED: Final = {
"OMP_NUM_THREADS": "1",
"OPENBLAS_NUM_THREADS": "1",
"MKL_NUM_THREADS": "1",
"NUMEXPR_NUM_THREADS": "1",
"NUMBA_NUM_THREADS": "1",
}
def default_worker_count() -> int:
"""One worker per core bar one, capped at :data:`_MAX_AUTO_WORKERS`.
The core left over is for the rest of the app: audio decoding, the web server and
the serial link all have to stay responsive while a first-time pass runs for hours.
Single-core machines get 1, which :func:`analysis_pool` turns into the in-process
path rather than a pool of one.
"""
return max(1, min(_MAX_AUTO_WORKERS, (os.cpu_count() or 1) - 1))
def analyze_one(
analyzer: Analyzer, path: Path
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
"""Analyze one file off the event loop - in a worker process, or on a thread here.
Lowers the caller's own scheduling priority first. On Linux ``os.nice`` affects only
the calling *thread*, so on the in-process path this makes idle-time analysis yield
CPU without touching threads doing other work; in a worker process there is nothing
else in the process to slow down anyway.
"""
with contextlib.suppress(OSError):
os.nice(_NICENESS)
return analyzer.analyze(path)
def _init_worker() -> None:
"""Runs once per worker process, before it imports librosa or numpy.
A spawned worker starts from a bare interpreter and pulls the analyzer in when it
unpickles its first task, so setting the thread-count variables here still lands
ahead of numpy reading them.
"""
os.environ.update(_SINGLE_THREADED)
with contextlib.suppress(OSError):
os.nice(_NICENESS)
@contextlib.contextmanager
def analysis_pool(workers: int) -> Iterator[Executor | None]:
"""A pool of `workers` analyzer processes, or ``None`` for "stay in this process".
``None`` - for ``workers <= 1``, and as the fallback when a pool cannot be started
at all - means the caller should run each track on a thread instead. That path is
what the tests and single-core devices use, and it is the only one where an
analyzer holding state in its own instance behaves as written.
Workers are *spawned*, never forked: this process has an asyncio loop, a serial
reader and libVLC's own threads running, and forking that is a well-known way to
inherit a held lock and deadlock in a child. The price is one librosa import per
worker, a few seconds paid once per pass - nothing next to the hours of DSP a
first-time pass over a real library costs.
"""
if workers <= 1:
yield None
return
try:
executor = ProcessPoolExecutor(
max_workers=workers,
mp_context=multiprocessing.get_context("spawn"),
initializer=_init_worker,
)
except (OSError, ValueError):
_log.warning(
"Could not start %d analysis workers; analyzing in this process instead",
workers,
exc_info=True,
)
yield None
return
try:
yield executor
finally:
# `wait=False`: shutdown happens on cancellation too (the app is stopping), and
# waiting there would hold it up for however long the tracks in flight take.
executor.shutdown(wait=False, cancel_futures=True)

View File

@@ -8,7 +8,7 @@ from __future__ import annotations
import logging
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
from musicmouse.bus import EventBus
from musicmouse.events import Event
@@ -20,12 +20,14 @@ _log = logging.getLogger(__name__)
__all__ = ["Reaction", "on", "register_all", "registered"]
type Reaction[E: Event] = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
E = TypeVar("E", bound=Event)
Reaction: TypeAlias = Callable[[E, "App"], Coroutine[Any, Any, None] | None]
_REGISTRY: list[tuple[type[Event], Reaction[Any]]] = []
def on[E: Event](event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
def on(event_type: type[E]) -> Callable[[Reaction[E]], Reaction[E]]:
"""Register a reaction for ``event_type`` (and any subclass of it)."""
def decorator(reaction: Reaction[E]) -> Reaction[E]:
@@ -46,7 +48,7 @@ def register_all(bus: EventBus, app: App) -> None:
_log.debug("Registered %d reactions", len(_REGISTRY))
def _bind[E: Event](reaction: Reaction[E], app: App) -> Callable[[E], Any]:
def _bind(reaction: Reaction[E], app: App) -> Callable[[E], Any]:
def handler(event: E) -> Any:
return reaction(event, app)

View File

@@ -13,7 +13,7 @@ from typing import Final
import httpx2
from musicmouse.library import MusicLibrary
from musicmouse.library.podcast_feeds import sync_all_shows
from musicmouse.library.podcast_feeds import DEFAULT_EPISODE_LIMIT, sync_all_shows
_log = logging.getLogger(__name__)
@@ -34,11 +34,13 @@ class PodcastFeedService:
client: httpx2.AsyncClient,
on_change: Callable[[], Awaitable[None]],
interval: float = _CHECK_INTERVAL_SECONDS,
episode_limit: int | None = DEFAULT_EPISODE_LIMIT,
) -> None:
self.library = library
self.client = client
self.on_change = on_change
self.interval = interval
self.episode_limit = episode_limit
async def run(self) -> None:
"""Checks immediately at startup, then every `interval` seconds.
@@ -51,7 +53,8 @@ class PodcastFeedService:
try:
while True:
try:
if await sync_all_shows(self.client, self.library.root):
keep = self.episode_limit
if await sync_all_shows(self.client, self.library.root, keep=keep):
await self.on_change()
except Exception:
_log.exception("Podcast feed check failed; will retry next interval")

View File

@@ -104,9 +104,20 @@ def build_router(
raise HTTPException(status_code=404, detail="no cover")
return FileResponse(
album.cover,
# Cover files are content-addressed by album id and rewritten only by a
# rescan, so a long cache is safe and saves 20 requests per page load.
headers={"Cache-Control": "public, max-age=604800"},
# This used to send `public, max-age=604800`, on the reasoning that a cover
# is "content-addressed by album id". It is not: the id addresses *which
# album* the art belongs to, and the bytes behind that URL change whenever
# the art is reprocessed. When `MAX_COVER_PX` arrived and every cover was
# rewritten smaller, every browser that had loaded the page in the previous
# week went on decoding the old 3000px file out of its own disk cache, and
# a trace was the only way to see it.
#
# `no-cache` does not mean "do not store", it means "revalidate before
# reusing" - so the browser keeps the file and usually gets a 304. The cost
# is one conditional request per cover, and it is not on the paint path at
# all: the service worker answers covers from its own cache first and does
# the revalidation behind the page (see web/public/sw.js).
headers={"Cache-Control": "no-cache"},
)
@router.get("/tracks/{album_id}/{index}/analysis")

View File

@@ -372,7 +372,6 @@ class TippenSettingsIn(BaseModel):
class TippenProgressOut(BaseModel):
lessons: dict[str, TippenLessonProgressOut]
key_stats: dict[str, TippenKeyStatOut]
pearls: int
aquarium: list[str]
streak: TippenStreakOut
settings: TippenSettingsOut
@@ -395,7 +394,6 @@ class TippenRunIn(BaseModel):
animal: AnimalId
points: float
passed: bool
pearls: int = Field(ge=0)
strokes: list[TippenStrokeIn] = Field(default_factory=list)

View File

@@ -102,7 +102,6 @@ def progress_out(progress: TypingProgress) -> TippenProgressOut:
key: TippenKeyStatOut(ema=stat.ema, attempts=stat.attempts, errors=stat.errors)
for key, stat in progress.key_stats.items()
},
pearls=progress.pearls,
aquarium=list(progress.aquarium),
streak=TippenStreakOut(days=progress.streak.days, last_played=progress.streak.last_played),
settings=TippenSettingsOut(
@@ -159,7 +158,6 @@ def record_tippen_run(app: App, body: TippenRunIn) -> TippenRunOut:
animal=body.animal,
points=body.points,
passed=body.passed,
pearls=body.pearls,
strokes=tuple(
Stroke(key=s.key, expected=s.expected, correct=s.correct, at=s.at) for s in body.strokes
),

View File

@@ -17,6 +17,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TypeVar
from musicmouse.app import App
from musicmouse.clock import Clock, FakeClock
@@ -329,9 +330,10 @@ def _int(text: str) -> int:
raise ScriptError(f"{text!r} is not a whole number") from None
def _enum_by_name[T: (Button, ButtonAction, TouchButton)](
enum: type[T], name: str, what: str
) -> T:
_EnumT = TypeVar("_EnumT", Button, ButtonAction, TouchButton)
def _enum_by_name(enum: type[_EnumT], name: str, what: str) -> _EnumT:
try:
return enum[name.upper()]
except KeyError:

View File

@@ -15,7 +15,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Literal
from typing import Final, Literal, TypeAlias
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
from ruamel.yaml import YAML
@@ -37,9 +37,9 @@ __all__ = [
"world_reward",
]
type LessonKind = Literal["letters", "fragments", "words", "sentences"]
type ModeId = Literal["dive", "bubbles", "jellyfish", "feed", "race"]
type CreatureId = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
LessonKind: TypeAlias = Literal["letters", "fragments", "words", "sentences"]
ModeId: TypeAlias = Literal["dive", "bubbles", "feed", "race"]
CreatureId: TypeAlias = Literal["clownfish", "octopus", "seahorse", "turtle", "pearlmussel"]
#: In world order - see ``tippen/src/lib/aquarium.ts``, the one place this list is
#: allowed to grow, since each id names a drawing under ``public/aquarium/``.
@@ -52,9 +52,9 @@ CREATURE_IDS: Final[tuple[CreatureId, ...]] = (
)
#: Which modes make sense for a kind - letters rounds are single keys, so only the
#: arcade modes fit; only words and sentences are long enough for a race.
#: arcade mode fits; only words and sentences are long enough for a race.
ELIGIBLE_MODES: Final[dict[LessonKind, tuple[ModeId, ...]]] = {
"letters": ("bubbles", "jellyfish"),
"letters": ("bubbles",),
"fragments": ("dive", "feed"),
"words": ("dive", "feed", "race"),
"sentences": ("dive", "race"),
@@ -104,6 +104,10 @@ class _YamlRoot(_Strict):
problems.append(f"expected {len(CREATURE_IDS)} worlds, found {len(self.worlds)}")
seen_rewards: set[CreatureId] = set()
# A lesson's `keys` is its spotlight, not only what is new: a round may list
# every key learned so far to drill them evenly. What must stay small is how
# much is *new* in one round - two keys, the mirrored finger pair.
seen_keys: set[str] = set()
for world in self.worlds:
if world.reward in seen_rewards:
problems.append(f"world {world.number} reuses reward {world.reward!r}")
@@ -121,8 +125,10 @@ class _YamlRoot(_Strict):
problems.append(f'{where}: kind "{kind}" needs a non-empty words list')
if lesson.drill and lesson.keys:
problems.append(f"{where}: a drill cannot also introduce keys")
if lesson.keys and len(lesson.keys) > 2:
problems.append(f"{where}: at most two keys per lesson")
new_keys = [key for key in (lesson.keys or ()) if key not in seen_keys]
if len(new_keys) > 2:
problems.append(f"{where}: at most two new keys per lesson")
seen_keys.update(lesson.keys or ())
if lesson.mode and lesson.mode not in ELIGIBLE_MODES[kind]:
problems.append(f'{where}: mode "{lesson.mode}" does not fit kind "{kind}"')
@@ -230,9 +236,6 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
lessons: list[Lesson] = []
active: set[str] = set()
seen_before: set[str] = set()
# Letters rounds alternate bubbles/jellyfish across the whole course, so the arcade
# game never repeats twice in a row even across a fragments/words lesson in between.
last_arcade: ModeId = "jellyfish" # so lesson 1 opens on bubbles
for world in worlds:
for entry in world.lessons:
@@ -260,16 +263,10 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
else:
emphasis = "mixed"
primary_mode: ModeId
if kind == "letters":
default_arcade: ModeId = "jellyfish" if last_arcade == "bubbles" else "bubbles"
primary_mode = entry.mode or default_arcade
last_arcade = primary_mode
else:
primary_mode = entry.mode or "dive"
primary_mode: ModeId = entry.mode or ("bubbles" if kind == "letters" else "dive")
# Bonus replays are only ever feed/race - dive is the plain default, and
# bubbles/jellyfish already alternate on their own.
# bubbles is the only letters-round arcade mode.
bonus_modes = tuple(
mode
for mode in ELIGIBLE_MODES[kind]
@@ -287,7 +284,13 @@ def _build_lessons(worlds: tuple[_YamlWorld, ...]) -> tuple[Lesson, ...]:
subtitle=entry.subtitle,
kind=kind,
new_keys=new_keys,
spotlight_keys=keys if kind == "letters" and not is_drill else (),
# A round that lists every key learned so far still spotlights only
# what it introduces - a lesson titled "K" must drill K, not spread
# itself evenly over all five keys it happens to name. With nothing
# new, the whole list is the spotlight: that is the "mixed" replay.
spotlight_keys=(
(new_keys or keys) if kind == "letters" and not is_drill else ()
),
emphasis=emphasis,
active_keys=active_keys,
primary_mode=primary_mode,

View File

@@ -90,7 +90,6 @@ class RunResult:
animal: AnimalId
points: float
passed: bool
pearls: int
strokes: tuple[Stroke, ...]
@@ -143,7 +142,6 @@ class TypingProgress(BaseModel):
version: Literal[1] = 1
lessons: dict[str, LessonProgress] = Field(default_factory=dict)
key_stats: dict[str, KeyStat] = Field(default_factory=dict)
pearls: int = 0
#: Pets that have moved into the aquarium, in the order they arrived.
aquarium: list[CreatureId] = Field(default_factory=list)
streak: Streak = Field(default_factory=Streak)
@@ -265,7 +263,7 @@ def record_run(
curriculum: Curriculum,
day: str | None = None,
) -> RecordOutcome:
"""Record a finished run: stars, animal, pearls, key stats, streak, and the unlock.
"""Record a finished run: stars, animal, key stats, streak, and the unlock.
The unlock rule, in one place: two stars unlocks the next lesson, and so does the
fifth attempt whatever the score. Speed is nowhere in it.
@@ -317,7 +315,6 @@ def record_run(
"lessons": lessons,
"aquarium": aquarium,
"key_stats": _fold_key_stats(progress.key_stats, result.strokes),
"pearls": progress.pearls + result.pearls,
"streak": _bump_streak(progress.streak, day),
}
)

View File

@@ -2,7 +2,11 @@
name = "musicmouse"
version = "2.0.0"
description = "Host backend for the MusicMouse RFID music player"
requires-python = ">=3.13"
# Raspberry Pi OS (Bookworm) ships Python 3.11, and its system interpreter is what the
# device runs. Staying on it means apt and piwheels supply prebuilt armhf wheels for
# the native dependencies (pydantic-core, Pillow), instead of needing a separate
# toolchain to fetch a newer interpreter and compile them on the Pi.
requires-python = ">=3.11"
dependencies = [
"aiomqtt>=2.0",
"fastapi>=0.115",
@@ -50,7 +54,7 @@ filterwarnings = ["error"]
[tool.ruff]
line-length = 100
target-version = "py313"
target-version = "py311"
# Course material and scratch work, not part of the backend. See notebooks/README.md.
extend-exclude = ["notebooks"]
@@ -63,7 +67,7 @@ select = ["ARG", "B", "C4", "E", "F", "I", "N", "PTH", "RUF", "SIM", "UP", "W"]
"musicmouse/services/mqtt/entity.py" = ["ARG002", "B027"]
[tool.mypy]
python_version = "3.13"
python_version = "3.11"
strict = true
files = ["musicmouse"]
warn_unreachable = true

View File

@@ -4,15 +4,18 @@ from __future__ import annotations
import asyncio
import contextlib
import io
import json
import os
import threading
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
import pytest
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
from musicmouse.library import Album, Analyzer, MusicLibrary
from musicmouse.library import Album, Analyzer, MusicLibrary, default_worker_count
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.cache import LibraryCache
from musicmouse.library.colors import colors_from_id
@@ -52,6 +55,19 @@ class FakeAnalyzer:
return analysis, BeatGrid((0.1,), (1.0,)), curves
class PidAnalyzer(FakeAnalyzer):
"""Reports *which process* analyzed each track, as `tempo`.
The point of a pool is that this is never the process running the test - and a
class at module scope is also the only kind of analyzer a worker can be handed,
since it has to survive being pickled over to one.
"""
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
analysis, grid, curves = super().analyze(path)
return replace(analysis, tempo=float(os.getpid())), grid, curves
def album_named(library: MusicLibrary, title: str) -> Album:
return next(album for album in library.albums if album.title == title)
@@ -231,9 +247,14 @@ async def test_a_cover_file_is_found_and_used(config_dir: Path) -> None:
Image.new("RGB", (32, 32), (200, 40, 30)).save(folder / "cover.jpg")
album = album_named(await build(config_dir), "Kinderparty Lieder")
assert album.cover == folder / "cover.jpg"
# Into the cache, not at the library file: a folder cover is whatever size it came
# in at, and serving it directly is what used to put 1920px art in a 185px card.
assert album.cover is not None
assert album.cover.parent == config_dir / ".cache" / "covers"
assert album.cover.stem == album.id
# A solid red cover has one usable colour; the rest fall back to the synthesised
# palette rather than repeating it.
# palette rather than repeating it - and that still reads the real art, not the
# downscaled copy.
assert album.colors[0] != colors_from_id(album.id)[0]
@@ -248,9 +269,19 @@ async def test_an_episodes_sidecar_cover_wins_over_the_shows_shared_one(config_d
library = await build(config_dir)
assert album_named(library, "Neu").cover == podcast / "20260101 - Neu.jpg"
# Both covers now live in the cache under their own album id, so which file won is
# no longer visible in the path - read the colour back out instead.
def cover_colour(title: str) -> tuple[int, int, int]:
cover = album_named(library, title).cover
assert cover is not None
with Image.open(cover) as image:
return image.convert("RGB").getpixel((0, 0)) # type: ignore[return-value]
red, green, blue = cover_colour("Neu")
assert red > 150 and green < 90, "the episode's own sidecar art should win"
# No sidecar for this one, so it still falls back to the show's shared cover.
assert album_named(library, "Alt").cover == podcast / "cover.jpg"
red, green, blue = cover_colour("Alt")
assert red < 60 and blue > red, "falls back to the show's shared cover"
# --------------------------------------------------------------------------- cache
@@ -641,3 +672,172 @@ async def test_requests_raised_during_a_pass_coalesce_into_one_more_pass(
assert pass_count == 2
assert len(analyzer.calls) == 5
# ------------------------------------------------------------------ worker processes
async def test_a_parallel_pass_analyzes_every_track_in_worker_processes(
config_dir: Path,
) -> None:
"""The whole point of `analysis_workers`: a machine with cores gets to use them.
Two workers rather than the real default, because what is being checked is that
the work left this process at all - not how fast a five-track fixture goes.
"""
library = await build(config_dir, analyzer=PidAnalyzer(fails={"00 - lied.mp3"}))
done = await library.analyze_pending(workers=2)
assert done == 5
analyzed = [
track.analysis
for album in library.albums
if album.kind == "music"
for track in album.tracks
]
assert all(a is not None and a.version == ANALYZER_VERSION for a in analyzed)
# The one file the analyzer chokes on is recorded as attempted, exactly as on the
# in-process path - a raise in a worker must not abort the other four.
failed = [a for a in analyzed if a is not None and a.tempo is None]
assert len(failed) == 1
workers = {a.tempo for a in analyzed if a is not None and a.tempo is not None}
assert workers
assert os.getpid() not in workers
async def test_a_parallel_pass_starts_no_workers_while_playback_is_busy(
config_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Being busy holds the pass *before* the pool exists, so a device playing an album
is not also hosting a set of idle analyzer processes."""
import musicmouse.library as library_module
monkeypatch.setattr(library_module, "_BUSY_POLL_SECONDS", 0.01)
library = await build(config_dir, analyzer=PidAnalyzer())
busy = True
task = asyncio.create_task(library.analyze_pending(workers=2, is_busy=lambda: busy))
try:
await asyncio.sleep(0.2)
assert list(library.cache.analysis.glob("*.json")) == []
busy = False
assert await asyncio.wait_for(task, timeout=30) == 5
finally:
if not task.done():
await _cancel(task)
async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> None:
"""Shutdown cancels this task like any other, and it must not hang waiting for
worker processes to finish the tracks they are on."""
library = await build(config_dir, analyzer=PidAnalyzer())
task = asyncio.create_task(library.analyze_pending(workers=2))
await asyncio.sleep(0.1) # long enough to have workers starting up
await asyncio.wait_for(_cancel(task), timeout=10)
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
count = default_worker_count()
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
class TestCoverDownscaling:
"""Album art arrives sized for a record sleeve; screens want a fraction of that."""
@staticmethod
def _jpeg(size: tuple[int, int]) -> bytes:
from PIL import Image
buffer = io.BytesIO()
Image.new("RGB", size, (120, 40, 200)).save(buffer, format="JPEG")
return buffer.getvalue()
def test_oversized_art_is_shrunk_to_the_long_edge(self) -> None:
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, shrink_cover
original = self._jpeg((3000, 2000))
shrunk = shrink_cover(original)
with Image.open(io.BytesIO(shrunk)) as image:
width, height = image.size
assert max(width, height) == MAX_COVER_PX
# Aspect ratio survives, to within the rounding of a whole pixel.
assert abs(width / height - 3000 / 2000) < 0.01
assert len(shrunk) < len(original)
def test_art_already_small_enough_is_returned_untouched(self) -> None:
"""Byte-identical, not merely similar: repeated scans must not re-encode art
over and over, each pass losing a little more to JPEG."""
from musicmouse.library.cache import shrink_cover
original = self._jpeg((300, 300))
assert shrink_cover(original) is original
def test_unreadable_art_is_passed_through_rather_than_dropped(self) -> None:
"""A cover too big is a performance problem; a cover missing is a visible one."""
from musicmouse.library.cache import shrink_cover
assert shrink_cover(b"not an image at all") == b"not an image at all"
def test_store_cover_shrinks_on_the_way_in(self, tmp_path: Path) -> None:
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
path = cache.store_cover("abc123", self._jpeg((2400, 2400)))
with Image.open(path) as image:
assert max(image.size) == MAX_COVER_PX
def test_a_cover_jpg_in_the_library_folder_is_cached_downscaled_too(
self, tmp_path: Path
) -> None:
"""The branch that quietly undid all of this: an album with art already sitting
beside it used to have `cover` point straight at that file, so it was served at
whatever size the internet gave it - often 1920px into a 185px card."""
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
from musicmouse.library.scanner import _cover_for
cache = LibraryCache(tmp_path / "cache")
cache.prepare()
folder = tmp_path / "album"
folder.mkdir()
(folder / "cover.jpg").write_bytes(self._jpeg((1920, 1920)))
path, art = _cover_for(folder, [], "album1", cache)
assert path is not None
assert path.parent == cache.covers, "cover must point into the cache, not the library"
with Image.open(path) as image:
assert max(image.size) == MAX_COVER_PX
# The untouched original still comes back, because colour extraction wants it.
assert art == (folder / "cover.jpg").read_bytes()
def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None:
"""The scanner reuses an unchanged album without re-reading its tags, so a
cover stored before the limit existed would otherwise never be rewritten."""
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
stale = cache.cover_path("old")
stale.write_bytes(self._jpeg((3000, 3000)))
fresh = cache.cover_path("new")
fresh.write_bytes(self._jpeg((320, 320)))
fresh_before = fresh.read_bytes()
assert cache.shrink_stored_covers() == 1
with Image.open(stale) as image:
assert max(image.size) == MAX_COVER_PX
assert fresh.read_bytes() == fresh_before
# Second pass has nothing left to do - the cheap steady state.
assert cache.shrink_stored_covers() == 0

View File

@@ -70,6 +70,7 @@ class ScriptedLircd:
def __init__(self) -> None:
self._writer: asyncio.StreamWriter | None = None
self._connected = asyncio.Event()
self._closing = asyncio.Event()
self.connection_count = 0
async def _handle(self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
@@ -77,7 +78,14 @@ class ScriptedLircd:
self.connection_count += 1
self._connected.set()
with contextlib.suppress(asyncio.CancelledError):
await asyncio.Event().wait() # held open until the test drops it
await self._closing.wait() # held open until the test drops it
writer.close()
with contextlib.suppress(OSError, asyncio.CancelledError):
await writer.wait_closed()
def shutdown(self) -> None:
"""Let go of every connection still being held open, so the server can close."""
self._closing.set()
async def wait_connected(self) -> None:
await self._connected.wait()
@@ -108,8 +116,10 @@ async def lircd() -> AsyncIterator[tuple[ScriptedLircd, asyncio.base_events.Serv
finally:
server.close()
# The scripted connection handler holds its connection open forever (it does
# not know the test is done), so `wait_closed()` alone would hang here.
server.close_clients()
# not know the test is done), so `wait_closed()` alone would hang here. 3.13
# has Server.close_clients() for exactly this; ask the handlers to let go
# instead, because the device runs the Pi's Python 3.11.
station.shutdown()
await server.wait_closed()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import TypeVar
import pytest
@@ -58,7 +59,10 @@ def seen(bus: EventBus) -> list[Event]:
return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
T = TypeVar("T", bound=Event)
def only(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from typing import TypeVar
import pytest
@@ -55,7 +56,10 @@ def seen(bus: EventBus) -> list[Event]:
return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
T = TypeVar("T", bound=Event)
def only(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]

View File

@@ -19,7 +19,9 @@ from musicmouse.library.podcast_feeds import (
episode_filename,
find_feed_shows,
missing_episodes,
newest_episodes,
parse_feed,
prune_show,
resolve_episode_cover,
sync_all_shows,
sync_show,
@@ -630,3 +632,148 @@ async def test_sync_show_survives_a_corrupt_failure_record(tmp_path: Path) -> No
assert changed is True
assert (folder / "20250101 - Episode One.mp3").exists()
# --------------------------------------------------------------- episode retention
def _episode(day: int, *, title: str | None = None) -> Episode:
return Episode(
title=title or f"Episode {day}",
published=datetime(2025, 1, day, tzinfo=UTC),
enclosure_url=f"http://example.com/ep{day}.mp3",
enclosure_type="audio/mpeg",
)
def _write_episode(folder: Path, name: str) -> Path:
path = folder / name
path.write_bytes(b"audio")
return path
def test_newest_episodes_takes_the_most_recent_and_orders_them_newest_first() -> None:
episodes = [_episode(1), _episode(5), _episode(3)]
assert [e.published.day for e in newest_episodes(episodes, 2)] == [5, 3]
def test_newest_episodes_keeps_everything_when_the_limit_is_none() -> None:
episodes = [_episode(1), _episode(5), _episode(3)]
assert len(newest_episodes(episodes, None)) == 3
def test_prune_show_deletes_only_the_oldest_beyond_the_limit(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
for day in range(1, 6):
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
assert prune_show(folder, 2) == 3
remaining = sorted(path.name for path in folder.iterdir())
assert remaining == ["20250104 - Episode 4.mp3", "20250105 - Episode 5.mp3"]
def test_prune_show_is_a_noop_below_the_limit(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Episode 1.mp3")
assert prune_show(folder, 50) == 0
assert prune_show(folder, None) == 0
assert (folder / "20250101 - Episode 1.mp3").exists()
def test_prune_show_takes_the_sidecar_cover_with_the_episode(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Old.mp3")
(folder / "20250101 - Old.jpg").write_bytes(b"art")
_write_episode(folder, "20250102 - New.mp3")
(folder / "20250102 - New.jpg").write_bytes(b"art")
assert prune_show(folder, 1) == 1
assert not (folder / "20250101 - Old.jpg").exists()
assert (folder / "20250102 - New.jpg").exists()
def test_prune_show_leaves_everything_it_did_not_name(tmp_path: Path) -> None:
"""A folder holds more than episodes, and a hand-placed file is not ours to delete."""
folder = tmp_path / "show"
folder.mkdir()
for day in range(1, 4):
_write_episode(folder, f"2025010{day} - Episode {day}.mp3")
(folder / "feed.txt").write_text("http://example.com/feed.xml\n")
(folder / "folder.jpg").write_bytes(b"art")
(folder / ".failed-downloads.json").write_text("{}")
_write_episode(folder, "Grandpa's tape.mp3")
prune_show(folder, 1)
assert (folder / "feed.txt").exists()
assert (folder / "folder.jpg").exists()
assert (folder / ".failed-downloads.json").exists()
assert (folder / "Grandpa's tape.mp3").exists()
@pytest.mark.asyncio
async def test_sync_show_prunes_to_the_limit_after_downloading(tmp_path: Path) -> None:
folder = tmp_path / "show"
folder.mkdir()
def handler(request: httpx2.Request) -> httpx2.Response:
if str(request.url) == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED)
return httpx2.Response(200, content=b"audio-bytes")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed is True
episodes = sorted(path.name for path in folder.iterdir() if path.suffix == ".mp3")
assert episodes == ["20250103 - Episode Two_ Special_Chars_.mp3"]
@pytest.mark.asyncio
async def test_sync_show_does_not_redownload_what_it_pruned(tmp_path: Path) -> None:
"""The whole point of capping downloads as well as deletions.
Prune to the newest N but fetch everything the feed offers, and each poll would
re-download the episodes the previous one deleted - forever, at full size.
"""
folder = tmp_path / "show"
folder.mkdir()
downloads: list[str] = []
def handler(request: httpx2.Request) -> httpx2.Response:
if str(request.url) == "http://example.com/feed.xml":
return httpx2.Response(200, content=_FEED)
downloads.append(str(request.url))
return httpx2.Response(200, content=b"audio-bytes")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
first_pass = len(downloads)
changed_again = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed_again is False
assert len(downloads) == first_pass
@pytest.mark.asyncio
async def test_sync_show_reports_a_change_when_it_only_pruned(tmp_path: Path) -> None:
"""A deletion needs a rescan as much as a download does, or the library goes on
offering tracks whose files are gone."""
folder = tmp_path / "show"
folder.mkdir()
_write_episode(folder, "20250101 - Episode One.mp3")
_write_episode(folder, "20250103 - Episode Two_ Special_Chars_.mp3")
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, content=_FEED)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client:
changed = await sync_show(client, folder, "http://example.com/feed.xml", keep=1)
assert changed is True
assert not (folder / "20250101 - Episode One.mp3").exists()

View File

@@ -32,7 +32,9 @@ async def test_run_checks_immediately_and_calls_on_change_only_when_something_ch
) -> None:
calls = 0
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
async def fake_sync_all_shows(
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
) -> bool:
nonlocal calls
calls += 1
return calls == 1 # only the very first pass finds something new
@@ -63,7 +65,9 @@ async def test_run_survives_a_failing_pass(
) -> None:
calls = 0
async def fake_sync_all_shows(client: httpx2.AsyncClient, root: Path) -> bool:
async def fake_sync_all_shows(
client: httpx2.AsyncClient, root: Path, *, keep: int | None = None
) -> bool:
nonlocal calls
calls += 1
if calls == 1:

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from typing import TypeVar
import pytest
@@ -49,7 +50,10 @@ def seen(sim: Simulation) -> list[Event]:
return events
def only[T: Event](events: list[Event], event_type: type[T]) -> list[T]:
T = TypeVar("T", bound=Event)
def only(events: list[Event], event_type: type[T]) -> list[T]:
return [e for e in events if isinstance(e, event_type)]

View File

@@ -56,7 +56,6 @@ def _run_body(lesson_id: str, *, stars: int = 3, passed: bool = True) -> dict:
"animal": "fish",
"points": 42.0,
"passed": passed,
"pearls": 5,
"strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}],
}
@@ -171,7 +170,6 @@ async def test_get_progress_is_fresh_with_only_the_first_lesson_unlocked(
body = (await client.get("/api/tippen/progress")).json()
assert body["lessons"]["l01"]["unlocked"] is True
assert body["lessons"]["l02"]["unlocked"] is False
assert body["pearls"] == 0
async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None:

View File

@@ -117,19 +117,21 @@ def test_wrong_world_count_is_one_problem(tmp_path: Path) -> None:
def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
worlds = [dict(w) for w in VALID["worlds"]]
# Two independent problems in one file: a reused reward, and a lesson with too many
# keys - both should show up in a single error, not just the first one found.
# Two independent problems in one file: a reused reward, and a lesson introducing
# too many keys at once - both should show up in a single error, not just the first
# one found. ("a" and "s" are already taught in world 1, so only the three unseen
# keys count against the cap.)
worlds[1] = {**worlds[1], "reward": "clownfish"}
worlds[2] = {
**worlds[2],
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "d"]}],
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "q", "w", "e"]}],
}
with pytest.raises(CurriculumError) as excinfo:
load_curriculum(_write(tmp_path, {"worlds": worlds}))
message = str(excinfo.value)
assert "2 problems" in message
assert "reuses reward" in message
assert "at most two keys" in message
assert "at most two new keys" in message
def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:
@@ -244,11 +246,10 @@ def test_plays_a_mode_that_fits_its_kind(curriculum) -> None:
assert mode in ELIGIBLE_MODES[lesson.kind], where
def test_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None:
arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"]
for i in range(1, len(arcade)):
where = f"{arcade[i].number}: {arcade[i].title}"
assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where
def test_letters_rounds_always_play_bubbles(curriculum) -> None:
for lesson in curriculum.lessons:
if lesson.kind == "letters":
assert lesson.primary_mode == "bubbles", f"{lesson.number}: {lesson.title}"
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:

View File

@@ -53,13 +53,12 @@ def _curriculum() -> Curriculum:
return Curriculum(worlds=worlds, lessons=lessons)
def _result(*, stars: int, passed: bool, points: float = 10.0, pearls: int = 3) -> RunResult:
def _result(*, stars: int, passed: bool, points: float = 10.0) -> RunResult:
return RunResult(
stars=stars, # type: ignore[arg-type]
animal="fish",
points=points,
passed=passed,
pearls=pearls,
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
)
@@ -99,7 +98,6 @@ def test_save_then_load_round_trips(tmp_path: Path) -> None:
assert reloaded.lessons["l01"].best_stars == 3
assert reloaded.lessons["l02"].unlocked is True
assert reloaded.pearls == 3
# Atomic write leaves no temp file behind.
assert list(tmp_path.glob("*.tmp*")) == []
@@ -209,16 +207,15 @@ def test_finishing_a_world_awards_its_creature_exactly_once() -> None:
assert step3.progress.aquarium == ["clownfish"]
def test_pearls_and_streak_accumulate() -> None:
def test_streak_accumulates() -> None:
curriculum = _curriculum()
progress = fresh_progress(curriculum)
day1 = record_run(
progress, "l01", _result(stars=1, passed=False, pearls=3), curriculum, day="2026-01-01"
progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01"
)
day2 = record_run(
day1.progress, "l01", _result(stars=1, passed=False, pearls=4), curriculum, day="2026-01-02"
day1.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-02"
)
assert day2.progress.pearls == 7
assert day2.progress.streak.days == 2
assert day2.progress.streak.last_played == "2026-01-02"

View File

@@ -11,6 +11,7 @@ import contextlib
import json
from collections.abc import AsyncIterator
from pathlib import Path
from typing import TypeAlias
import httpx2
import pytest
@@ -27,7 +28,7 @@ from tests.websocket_harness import WebSocketSession, websocket_connect
TRACK_SECONDS = 10.0
#: Shorthand: every test takes the same client type.
type Client = httpx2.AsyncClient
Client: TypeAlias = httpx2.AsyncClient
@pytest.fixture

View File

@@ -1,11 +1,14 @@
# The typing game's lesson plan, referenced from config.yml's general.tippen.curriculum_file.
#
# Every lesson: title, subtitle (read aloud). Then either:
# - keys: the letters-kind key(s) this round drills. The loader tracks which keys were
# already active: the first time a key appears its lesson is "isolated" (heavy
# weight, alone); the second (identical) appearance is "mixed" (lighter weight,
# blended with everything learned so far). Two lessons with the same `keys` back to
# back is exactly how you write "isolated, then mixed" - no separate flag needed.
# - keys: the letters-kind keys this round has on the table. At most two of them may
# be new; the rest are keys already taught, written out to say "these are in play
# too". The loader tracks which keys were already active and spotlights only what is
# new, so `keys: [f, j, a, d, k]` right after `[f, j, a, d]` is a K lesson with the
# other four as review ("isolated" - heavy weight on K). A round that introduces
# nothing new spotlights its whole list instead ("mixed" - lighter weight, blended
# with everything learned so far), so two lessons with the same `keys` back to back
# is exactly how you write "isolated, then mixed" - no separate flag needed.
# - drill: true - a pure review round, no new content, whatever is active so far.
# - kind + words - a fragments/words/sentences consolidation round (dive mode by
# default; `mode:` overrides it - see the backend's `ELIGIBLE_MODES` for which
@@ -18,8 +21,11 @@
#
# unlocks: <path>
#
# A path into the music library (see config.yml's general.library.root - absolute, or
# relative to it; "~" is expanded). Passing this lesson (two stars, or five attempts
# A path into the music library, written relative to config.yml's
# general.library.root - "Musik/Klaviersammlung/Lied.mp3", not a path from "/" or "~".
# (An absolute path, or one starting with "~", still resolves, so an old file keeps
# working - but relative is the form to write, since it survives moving the library.)
# Passing this lesson (two stars, or five attempts
# regardless of score) unlocks that track and everything before it in the same album,
# inclusive - so pointing at an album's last track unlocks the whole album, and
# pointing at track 5 of an audiobook unlocks chapters 1 through 5. The web front-end
@@ -59,7 +65,7 @@ worlds:
subtitle: "Die Ringfinger"
keys: [s, l]
# Example - unlocks tracks 1 through 5 (inclusive) of this album:
# unlocks: ~/Music/Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3
# unlocks: "Musik/Kinderparty - Kinderparty Lieder/05 - Lied.mp3"
- title: "S und L üben"
subtitle: "Die neuen Tasten festigen"
keys: [s, l]
@@ -195,7 +201,7 @@ worlds:
drill: true
# Example - unlocks a whole audiobook (its last chapter, inclusive of every
# chapter before it):
# unlocks: ~/Music/Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3
# unlocks: "Hörbücher/Conni - Conni in den Bergen/06 - Teil.mp3"
- number: 3
title: "Nach unten"
@@ -295,7 +301,7 @@ worlds:
drill: true
# Example - unlocks a whole podcast show (its most recent episode, and every
# earlier one of the same show, chronologically):
# unlocks: ~/Music/Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3
# unlocks: "Kinderpodcasts/Wissen macht Ah/20260101 - Neu.mp3"
- number: 4
title: "Große Buchstaben"

244
scripts/sync-library.sh Executable file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env bash
#
# Copy a local music library, and the expensive parts of its scan cache, onto a
# MusicMouse device.
#
# scripts/sync-library.sh --host musicdolphin
#
# The cache is the reason this is a script rather than one rsync line. Its three kinds
# of content behave completely differently when they move between machines (see
# python-backend/musicmouse/library/cache.py):
#
# covers/<album_id>.jpg album_id = sha1(folder path relative to the library
# root). Portable, as long as the shelf layout under the
# target root matches the source - which it does, because
# this script syncs the shelves verbatim.
#
# analysis/<track_key>.json track_key = sha1("<name>:<size>:<int mtime>"). Portable
# *only if mtimes survive the copy*. That is why every
# rsync here is -a and why you must never reach for scp
# without -p: losing mtimes silently invalidates every
# analysis file, each of which costs minutes of DSP to
# rebuild.
#
# index.json NOT copied. It holds absolute source-machine paths, and
# scan_library() reuses a cached album whenever its
# (name, size, mtime) fingerprint matches, without
# re-checking the path. Copying it makes every album come
# back pointing at /home/<you>/Music/..., and playback
# fails on files that are sitting right there. It is the
# cheap part of the cache - the device rebuilds it on its
# first scan, reusing every cover and analysis file we did
# copy.
set -euo pipefail
HOST="musicdolphin"
SSH_USER="root"
SRC="${HOME}/Music"
CACHE=""
DEST="/media/musicmouse"
DRY_RUN=0
COPY_CACHE=1
# Shelves the scanner knows about (musicmouse/library/sections.py). Syncing these by
# name rather than the whole directory is what makes --delete safe: config.yml,
# tippen-curriculum.yml, tippen-progress.json and .musicmouse-cache all live in $DEST
# on the device and must survive.
SECTIONS=("Figuren" "Musik" "Hörbücher" "Kinderpodcasts")
# Leave this much room on the target after the copy. These devices are SD cards with
# little to spare, and a full root filesystem breaks far more than the music player.
HEADROOM_BYTES=$((1024 * 1024 * 1024))
usage() {
cat <<'USAGE'
Usage: scripts/sync-library.sh [options]
--host HOST Target device (default: musicdolphin)
--user USER SSH user (default: root)
--src DIR Local library (default: ~/Music)
--dest DIR Library root on target (default: /media/musicmouse)
--cache DIR Local cache directory (default: <src>/.musicmouse-cache, falling
back to python-backend/.musicmouse-cache next to this script)
--no-cache Skip the cache; the device recomputes everything from scratch
--dry-run Show what would be transferred, change nothing
-h, --help This message
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--user) SSH_USER="$2"; shift 2 ;;
--src) SRC="$2"; shift 2 ;;
--dest) DEST="$2"; shift 2 ;;
--cache) CACHE="$2"; shift 2 ;;
--no-cache) COPY_CACHE=0; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
SSH_TARGET="${SSH_USER}@${HOST}"
SRC="${SRC%/}"
DEST="${DEST%/}"
die() { echo "error: $*" >&2; exit 1; }
step() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
human() {
# Bytes -> something a person can judge at a glance.
numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1 bytes"
}
# ---------------------------------------------------------------------- cache location
if [[ -z "$CACHE" ]]; then
if [[ -d "${SRC}/.musicmouse-cache" ]]; then
CACHE="${SRC}/.musicmouse-cache"
else
# The dev setup keeps config.yml (and therefore the cache, which resolves
# relative to it) in python-backend/ rather than beside the music.
CACHE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/python-backend/.musicmouse-cache"
fi
fi
# ------------------------------------------------------------------------- preflight
step "Preflight"
[[ -d "$SRC" ]] || die "source library not found: $SRC"
present_sections=()
for section in "${SECTIONS[@]}"; do
[[ -d "${SRC}/${section}" ]] && present_sections+=("$section")
done
[[ ${#present_sections[@]} -gt 0 ]] \
|| die "no known shelves under $SRC (looked for: ${SECTIONS[*]})"
echo "source: $SRC"
echo "shelves: ${present_sections[*]}"
if [[ $COPY_CACHE -eq 1 ]]; then
if [[ -d "$CACHE" ]]; then
echo "cache: $CACHE"
else
echo "cache: none found at $CACHE - continuing without it"
COPY_CACHE=0
fi
else
echo "cache: skipped (--no-cache)"
fi
ssh -o ConnectTimeout=10 -o BatchMode=yes "$SSH_TARGET" true 2>/dev/null \
|| die "cannot ssh to $SSH_TARGET (needs key auth, no password prompt)"
echo "target: ${SSH_TARGET}:${DEST}"
# Size the transfer. This is an upper bound: rsync skips files already present and
# identical, so a repeat run moves far less than this.
src_bytes=0
for section in "${present_sections[@]}"; do
section_bytes=$(du -sb "${SRC}/${section}" | cut -f1)
src_bytes=$((src_bytes + section_bytes))
printf ' %-18s %s\n' "$section" "$(human "$section_bytes")"
done
if [[ $COPY_CACHE -eq 1 ]]; then
for part in covers analysis; do
if [[ -d "${CACHE}/${part}" ]]; then
part_bytes=$(du -sb "${CACHE}/${part}" | cut -f1)
src_bytes=$((src_bytes + part_bytes))
printf ' %-18s %s\n' "cache/${part}" "$(human "$part_bytes")"
fi
done
fi
# df on the parent: $DEST may not exist yet on a first run.
avail_kb=$(ssh "$SSH_TARGET" "df -Pk '$(dirname "$DEST")' | awk 'NR==2 {print \$4}'")
[[ -n "$avail_kb" ]] || die "could not read free space on $HOST"
avail_bytes=$((avail_kb * 1024))
echo
echo "to transfer (upper bound): $(human "$src_bytes")"
echo "free on target: $(human "$avail_bytes")"
if (( src_bytes + HEADROOM_BYTES > avail_bytes )); then
# Already-synced content counts against src_bytes but costs nothing, so this is a
# warning on a repeat run and a hard stop only when nothing is there yet.
remote_used=$(ssh "$SSH_TARGET" "du -sb '$DEST' 2>/dev/null | cut -f1" || echo 0)
remote_used=${remote_used:-0}
if (( remote_used > 0 )); then
echo
echo "warning: the full library would not fit in the free space, but $(human "$remote_used")"
echo " is already at ${DEST}. rsync only moves the difference; watch the"
echo " free space as it runs."
else
die "not enough space: need $(human $((src_bytes + HEADROOM_BYTES))) including $(human "$HEADROOM_BYTES") headroom, have $(human "$avail_bytes")"
fi
fi
# --------------------------------------------------------------------------- transfer
RSYNC_OPTS=(-aH --info=progress2 --human-readable)
[[ $DRY_RUN -eq 1 ]] && RSYNC_OPTS+=(-n)
if [[ $DRY_RUN -eq 1 ]]; then
echo
echo "(dry run - nothing will be written)"
else
step "Creating ${DEST} on ${HOST}"
ssh "$SSH_TARGET" "mkdir -p '$DEST'"
fi
step "Library"
for section in "${present_sections[@]}"; do
echo "--- ${section}"
# Per-shelf, with --delete scoped to that shelf. A --delete on $DEST as a whole
# would take out config.yml and the cache, which live in the same directory on the
# device but have no counterpart here.
rsync "${RSYNC_OPTS[@]}" --delete \
"${SRC}/${section}/" "${SSH_TARGET}:${DEST}/${section}/"
done
if [[ $COPY_CACHE -eq 1 ]]; then
step "Cache (covers and analysis; index.json deliberately not copied)"
for part in covers analysis; do
[[ -d "${CACHE}/${part}" ]] || continue
echo "--- ${part}"
# rsync creates the last path component but not a missing one above it, and on a
# fresh device neither .musicmouse-cache nor its subdirectory exists yet.
[[ $DRY_RUN -eq 1 ]] || ssh "$SSH_TARGET" "mkdir -p '${DEST}/.musicmouse-cache/${part}'"
# No --delete: the device may have analysed tracks this machine never saw.
rsync "${RSYNC_OPTS[@]}" \
"${CACHE}/${part}/" "${SSH_TARGET}:${DEST}/.musicmouse-cache/${part}/"
done
fi
# ----------------------------------------------------------------------------- report
step "Done"
if [[ $DRY_RUN -eq 1 ]]; then
echo "Dry run only - nothing was written to ${HOST}."
exit 0
fi
ssh "$SSH_TARGET" "
echo 'library: '\$(du -sh '$DEST' 2>/dev/null | cut -f1)
echo 'free: '\$(df -Ph '$DEST' | awk 'NR==2 {print \$4}')
if [ -d '${DEST}/.musicmouse-cache' ]; then
echo 'covers: '\$(find '${DEST}/.musicmouse-cache/covers' -type f 2>/dev/null | wc -l)' files'
echo 'analysis: '\$(find '${DEST}/.musicmouse-cache/analysis' -type f 2>/dev/null | wc -l)' files'
fi
"
cat <<EOF
The backend rescans on its next start - a few minutes for a large library - and writes
a fresh index.json with this device's own paths. Every cover and analysis file copied
above is reused, so the expensive work does not happen again.
ssh ${SSH_TARGET} systemctl restart musicmouse
ssh ${SSH_TARGET} journalctl -u musicmouse -f
EOF

1
slint-frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target

5908
slint-frontend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

65
slint-frontend/Cargo.toml Normal file
View File

@@ -0,0 +1,65 @@
[package]
name = "musicmouse-slint"
version = "0.1.0"
edition = "2021"
description = "Native Slint front-end for the MusicMouse player"
[dependencies]
# default-features off so the renderer is an explicit choice below, never an
# accident: on a Pi 5 (Mesa V3D) the default FemtoVG renderer draws every runtime
# loaded Image as solid black - see slint-ui/slint#11785 - which for this app means
# every album cover. The `skia` feature is the device's way out.
slint = { version = "1.15", default-features = false, features = [
"compat-1-2",
"std",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Blocking HTTP and blocking websockets, each on its own thread, rather than an async
# runtime. The whole client makes one library request, one websocket connection and a
# trickle of covers; tokio would be more machinery than the job has work.
ureq = { version = "2.12", default-features = false, features = ["json", "gzip"] }
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
dirs = "5"
# Only for `normalize()`: folding "Grüffelo" to "gruffelo" needs a real NFD pass, which
# is what the web client gets from String.prototype.normalize for free.
unicode-normalization = "0.1"
[build-dependencies]
slint-build = "1.15"
[features]
default = ["desktop"]
# Development on a normal desktop session. FemtoVG is fine on x86/Mesa and builds in
# seconds, which is what makes it the right default *here* and the wrong one on the Pi.
# `renderer-software` rides along so `SLINT_BACKEND=winit-software` works without a
# rebuild: it is how the UI can be rendered and screenshotted on a machine with no GPU
# (or no session), and it is the fallback the Pi 5 FemtoVG bug leaves standing.
desktop = ["slint/backend-winit", "slint/renderer-femtovg", "slint/renderer-software"]
# Same window, Skia instead - the combination the device runs, so visual checks here
# match what ships. Costs a long first build while rust-skia bootstraps.
skia = ["slint/backend-winit", "slint/renderer-skia"]
# The device: straight onto KMS/DRM with no X, no Wayland and no compositor, input
# via libinput. (`backend-linuxkms-noseat` is deprecated upstream in favour of this.)
#
# Build it with `--no-default-features --features kiosk`: cargo features are additive,
# so leaving `desktop` enabled links winit and linuxkms into the same binary.
kiosk = ["slint/backend-linuxkms-libinput", "slint/renderer-skia"]
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
# The UI is laid out and hit-tested in debug builds too; an unoptimised image resize
# on a 343-album first run is otherwise the slowest thing in the app by far.
[profile.dev.package.image]
opt-level = 3
[profile.dev.package.slint]
opt-level = 2

168
slint-frontend/README.md Normal file
View File

@@ -0,0 +1,168 @@
# Musik Delfin — the Slint front-end
A native front-end for the music player, next to `../web/`. Same backend, same layout,
same German copy; no browser. It exists because the React UI is sluggish on the Pi 5
kiosk, and the things making it sluggish are browser-shaped: a compositor deciding what
gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a
JPEG decode per album card per cold start.
Only the music player is here. The typing game (`Tippen`), the room-lights page, parent
mode and the IR-remote assignment are all still web-only.
## Running it
Start the backend, then:
```sh
nix-shell # cargo, the GL/X11/Wayland libs, and Nunito
cargo run # windowed, FemtoVG
cargo run -- --server http://musicdolphin:8080
```
Checks:
```sh
cargo test && cargo clippy --all-targets && cargo fmt --check
```
`tools/headless-shots.sh` renders the UI and screenshots it inside a nested headless
compositor, for checking a layout change on a machine with no display (or a locked one).
A Slint window needs a real compositor - under bare Xvfb nothing maps at all.
## Build profiles
| | Backend | Renderer | For |
|---|---|---|---|
| `cargo run` | winit | FemtoVG | development here |
| `cargo run --features skia` | winit | Skia | checking what the device will show |
| `cargo build --release --no-default-features --features kiosk` | linuxkms + libinput | Skia | the Pi |
**The device must not use FemtoVG.** On a Pi 5 (Mesa V3D), FemtoVG draws every
runtime-loaded `Image` as solid black - [slint#11785][bug], open as of this writing -
which for this app means every album cover. Skia and the software renderer are both
unaffected. FemtoVG stays the default *here* because it builds in seconds and is fine on
desktop Mesa; `kiosk` is the profile that ships.
`--no-default-features` is not optional on that last one: the features are additive, so
leaving `desktop` on would link winit *and* linuxkms into the same binary.
`kiosk` takes the framebuffer directly through KMS/DRM: no X, no Wayland, no compositor.
(`backend-linuxkms-noseat` is deprecated upstream in favour of the libinput variant used
here.) `SLINT_BACKEND=winit-software` forces the software renderer without a rebuild.
[bug]: https://github.com/slint-ui/slint/issues/11785
## Where the speed comes from
Four things, in rough order of how much they matter on the device:
**Covers are downscaled once, ever.** The backend serves one size - 640 px on the long
edge - and has no `?size=`. A grid card is 180 px. `src/covers.rs` fetches each cover
once on a worker thread, resizes it to the tier that was asked for, and writes a JPEG
thumbnail to `~/.cache/musicmouse-slint/covers/<album-id>@<px>.jpg`. Every later start
reads that instead. There are two tiers and only two, which is what makes the cache
worth having: a per-widget pixel size would give each album a dozen near-identical files
and a fresh decode for each.
The cache is dropped wholesale when the websocket announces a rescan
(`{"type": "library"}`), because that is the one moment the backend rewrites the art
behind an unchanged cover URL. That is the bug the web client had to learn about the
hard way - see the comment at `python-backend/musicmouse/services/web/api.py:106`.
**The grid is virtualized.** Rust hands the UI a `[GridRow]` - the album list
pre-chunked into rows - and the view puts those in a `ListView`, which only instantiates
the rows on screen. A flat list of 660 cards gives it no rows to skip, hence the
chunking. This is the counterpart of `content-visibility: auto` on `.grid > .card` in
the web UI, which exists for exactly the same measurement: 340 live cards saturated the
Pi's renderer so completely that the main thread stopped scheduling anything.
**The progress bar animates instead of ticking.** Position arrives twice a second. The
web client interpolates it onto animation frames with a JS clock and a render budget;
here the fill is a Slint `animate width` between the pushed values, so interpolation
costs a property evaluation per frame on the render side and there is no clock at all.
It is suppressed for the one frame a track changes on, so a new track jumps rather than
sliding across two unrelated positions.
**Nothing on screen animates by itself.** The web UI's per-track ambient canvas is not
here. Halving its resolution and its frame rate took it from 53.5% of a core to 25.0%
and 25% was still too much: a loop repainting the viewport is a floor you cannot get
under while it runs at all. The stage keeps its gradient, which is what `?pi=1` already
does on the device this replaces.
## What is deliberately different from the web UI
**No glass blur.** Slint has no `backdrop-filter`, so the panels use a flat translucent
fill - exactly the web app's own `data-blur="off"` fallback. Some depth is genuinely
lost. Faking it by compositing a pre-blurred copy would reintroduce the per-frame cost
that dropping the blur was meant to avoid.
Worth knowing if you ever port the blur back: on the Pi, `backdrop-filter` measured five
times *better* than no blur (25% of a core against 118%), because it is what promotes
each panel to its own compositing layer. That is a browser-compositor artifact and does
not transfer here - but it is why the web UI keeps the panel blur under `?pi=1`.
**No dolphin.** The mascot and its swim/bob loops are not ported.
**Layout** is Slint's, not a transcription of the CSS: fixed row heights instead of
`clamp()` and `vh`, a 1080p-shaped fixed measure for the track list rather than
`max-width`. The colours are the same - `ui/theme.slint` carries the web UI's oklch
tokens converted to sRGB, with the oklch source kept beside each one, because oklch is
the form the design is actually maintained in.
## How it is put together
The split that matters is the web UI's, unchanged: **what is playing** comes from the
backend, **what you are looking at** lives here. The buttons on the mouse, a figurine on
the reader and Home Assistant all move playback too, so this follows it rather than
assuming it is in charge.
Three worker threads and no async runtime - the whole client makes one library request,
one websocket connection and a trickle of covers.
| Path | What it is |
|---|---|
| `src/api.rs` | The wire types and the HTTP calls. Mirrors `services/web/schemas.py`. |
| `src/ws.rs` | The state websocket, with reconnect-and-reseed. |
| `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. |
| `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. |
| `src/covers.rs` | The thumbnail cache, and the generated stand-in art. |
| `src/oklch.rs`, `src/format.rs` | Colour conversion; durations. |
| `src/main.rs` | Wiring: worker channels in, Slint models out. |
| `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. |
`src/keymap.rs` and `src/library.rs` are ports of modules that were already pure
functions with their own tests, and they keep them: 43 tests, no window required.
## Keyboard
The same map as the web UI.
| Key | |
|---|---|
| `SPACE` | play / pause (types a space mid-query) |
| `SHIFT+H` / `SHIFT+L` | previous / next |
| `SHIFT+J` / `SHIFT+K` | quieter / louder |
| `SHIFT+M` | mute |
| `SHIFT+←` / `SHIFT+→` | seek 15 s |
| `A-Z`, `0-9` | search albums as you type |
| `?` | search individual tracks |
| `TAB` | cycle Musik / Hörbücher / Podcasts |
| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing |
| `CTRL+H/J/K/L` | the same, without leaving the home row |
| `CTRL+D` / `CTRL+U` | half a page |
| `ENTER` | play the selection |
| `SHIFT+ENTER` | open the track list |
| `ESC` | back out one layer at a time |
| `/` | back to browsing |
Everything is clickable too.
The special keys are named in `ui/app.slint`, where the `Key.*` constants live, and
reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable
without a window and without hardcoding keysym values.
## Fonts
Nunito, via `SLINT_DEFAULT_FONT`, which `shell.nix` sets. Slint cannot read the woff2
the web app self-hosts, so this wants the TTF; a deployment can drop one at
`fonts/Nunito.ttf` instead. With neither, everything still renders in the system sans.

3
slint-frontend/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
slint_build::compile("ui/app.slint").expect("compiling ui/app.slint");
}

Binary file not shown.

View File

@@ -0,0 +1,9 @@
Nunito, the face the design is drawn in, as a variable TTF.
`web/public/fonts/` has the same family as woff2, which is right for a browser and
useless here: Slint cannot read woff2. This is the upstream variable TTF (weights
200-1000), which `SLINT_DEFAULT_FONT` is pointed at - by `shell.nix` for development,
and by the launcher `roles/pi_slint_frontend` installs on the device.
Nunito is licensed under the SIL Open Font License 1.1:
https://github.com/googlefonts/nunito

62
slint-frontend/shell.nix Normal file
View File

@@ -0,0 +1,62 @@
# Dev shell for the Slint front-end.
#
# NixOS has no rustup-installed toolchain here, and Slint's renderers link against
# system GL/X11/Wayland libraries that a plain `cargo build` cannot find on its own -
# hence LD_LIBRARY_PATH below rather than just a list of buildInputs.
{ pkgs ? import <nixpkgs> { } }:
let
# Everything the winit backend dlopen()s at runtime. Missing one of these shows up
# as a panic inside winit, not as a link error, so they are easy to misdiagnose.
runtimeLibs = with pkgs; [
libGL
libxkbcommon
wayland
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
fontconfig
freetype
];
in
pkgs.mkShell {
nativeBuildInputs = with pkgs; [
cargo
rustc
rustfmt
clippy
pkg-config
# Only needed when building with --features skia: rust-skia compiles its own
# bindings shim and wants a C++ toolchain plus python for the GN build.
clang
python3
];
# Nunito is the face the design is drawn in. `SLINT_DEFAULT_FONT` takes one file and
# makes it the primary font. This points at the copy vendored in `fonts/` rather than
# at nixpkgs, so development and the device render from the identical file.
SLINT_DEFAULT_FONT = toString ./fonts/Nunito.ttf;
buildInputs = runtimeLibs ++ (with pkgs; [
fontconfig
# libinput/libdrm/libseat are the linuxkms (kiosk) backend's dependencies. They
# cost nothing to have present on a desktop build.
libinput
libdrm
seatd
udev
]);
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
# rust-skia downloads a prebuilt libskia rather than building it, when it can.
SKIA_BINARIES_URL_TEMPLATE = null;
shellHook = ''
echo "musicmouse slint-frontend cargo $(cargo --version | cut -d' ' -f2)"
echo " cargo run # windowed, femtovg"
echo " cargo run --features skia # windowed, skia (what the Pi uses)"
echo " cargo build --features kiosk # linuxkms + skia, for the device"
'';
}

312
slint-frontend/src/api.rs Normal file
View File

@@ -0,0 +1,312 @@
//! The shapes the backend serves, and the calls that fetch them.
//!
//! Mirrors `python-backend/musicmouse/services/web/schemas.py`, by way of
//! `web/src/api/types.ts` - the three are hand-kept in step. Only the music player's
//! share of the API is here; settings, the IR remote, Home Assistant and the typing
//! game are all reachable and all deliberately absent.
//!
//! Every field the backend can omit is modelled with `#[serde(default)]` rather than a
//! required `Option`, so a backend that grows a field does not break this client and
//! one that loses a field fails at the one call site that cared.
use serde::Deserialize;
/// Default for a development run; `--server` overrides it.
pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AlbumKind {
#[default]
Music,
Book,
}
impl AlbumKind {
/// Shape is how you tell the two apart without reading anything: albums square,
/// audiobooks taller than wide, everywhere they appear. Nothing else in this app
/// may set a cover's aspect ratio. Ported from `web/src/lib/covers.ts`.
pub fn aspect(self) -> f32 {
match self {
Self::Music => 1.0,
Self::Book => 0.82,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Track {
pub title: String,
/// Seconds, read from the file's tags at scan time. `0.0` when it carries none.
#[serde(default)]
pub duration: f64,
/// A typing-reward track not yet earned. This front-end does not host the typing
/// game, but it still has to not show what the game is withholding.
#[serde(default)]
pub locked: bool,
}
// `series`, and the parts of `PlayerState` the UI does not read, are kept because these
// structs are the record of what the backend serves - dropping a field would make this
// file a list of what this front-end happens to use today, which is a much less useful
// thing to read next to `schemas.py`.
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct Album {
pub id: String,
/// One of the four fixed shelf folder names: `Figuren`, `Musik`, `Hörbücher`,
/// `Kinderpodcasts`. What `Group::of` reads to spot a podcast.
pub section: String,
#[serde(default)]
pub kind: AlbumKind,
pub title: String,
#[serde(default)]
pub artist: String,
#[serde(default)]
pub series: Option<String>,
/// The figurine whose folder this is, when it has one.
#[serde(default)]
pub figure: Option<String>,
/// Series for audiobooks and podcasts, artist for music. What browsing groups by.
#[serde(default)]
pub category: String,
/// Exactly three `#rrggbb`: primary, secondary, accent, pulled out of the cover art
/// or synthesised from the id. The generated stand-in cover is painted from these.
#[serde(default)]
pub colors: Vec<String>,
#[serde(default)]
pub has_cover: bool,
#[serde(default)]
pub duration: f64,
#[serde(default)]
pub tracks: Vec<Track>,
#[serde(default)]
pub locked: bool,
}
impl Album {
pub fn is_book(&self) -> bool {
self.kind == AlbumKind::Book
}
/// `"Album · Artist"`, unless a podcast makes those the same string.
pub fn line(&self) -> String {
let prefix = if self.is_book() { "📖 " } else { "" };
if !self.artist.is_empty() && self.artist != self.title {
format!("{prefix}{} · {}", self.title, self.artist)
} else {
format!("{prefix}{}", self.title)
}
}
/// `colors` padded out to the three the painter wants, with the same fallbacks the
/// web client uses, so a library entry missing them looks the same in both.
pub fn palette(&self) -> [&str; 3] {
[
self.colors.first().map_or("#4a6fa5", String::as_str),
self.colors.get(1).map_or("#6a8fc5", String::as_str),
self.colors.get(2).map_or("#a5804a", String::as_str),
]
}
}
#[derive(Debug, Clone, Deserialize)]
struct LibraryOut {
albums: Vec<Album>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Connection {
#[serde(default)]
pub firmware: bool,
#[serde(default)]
pub mqtt: bool,
#[serde(default)]
pub lirc: bool,
}
/// What is playing. Owned entirely by the backend: the buttons on the mouse, a figurine
/// on the reader and Home Assistant all move it too, so this client follows it rather
/// than assuming it is in charge.
#[allow(dead_code)] // see the note above `Album`
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PlayerState {
#[serde(default)]
pub playing: bool,
#[serde(default)]
pub album_id: Option<String>,
#[serde(default)]
pub album_title: Option<String>,
#[serde(default)]
pub artist: Option<String>,
#[serde(default)]
pub kind: Option<AlbumKind>,
#[serde(default)]
pub track_index: i32,
#[serde(default)]
pub track_title: Option<String>,
#[serde(default)]
pub track_count: i32,
#[serde(default)]
pub position: f64,
#[serde(default)]
pub duration: f64,
/// Percent, 0..100. The device's configured range never leaves the backend.
#[serde(default)]
pub volume: i32,
#[serde(default)]
pub active_figure: Option<String>,
#[serde(default)]
pub connected: Connection,
}
/// The push-only websocket's frames. `library` carries no payload - it is a bare "your
/// index is stale, fetch it again" after a rescan.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ServerMessage {
State {
state: PlayerState,
},
Position {
position: f64,
#[serde(default)]
duration: f64,
},
Library,
/// A frame this build does not know. Ignored rather than fatal, so the backend can
/// grow a message type without this client falling over on it.
#[serde(other)]
Unknown,
}
// ------------------------------------------------------------------------- client --
#[derive(Debug, Clone)]
pub struct Client {
base: String,
agent: ureq::Agent,
}
/// Enough of an error type to put a cause on screen. Nothing here is recoverable in a
/// way the caller could branch on - the UI either has a library or says it cannot reach
/// the mouse - so the variants are not worth splitting.
pub type Error = String;
impl Client {
pub fn new(base_url: &str) -> Self {
Self {
base: base_url.trim_end_matches('/').to_string(),
agent: ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(4))
.timeout_read(std::time::Duration::from_secs(30))
.build(),
}
}
/// `http://host:port` -> `ws://host:port/api/ws`. The device serves plain HTTP, but
/// deriving the scheme rather than hardcoding it keeps a TLS proxy in front working.
pub fn websocket_url(&self) -> String {
let ws = if let Some(rest) = self.base.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = self.base.strip_prefix("http://") {
format!("ws://{rest}")
} else {
format!("ws://{}", self.base)
};
format!("{ws}/api/ws")
}
pub fn cover_url(&self, album_id: &str) -> String {
format!("{}/api/albums/{album_id}/cover", self.base)
}
pub fn library(&self) -> Result<Vec<Album>, Error> {
let out: LibraryOut = self
.agent
.get(&format!("{}/api/library", self.base))
.call()
.map_err(|e| format!("GET /api/library: {e}"))?
.into_json()
.map_err(|e| format!("GET /api/library: bad JSON: {e}"))?;
Ok(out.albums)
}
pub fn state(&self) -> Result<PlayerState, Error> {
self.agent
.get(&format!("{}/api/state", self.base))
.call()
.map_err(|e| format!("GET /api/state: {e}"))?
.into_json()
.map_err(|e| format!("GET /api/state: bad JSON: {e}"))
}
/// Raw cover bytes, or `Ok(None)` for a 404 - which is a normal answer, not a
/// failure: `has_cover` says so in advance and the client paints the album's own
/// colours instead.
pub fn cover(&self, album_id: &str) -> Result<Option<Vec<u8>>, Error> {
let response = match self.agent.get(&self.cover_url(album_id)).call() {
Ok(response) => response,
Err(ureq::Error::Status(404, _)) => return Ok(None),
Err(e) => return Err(format!("GET cover {album_id}: {e}")),
};
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)
.map_err(|e| format!("GET cover {album_id}: {e}"))?;
Ok(Some(bytes))
}
// -------------------------------------------------------------------- commands --
//
// Every one of these emits the same *intent* the physical buttons emit, so this UI
// has no privileged path and no way to get out of step with a figure someone puts
// on the reader. They all answer 204 with no body - nothing to parse.
fn command(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
let request = self.agent.post(&format!("{}/api{path}", self.base));
let result = if body.is_null() {
request.call()
} else {
request.send_json(body)
};
result.map(|_| ()).map_err(|e| format!("POST {path}: {e}"))
}
pub fn play(&self, album_id: &str, track_index: i32) -> Result<(), Error> {
self.command(
"/play",
serde_json::json!({ "album_id": album_id, "track_index": track_index.max(0) }),
)
}
pub fn resume(&self) -> Result<(), Error> {
self.command("/resume", serde_json::Value::Null)
}
pub fn pause(&self) -> Result<(), Error> {
self.command("/pause", serde_json::Value::Null)
}
pub fn next(&self) -> Result<(), Error> {
self.command("/next", serde_json::Value::Null)
}
pub fn previous(&self) -> Result<(), Error> {
self.command("/previous", serde_json::Value::Null)
}
pub fn seek(&self, position: f64) -> Result<(), Error> {
self.command(
"/seek",
serde_json::json!({ "position": position.max(0.0) }),
)
}
pub fn volume(&self, percent: i32) -> Result<(), Error> {
self.command(
"/volume",
serde_json::json!({ "percent": percent.clamp(0, 100) }),
)
}
}

View File

@@ -0,0 +1,406 @@
//! Album art: fetched once, downscaled once, then reused forever.
//!
//! The backend serves one size and only one - `MAX_COVER_PX = 640` on the longest edge,
//! JPEG, at `/api/albums/{id}/cover`, with no `?size=` to ask for less. A browse grid
//! draws that at 192 px and the play view at 384, so painting the served file directly
//! means decoding a 640x640 JPEG for every visible card on every cold start. At 343
//! albums on a Pi that is the whole of "the UI feels slow".
//!
//! So this keeps a thumbnail cache on disk, keyed by album id *and* the size that was
//! asked for, and the decode-and-resize cost is paid once ever rather than once per
//! launch. A miss goes to the network on a worker thread; the UI never blocks on one.
//!
//! **Invalidation.** The bytes behind a cover URL do change while the URL does not -
//! the id addresses which album the art belongs to, not which art. Rather than pay a
//! conditional request per cover per start, this cache is cleared wholesale when the
//! websocket announces a rescan (`{"type": "library"}`), which is the one moment the
//! backend reprocesses cover art. See the long comment at
//! `python-backend/musicmouse/services/web/api.py:106` for how the web client learned
//! this the hard way.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use image::imageops::FilterType;
use image::{Rgba, RgbaImage};
use crate::api::{AlbumKind, Client};
use crate::library::Group;
use crate::oklch::{mix, oklch_to_rgb, parse_hex};
/// The two sizes anything on screen actually asks for. Keeping it to two is what makes
/// the disk cache worth having: a per-widget pixel size would give every album a dozen
/// near-identical files and a fresh decode for each.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Tier {
/// Grid cards, shelf tiles, track rows and the player bar's 56 px thumbnail, which
/// scales down from this rather than earning a tier of its own.
Thumb,
/// The play view's cover, and the album sheet's.
Hero,
}
impl Tier {
pub fn px(self) -> u32 {
match self {
Self::Thumb => 192,
Self::Hero => 384,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Key {
pub album_id: String,
pub tier: Tier,
}
/// Everything the worker needs about an album, flattened so it can cross a thread
/// boundary without the `Rc<Album>` the UI side holds.
#[derive(Debug, Clone)]
pub struct Request {
pub album_id: String,
pub tier: Tier,
pub has_cover: bool,
pub is_book: bool,
pub locked: bool,
pub colors: [String; 3],
}
/// A finished bitmap on its way back to the UI thread. Raw RGBA rather than a
/// `slint::Image`, because the image types are built on the UI thread by whoever
/// receives this.
pub struct Ready {
pub key: Key,
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
// ------------------------------------------------------------------ generated art --
/// Diagonal two-tone stripes: the stand-in for a music cover, and the thing that shows
/// through underneath one that has not arrived yet.
fn stripes(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
// `max(6, size / 13)` in `web/src/lib/covers.ts` - the stripe scales with the art so
// a 56 px thumbnail does not turn into a grey smear of forty bands.
let band = (width / 13).max(6) as f32;
RgbaImage::from_fn(width, height, |x, y| {
// 135deg in CSS points the gradient line at the bottom-right corner, so the
// position along it is the projection of (x, y) onto (1, 1)/sqrt(2).
let along = (x + y) as f32 * std::f32::consts::FRAC_1_SQRT_2;
let c = if ((along / band) as u32).is_multiple_of(2) {
primary
} else {
secondary
};
Rgba([c[0], c[1], c[2], 255])
})
}
/// A book: page edges down the right, a darker board down the left, over a diagonal
/// wash. Four layers in CSS, four passes here, in the same order.
fn spine(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
// The audiobook shelf's own hue - warm amber, not a duller yellow-brown - so a
// generated spine and the shelf it sits on are the same family by construction.
let hue = Group::Audiobooks.hue();
let page_light = oklch_to_rgb(0.97, 0.02, hue);
let page_dark = oklch_to_rgb(0.90, 0.03, hue);
RgbaImage::from_fn(width, height, |x, y| {
let fx = x as f32 / width.max(1) as f32;
let fy = y as f32 / height.max(1) as f32;
// Layer 4 (bottom): linear-gradient(155deg, primary -> secondary).
let t = ((fx * 0.42 + fy * 0.91) / 1.33).clamp(0.0, 1.0);
let mut c = mix(primary, secondary, t);
// Layer 3: the board down the left edge.
if fx < 0.11 {
c = secondary;
} else if fx < 0.13 {
c = primary;
}
// Layer 2: two thin highlight rules over the board.
if (0.035..0.043).contains(&fx) || (0.070..0.078).contains(&fx) {
c = mix(c, [255, 255, 255], 0.35);
}
// Layer 1 (top): the page block on the right.
if fx >= 0.975 {
c = page_dark;
} else if fx >= 0.95 {
c = page_light;
}
Rgba([c[0], c[1], c[2], 255])
})
}
/// What an album looks like before - or instead of - its real art.
pub fn generated(request: &Request) -> RgbaImage {
let primary = parse_hex(&request.colors[0], [0x4a, 0x6f, 0xa5]);
let secondary = parse_hex(&request.colors[1], [0x6a, 0x8f, 0xc5]);
let px = request.tier.px();
let (width, height) = dimensions(px, request.is_book);
if request.is_book {
spine(width, height, primary, secondary)
} else {
stripes(width, height, primary, secondary)
}
}
/// Shape comes from the media type and from nowhere else: albums square, audiobooks
/// taller than wide. `px` fixes the longest edge either way.
fn dimensions(px: u32, is_book: bool) -> (u32, u32) {
let kind = if is_book {
AlbumKind::Book
} else {
AlbumKind::Music
};
(((px as f32) * kind.aspect()).round() as u32, px)
}
// ------------------------------------------------------------------------- worker --
pub struct Worker {
client: Client,
dir: Option<PathBuf>,
}
impl Worker {
pub fn new(client: Client) -> Self {
let dir = dirs::cache_dir().map(|d| d.join("musicmouse-slint").join("covers"));
if let Some(dir) = &dir {
if let Err(e) = std::fs::create_dir_all(dir) {
// Not fatal: without a disk cache every cover is simply fetched and
// resized again next launch, which is the old behaviour, not a failure.
eprintln!("cover cache unavailable at {}: {e}", dir.display());
}
}
Self { client, dir }
}
fn path(&self, key: &Key) -> Option<PathBuf> {
self.dir
.as_ref()
.map(|d| d.join(format!("{}@{}.jpg", key.album_id, key.tier.px())))
}
/// Drop every stored thumbnail. Called when the backend says it has rescanned, and
/// therefore possibly rewritten the art behind these ids.
pub fn clear_disk(&self) {
let Some(dir) = &self.dir else { return };
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let _ = std::fs::remove_file(entry.path());
}
}
}
fn render(&self, request: &Request) -> RgbaImage {
let px = request.tier.px();
let (want_w, want_h) = dimensions(px, request.is_book);
// A locked album is one the typing game is withholding. It has real art, and
// showing it would give away the reward, so it never gets fetched at all.
if request.locked || !request.has_cover {
return generated(request);
}
if let Some(path) = self.path(&Key {
album_id: request.album_id.clone(),
tier: request.tier,
}) {
if let Ok(image) = image::open(&path) {
return image.to_rgba8();
}
}
let bytes = match self.client.cover(&request.album_id) {
Ok(Some(bytes)) => bytes,
// 404 is a normal answer - `has_cover` can be stale by a rescan - and a
// network error should show the album, not a hole.
Ok(None) | Err(_) => return generated(request),
};
let Ok(decoded) = image::load_from_memory(&bytes) else {
return generated(request);
};
// `resize_to_fill` rather than `resize`: a real cover is square but a book's
// slot is not, and letterboxing one inside the other would break the shelf
// metaphor that the whole browse screen is built on.
let thumb = decoded.resize_to_fill(want_w, want_h, FilterType::CatmullRom);
if let Some(path) = self.path(&Key {
album_id: request.album_id.clone(),
tier: request.tier,
}) {
// Storing JPEG, not PNG: these are photographs, so JPEG is both smaller on
// disk and faster to decode on the next start, which is the whole point.
let _ = thumb
.to_rgb8()
.save_with_format(&path, image::ImageFormat::Jpeg);
}
thumb.to_rgba8()
}
}
/// Runs the fetch/decode/resize loop off the UI thread, calling `deliver` for each
/// finished bitmap. `deliver` is expected to hop back onto the Slint event loop.
pub fn spawn(
client: Client,
jobs: Receiver<Job>,
deliver: impl Fn(Ready) + Send + 'static,
) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("covers".into())
.spawn(move || {
let worker = Worker::new(client);
// A cover asked for while it is already in flight - a card scrolled past
// twice, a grid rebuilt on a keystroke - must not become a second fetch.
let mut done: HashSet<Key> = HashSet::new();
for job in jobs {
match job {
Job::Clear => {
worker.clear_disk();
done.clear();
}
Job::Fetch(request) => {
let key = Key {
album_id: request.album_id.clone(),
tier: request.tier,
};
if !done.insert(key.clone()) {
continue;
}
let image = worker.render(&request);
deliver(Ready {
key,
width: image.width(),
height: image.height(),
rgba: image.into_raw(),
});
}
}
}
})
.expect("spawning the cover worker")
}
pub enum Job {
Fetch(Request),
Clear,
}
/// The UI thread's half: what is already decoded, and what has been asked for.
pub struct Store {
jobs: Sender<Job>,
ready: HashMap<Key, slint::Image>,
requested: HashSet<Key>,
}
impl Store {
pub fn new(jobs: Sender<Job>) -> Self {
Self {
jobs,
ready: HashMap::new(),
requested: HashSet::new(),
}
}
pub fn insert(&mut self, ready: Ready) {
let mut buffer =
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(ready.width, ready.height);
buffer.make_mut_bytes().copy_from_slice(&ready.rgba);
self.ready
.insert(ready.key, slint::Image::from_rgba8(buffer));
}
/// The cover for an album if it is decoded, and a fetch queued if it is not. Callers
/// paint the album's own colours in the meantime, so this returning `None` is a
/// normal frame rather than a missing one.
pub fn get(&mut self, request: Request) -> Option<slint::Image> {
let key = Key {
album_id: request.album_id.clone(),
tier: request.tier,
};
if let Some(image) = self.ready.get(&key) {
return Some(image.clone());
}
if self.requested.insert(key) {
let _ = self.jobs.send(Job::Fetch(request));
}
None
}
pub fn clear(&mut self) {
self.ready.clear();
self.requested.clear();
let _ = self.jobs.send(Job::Clear);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request(is_book: bool, tier: Tier) -> Request {
Request {
album_id: "abc".into(),
tier,
has_cover: false,
is_book,
locked: false,
colors: ["#4a6fa5".into(), "#6a8fc5".into(), "#a5804a".into()],
}
}
#[test]
fn shape_follows_the_media_type() {
assert_eq!(dimensions(192, false), (192, 192));
assert_eq!(dimensions(192, true), (157, 192));
assert_eq!(dimensions(384, true), (315, 384));
}
#[test]
fn generated_art_fills_the_tier_it_was_asked_for() {
let music = generated(&request(false, Tier::Thumb));
assert_eq!((music.width(), music.height()), (192, 192));
let book = generated(&request(true, Tier::Hero));
assert_eq!((book.width(), book.height()), (315, 384));
}
#[test]
fn stripes_actually_alternate() {
let art = generated(&request(false, Tier::Thumb));
let colours: HashSet<[u8; 3]> = (0..192)
.map(|x| {
let p = art.get_pixel(x, 0).0;
[p[0], p[1], p[2]]
})
.collect();
assert_eq!(colours.len(), 2, "expected exactly the two stripe colours");
}
#[test]
fn a_book_has_bright_page_edges_on_its_right() {
let art = generated(&request(true, Tier::Hero));
let (w, h) = (art.width(), art.height());
let edge = art.get_pixel(w - 2, h / 2).0;
let middle = art.get_pixel(w / 2, h / 2).0;
let brightness = |p: [u8; 4]| p[0] as u32 + p[1] as u32 + p[2] as u32;
assert!(
brightness(edge) > brightness(middle),
"page edges should be lighter than the cover"
);
}
#[test]
fn every_generated_pixel_is_opaque() {
let art = generated(&request(true, Tier::Thumb));
assert!(art.pixels().all(|p| p.0[3] == 255));
}
}

View File

@@ -0,0 +1,61 @@
//! Durations, as the screen says them. Ported from `web/src/lib/format.ts`.
/// `m:ss`, or `h:mm:ss` once there is an hour to show. Negative and non-finite inputs
/// collapse to `0:00` rather than rendering `-1:-3`, because both reach here honestly:
/// a position can briefly exceed a tag-derived duration, and a track with no duration
/// tag at all arrives as `0.0`.
pub fn clock(seconds: f64) -> String {
if !seconds.is_finite() || seconds <= 0.0 {
return "0:00".into();
}
let total = seconds.round() as u64;
let (hours, minutes, secs) = (total / 3600, (total % 3600) / 60, total % 60);
if hours > 0 {
format!("{hours}:{minutes:02}:{secs:02}")
} else {
format!("{minutes}:{secs:02}")
}
}
/// How much of the album is left: the rest of this track plus every track after it.
pub fn remaining_in_album(durations: &[f64], track_index: usize, position: f64) -> f64 {
let current = durations.get(track_index).copied().unwrap_or(0.0);
let rest: f64 = durations.iter().skip(track_index + 1).sum();
(current - position).max(0.0) + rest
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clock_formats_minutes_and_hours() {
assert_eq!(clock(0.0), "0:00");
assert_eq!(clock(9.0), "0:09");
assert_eq!(clock(61.0), "1:01");
assert_eq!(clock(599.0), "9:59");
assert_eq!(clock(3600.0), "1:00:00");
assert_eq!(clock(12130.0), "3:22:10");
}
#[test]
fn clock_survives_the_inputs_a_real_player_produces() {
assert_eq!(clock(-4.0), "0:00");
assert_eq!(clock(f64::NAN), "0:00");
assert_eq!(clock(f64::INFINITY), "0:00");
}
#[test]
fn remaining_counts_this_track_and_the_ones_after_it() {
let durations = [100.0, 200.0, 300.0];
assert_eq!(remaining_in_album(&durations, 0, 0.0), 600.0);
assert_eq!(remaining_in_album(&durations, 1, 50.0), 450.0);
assert_eq!(remaining_in_album(&durations, 2, 300.0), 0.0);
}
#[test]
fn a_position_past_the_tagged_duration_does_not_go_negative() {
assert_eq!(remaining_in_album(&[100.0, 50.0], 0, 130.0), 50.0);
assert_eq!(remaining_in_album(&[], 7, 10.0), 0.0);
}
}

View File

@@ -0,0 +1,833 @@
//! What you are looking at, and what a keypress does to it.
//!
//! Ported from `web/src/lib/keyboard.ts`, which is already a pure function from a
//! keypress to a list of actions - the single most portable thing in that codebase, and
//! tested there without a DOM for the same reason it is tested here without a window.
//!
//! The split this rests on: **what is playing** comes from the backend, **what you are
//! looking at** lives here. A real player has other front-ends - the buttons on the
//! mouse, a figurine on the reader, Home Assistant - so this UI follows playback rather
//! than owning it, and owns only the browse state.
//!
//! Not ported: the typing game, the room-lights page, the in-app help overlay and the
//! IR-remote assign mode, none of which this front-end hosts.
use crate::library::{Group, Results, GROUPS};
pub const VOLUME_STEP: i32 = 10;
pub const SEEK_STEP: f64 = 15.0;
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
pub const PAGE_ROWS: i32 = 3;
#[derive(Debug, Clone, PartialEq)]
pub struct UiState {
pub search: String,
pub tracks_mode: bool,
/// `None` is the bare root screen (three shelves); otherwise which one is open.
pub group: Option<Group>,
pub category: Option<String>,
pub sel_index: usize,
/// Which of the three root shelves is focused. Only meaningful at the bare root,
/// where `sel_index` doubles as "which tile in that row".
pub shelf_row: usize,
pub play_view: bool,
pub open_album: Option<String>,
/// Which track is highlighted in the open album's list.
pub sheet_index: usize,
/// Grid columns, recomputed from the window width - `move_selection` needs it to
/// know what one row down means.
pub cols: usize,
}
impl Default for UiState {
fn default() -> Self {
Self {
search: String::new(),
tracks_mode: false,
group: None,
category: None,
sel_index: 0,
shelf_row: 0,
play_view: false,
open_album: None,
sheet_index: 0,
cols: 4,
}
}
}
impl UiState {
/// The true root: nothing chosen yet, drawn as three shelves rather than a list.
pub fn is_root_shelf(&self) -> bool {
self.group.is_none()
&& self.search.is_empty()
&& !self.tracks_mode
&& self.category.is_none()
}
pub fn browsing(&self) -> bool {
!self.play_view && self.open_album.is_none()
}
/// One step of "back": search, then track-search mode, then group and category
/// together - the order ESC and the corner button both use.
///
/// `group` and `category` clear as one step because a root shelf tile sets both at
/// once, jumping straight to one category's albums; undoing that jump should be one
/// step too, however the category was reached.
pub fn browse_back(&mut self) {
self.sel_index = 0;
if !self.search.is_empty() {
self.search.clear();
} else if self.tracks_mode {
self.tracks_mode = false;
} else {
self.group = None;
self.category = None;
}
}
/// Whether anything is left to back out of - what the corner button's presence is.
pub fn can_go_back(&self) -> bool {
self.play_view
|| self.open_album.is_some()
|| !self.search.is_empty()
|| self.tracks_mode
|| self.group.is_some()
|| self.category.is_some()
}
}
/// What a keypress asks the *player* to do. Everything else a key can do is a change to
/// [`UiState`], applied in place.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
Play {
album_id: String,
track_index: usize,
},
Toggle,
Next,
Previous,
/// Percentage points, positive or negative.
Volume(i32),
/// Seconds, positive or negative.
Seek(f64),
Mute,
}
/// Where the flat selection index is pointing. Songs, then categories, then albums.
enum Selection {
Song {
album_id: String,
track_index: usize,
},
Category(String),
Album(String),
None,
}
fn selection_at(results: &Results, sel_index: usize) -> Selection {
let total = results.total();
if total == 0 {
return Selection::None;
}
let index = sel_index.min(total - 1);
if let Some(hit) = results.songs.get(index) {
return Selection::Song {
album_id: hit.album.id.clone(),
track_index: hit.index,
};
}
let after_songs = index - results.songs.len();
if let Some(category) = results.categories.get(after_songs) {
return Selection::Category(category.key.clone());
}
match results.albums.get(after_songs - results.categories.len()) {
Some(album) => Selection::Album(album.id.clone()),
None => Selection::None,
}
}
/// The album behind the selection, when "show me the tracks" makes sense for it: an
/// album tile directly, or - so you can reach the rest of it - the album behind a song
/// hit. `None` for a category tile, and for a podcast episode, which is a single track
/// with nothing else to show.
pub fn selection_album(results: &Results, sel_index: usize) -> Option<String> {
let total = results.total();
if total == 0 {
return None;
}
let index = sel_index.min(total - 1);
let album = if let Some(hit) = results.songs.get(index) {
Some(&hit.album)
} else {
let after_songs = index - results.songs.len();
if after_songs < results.categories.len() {
return None;
}
results.albums.get(after_songs - results.categories.len())
}?;
(Group::of(album) != Group::Podcasts).then(|| album.id.clone())
}
/// The bare root is a genuine two-axis layout: three rows, each a differently-long row
/// of tiles. `shelf_lengths` is how long each row actually is, so a vertical move can
/// clamp the column to the row it lands on rather than guessing.
fn move_root_shelf(state: &mut UiState, dx: i32, dy: i32, shelf_lengths: &[usize; 3]) {
if dy != 0 {
state.shelf_row = (state.shelf_row as i32 + dy).clamp(0, GROUPS.len() as i32 - 1) as usize;
state.sel_index = state
.sel_index
.min(shelf_lengths[state.shelf_row].saturating_sub(1));
}
if dx != 0 {
let last = shelf_lengths[state.shelf_row].saturating_sub(1) as i32;
state.sel_index = (state.sel_index as i32 + dx).clamp(0, last.max(0)) as usize;
}
}
fn move_selection(state: &mut UiState, results: &Results, dx: i32, dy: i32) {
let total = results.total();
if total == 0 {
return;
}
let cols = state.cols.max(1) as i32;
let mut index = state.sel_index.min(total - 1) as i32;
index += dx;
if dy != 0 {
// Song hits are a single-column list; everything below them is a grid.
index += if (index as usize) < results.songs.len() {
dy
} else {
dy * cols
};
}
state.sel_index = index.clamp(0, total as i32 - 1) as usize;
}
/// The keys that mean the same thing from anywhere, matching the muscle memory of every
/// other vim-ish media app. Keyed on the exact shifted letter, so this intercepts only
/// these five - every other Shift+letter still reaches the search box. Search is
/// case-insensitive, so nothing searchable is lost.
fn shift_media(key: &str) -> Option<Action> {
match key.to_ascii_uppercase().as_str() {
"H" => Some(Action::Previous),
"L" => Some(Action::Next),
"K" => Some(Action::Volume(VOLUME_STEP)),
"J" => Some(Action::Volume(-VOLUME_STEP)),
"M" => Some(Action::Mute),
_ => None,
}
}
/// A plain character key's default: type it into the search box. Letters and digits
/// only - `char::is_alphanumeric` rather than `[a-z0-9]`, so the umlauts a German title
/// needs are searchable too.
fn type_into_search(state: &mut UiState, key: &str) -> bool {
let mut chars = key.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_alphanumeric() => {
state.search.push(c);
state.play_view = false;
state.sel_index = 0;
true
}
_ => false,
}
}
/// Translate one keypress: mutate `state`, and return whatever the player should be
/// asked to do.
///
/// `key` is already named by the UI layer - `"escape"`, `"left"`, `"enter"`, … - or is
/// the literal character typed. `shelf_lengths` and `sheet_tracks` are the two counts
/// that `results` does not carry but the clamping needs.
pub fn handle_key(
key: &str,
ctrl: bool,
shift: bool,
state: &mut UiState,
results: &Results,
shelf_lengths: &[usize; 3],
sheet_tracks: usize,
) -> Vec<Action> {
// Reachable from anywhere, including from the play view and with an album sheet open.
if shift && !ctrl {
if let Some(action) = shift_media(key) {
return vec![action];
}
}
if key == "space" {
// Except mid-search, where a space is punctuation the query needs, as in
// "geolino azte".
if state.browsing() && !state.search.is_empty() {
state.search.push(' ');
state.sel_index = 0;
return vec![];
}
return vec![Action::Toggle];
}
// Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
if ctrl {
if state.open_album.is_some() {
match key.to_ascii_lowercase().as_str() {
"j" => {
state.sheet_index = (state.sheet_index + 1).min(sheet_tracks.saturating_sub(1))
}
"k" => state.sheet_index = state.sheet_index.saturating_sub(1),
_ => {}
}
return vec![];
}
if state.play_view {
return vec![];
}
let lower = key.to_ascii_lowercase();
if state.is_root_shelf() {
match lower.as_str() {
"h" => move_root_shelf(state, -1, 0, shelf_lengths),
"l" => move_root_shelf(state, 1, 0, shelf_lengths),
"j" | "d" => move_root_shelf(state, 0, 1, shelf_lengths),
"k" | "u" => move_root_shelf(state, 0, -1, shelf_lengths),
_ => {}
}
return vec![];
}
match lower.as_str() {
"h" => move_selection(state, results, -1, 0),
"l" => move_selection(state, results, 1, 0),
"j" => move_selection(state, results, 0, 1),
"k" => move_selection(state, results, 0, -1),
"d" => move_selection(state, results, 0, PAGE_ROWS),
"u" => move_selection(state, results, 0, -PAGE_ROWS),
_ => {}
}
return vec![];
}
let browsing = state.browsing();
match key {
"tab" => {
let current = state.group.map_or(-1, |g| g.index() as i32);
state.group = Some(Group::from_index(
((current + 1) % GROUPS.len() as i32) as usize,
));
state.category = None;
state.sel_index = 0;
state.play_view = false;
vec![]
}
"/" => {
state.play_view = false;
vec![]
}
// Arrows browse while browsing and drive the player while playing - the one
// pair of keys that changes meaning with the screen, because on the play view
// there is no selection for them to move.
"right" => {
if shift {
return vec![Action::Seek(SEEK_STEP)];
}
if !browsing {
return vec![Action::Next];
}
if state.is_root_shelf() {
move_root_shelf(state, 1, 0, shelf_lengths);
} else {
move_selection(state, results, 1, 0);
}
vec![]
}
"left" => {
if shift {
return vec![Action::Seek(-SEEK_STEP)];
}
if !browsing {
return vec![Action::Previous];
}
if state.is_root_shelf() {
move_root_shelf(state, -1, 0, shelf_lengths);
} else {
move_selection(state, results, -1, 0);
}
vec![]
}
"down" => {
if !browsing {
return vec![Action::Volume(-VOLUME_STEP)];
}
if state.is_root_shelf() {
move_root_shelf(state, 0, 1, shelf_lengths);
} else {
move_selection(state, results, 0, 1);
}
vec![]
}
"up" => {
if !browsing {
return vec![Action::Volume(VOLUME_STEP)];
}
if state.is_root_shelf() {
move_root_shelf(state, 0, -1, shelf_lengths);
} else {
move_selection(state, results, 0, -1);
}
vec![]
}
"?" => {
state.tracks_mode = true;
state.search.clear();
state.sel_index = 0;
state.play_view = false;
vec![]
}
"escape" => {
// Peel one layer at a time rather than dumping you back at the top.
if state.open_album.is_some() {
state.open_album = None;
} else if state.play_view {
state.play_view = false;
} else {
state.browse_back();
}
vec![]
}
"backspace" => {
if !state.search.is_empty() {
state.search.pop();
state.sel_index = 0;
}
vec![]
}
"enter" => {
if let Some(album_id) = state.open_album.clone() {
return vec![Action::Play {
album_id,
track_index: state.sheet_index,
}];
}
if browsing && state.is_root_shelf() {
let row = state.shelf_row.min(GROUPS.len() - 1);
state.group = Some(Group::from_index(row));
// A shelf row with no tiles still opens its group; there is simply no
// category to pre-select.
state.category = None;
state.sel_index = 0;
return vec![];
}
if shift {
if let Some(album_id) = selection_album(results, state.sel_index) {
state.open_album = Some(album_id);
state.sheet_index = 0;
return vec![];
}
}
match selection_at(results, state.sel_index) {
Selection::Song {
album_id,
track_index,
} => {
vec![Action::Play {
album_id,
track_index,
}]
}
Selection::Album(album_id) => vec![Action::Play {
album_id,
track_index: 0,
}],
Selection::Category(key) => {
state.category = Some(key);
state.sel_index = 0;
vec![]
}
Selection::None => vec![],
}
}
"f1" => vec![],
_ => {
type_into_search(state, key);
vec![]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::{Category, Library, SongHit};
fn empty() -> Results {
Results::default()
}
fn press(key: &str, state: &mut UiState) -> Vec<Action> {
handle_key(key, false, false, state, &empty(), &[0, 0, 0], 0)
}
#[test]
fn typing_a_letter_searches_and_leaves_the_play_view() {
let mut state = UiState {
play_view: true,
..Default::default()
};
press("k", &mut state);
press("i", &mut state);
assert_eq!(state.search, "ki");
assert!(!state.play_view);
}
#[test]
fn umlauts_are_searchable_like_any_other_letter() {
let mut state = UiState::default();
press("ö", &mut state);
assert_eq!(state.search, "ö");
}
#[test]
fn space_toggles_but_types_mid_search() {
let mut state = UiState::default();
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
state.search = "geolino".into();
assert!(press("space", &mut state).is_empty());
assert_eq!(state.search, "geolino ");
}
#[test]
fn space_toggles_from_the_play_view_even_with_a_query_behind_it() {
let mut state = UiState {
search: "conni".into(),
play_view: true,
..Default::default()
};
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
}
#[test]
fn shift_media_works_from_every_screen() {
for play_view in [false, true] {
let mut state = UiState {
play_view,
..Default::default()
};
let got = handle_key("H", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(got, vec![Action::Previous]);
let got = handle_key("K", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(got, vec![Action::Volume(VOLUME_STEP)]);
}
}
#[test]
fn other_shifted_letters_still_reach_the_search_box() {
let mut state = UiState::default();
handle_key("C", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(state.search, "C");
}
#[test]
fn arrows_browse_while_browsing_and_drive_the_player_while_playing() {
let mut state = UiState {
group: Some(Group::Music),
..Default::default()
};
assert!(press("right", &mut state).is_empty());
state.play_view = true;
assert_eq!(press("right", &mut state), vec![Action::Next]);
assert_eq!(press("up", &mut state), vec![Action::Volume(VOLUME_STEP)]);
assert_eq!(
press("down", &mut state),
vec![Action::Volume(-VOLUME_STEP)]
);
}
#[test]
fn shift_arrows_seek_from_either_screen() {
let mut state = UiState::default();
let got = handle_key("right", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(got, vec![Action::Seek(SEEK_STEP)]);
let got = handle_key("left", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(got, vec![Action::Seek(-SEEK_STEP)]);
}
#[test]
fn the_root_shelf_is_two_axis_and_clamps_to_the_row_it_lands_on() {
let mut state = UiState::default();
let lengths = [5, 2, 0];
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
assert_eq!((state.shelf_row, state.sel_index), (0, 3));
// Row 1 only has two tiles, so the column has to come back with it.
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
assert_eq!((state.shelf_row, state.sel_index), (1, 1));
// Row 2 has none at all.
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
assert_eq!((state.shelf_row, state.sel_index), (2, 0));
// And it does not walk off the bottom.
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
assert_eq!(state.shelf_row, 2);
}
#[test]
fn enter_at_the_root_opens_the_focused_shelfs_group() {
let mut state = UiState {
shelf_row: 1,
..Default::default()
};
assert!(press("enter", &mut state).is_empty());
assert_eq!(state.group, Some(Group::Audiobooks));
assert_eq!(state.sel_index, 0);
}
#[test]
fn escape_peels_one_layer_at_a_time() {
let mut state = UiState {
group: Some(Group::Music),
category: Some("Rolf".into()),
search: "abc".into(),
tracks_mode: true,
play_view: true,
open_album: Some("x".into()),
..Default::default()
};
press("escape", &mut state);
assert!(state.open_album.is_none(), "the sheet closes first");
press("escape", &mut state);
assert!(!state.play_view, "then the play view");
press("escape", &mut state);
assert_eq!(state.search, "", "then the query");
press("escape", &mut state);
assert!(!state.tracks_mode, "then track-search mode");
press("escape", &mut state);
assert_eq!(
(state.group, state.category.clone()),
(None, None),
"then both at once"
);
assert!(!state.can_go_back());
}
#[test]
fn tab_cycles_the_three_groups_and_wraps() {
let mut state = UiState::default();
for expected in [
Group::Music,
Group::Audiobooks,
Group::Podcasts,
Group::Music,
] {
press("tab", &mut state);
assert_eq!(state.group, Some(expected));
}
}
#[test]
fn backspace_only_bites_when_there_is_a_query() {
let mut state = UiState {
search: "ab".into(),
..Default::default()
};
press("backspace", &mut state);
assert_eq!(state.search, "a");
press("backspace", &mut state);
press("backspace", &mut state);
assert_eq!(state.search, "");
}
#[test]
fn question_mark_switches_to_track_search_and_clears_the_query() {
let mut state = UiState {
search: "conni".into(),
..Default::default()
};
press("?", &mut state);
assert!(state.tracks_mode);
assert_eq!(state.search, "");
}
// ---- selection over a real results set ----
fn results_with(songs: usize, categories: usize, albums: usize) -> Results {
let library = Library::build(
(0..albums.max(1))
.map(|i| {
serde_json::from_value(serde_json::json!({
"id": format!("al{i}"),
"section": "Musik",
"kind": "music",
"title": format!("Album {i}"),
"artist": "A",
"category": "A",
"tracks": [{ "title": "T", "duration": 10.0 }],
}))
.unwrap()
})
.collect(),
);
Results {
songs: (0..songs)
.map(|i| SongHit {
album: std::rc::Rc::clone(&library.albums[0]),
index: i,
title: format!("S{i}"),
duration: 10.0,
})
.collect(),
categories: (0..categories)
.map(|i| Category {
key: format!("C{i}"),
albums: vec![],
})
.collect(),
albums: library
.albums
.iter()
.take(albums)
.map(std::rc::Rc::clone)
.collect(),
}
}
#[test]
fn the_selection_runs_flat_across_songs_then_categories_then_albums() {
let results = results_with(2, 2, 3);
assert_eq!(results.total(), 7);
let mut state = UiState {
group: Some(Group::Music),
cols: 3,
..Default::default()
};
// Inside the song list a vertical move is one row, not one grid row.
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(state.sel_index, 1);
// Past the songs it jumps a whole grid row.
state.sel_index = 2;
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(state.sel_index, 5);
// And it never runs off the end.
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(state.sel_index, 6);
}
#[test]
fn enter_plays_a_song_hit_at_its_own_track_index() {
let results = results_with(2, 0, 0);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 1,
..Default::default()
};
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(
got,
vec![Action::Play {
album_id: "al0".into(),
track_index: 1
}]
);
}
#[test]
fn enter_on_a_category_drills_in_rather_than_playing() {
let results = results_with(0, 2, 0);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 1,
..Default::default()
};
assert!(handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0).is_empty());
assert_eq!(state.category.as_deref(), Some("C1"));
}
#[test]
fn enter_on_an_album_plays_it_from_the_top() {
let results = results_with(0, 0, 3);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 2,
..Default::default()
};
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(
got,
vec![Action::Play {
album_id: "al2".into(),
track_index: 0
}]
);
}
#[test]
fn shift_enter_opens_the_track_list_instead() {
let results = results_with(0, 0, 2);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 1,
..Default::default()
};
assert!(handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0).is_empty());
assert_eq!(state.open_album.as_deref(), Some("al1"));
}
#[test]
fn shift_enter_on_a_category_falls_through_to_drilling_in() {
let results = results_with(0, 1, 0);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 0,
..Default::default()
};
handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0);
assert!(state.open_album.is_none());
assert_eq!(state.category.as_deref(), Some("C0"));
}
#[test]
fn ctrl_jk_walks_the_open_track_list_and_clamps() {
let mut state = UiState {
open_album: Some("al0".into()),
..Default::default()
};
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
assert_eq!(state.sheet_index, 2);
handle_key("k", true, false, &mut state, &empty(), &[0, 0, 0], 3);
assert_eq!(state.sheet_index, 1);
}
#[test]
fn enter_with_the_sheet_open_plays_the_highlighted_track() {
let mut state = UiState {
open_album: Some("al0".into()),
sheet_index: 2,
..Default::default()
};
let got = handle_key("enter", false, false, &mut state, &empty(), &[0, 0, 0], 4);
assert_eq!(
got,
vec![Action::Play {
album_id: "al0".into(),
track_index: 2
}]
);
}
}

View File

@@ -0,0 +1,504 @@
//! Browsing and searching the library, entirely on the client.
//!
//! The whole index arrives in one response, so type-to-search has no round trip - which
//! is the point of a keyboard-first UI. Ported from `web/src/lib/search.ts`, including
//! the reason that module is shaped the way it is:
//!
//! The cost that matters is per keystroke, not per library load. A real collection is
//! 343 albums and 4969 tracks, and the naive version re-derives its search keys from
//! scratch on every one of them for every letter typed - `normalize` runs a lowercase,
//! a Unicode NFD decomposition and two filtering passes, so track search would mean
//! five thousand NFD normalizations before a single comparison. That is work whose
//! answer cannot change until the library does, so it happens once, in
//! [`Library::build`], and a keystroke becomes `str::contains` over strings that
//! already exist.
use std::collections::HashMap;
use std::rc::Rc;
use unicode_normalization::UnicodeNormalization;
use crate::api::Album;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Group {
Music,
Audiobooks,
Podcasts,
}
pub const GROUPS: [Group; 3] = [Group::Music, Group::Audiobooks, Group::Podcasts];
impl Group {
/// Which of the three shelves an album belongs on. A podcast is exactly a
/// `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
/// album joins whichever of music/audiobooks matches what it actually holds.
pub fn of(album: &Album) -> Self {
if album.section == "Kinderpodcasts" {
Self::Podcasts
} else if album.is_book() {
Self::Audiobooks
} else {
Self::Music
}
}
pub fn index(self) -> usize {
match self {
Self::Music => 0,
Self::Audiobooks => 1,
Self::Podcasts => 2,
}
}
pub fn from_index(index: usize) -> Self {
GROUPS[index.min(2)]
}
pub fn label(self) -> &'static str {
match self {
Self::Music => "Musik",
Self::Audiobooks => "Hörbücher",
Self::Podcasts => "Podcasts",
}
}
pub fn icon(self) -> &'static str {
match self {
Self::Music => "🎵",
Self::Audiobooks => "📖",
Self::Podcasts => "🎙️",
}
}
/// Heading over the category tiles inside this group.
pub fn category_label(self) -> &'static str {
match self {
Self::Music => "Künstler",
Self::Audiobooks => "Figuren",
Self::Podcasts => "Sendungen",
}
}
/// Heading over the album grid inside this group.
pub fn section_label(self) -> &'static str {
match self {
Self::Music => "Alben",
Self::Audiobooks => "Hörbücher",
Self::Podcasts => "Episoden",
}
}
/// Per-group hue in oklch degrees, shared with the generated cover art so a shelf
/// already hints at the mood its albums play into.
pub fn hue(self) -> f32 {
match self {
Self::Music => 210.0, // today's sea - the baseline hue
Self::Audiobooks => 55.0, // warm amber
Self::Podcasts => 300.0, // the one otherwise-unused family
}
}
}
/// Diacritics and punctuation are noise when a child is hunting for letters.
pub fn normalize(value: &str) -> String {
value
.to_lowercase()
.nfd()
// Combining marks, i.e. what NFD just split off the base letters.
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
/// Words of a query, normalized individually. A space-separated query like "conni rad"
/// should find "Conni lernt Rad fahren" even though "rad" is not right after "conni",
/// so requiring the whole phrase to be one contiguous substring is too strict.
fn search_words(value: &str) -> Vec<String> {
value
.split_whitespace()
.map(normalize)
.filter(|w| !w.is_empty())
.collect()
}
/// Every word has to show up somewhere in the haystack, in any order.
fn matches_words(haystack: &str, words: &[String]) -> bool {
words.iter().all(|word| haystack.contains(word.as_str()))
}
pub struct AlbumEntry {
pub album: Rc<Album>,
/// `normalize(title + artist)`, as a naive `album_matches` would recompute per key.
haystack: String,
}
pub struct TrackEntry {
pub album: Rc<Album>,
/// Index within `album.tracks`, which is what playback wants.
pub index: usize,
pub title: String,
pub duration: f64,
haystack: String,
}
#[derive(Clone)]
pub struct Category {
pub key: String,
pub albums: Vec<Rc<Album>>,
}
#[derive(Clone)]
pub struct SongHit {
pub album: Rc<Album>,
pub index: usize,
pub title: String,
pub duration: f64,
}
/// Everything a search needs, derived once per library payload.
#[derive(Default)]
pub struct Library {
pub albums: Vec<Rc<Album>>,
entries: Vec<AlbumEntry>,
by_group: [Vec<usize>; 3],
/// Each group's categories, already grouped and sorted.
categories_by_group: [Vec<Category>; 3],
/// Unlocked tracks only, album order then track order. A locked track shows as a
/// question mark wherever it appears, so it has no title to match on and is left
/// out of the index entirely rather than skipped at match time.
tracks: Vec<TrackEntry>,
tracks_by_group: [Vec<usize>; 3],
by_id: HashMap<String, Rc<Album>>,
}
impl Library {
pub fn build(albums: Vec<Album>) -> Self {
let mut library = Self::default();
// One map per group rather than one overall: two groups may legitimately hold
// the same category name, and merging them would put an artist's albums on
// the audiobook shelf.
let mut category_maps: [HashMap<String, Vec<Rc<Album>>>; 3] = Default::default();
for album in albums {
let album = Rc::new(album);
let group = Group::of(&album);
library.by_id.insert(album.id.clone(), Rc::clone(&album));
library.albums.push(Rc::clone(&album));
library.by_group[group.index()].push(library.entries.len());
library.entries.push(AlbumEntry {
haystack: normalize(&format!("{}{}", album.title, album.artist)),
album: Rc::clone(&album),
});
category_maps[group.index()]
.entry(album.category.clone())
.or_default()
.push(Rc::clone(&album));
for (index, track) in album.tracks.iter().enumerate() {
if track.locked {
continue;
}
library.tracks_by_group[group.index()].push(library.tracks.len());
library.tracks.push(TrackEntry {
haystack: normalize(&track.title),
album: Rc::clone(&album),
index,
title: track.title.clone(),
duration: track.duration,
});
}
}
for (slot, map) in library.categories_by_group.iter_mut().zip(category_maps) {
let mut categories: Vec<Category> = map
.into_iter()
.map(|(key, albums)| Category { key, albums })
.collect();
// `localeCompare(_, "de")` in the original. A full collator is more than
// this earns; normalizing first folds the umlauts, which is the whole of
// what German ordering asks for here.
categories.sort_by_key(|c| normalize(&c.key));
*slot = categories;
}
library
}
pub fn get(&self, album_id: &str) -> Option<&Rc<Album>> {
self.by_id.get(album_id)
}
pub fn categories(&self, group: Group) -> &[Category] {
&self.categories_by_group[group.index()]
}
}
// --------------------------------------------------------------------- queries --
/// `group: None` means unrestricted - typing at the root searches every group at once.
pub struct Query<'a> {
pub search: &'a str,
pub tracks_mode: bool,
pub group: Option<Group>,
pub category: Option<&'a str>,
}
/// Which of the three lists the browse view is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListMode {
Tracks,
Albums,
Categories,
}
/// Capped, because an empty query over 900 podcast episodes is not a useful screen.
pub const MAX_SONG_HITS: usize = 40;
/// Selection indices run flat across songs, then categories, then albums, so one pair
/// of arrow keys walks the whole page.
#[derive(Default)]
pub struct Results {
pub songs: Vec<SongHit>,
pub categories: Vec<Category>,
pub albums: Vec<Rc<Album>>,
}
impl Results {
pub fn total(&self) -> usize {
self.songs.len() + self.categories.len() + self.albums.len()
}
}
impl Library {
pub fn list_mode(&self, query: &Query) -> ListMode {
if query.tracks_mode {
ListMode::Tracks
} else if !query.search.is_empty() || query.category.is_some() {
ListMode::Albums
} else {
ListMode::Categories
}
}
fn album_pool(&self, query: &Query) -> Vec<usize> {
match query.group {
None => (0..self.entries.len()).collect(),
Some(group) => self.by_group[group.index()].clone(),
}
}
pub fn category_matches(&self, query: &Query) -> Vec<Category> {
// `group: None` is the bare root screen, rendered as three shelves instead of a
// flat category list - each shelf asks again with its own group filled in.
match (self.list_mode(query), query.group) {
(ListMode::Categories, Some(group)) => self.categories(group).to_vec(),
_ => Vec::new(),
}
}
pub fn album_matches(&self, query: &Query) -> Vec<Rc<Album>> {
if query.tracks_mode {
return Vec::new();
}
let words = search_words(query.search);
if words.is_empty() && query.category.is_none() {
return Vec::new();
}
self.album_pool(query)
.into_iter()
.map(|i| &self.entries[i])
.filter(|entry| match query.category {
Some(category) => entry.album.category == category,
None => true,
})
.filter(|entry| words.is_empty() || matches_words(&entry.haystack, &words))
.map(|entry| Rc::clone(&entry.album))
.collect()
}
pub fn song_matches(&self, query: &Query) -> Vec<SongHit> {
if !query.tracks_mode {
return Vec::new();
}
let words = search_words(query.search);
let pool: &[usize] = match query.group {
None => return self.take_song_hits(self.tracks.iter(), &words),
Some(group) => &self.tracks_by_group[group.index()],
};
self.take_song_hits(pool.iter().map(|&i| &self.tracks[i]), &words)
}
/// Stopping at the cap rather than filling every hit and slicing is what keeps an
/// empty query in tracks mode from walking all 4969 tracks to show 40 rows.
fn take_song_hits<'a>(
&self,
pool: impl Iterator<Item = &'a TrackEntry>,
words: &[String],
) -> Vec<SongHit> {
pool.filter(|track| words.is_empty() || matches_words(&track.haystack, words))
.take(MAX_SONG_HITS)
.map(|track| SongHit {
album: Rc::clone(&track.album),
index: track.index,
title: track.title.clone(),
duration: track.duration,
})
.collect()
}
pub fn results(&self, query: &Query) -> Results {
Results {
songs: self.song_matches(query),
categories: self.category_matches(query),
albums: self.album_matches(query),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn album(id: &str, title: &str, artist: &str, section: &str, tracks: &[&str]) -> Album {
serde_json::from_value(serde_json::json!({
"id": id,
"section": section,
"kind": if section == "Musik" { "music" } else { "book" },
"title": title,
"artist": artist,
"category": artist,
"tracks": tracks.iter().map(|t| serde_json::json!({ "title": t, "duration": 60.0 })).collect::<Vec<_>>(),
}))
.unwrap()
}
fn fixture() -> Library {
Library::build(vec![
album(
"a",
"Grüffelo",
"Julia Donaldson",
"Hörbücher",
&["Kapitel 1", "Kapitel 2"],
),
album("b", "Bibi Blocksberg", "Bibi", "Hörbücher", &["Hexerei"]),
album(
"c",
"Rote Lieder",
"Rolf Zuckowski",
"Musik",
&["Wie schön"],
),
album(
"d",
"Maus Folge 3",
"Sendung mit der Maus",
"Kinderpodcasts",
&["Folge 3"],
),
])
}
#[test]
fn normalize_folds_umlauts_and_punctuation() {
assert_eq!(normalize("Hörbücher!"), "horbucher");
assert_eq!(normalize("Grüffelo"), "gruffelo");
assert_eq!(normalize("A-B C"), "abc");
}
#[test]
fn groups_split_podcasts_out_of_books() {
let library = fixture();
assert_eq!(Group::of(library.get("d").unwrap()), Group::Podcasts);
assert_eq!(Group::of(library.get("a").unwrap()), Group::Audiobooks);
assert_eq!(Group::of(library.get("c").unwrap()), Group::Music);
}
#[test]
fn album_search_is_word_order_independent() {
let library = fixture();
let hits = library.album_matches(&Query {
search: "donaldson gruffelo",
tracks_mode: false,
group: None,
category: None,
});
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "a");
}
#[test]
fn an_empty_query_lists_no_albums_but_does_list_categories() {
let library = fixture();
let query = Query {
search: "",
tracks_mode: false,
group: Some(Group::Audiobooks),
category: None,
};
assert!(library.album_matches(&query).is_empty());
assert_eq!(library.category_matches(&query).len(), 2);
}
#[test]
fn the_root_screen_has_no_flat_category_list_of_its_own() {
let library = fixture();
let query = Query {
search: "",
tracks_mode: false,
group: None,
category: None,
};
assert!(library.category_matches(&query).is_empty());
}
#[test]
fn a_category_lists_its_albums_without_a_search() {
let library = fixture();
let hits = library.album_matches(&Query {
search: "",
tracks_mode: false,
group: Some(Group::Audiobooks),
category: Some("Bibi"),
});
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "b");
}
#[test]
fn track_search_is_capped_and_group_scoped() {
let library = fixture();
let hits = library.song_matches(&Query {
search: "kapitel",
tracks_mode: true,
group: None,
category: None,
});
assert_eq!(hits.len(), 2);
assert!(hits.len() <= MAX_SONG_HITS);
let scoped = library.song_matches(&Query {
search: "",
tracks_mode: true,
group: Some(Group::Music),
category: None,
});
assert_eq!(scoped.len(), 1);
assert_eq!(scoped[0].title, "Wie schön");
}
#[test]
fn categories_sort_by_folded_key() {
let library = fixture();
let keys: Vec<&str> = library
.categories(Group::Audiobooks)
.iter()
.map(|c| c.key.as_str())
.collect();
assert_eq!(keys, ["Bibi", "Julia Donaldson"]);
}
}

999
slint-frontend/src/main.rs Normal file
View File

@@ -0,0 +1,999 @@
//! A native front-end for the MusicMouse player.
//!
//! The same split the web front-end is built on: **what is playing** comes from the
//! backend over a push-only websocket, **what you are looking at** lives here. The
//! device has other front-ends - the buttons on the mouse, a figurine on the reader,
//! Home Assistant - so this follows playback rather than owning it.
//!
//! Three threads and no async runtime. The UI thread does layout and nothing else; a
//! websocket thread reads state; a cover thread fetches and downscales album art; a
//! command thread makes the HTTP calls, because a blocking POST on the UI thread is
//! exactly the stall this front-end exists to remove.
mod api;
mod covers;
mod format;
mod keymap;
mod library;
mod oklch;
mod ws;
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use std::sync::mpsc::{channel, Sender};
use std::sync::{Arc, Mutex};
use slint::{ModelRc, SharedString, VecModel};
use api::{Album, Client, PlayerState};
use covers::Tier;
use keymap::{Action, UiState};
use library::{Group, Library, Query, Results, GROUPS};
slint::include_modules!();
// The application state lives on the UI thread and is reached from the event-loop
// closures the worker relay posts. See `pump_workers`.
thread_local! {
static APP: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
}
// --------------------------------------------------------------------- commands --
/// Anything that talks to the backend, run off the UI thread. They all answer 204 with
/// no body, so nothing comes back: the authoritative answer arrives as a pushed state
/// frame, which is also what makes the optimistic updates below safe.
enum Command {
Play {
album_id: String,
track_index: usize,
},
Resume,
Pause,
Next,
Previous,
Seek(f64),
Volume(i32),
ReloadLibrary,
}
enum FromWorker {
Library(Result<Vec<Album>, String>),
Cover(covers::Ready),
Ws(WsEvent),
}
enum WsEvent {
Connected,
Disconnected,
State(Box<PlayerState>),
Position { position: f64, duration: f64 },
LibraryChanged,
}
// ------------------------------------------------------------------------ state --
struct App {
commands: Sender<Command>,
ui: UiState,
player: PlayerState,
library: Library,
covers: covers::Store,
online: bool,
loaded: bool,
error: Option<String>,
/// Changes whenever the track itself changes, so the progress bar can stop
/// animating across two unrelated positions instead of sliding between them.
track_key: String,
/// False for exactly one render: the frame a track changes on, and the frame a seek
/// lands on.
smooth: bool,
}
impl App {
fn send(&self, command: Command) {
let _ = self.commands.send(command);
}
fn query(&self) -> Query<'_> {
Query {
search: &self.ui.search,
tracks_mode: self.ui.tracks_mode,
group: self.ui.group,
category: self.ui.category.as_deref(),
}
}
fn current_album(&self) -> Option<&Rc<Album>> {
self.player
.album_id
.as_deref()
.and_then(|id| self.library.get(id))
}
/// How long each of the three root shelves is, for the keyboard's column clamping.
fn shelf_lengths(&self) -> [usize; 3] {
[
self.library.categories(Group::Music).len(),
self.library.categories(Group::Audiobooks).len(),
self.library.categories(Group::Podcasts).len(),
]
}
fn apply(&mut self, actions: Vec<Action>) {
for action in actions {
match action {
Action::Play {
album_id,
track_index,
} => {
// Starting something switches to the full-screen view and closes
// the track list behind it, as the web front-end does: what you
// just chose becomes the thing on screen. ESC and `/` come back.
self.ui.play_view = true;
self.ui.open_album = None;
self.send(Command::Play {
album_id,
track_index,
});
}
Action::Toggle => {
// Optimistic: flip it here and let the pushed frame confirm. Safe
// precisely because state only ever arrives pushed, never polled,
// so there is no poll in flight to race with.
self.player.playing = !self.player.playing;
self.send(if self.player.playing {
Command::Resume
} else {
Command::Pause
});
}
Action::Next => self.send(Command::Next),
Action::Previous => self.send(Command::Previous),
Action::Volume(delta) => {
self.player.volume = (self.player.volume + delta).clamp(0, 100);
self.send(Command::Volume(self.player.volume));
}
Action::Seek(delta) => {
let target = (self.player.position + delta).clamp(0.0, self.player.duration);
self.player.position = target;
self.smooth = false;
self.send(Command::Seek(target));
}
Action::Mute => {
// Unmuting restores a sensible level rather than whatever it was:
// the level before a mute is not recorded anywhere the device
// agrees on, and coming back at 8% reads as still broken.
let target = if self.player.volume == 0 { 60 } else { 0 };
self.player.volume = target;
self.send(Command::Volume(target));
}
}
}
}
}
// ------------------------------------------------------------------ presentation --
fn cover_of(store: &mut covers::Store, album: &Album, tier: Tier) -> slint::Image {
let palette = album.palette();
store
.get(covers::Request {
album_id: album.id.clone(),
tier,
has_cover: album.has_cover,
is_book: album.is_book(),
locked: album.locked,
colors: [palette[0].into(), palette[1].into(), palette[2].into()],
})
.unwrap_or_default()
}
/// "12 Songs · 45:03 · 🧸" - the line under an album's title.
fn album_meta(album: &Album) -> String {
let unit = if album.is_book() {
"Kapitel"
} else if album.tracks.len() == 1 {
"Song"
} else {
"Songs"
};
let mut meta = format!(
"{} {unit} · {}",
album.tracks.len(),
format::clock(album.duration)
);
if album.figure.is_some() {
meta.push_str(" · 🧸");
}
meta
}
/// "7 Hörbücher" - the line under a category tile.
fn category_meta(group: Group, count: usize) -> String {
let unit = match (group, count) {
(Group::Music, 1) => "Album",
(Group::Music, _) => "Alben",
(Group::Audiobooks, _) => "Hörbücher",
(Group::Podcasts, 1) => "Episode",
(Group::Podcasts, _) => "Episoden",
};
format!("{count} {unit}")
}
fn album_tile(store: &mut covers::Store, album: &Album, current: bool, nav: usize) -> AlbumTile {
AlbumTile {
id: album.id.as_str().into(),
title: if album.locked {
"❓ Geheimnis".into()
} else {
album.title.as_str().into()
},
// A podcast episode's artist is its show, which the title already says.
artist: if album.artist == album.title {
SharedString::new()
} else {
album.artist.as_str().into()
},
meta: album_meta(album).into(),
cover: cover_of(store, album, Tier::Thumb),
is_book: album.is_book(),
locked: album.locked,
current,
nav: nav as i32,
}
}
fn category_tile(
store: &mut covers::Store,
group: Group,
category: &library::Category,
nav: usize,
) -> CategoryTile {
// Up to four covers in a 2x2 contact sheet, which is how a category reads as "a
// stack of these" without a label saying so.
let mut covers_out = [
slint::Image::default(),
slint::Image::default(),
slint::Image::default(),
slint::Image::default(),
];
for (slot, album) in covers_out.iter_mut().zip(category.albums.iter()) {
*slot = cover_of(store, album, Tier::Thumb);
}
let [c1, c2, c3, c4] = covers_out;
CategoryTile {
key: category.key.as_str().into(),
count_label: category_meta(group, category.albums.len()).into(),
cover1: c1,
cover2: c2,
cover3: c3,
cover4: c4,
cover_count: category.albums.len().min(4) as i32,
is_book: category.albums.first().is_some_and(|a| a.is_book()),
nav: nav as i32,
}
}
fn track_row(
store: &mut covers::Store,
album: &Album,
index: usize,
title: &str,
duration: f64,
current: bool,
nav: usize,
) -> TrackRow {
TrackRow {
title: title.into(),
subtitle: album.line().into(),
duration: format::clock(duration).into(),
cover: cover_of(store, album, Tier::Thumb),
is_book: album.is_book(),
current,
locked: false,
album_id: album.id.as_str().into(),
index: index as i32,
nav: nav as i32,
}
}
fn now_playing(app: &mut App) -> NowPlaying {
let album = app.current_album().map(Rc::clone);
let state = &app.player;
let is_book = album.as_ref().is_some_and(|a| a.is_book());
let is_podcast = album
.as_ref()
.is_some_and(|a| Group::of(a) == Group::Podcasts);
let (cover, hero) = match &album {
Some(album) => (
cover_of(&mut app.covers, album, Tier::Thumb),
cover_of(&mut app.covers, album, Tier::Hero),
),
None => (slint::Image::default(), slint::Image::default()),
};
let remaining_label = match &album {
// "Noch ... im Hörbuch" means nothing for a podcast, where an episode is the
// whole of what was chosen.
Some(album) if !is_podcast => {
let durations: Vec<f64> = album.tracks.iter().map(|t| t.duration).collect();
let left = format::remaining_in_album(
&durations,
state.track_index.max(0) as usize,
state.position,
);
let what = if is_book { "im Hörbuch" } else { "im Album" };
format!("Noch {} {what}", format::clock(left))
}
_ => String::new(),
};
NowPlaying {
title: state
.track_title
.clone()
.unwrap_or_else(|| "Wähl ein Album!".into())
.into(),
subtitle: album
.as_ref()
.map_or_else(|| "Tippen oder klicken".to_string(), |a| a.line())
.into(),
counter: format!(
"{} {} von {}",
if is_book { "Kapitel" } else { "Song" },
state.track_index + 1,
state.track_count.max(1)
)
.into(),
cover,
hero_cover: hero,
is_book,
has_album: album.is_some(),
clock_label: if state.duration > 0.0 {
format!("{}", format::clock(state.duration - state.position)).into()
} else {
SharedString::new()
},
remaining_label: remaining_label.into(),
}
}
// ---------------------------------------------------------------------- rendering --
fn render(app: &mut App, window: &AppWindow) {
window.set_loading(!app.loaded);
window.set_splash_text(
match &app.error {
Some(error) => format!("Der Delfin ist nicht erreichbar …\n{error}"),
None => "Einen Moment …".to_string(),
}
.into(),
);
window.set_online(app.online);
window.set_status_label(
if !app.online {
"Keine Verbindung".into()
} else {
match &app.player.active_figure {
Some(figure) => format!("🧸 {figure}"),
None => String::new(),
}
}
.into(),
);
// How many columns fit, from the width the window is actually giving the grid. The
// keyboard needs the same number to know what "one row down" means.
let gap = 24.0_f32;
let card = 180.0_f32;
let cols = (((window.get_grid_width() + gap) / (card + gap)).floor() as usize).max(1);
app.ui.cols = cols;
window.set_play_view(app.ui.play_view);
window.set_can_go_back(app.ui.can_go_back());
window.set_playing(app.player.playing);
window.set_volume(app.player.volume);
window.set_smooth(app.smooth);
window.set_progress(if app.player.duration > 0.0 {
(app.player.position / app.player.duration).clamp(0.0, 1.0) as f32
} else {
0.0
});
let now = now_playing(app);
let has_album = now.has_album;
window.set_now(now);
window.set_has_bar(has_album && !app.ui.play_view);
let root_screen = app.ui.is_root_shelf();
window.set_root_screen(root_screen);
window.set_shelf_row(app.ui.shelf_row as i32);
window.set_sel_index(app.ui.sel_index as i32);
if root_screen {
render_shelves(app, window);
} else {
render_results(app, window, cols);
}
render_sheet(app, window);
}
fn render_shelves(app: &mut App, window: &AppWindow) {
let mut shelves = Vec::with_capacity(GROUPS.len());
for group in GROUPS {
let categories: Vec<library::Category> = app.library.categories(group).to_vec();
let tiles: Vec<CategoryTile> = categories
.iter()
.enumerate()
.map(|(i, category)| category_tile(&mut app.covers, group, category, i))
.collect();
shelves.push(Shelf {
label: group.label().into(),
icon: group.icon().into(),
tiles: ModelRc::new(VecModel::from(tiles)),
empty_label: "Noch nichts hier".into(),
});
}
window.set_shelves(ModelRc::new(VecModel::from(shelves)));
// The shelves replace the flat result lists rather than sitting beside them.
window.set_songs(ModelRc::new(VecModel::from(Vec::<TrackRow>::new())));
window.set_grid(ModelRc::new(VecModel::from(Vec::<GridRow>::new())));
window.set_category_grid(ModelRc::new(VecModel::from(Vec::<CategoryRow>::new())));
}
fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
let results: Results = app.library.results(&app.query());
let total = results.total();
let current_album = app.player.album_id.clone();
let current_track = app.player.track_index.max(0) as usize;
// Selection indices run flat across songs, then categories, then albums.
let mut nav = 0usize;
let songs: Vec<TrackRow> = results
.songs
.iter()
.map(|hit| {
let row = track_row(
&mut app.covers,
&hit.album,
hit.index,
&hit.title,
hit.duration,
current_album.as_deref() == Some(hit.album.id.as_str())
&& current_track == hit.index,
nav,
);
nav += 1;
row
})
.collect();
let group = app.ui.group.unwrap_or(Group::Music);
let categories: Vec<CategoryTile> = results
.categories
.iter()
.map(|category| {
let tile = category_tile(&mut app.covers, group, category, nav);
nav += 1;
tile
})
.collect();
// Chunked like the album grid, and for the same reason: the view puts each row in
// a virtualizing list rather than one ever-widening horizontal layout.
let category_grid: Vec<CategoryRow> = categories
.chunks(cols)
.map(|chunk| CategoryRow {
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
})
.collect();
let tiles: Vec<AlbumTile> = results
.albums
.iter()
.map(|album| {
let tile = album_tile(
&mut app.covers,
album,
current_album.as_deref() == Some(album.id.as_str()),
nav,
);
nav += 1;
tile
})
.collect();
// Pre-chunked into rows: a ListView virtualizes its model, and a flat list of 343
// cards has no rows for it to skip. The web grid reaches the same place with
// `content-visibility: auto`.
let first_album_nav = songs.len() + categories.len();
let grid: Vec<GridRow> = tiles
.chunks(cols)
.map(|chunk| GridRow {
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
})
.collect();
// Which grid row the selection is in, so the scroller can follow it without
// measuring anything - every row is the same height by construction.
let sel_row = app
.ui
.sel_index
.checked_sub(first_album_nav)
.map_or(0, |within| within / cols.max(1));
let count_label = if app.ui.tracks_mode {
format!("{} Titel gefunden", results.songs.len())
} else if results.albums.is_empty() {
"nichts gefunden".to_string()
} else if results.albums.len() == 1 {
"1 Album gefunden".to_string()
} else {
format!("{} Alben gefunden", results.albums.len())
};
window.set_group_label(
app.ui
.group
.map_or(SharedString::new(), |g| g.label().into()),
);
window.set_category_heading(group.category_label().into());
window.set_album_heading(
match app.ui.category.as_deref() {
Some(category) => category.to_string(),
None => group.section_label().to_string(),
}
.into(),
);
window.set_show_search(!app.ui.search.is_empty() || app.ui.tracks_mode);
window.set_search_text(
if app.ui.search.is_empty() {
"tippe …".to_string()
} else {
format!("\"{}\"", app.ui.search)
}
.into(),
);
window.set_mode_label(if app.ui.tracks_mode {
"♪ Titel".into()
} else {
"🔎 Alben".into()
});
window.set_count_label(count_label.into());
window.set_no_results(total == 0);
window.set_sel_row(sel_row as i32);
window.set_songs(ModelRc::new(VecModel::from(songs)));
window.set_category_grid(ModelRc::new(VecModel::from(category_grid)));
window.set_grid(ModelRc::new(VecModel::from(grid)));
}
fn render_sheet(app: &mut App, window: &AppWindow) {
let Some(album) = app
.ui
.open_album
.clone()
.and_then(|id| app.library.get(&id).map(Rc::clone))
else {
window.set_show_sheet(false);
return;
};
let playing_here = app.player.album_id.as_deref() == Some(album.id.as_str());
let current = app.player.track_index.max(0) as usize;
let rows: Vec<TrackRow> = album
.tracks
.iter()
.enumerate()
.map(|(i, track)| {
let title = if track.locked {
"".to_string()
} else {
track.title.clone()
};
track_row(
&mut app.covers,
&album,
i,
&title,
track.duration,
playing_here && current == i,
i,
)
})
.collect();
let kind = if album.is_book() {
"📖 Hörbuch"
} else {
"🎵 Album"
};
let mut meta = format!("{kind} · {}", album_meta(&album));
if let Some(figure) = &album.figure {
meta.push_str(&format!(" · {figure}"));
}
window.set_show_sheet(true);
window.set_sheet_title(album.title.as_str().into());
window.set_sheet_subtitle(album.artist.as_str().into());
window.set_sheet_meta(meta.into());
window.set_sheet_cover(cover_of(&mut app.covers, &album, Tier::Hero));
window.set_sheet_is_book(album.is_book());
window.set_sheet_index(app.ui.sheet_index.min(album.tracks.len().saturating_sub(1)) as i32);
window.set_sheet_tracks(ModelRc::new(VecModel::from(rows)));
}
// ----------------------------------------------------------------------- wiring --
fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = std::env::args()
.skip_while(|a| a != "--server")
.nth(1)
.unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
println!("musicmouse-slint → {base}");
// Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because
// Slint cannot read the woff2 the web front-end self-hosts. Slint takes one file as
// the primary font through `SLINT_DEFAULT_FONT`; the dev shell and the device's
// launcher both set it, and this is the fallback for running the binary directly.
// None of them finding it just means the system sans - a missing font should change
// how this looks, not whether it runs.
if std::env::var_os("SLINT_DEFAULT_FONT").is_none() {
let beside_exe = std::env::current_exe()
.ok()
// target/release/<bin> -> the crate root
.and_then(|exe| Some(exe.parent()?.parent()?.parent()?.join("fonts/Nunito.ttf")));
for candidate in [
Some(std::path::PathBuf::from("fonts/Nunito.ttf")),
beside_exe,
]
.into_iter()
.flatten()
{
if candidate.exists() {
std::env::set_var("SLINT_DEFAULT_FONT", candidate);
break;
}
}
}
let window = AppWindow::new()?;
let client = Client::new(&base);
let (to_ui, from_workers) = channel::<FromWorker>();
let (cover_jobs, cover_rx) = channel::<covers::Job>();
let (commands, command_rx) = channel::<Command>();
// Covers: fetch, downscale, cache to disk. Never on the UI thread.
{
let to_ui = to_ui.clone();
covers::spawn(client.clone(), cover_rx, move |ready| {
let _ = to_ui.send(FromWorker::Cover(ready));
});
}
// Commands, and the library fetch that seeds everything.
{
let client = client.clone();
let to_ui = to_ui.clone();
std::thread::Builder::new()
.name("commands".into())
.spawn(move || {
let _ = to_ui.send(FromWorker::Library(client.library()));
for command in command_rx {
let result = match command {
Command::Play {
album_id,
track_index,
} => client.play(&album_id, track_index as i32),
Command::Resume => client.resume(),
Command::Pause => client.pause(),
Command::Next => client.next(),
Command::Previous => client.previous(),
Command::Seek(position) => client.seek(position),
Command::Volume(percent) => client.volume(percent),
Command::ReloadLibrary => {
let _ = to_ui.send(FromWorker::Library(client.library()));
Ok(())
}
};
if let Err(error) = result {
// A failed command is not fatal: the next pushed frame says
// what actually happened, which is the only truth anyway.
eprintln!("{error}");
}
}
})?;
}
// State.
{
let to_ui = to_ui.clone();
let (ws_tx, ws_rx) = channel();
ws::spawn(client.clone(), ws_tx);
std::thread::Builder::new()
.name("ws-relay".into())
.spawn(move || {
for update in ws_rx {
let event = match update {
ws::Update::Connected => WsEvent::Connected,
ws::Update::Disconnected => WsEvent::Disconnected,
ws::Update::State(state) => WsEvent::State(state),
ws::Update::Position { position, duration } => {
WsEvent::Position { position, duration }
}
ws::Update::LibraryChanged => WsEvent::LibraryChanged,
};
if to_ui.send(FromWorker::Ws(event)).is_err() {
return;
}
}
})?;
}
let app = Rc::new(RefCell::new(App {
commands,
ui: UiState::default(),
player: PlayerState::default(),
library: Library::default(),
covers: covers::Store::new(cover_jobs),
online: false,
loaded: false,
error: None,
track_key: String::new(),
smooth: true,
}));
install_callbacks(&app, &window);
pump_workers(&app, &window, from_workers);
render(&mut app.borrow_mut(), &window);
window.run()?;
Ok(())
}
/// Bridge the worker channels onto the Slint event loop.
///
/// `invoke_from_event_loop` takes a `Send` closure, and the application state is
/// `Rc<RefCell<..>>` that never leaves the UI thread - so the state stays in a
/// thread-local here and only the *messages* cross, which are `Send` by construction.
///
/// The queue is drained rather than handled one at a time, and that is not incidental:
/// a cold start delivers a few hundred covers in a burst, and one re-render per cover
/// would rebuild every model a few hundred times. Coalescing collapses each burst into
/// a single render.
fn pump_workers(
app: &Rc<RefCell<App>>,
window: &AppWindow,
from_workers: std::sync::mpsc::Receiver<FromWorker>,
) {
APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(app)));
let queue: Arc<Mutex<VecDeque<FromWorker>>> = Arc::default();
let weak = window.as_weak();
let producer = Arc::clone(&queue);
std::thread::Builder::new()
.name("relay".into())
.spawn(move || {
for message in from_workers {
match producer.lock() {
Ok(mut queue) => queue.push_back(message),
// Only reachable if a UI-thread panic poisoned the lock, in which
// case there is no UI left to deliver to.
Err(_) => return,
}
let drain = Arc::clone(&producer);
let posted = weak.upgrade_in_event_loop(move |window| {
let messages: Vec<FromWorker> = match drain.lock() {
Ok(mut queue) => queue.drain(..).collect(),
Err(_) => return,
};
if messages.is_empty() {
return;
}
APP.with(|slot| {
let Some(app) = slot.borrow().clone() else {
return;
};
{
let mut app = app.borrow_mut();
for message in messages {
handle_worker_message(&mut app, message);
}
}
render(&mut app.borrow_mut(), &window);
});
});
if posted.is_err() {
return;
}
}
})
.expect("spawning the worker relay");
}
fn handle_worker_message(app: &mut App, message: FromWorker) {
match message {
FromWorker::Library(Ok(albums)) => {
app.library = Library::build(albums);
app.loaded = true;
app.error = None;
}
FromWorker::Library(Err(error)) => {
app.error = Some(error);
}
FromWorker::Cover(ready) => app.covers.insert(ready),
FromWorker::Ws(WsEvent::Connected) => app.online = true,
FromWorker::Ws(WsEvent::Disconnected) => app.online = false,
FromWorker::Ws(WsEvent::State(state)) => {
let key = format!("{:?}:{}", state.album_id, state.track_index);
// A new track means the bar must jump, not slide: animating across two
// unrelated positions is what makes a seek look like it drifts backwards.
app.smooth = key == app.track_key;
app.track_key = key;
app.player = *state;
}
FromWorker::Ws(WsEvent::Position { position, duration }) => {
app.player.position = position;
if duration > 0.0 {
app.player.duration = duration;
}
app.smooth = true;
}
FromWorker::Ws(WsEvent::LibraryChanged) => {
// A rescan is the one moment the backend rewrites the art behind an
// unchanged cover URL, so the thumbnails go with it.
app.covers.clear();
app.send(Command::ReloadLibrary);
}
}
}
fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
// Every callback does the same three things: mutate state, maybe queue a command,
// re-render. `bind!` is that shape written once, so no individual handler has to
// remember the third.
macro_rules! bind {
($setter:ident, |$a:ident $(, $arg:ident : $ty:ty)*| $body:block) => {
let weak = window.as_weak();
let handle = Rc::clone(app);
window.$setter(move |$($arg: $ty),*| {
let Some(window) = weak.upgrade() else { return };
{
let mut $a = handle.borrow_mut();
$body
}
render(&mut handle.borrow_mut(), &window);
});
};
}
bind!(on_key, |a, key: SharedString, ctrl: bool, shift: bool| {
let results = a.library.results(&a.query());
let shelves = a.shelf_lengths();
let sheet_tracks =
a.ui.open_album
.as_deref()
.and_then(|id| a.library.get(id))
.map_or(0, |album| album.tracks.len());
let mut ui = a.ui.clone();
let actions = keymap::handle_key(
key.as_str(),
ctrl,
shift,
&mut ui,
&results,
&shelves,
sheet_tracks,
);
a.ui = ui;
a.apply(actions);
});
bind!(on_toggle, |a| {
a.apply(vec![Action::Toggle]);
});
bind!(on_next, |a| {
a.apply(vec![Action::Next]);
});
bind!(on_previous, |a| {
a.apply(vec![Action::Previous]);
});
bind!(on_mute, |a| {
a.apply(vec![Action::Mute]);
});
bind!(on_seek, |a, fraction: f32| {
let target = (fraction as f64 * a.player.duration).clamp(0.0, a.player.duration);
a.player.position = target;
a.smooth = false;
a.send(Command::Seek(target));
});
bind!(on_set_volume, |a, percent: i32| {
a.player.volume = percent.clamp(0, 100);
a.send(Command::Volume(a.player.volume));
});
bind!(on_go_back, |a| {
if a.ui.open_album.is_some() {
a.ui.open_album = None;
} else if a.ui.play_view {
a.ui.play_view = false;
} else {
a.ui.browse_back();
}
});
bind!(on_enter_group, |a, shelf: i32| {
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
a.ui.category = None;
a.ui.sel_index = 0;
});
bind!(on_enter_category, |a, shelf: i32, key: SharedString| {
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
a.ui.category = Some(key.to_string());
a.ui.sel_index = 0;
});
bind!(on_open_category, |a, key: SharedString| {
a.ui.category = Some(key.to_string());
a.ui.sel_index = 0;
});
bind!(on_open_album, |a, id: SharedString| {
// A podcast episode is a single track with nothing to choose between, so the
// cover plays it rather than opening a list of one.
match a.library.get(id.as_str()).map(Rc::clone) {
Some(album) if Group::of(&album) == Group::Podcasts => {
a.apply(vec![Action::Play {
album_id: album.id.clone(),
track_index: 0,
}]);
}
Some(album) => {
a.ui.open_album = Some(album.id.clone());
a.ui.sheet_index = 0;
}
None => {}
}
});
bind!(on_play_album, |a, id: SharedString| {
a.apply(vec![Action::Play {
album_id: id.to_string(),
track_index: 0,
}]);
});
bind!(on_play_song, |a, id: SharedString, index: i32| {
a.apply(vec![Action::Play {
album_id: id.to_string(),
track_index: index.max(0) as usize,
}]);
});
bind!(on_open_play_view, |a| {
a.ui.play_view = true;
});
bind!(on_close_sheet, |a| {
a.ui.open_album = None;
});
bind!(on_play_sheet_track, |a, index: i32| {
if let Some(album_id) = a.ui.open_album.clone() {
a.apply(vec![Action::Play {
album_id,
track_index: index.max(0) as usize,
}]);
a.ui.open_album = None;
}
});
}

View File

@@ -0,0 +1,95 @@
//! oklch -> sRGB.
//!
//! The web front-end's palette is oklch throughout (`web/src/styles/app.css`) and
//! Slint has no such colour space, so every value crossing over is converted here.
//! Ported from `web/src/lib/oklch.ts`.
//!
//! The static palette is converted once at build time into the literals in
//! `ui/theme.slint`; this module exists for the values that are only known at runtime -
//! the per-group hues behind the generated book-spine art.
/// `l` is 0..1 (not the 0..100% that CSS writes), `c` is chroma, `h` is degrees.
pub fn oklch_to_rgb(l: f32, c: f32, h: f32) -> [u8; 3] {
let (sin, cos) = (h.to_radians().sin(), h.to_radians().cos());
let (a, b) = (c * cos, c * sin);
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
let m_ = l - 0.105_561_34 * a - 0.063_854_17 * b;
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
let lin = [
4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3,
-1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3,
-0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3,
];
lin.map(|v| {
// Gamut clipping is per channel and deliberately crude: every colour this app
// actually asks for is inside sRGB, so a channel landing outside it means a
// typo, and a hard clamp shows that as a flat primary rather than hiding it.
let v = v.clamp(0.0, 1.0);
let encoded = if v <= 0.003_130_8 {
12.92 * v
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
};
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
})
}
/// `#rrggbb` as the backend writes it. Anything unparseable falls back rather than
/// failing: the colours are decoration, and an album with a malformed one should still
/// appear on the shelf.
pub fn parse_hex(hex: &str, fallback: [u8; 3]) -> [u8; 3] {
let digits = hex.trim().trim_start_matches('#');
if digits.len() != 6 {
return fallback;
}
let byte = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).ok();
match (byte(0), byte(2), byte(4)) {
(Some(r), Some(g), Some(b)) => [r, g, b],
_ => fallback,
}
}
/// Linear blend, for the spine's translucent highlight lines.
pub fn mix(base: [u8; 3], over: [u8; 3], alpha: f32) -> [u8; 3] {
let alpha = alpha.clamp(0.0, 1.0);
[0, 1, 2].map(|i| (base[i] as f32 * (1.0 - alpha) + over[i] as f32 * alpha).round() as u8)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_anchors_round_trip_into_srgb() {
// Pure white and pure black are the two values with no room to be subtly wrong.
assert_eq!(oklch_to_rgb(1.0, 0.0, 0.0), [255, 255, 255]);
assert_eq!(oklch_to_rgb(0.0, 0.0, 0.0), [0, 0, 0]);
}
#[test]
fn the_accent_pink_lands_where_the_web_ui_puts_it() {
// oklch(70% 0.16 340) is `--accent`; the browser resolves it to ~#e A pink in
// the red-magenta corner. Assert the shape of it rather than an exact byte.
let [r, g, b] = oklch_to_rgb(0.70, 0.16, 340.0);
assert!(r > g && b > g, "expected a pink, got {r},{g},{b}");
assert!(r > 200, "expected a light pink, got {r},{g},{b}");
}
#[test]
fn hex_parsing_falls_back_rather_than_failing() {
assert_eq!(parse_hex("#4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]);
assert_eq!(parse_hex("4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]);
assert_eq!(parse_hex("nope", [1, 2, 3]), [1, 2, 3]);
assert_eq!(parse_hex("", [1, 2, 3]), [1, 2, 3]);
}
#[test]
fn mix_interpolates_between_the_two_ends() {
assert_eq!(mix([0, 0, 0], [255, 255, 255], 0.0), [0, 0, 0]);
assert_eq!(mix([0, 0, 0], [255, 255, 255], 1.0), [255, 255, 255]);
assert_eq!(mix([0, 0, 0], [200, 100, 50], 0.5), [100, 50, 25]);
}
}

97
slint-frontend/src/ws.rs Normal file
View File

@@ -0,0 +1,97 @@
//! The state websocket, with reconnect-and-reseed.
//!
//! Ported from `web/src/hooks/usePlayerState.ts`. The backend pushes and never listens,
//! so this is a read loop and nothing else. Two details are load-bearing:
//!
//! * **Reseed on open, not just on connect.** A reconnect can have missed every change
//! between the drop and the new socket, and the server only sends a snapshot to a
//! client it already considers connected. So each successful open is followed by a
//! `GET /api/state`.
//! * **Position is not on the event bus.** The server polls its player on a 0.5 s timer
//! and sends `position` frames only while something is playing - so the absence of
//! them is not a stall, and the UI interpolates between them rather than waiting.
use std::sync::mpsc::Sender;
use std::time::Duration;
use crate::api::{Client, PlayerState, ServerMessage};
/// What the UI thread hears from this connection. Wider than `ServerMessage` because
/// connectivity is itself a thing the screen shows.
pub enum Update {
Connected,
Disconnected,
State(Box<PlayerState>),
Position {
position: f64,
duration: f64,
},
/// The library was rescanned: re-fetch it, and drop the cover thumbnails, since a
/// rescan is exactly when the art behind an unchanged URL gets rewritten.
LibraryChanged,
}
/// Matches the web client's retry interval. Short enough that the screen comes back
/// before a child gives up on it, long enough not to spin while the backend restarts.
const RETRY: Duration = Duration::from_millis(1500);
pub fn spawn(client: Client, updates: Sender<Update>) -> std::thread::JoinHandle<()> {
std::thread::Builder::new()
.name("ws".into())
.spawn(move || loop {
match connect_once(&client, &updates) {
Ok(()) | Err(()) => {}
}
// `send` failing means the UI is gone, which is the one reason to stop.
if updates.send(Update::Disconnected).is_err() {
return;
}
std::thread::sleep(RETRY);
})
.expect("spawning the websocket thread")
}
fn connect_once(client: &Client, updates: &Sender<Update>) -> Result<(), ()> {
let (mut socket, _) = tungstenite::connect(client.websocket_url()).map_err(|_| ())?;
if updates.send(Update::Connected).is_err() {
return Err(());
}
// Reseed: see the note at the top of this module.
if let Ok(state) = client.state() {
if updates.send(Update::State(Box::new(state))).is_err() {
return Err(());
}
}
loop {
let message = match socket.read() {
Ok(message) => message,
Err(_) => return Err(()),
};
let text = match message {
tungstenite::Message::Text(text) => text,
tungstenite::Message::Close(_) => return Ok(()),
// Ping/Pong are answered by tungstenite itself on the next write; binary
// frames are not part of this protocol.
_ => continue,
};
let parsed: ServerMessage = match serde_json::from_str(&text) {
Ok(parsed) => parsed,
// A frame this build cannot read is not a reason to drop the connection -
// the next one is probably fine.
Err(_) => continue,
};
let update = match parsed {
ServerMessage::State { state } => Update::State(Box::new(state)),
ServerMessage::Position { position, duration } => {
Update::Position { position, duration }
}
ServerMessage::Library => Update::LibraryChanged,
ServerMessage::Unknown => continue,
};
if updates.send(update).is_err() {
return Err(());
}
}
}

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
#
# Render the UI and screenshot it without touching your session.
#
# A Slint window needs a compositor: under bare Xvfb nothing maps, and on a locked or
# remote machine there is no session to draw into. So this starts a nested headless
# sway, runs the app inside it, drives it with synthetic keys and grabs a frame per
# screen. It is how the shots in README.md were made, and how to check a layout change
# on a machine with no display.
#
# nix-shell -E '(import ./shell.nix {}).overrideAttrs (o: {
# nativeBuildInputs = o.nativeBuildInputs ++ (with (import <nixpkgs> {}); [ sway grim wtype ]);
# })' --run 'tools/headless-shots.sh out/ http://127.0.0.1:8080'
#
set -euo pipefail
OUTDIR="${1:?usage: headless-shots.sh <outdir> [server-url]}"
SERVER="${2:-http://127.0.0.1:8080}"
BIN="${BIN:-./target/release/musicmouse-slint}"
mkdir -p "$OUTDIR"
export XDG_RUNTIME_DIR="$(mktemp -d)"
unset WAYLAND_DISPLAY DISPLAY
export WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman
trap 'kill %1 2>/dev/null || true; rm -rf "$XDG_RUNTIME_DIR"' EXIT
launcher="$XDG_RUNTIME_DIR/launch.sh"
printf '#!/usr/bin/env bash\nexec %q --server %q >%q/app.log 2>&1\n' \
"$(realpath "$BIN")" "$SERVER" "$XDG_RUNTIME_DIR" > "$launcher"
chmod +x "$launcher"
printf 'output HEADLESS-1 mode 1920x1080\ndefault_border none\nexec %s\n' "$launcher" \
> "$XDG_RUNTIME_DIR/sway.cfg"
sway -c "$XDG_RUNTIME_DIR/sway.cfg" >"$XDG_RUNTIME_DIR/sway.log" 2>&1 &
for _ in $(seq 30); do [ -S "$XDG_RUNTIME_DIR/wayland-1" ] && break; sleep 1; done
export WAYLAND_DISPLAY=wayland-1
sleep 16 # library fetch plus the first covers
# Each step is ONE wtype invocation, and starts with a throwaway keypress.
#
# wtype creates a virtual keyboard, sends, and exits. The client needs a moment to
# notice the seat gained a keyboard, so the first key or two of any invocation is
# dropped - which makes a per-key loop lose every key it sends. Batching a step into
# one invocation and opening it with "x" + BackSpace spends that warm-up on a keypress
# that cancels itself out.
WARM=(-s 260 "x" -k BackSpace)
shot() { sleep 2.5; grim "$OUTDIR/$1.png"; echo " $1"; }
shot 01-root
wtype "${WARM[@]}" -k Down -k Return; shot 02-categories
wtype "${WARM[@]}" -k Return; shot 03-grid
wtype "${WARM[@]}" -k Escape -k Escape "maja"; shot 04-search
wtype "${WARM[@]}" -k Escape -k question "brumm"; shot 05-tracksearch
wtype "${WARM[@]}" -k Return; sleep 4; shot 06-play
wtype "${WARM[@]}" -k Escape; shot 07-bar
echo "wrote 7 frames to $OUTDIR"

261
slint-frontend/ui/app.slint Normal file
View File

@@ -0,0 +1,261 @@
import { Theme } from "theme.slint";
import { RoundButton } from "widgets.slint";
import { ShelfView, ResultsView } from "browse.slint";
import { PlayerBar, PlayView, AlbumSheet } from "player.slint";
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying } from "models.slint";
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, Theme }
export component AppWindow inherits Window {
title: "Musik Delfin";
default-font-family: Theme.font;
default-font-size: Theme.text-body;
// The stage. In the web app a per-track animated canvas sits on top of this; here
// it is the whole background, on purpose. A full-viewport repaint loop is a floor
// you cannot get under while it runs at all, and it is the first thing `?pi=1`
// already removes on the device this is meant to replace.
background: @linear-gradient(180deg,
Theme.stage-top 0%,
Theme.stage-mid 45%,
Theme.sea-deep 100%);
// ------------------------------------------------------------------- inputs --
in property <bool> loading: true;
in property <string> splash-text;
in property <string> status-label;
in property <bool> online: false;
in property <bool> play-view: false;
in property <bool> show-sheet: false;
in property <bool> can-go-back: false;
in property <[Shelf]> shelves;
in property <bool> root-screen: true;
in property <int> shelf-row: 0;
in property <string> group-label;
in property <string> category-heading;
in property <string> album-heading;
in property <bool> show-search: false;
in property <string> search-text;
in property <string> mode-label;
in property <string> count-label;
in property <[TrackRow]> songs;
in property <[CategoryRow]> category-grid;
in property <[GridRow]> grid;
in property <bool> no-results: false;
in property <int> sel-index: 0;
in property <int> sel-row: 0;
in property <NowPlaying> now;
in property <bool> playing: false;
in property <float> progress: 0.0;
in property <bool> smooth: true;
in property <int> volume: 60;
in property <bool> has-bar: false;
in property <string> sheet-title;
in property <string> sheet-subtitle;
in property <string> sheet-meta;
in property <image> sheet-cover;
in property <bool> sheet-is-book: false;
in property <[TrackRow]> sheet-tracks;
in property <int> sheet-index: 0;
// How wide the grid may be. Rust reads this back to decide the column count, so the
// chunking into `GridRow`s and the layout agree about how many fit.
out property <length> grid-width: self.width - 32px - Theme.rail-clearance;
// ---------------------------------------------------------------- callbacks --
// The special keys are named here rather than in Rust because this is where the
// `Key.*` constants exist; Rust receives "escape"/"left"/"enter"/… or the literal
// character that was typed.
callback key(string, bool, bool);
callback toggle();
callback next();
callback previous();
callback seek(float);
callback set-volume(int);
callback mute();
callback go-back();
callback enter-group(int);
callback enter-category(int, string);
callback open-category(string);
callback open-album(string);
callback play-album(string);
callback play-song(string, int);
callback open-play-view();
callback close-sheet();
callback play-sheet-track(int);
forward-focus: keys;
keys := FocusScope {
key-pressed(event) => {
root.key(
event.text == Key.Escape ? "escape"
: event.text == Key.Return ? "enter"
: event.text == Key.Backspace ? "backspace"
: event.text == Key.Tab ? "tab"
: event.text == Key.LeftArrow ? "left"
: event.text == Key.RightArrow ? "right"
: event.text == Key.UpArrow ? "up"
: event.text == Key.DownArrow ? "down"
: event.text == Key.F1 ? "f1"
: event.text == Key.Space ? "space"
: event.text,
event.modifiers.control,
event.modifiers.shift);
accept
}
VerticalLayout {
// ------------------------------------------------------------ header --
if !root.play-view: HorizontalLayout {
alignment: center;
spacing: 16px;
padding-top: 18px;
padding-bottom: 6px;
Text {
text: "Musik Delfin";
font-size: 34px;
font-weight: 900;
color: Theme.paper;
}
if root.status-label != "": VerticalLayout {
alignment: center;
Rectangle {
background: Theme.paper.with-alpha(0.16);
border-radius: Theme.pill;
width: status.preferred-width;
height: status.preferred-height;
status := HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 6px;
padding-bottom: 6px;
Text {
text: root.status-label;
font-size: Theme.text-small + 1px;
font-weight: 800;
color: root.online ? Theme.paper : #ffb4a2;
}
}
}
}
}
// -------------------------------------------------------------- body --
if !root.play-view && root.root-screen: ShelfView {
shelves: root.shelves;
shelf-row: root.shelf-row;
sel-index: root.sel-index;
enter-group(s) => { root.enter-group(s); }
enter-category(s, key) => { root.enter-category(s, key); }
}
if !root.play-view && !root.root-screen: ResultsView {
group-label: root.group-label;
category-heading: root.category-heading;
album-heading: root.album-heading;
show-search: root.show-search;
search-text: root.search-text;
mode-label: root.mode-label;
count-label: root.count-label;
songs: root.songs;
category-grid: root.category-grid;
grid: root.grid;
sel-index: root.sel-index;
sel-row: root.sel-row;
empty: root.no-results;
play-song(id, i) => { root.play-song(id, i); }
open-category(key) => { root.open-category(key); }
open-album(id) => { root.open-album(id); }
play-album(id) => { root.play-album(id); }
}
if root.play-view: PlayView {
now: root.now;
playing: root.playing;
progress: root.progress;
smooth: root.smooth;
volume: root.volume;
toggle => { root.toggle(); }
next => { root.next(); }
previous => { root.previous(); }
seek(f) => { root.seek(f); }
set-volume(v) => { root.set-volume(v); }
mute => { root.mute(); }
open-album => { root.open-album(root.now.title); }
}
}
}
// The player bar floats over the browse screen rather than displacing it, which is
// why the scrollers above reserve bottom padding for it. Geometry is set here
// rather than inside the component: a plain element takes its *preferred* width
// from its content, not its parent's width, so a bar that only declared a height
// left gaps for the grid behind it to show through.
if !root.play-view && root.has-bar: PlayerBar {
x: 0;
y: parent.height - self.height;
width: parent.width;
now: root.now;
playing: root.playing;
progress: root.progress;
smooth: root.smooth;
volume: root.volume;
toggle => { root.toggle(); }
next => { root.next(); }
previous => { root.previous(); }
seek(f) => { root.seek(f); }
set-volume(v) => { root.set-volume(v); }
mute => { root.mute(); }
open-play-view => { root.open-play-view(); }
}
// One fixed round button in the top-left: back, everywhere it applies, so "top
// left" always means the same thing.
if root.can-go-back: RoundButton {
x: 24px;
y: 18px;
label: "";
clicked => { root.go-back(); }
}
if root.show-sheet: AlbumSheet {
width: 100%;
height: 100%;
title: root.sheet-title;
subtitle: root.sheet-subtitle;
meta: root.sheet-meta;
cover: root.sheet-cover;
is-book: root.sheet-is-book;
tracks: root.sheet-tracks;
sel-index: root.sheet-index;
close => { root.close-sheet(); }
play(i) => { root.play-sheet-track(i); }
play-all => { root.play-sheet-track(0); }
}
if root.loading: Rectangle {
background: Theme.sea-deep.with-alpha(0.92);
TouchArea { }
VerticalLayout {
alignment: center;
spacing: 18px;
Text {
text: "🐬";
font-size: 96px;
horizontal-alignment: center;
}
Text {
text: root.splash-text;
font-size: 22px;
font-weight: 800;
color: Theme.paper;
horizontal-alignment: center;
}
}
}
}

View File

@@ -0,0 +1,567 @@
import { ListView } from "std-widgets.slint";
import { Theme } from "theme.slint";
import { GlassPanel, Cover } from "widgets.slint";
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow } from "models.slint";
// Heights are fixed rather than derived from content, and that is the whole reason the
// grid can be virtualized: a `ListView` that has to measure a row before it can decide
// whether to skip it has not skipped anything. The web grid does the same thing with
// `contain-intrinsic-size` for the same reason.
//
// A book is taller than an album, so the cover *slot* is sized for the taller of the
// two and each cover is centred in it. That keeps every row identical without
// flattening the shape difference that tells the two media apart.
export global Metrics {
out property <length> cover-slot: 200px;
out property <length> card-text: 78px;
out property <length> card-height: 278px; // cover-slot + card-text
out property <length> row-height: 302px; // card-height + grid-gap
out property <length> shelf-height: 236px;
out property <length> category-row-height: 260px;
}
component SelectionRing inherits Rectangle {
in property <bool> selected;
in property <bool> current;
// Selection outranks "currently playing": when the keyboard is on the album that is
// also playing, the ring it needs to see is the one that moves.
border-width: root.selected ? 5px : (root.current ? 4px : 0px);
border-color: root.selected ? Theme.paper : Theme.accent;
border-radius: Theme.radius-card + 6px;
}
component AlbumCard inherits Rectangle {
in property <AlbumTile> tile;
in property <bool> selected;
callback open();
callback play();
height: Metrics.card-height;
ring := SelectionRing {
selected: root.selected;
current: root.tile.current;
x: -7px;
y: -7px;
width: parent.width + 14px;
height: parent.height + 14px;
}
card := Rectangle {
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
border-radius: Theme.radius-card;
drop-shadow-color: #00101759;
drop-shadow-blur: 18px;
drop-shadow-offset-y: 6px;
// The lift on selection: 120ms, the same as the web card's transition.
y: root.selected ? -3px : 0px;
animate y { duration: 120ms; easing: ease-out; }
VerticalLayout {
padding: 10px;
spacing: 6px;
// The cover, centred in a slot tall enough for the taller media type.
Rectangle {
height: Metrics.cover-slot - 20px;
HorizontalLayout {
alignment: center;
Cover {
source: root.tile.cover;
is-book: root.tile.is-book;
height: parent.height;
width: root.tile.is-book ? parent.height * 0.82 : parent.height;
}
}
TouchArea {
// Cover opens the track list; the title below starts it playing.
// Two targets, because "show me what is in this" and "play this"
// are different intentions and a child has both.
clicked => { root.open(); }
}
}
Rectangle {
height: Metrics.card-text - 26px;
VerticalLayout {
spacing: 1px;
Text {
text: root.tile.title;
font-size: Theme.text-body - 1px;
font-weight: 800;
color: Theme.ink;
wrap: word-wrap;
overflow: elide;
// Two lines always, whether or not the title needs them, so
// every card in the row ends at the same place.
height: (Theme.text-body - 1px) * 2.4;
}
Text {
text: root.tile.artist;
font-size: Theme.text-small - 1px;
font-weight: 700;
color: Theme.ink.with-alpha(0.65);
overflow: elide;
}
}
TouchArea {
clicked => { root.play(); }
}
}
Text {
text: root.tile.meta;
font-size: Theme.text-tiny;
font-weight: 800;
color: Theme.count-pink;
overflow: elide;
}
}
}
}
component CategoryCard inherits Rectangle {
in property <CategoryTile> tile;
in property <bool> selected;
in property <length> tile-width: Theme.shelf-tile;
callback open();
width: root.tile-width;
height: Metrics.shelf-height;
SelectionRing {
selected: root.selected;
current: false;
x: -7px;
y: -7px;
width: parent.width + 14px;
height: parent.height + 14px;
}
Rectangle {
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
border-radius: Theme.radius-card;
drop-shadow-color: #00101759;
drop-shadow-blur: 18px;
drop-shadow-offset-y: 6px;
y: root.selected ? -3px : 0px;
animate y { duration: 120ms; easing: ease-out; }
VerticalLayout {
padding: 8px;
spacing: 5px;
// One album fills the tile; several show as a 2x2 contact sheet, which is
// how a category reads as "a stack of these" without a label saying so.
Rectangle {
height: root.tile-width - 16px;
clip: true;
if root.tile.cover-count <= 1: Cover {
source: root.tile.cover1;
is-book: root.tile.is-book;
width: 100%;
height: 100%;
}
if root.tile.cover-count > 1: VerticalLayout {
spacing: 4px;
HorizontalLayout {
spacing: 4px;
Cover { source: root.tile.cover1; radius: 6px; }
Cover { source: root.tile.cover2; radius: 6px; }
}
HorizontalLayout {
spacing: 4px;
Cover { source: root.tile.cover3; radius: 6px; }
Cover { source: root.tile.cover4; radius: 6px; }
}
}
}
Text {
text: root.tile.key;
font-size: Theme.text-body;
font-weight: 800;
color: Theme.ink;
overflow: elide;
}
Text {
text: root.tile.count-label;
font-size: Theme.text-tiny;
font-weight: 800;
color: Theme.count-pink;
overflow: elide;
}
}
TouchArea {
clicked => { root.open(); }
}
}
}
component TrackListRow inherits Rectangle {
in property <TrackRow> track;
in property <bool> selected;
callback play();
height: 58px;
background: root.track.current ? Theme.row-active : Theme.row-idle;
border-radius: Theme.radius-row;
border-width: root.selected ? 3px : 1px;
border-color: root.selected ? Theme.paper : (root.track.current ? Theme.accent : Theme.glass-border);
HorizontalLayout {
padding-left: 12px;
padding-right: 16px;
spacing: 12px;
VerticalLayout {
alignment: center;
Cover {
source: root.track.cover;
is-book: root.track.is-book;
radius: 8px;
height: 40px;
width: root.track.is-book ? 33px : 40px;
}
}
VerticalLayout {
alignment: center;
Text {
text: root.track.title;
font-size: Theme.text-body;
font-weight: 800;
color: Theme.paper;
overflow: elide;
}
Text {
text: root.track.subtitle;
font-size: Theme.text-tiny;
font-weight: 700;
color: Theme.text-sub.with-alpha(0.75);
overflow: elide;
}
}
VerticalLayout {
alignment: center;
Text {
text: root.track.duration;
font-family: Theme.mono;
font-size: Theme.text-small;
font-weight: 700;
color: Theme.text-dim.with-alpha(0.8);
}
}
}
TouchArea {
clicked => { root.play(); }
}
}
// The root screen: three shelves, each scrolling sideways.
export component ShelfView inherits Flickable {
in property <[Shelf]> shelves;
in property <int> shelf-row;
in property <int> sel-index;
callback enter-group(int); // shelf index
callback enter-category(int, string); // shelf index, category key
content-height: layout.preferred-height;
content-width: self.width;
layout := VerticalLayout {
padding-left: 32px;
padding-right: Theme.rail-clearance;
padding-top: 14px;
// Clear of the player bar, which floats over this rather than displacing it.
padding-bottom: 210px;
spacing: 25px;
for shelf[s] in root.shelves: GlassPanel {
height: Metrics.shelf-height + 74px;
// The panel itself is a target: it opens the whole group.
TouchArea {
clicked => { root.enter-group(s); }
}
VerticalLayout {
padding: 14px;
spacing: 10px;
HorizontalLayout {
spacing: 10px;
alignment: start;
// A round frosted badge behind the emoji, which otherwise renders
// thin and low-contrast straight on the stage gradient.
Rectangle {
width: 34px;
height: 34px;
border-radius: 17px;
background: Theme.paper.with-alpha(0.2);
border-width: 1px;
border-color: Theme.paper.with-alpha(0.4);
Text {
text: shelf.icon;
font-size: 17px;
vertical-alignment: center;
horizontal-alignment: center;
}
}
VerticalLayout {
alignment: center;
Text {
text: shelf.label;
font-size: Theme.text-heading;
font-weight: 900;
color: Theme.paper;
}
}
VerticalLayout {
alignment: center;
Text {
text: "";
font-size: 17px;
font-weight: 900;
color: Theme.paper.with-alpha(0.6);
}
}
}
if shelf.tiles.length == 0: Text {
text: shelf.empty-label;
font-size: Theme.text-small;
font-weight: 700;
color: Theme.text-dim.with-alpha(0.6);
horizontal-alignment: center;
vertical-alignment: center;
}
if shelf.tiles.length > 0: Flickable {
content-width: shelf.tiles.length * (Theme.shelf-tile + 12px) + 20px;
content-height: self.height;
// Keep the focused tile in view as the arrow keys walk past the fold.
content-x: root.shelf-row != s
? 0px
: -max(0px, min(
self.content-width - self.width,
root.sel-index * (Theme.shelf-tile + 12px) - self.width / 2 + Theme.shelf-tile / 2));
animate content-x { duration: 180ms; easing: ease-out; }
HorizontalLayout {
spacing: 12px;
padding: 10px;
alignment: start;
for tile[i] in shelf.tiles: CategoryCard {
tile: tile;
selected: root.shelf-row == s && root.sel-index == i;
open => { root.enter-category(s, tile.key); }
}
}
}
}
}
}
}
// Inside a group, or showing search results.
//
// Exactly one of the three lists is ever populated, and that is a property of the query
// model rather than a coincidence worth defending against: track hits only exist in
// track-search mode, which forces the album list empty; the category list only exists
// when there is neither a query nor a chosen category, which is exactly when the album
// list is empty. So this shows one list filling the page, instead of stacking three
// inside a scroller and nesting a second scroller in it.
export component ResultsView inherits VerticalLayout {
in property <string> group-label;
in property <string> category-heading;
in property <string> album-heading;
in property <bool> show-search;
in property <string> search-text;
in property <string> mode-label;
in property <string> count-label;
in property <[TrackRow]> songs;
in property <[CategoryRow]> category-grid;
in property <[GridRow]> grid;
in property <int> sel-index;
/// Which row of the visible grid holds the selection. Every row is the same height
/// by construction, so following the keyboard is arithmetic, not measurement.
in property <int> sel-row;
in property <bool> empty;
callback play-song(string, int);
callback open-category(string);
callback open-album(string);
callback play-album(string);
spacing: 6px;
padding-left: 32px;
padding-right: Theme.rail-clearance;
if root.group-label != "": Text {
text: root.group-label;
font-size: 15px;
font-weight: 800;
color: Theme.paper;
horizontal-alignment: center;
}
if root.show-search: HorizontalLayout {
alignment: center;
spacing: 12px;
padding-bottom: 4px;
Rectangle {
background: Theme.paper;
border-radius: Theme.pill;
drop-shadow-color: #00101759;
drop-shadow-blur: 14px;
drop-shadow-offset-y: 4px;
width: chip.preferred-width;
height: chip.preferred-height;
chip := HorizontalLayout {
padding-left: 10px;
padding-right: 20px;
padding-top: 10px;
padding-bottom: 10px;
spacing: 10px;
Rectangle {
background: Theme.ink;
border-radius: Theme.pill;
width: label.preferred-width;
label := HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
padding-top: 4px;
padding-bottom: 4px;
Text {
text: root.mode-label;
font-size: Theme.text-small + 1px;
font-weight: 800;
color: white;
}
}
}
Text {
text: root.search-text;
font-size: Theme.text-heading;
font-weight: 800;
color: Theme.ink;
vertical-alignment: center;
}
}
}
VerticalLayout {
alignment: center;
Text {
text: root.count-label;
font-size: Theme.text-small + 1px;
font-weight: 700;
color: Theme.text-dim.with-alpha(0.85);
}
}
}
if root.empty: VerticalLayout {
alignment: center;
Text {
text: "Nichts gefunden — probier andere Buchstaben!";
font-size: Theme.text-heading;
font-weight: 800;
color: Theme.paper;
horizontal-alignment: center;
}
}
// --- track hits -----------------------------------------------------------
// Capped at 40 rows upstream, so this one does not need virtualizing. Held to a
// readable column rather than the full width, which is what lets a long album
// line elide instead of running the width of a 1080p screen. The heading rides
// inside that column so the two are aligned with each other, not with the page.
if root.songs.length > 0: Flickable {
content-height: songs-column.preferred-height + 210px;
content-width: self.width;
HorizontalLayout {
alignment: center;
songs-column := VerticalLayout {
// Without this the column is centred in whatever vertical space is
// left over, which on a short result list leaves it floating.
alignment: start;
width: 860px;
spacing: 8px;
Text {
text: "Titel";
font-size: Theme.text-heading;
font-weight: 900;
color: Theme.paper;
}
GlassPanel {
height: song-rows.preferred-height;
song-rows := VerticalLayout {
padding: 12px;
spacing: 6px;
for song in root.songs: TrackListRow {
track: song;
selected: root.sel-index == song.nav;
play => { root.play-song(song.album-id, song.index); }
}
}
}
}
}
}
// --- categories -----------------------------------------------------------
if root.category-grid.length > 0: Text {
text: root.category-heading;
font-size: Theme.text-heading;
font-weight: 900;
color: Theme.paper;
}
if root.category-grid.length > 0: ListView {
for row in root.category-grid: HorizontalLayout {
height: Metrics.category-row-height;
spacing: Theme.grid-gap;
alignment: start;
for tile in row.tiles: CategoryCard {
tile: tile;
tile-width: Theme.card-width;
selected: root.sel-index == tile.nav;
open => { root.open-category(tile.key); }
}
}
}
// --- albums ---------------------------------------------------------------
if root.grid.length > 0: Text {
text: root.album-heading;
font-size: Theme.text-heading;
font-weight: 900;
color: Theme.paper;
}
if root.grid.length > 0: album-list := ListView {
// Follow the keyboard without measuring anything.
content-y: -max(0px, min(
max(0px, self.content-height - self.height),
root.sel-row * Metrics.row-height));
animate content-y { duration: 180ms; easing: ease-out; }
for row in root.grid: HorizontalLayout {
height: Metrics.row-height;
spacing: Theme.grid-gap;
alignment: start;
for tile in row.tiles: AlbumCard {
width: Theme.card-width;
tile: tile;
selected: root.sel-index == tile.nav;
open => { root.open-album(tile.id); }
play => { root.play-album(tile.id); }
}
}
}
}

View File

@@ -0,0 +1,91 @@
// The shapes that cross between Rust and the UI.
//
// Everything here is already *presentation*: labels are formatted, durations are
// strings, covers are decoded images. Nothing in the .slint files formats a number or
// picks a word, so the German copy and the duration format live in one place (Rust)
// rather than being split across two languages.
//
// `nav` is each item's position in the single flat selection order that runs songs ->
// categories -> albums, so one pair of arrow keys walks the whole page. It is assigned
// in Rust, where the three lists are built together.
export struct AlbumTile {
id: string,
title: string,
artist: string,
// "12 Songs · 45:03 · 🧸" - already assembled, including the figurine marker.
meta: string,
cover: image,
is-book: bool,
locked: bool,
// This album is the one playing; drawn with the accent ring rather than the
// selection ring, and outranked by it when they coincide.
current: bool,
nav: int,
}
// The grid is handed to the UI pre-chunked into rows, because a `ListView` virtualizes
// its model and a flat list of 343 cards has no rows to skip. This is the direct
// counterpart of the web app's `content-visibility: auto` on `.grid > .card`, which
// exists for exactly the same measurement: 340 live cards saturated the Pi's renderer.
export struct GridRow {
tiles: [AlbumTile],
}
export struct CategoryTile {
key: string,
// "7 Hörbücher"
count-label: string,
// Up to four covers in a 2x2, or one filling the tile when the category holds a
// single album. Four fields rather than an array: the count is fixed and small, and
// this keeps the tile a plain struct.
cover1: image,
cover2: image,
cover3: image,
cover4: image,
cover-count: int,
is-book: bool,
nav: int,
}
export struct CategoryRow {
tiles: [CategoryTile],
}
// One of the three shelves on the root screen.
export struct Shelf {
label: string,
icon: string,
tiles: [CategoryTile],
empty-label: string,
}
export struct TrackRow {
title: string,
subtitle: string,
duration: string,
cover: image,
is-book: bool,
current: bool,
locked: bool,
album-id: string,
index: int,
nav: int,
}
// What the player bar and the full-screen view both say. Shared so the two cannot
// drift apart on a copy change.
export struct NowPlaying {
title: string,
subtitle: string,
// "Song 3 von 8" / "Kapitel 3 von 8"
counter: string,
cover: image,
hero-cover: image,
is-book: bool,
has-album: bool,
// "4:12", the time left in this track.
clock-label: string,
// "Noch 38:20 im Album" - empty for a podcast, where it means nothing.
remaining-label: string,
}

View File

@@ -0,0 +1,413 @@
import { Theme } from "theme.slint";
import { Cover, Transport, ProgressBar, VolumeBars, RoundButton, GlassPanel } from "widgets.slint";
import { NowPlaying, TrackRow } from "models.slint";
// The bar along the bottom of the browse screen.
export component PlayerBar inherits Rectangle {
in property <NowPlaying> now;
in property <bool> playing;
in property <float> progress;
in property <bool> smooth;
in property <int> volume;
callback toggle();
callback next();
callback previous();
callback seek(float);
callback set-volume(int);
callback mute();
callback open-play-view();
height: 96px;
background: Theme.bar-bg;
// The accent rule along the top is what separates the bar from the stage without a
// shadow, which over a gradient reads as a smudge.
Rectangle {
y: 0;
height: 3px;
width: 100%;
background: Theme.accent.with-alpha(0.5);
}
HorizontalLayout {
padding-left: 24px;
padding-right: 24px;
padding-top: 16px;
padding-bottom: 16px;
spacing: 18px;
// Cover: the way back into the full-screen view.
VerticalLayout {
alignment: center;
Rectangle {
width: root.now.is-book ? 46px : 56px;
height: 56px;
Cover {
source: root.now.cover;
is-book: root.now.is-book;
radius: 10px;
width: 100%;
height: 100%;
}
TouchArea {
clicked => { root.open-play-view(); }
}
}
}
VerticalLayout {
alignment: center;
width: 240px;
Text {
text: root.now.title;
font-size: Theme.text-body;
font-weight: 800;
color: Theme.paper;
overflow: elide;
}
Text {
text: root.now.subtitle;
font-size: Theme.text-small;
font-weight: 600;
color: Theme.text-sub.with-alpha(0.75);
overflow: elide;
}
if root.now.has-album: Text {
text: root.now.counter;
font-size: Theme.text-tiny;
font-weight: 800;
color: Theme.accent-dim;
overflow: elide;
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
ProgressBar {
progress: root.progress;
smooth: root.smooth;
on-dark: true;
bar-height: 10px;
seek(fraction) => { root.seek(fraction); }
}
}
VerticalLayout {
alignment: center;
Transport {
playing: root.playing;
size: 48px;
toggle => { root.toggle(); }
next => { root.next(); }
previous => { root.previous(); }
}
}
VerticalLayout {
alignment: center;
VolumeBars {
volume: root.volume;
bar-width: 11px;
bar-height: 30px;
set-volume(v) => { root.set-volume(v); }
mute => { root.mute(); }
}
}
}
}
// The full-screen now-playing view.
export component PlayView inherits VerticalLayout {
in property <NowPlaying> now;
in property <bool> playing;
in property <float> progress;
in property <bool> smooth;
in property <int> volume;
callback toggle();
callback next();
callback previous();
callback seek(float);
callback set-volume(int);
callback mute();
callback open-album();
alignment: center;
spacing: 18px;
padding: 32px;
HorizontalLayout {
alignment: center;
Rectangle {
height: 320px;
width: root.now.is-book ? 262px : 320px;
drop-shadow-color: #0016208c;
drop-shadow-blur: 40px;
drop-shadow-offset-y: 20px;
if root.now.has-album: Cover {
source: root.now.hero-cover;
is-book: root.now.is-book;
radius: 28px;
width: 100%;
height: 100%;
}
if !root.now.has-album: Rectangle {
background: #2a4f57;
border-radius: 28px;
}
TouchArea {
clicked => { root.open-album(); }
}
}
}
VerticalLayout {
spacing: 6px;
Text {
text: root.now.title;
font-size: Theme.text-hero;
font-weight: 900;
color: Theme.paper;
horizontal-alignment: center;
wrap: word-wrap;
}
Text {
text: root.now.subtitle;
font-size: Theme.text-heading;
font-weight: 700;
color: Theme.text-dim.with-alpha(0.8);
horizontal-alignment: center;
overflow: elide;
}
if root.now.has-album: Text {
text: root.now.counter;
font-size: 15px;
font-weight: 800;
color: Theme.accent-dim;
horizontal-alignment: center;
}
}
HorizontalLayout {
alignment: center;
VerticalLayout {
width: 680px;
spacing: 8px;
ProgressBar {
progress: root.progress;
smooth: root.smooth;
bar-height: 14px;
seek(fraction) => { root.seek(fraction); }
}
HorizontalLayout {
Text {
text: root.now.clock-label;
font-family: Theme.mono;
font-size: Theme.text-small + 1px;
font-weight: 800;
color: Theme.text-dim.with-alpha(0.8);
}
Rectangle { horizontal-stretch: 1; }
Text {
text: root.now.remaining-label;
font-size: Theme.text-small + 1px;
font-weight: 800;
color: Theme.text-dim.with-alpha(0.8);
}
}
}
}
Transport {
playing: root.playing;
size: 72px;
toggle => { root.toggle(); }
next => { root.next(); }
previous => { root.previous(); }
}
VolumeBars {
volume: root.volume;
bar-width: 18px;
bar-height: 44px;
set-volume(v) => { root.set-volume(v); }
mute => { root.mute(); }
}
}
// The track list for one album, over a dimmed stage.
export component AlbumSheet inherits Rectangle {
in property <string> title;
in property <string> subtitle;
in property <string> meta;
in property <image> cover;
in property <bool> is-book;
in property <[TrackRow]> tracks;
in property <int> sel-index;
callback close();
callback play(int);
callback play-all();
background: Theme.backdrop.with-alpha(0.6);
// Catches every click that misses the sheet, so tapping the dimmed area closes it.
TouchArea {
clicked => { root.close(); }
}
Rectangle {
width: 640px;
height: min(root.height - 80px, 720px);
background: Theme.sheet-bg;
border-radius: Theme.radius-sheet;
drop-shadow-color: #0016208c;
drop-shadow-blur: 60px;
drop-shadow-offset-y: 24px;
// Without this the backdrop's TouchArea above would receive clicks aimed at the
// sheet's own background and close it.
TouchArea { }
VerticalLayout {
padding: 26px;
spacing: 16px;
HorizontalLayout {
spacing: 18px;
Cover {
source: root.cover;
is-book: root.is-book;
width: root.is-book ? 123px : 150px;
height: 150px;
}
VerticalLayout {
alignment: start;
spacing: 6px;
Text {
text: root.title;
font-size: Theme.text-title;
font-weight: 900;
color: Theme.ink;
wrap: word-wrap;
}
Text {
text: root.subtitle;
font-size: Theme.text-body;
font-weight: 700;
color: Theme.ink.with-alpha(0.7);
overflow: elide;
}
Text {
text: root.meta;
font-size: Theme.text-small;
font-weight: 800;
color: Theme.count-pink;
overflow: elide;
}
Rectangle {
height: 8px;
}
Rectangle {
background: play-all-touch.pressed ? Theme.accent-dim : Theme.accent;
border-radius: Theme.pill;
width: play-all.preferred-width;
height: play-all.preferred-height;
play-all := HorizontalLayout {
padding-left: 18px;
padding-right: 18px;
padding-top: 9px;
padding-bottom: 9px;
Text {
text: "▶ Alle abspielen";
font-size: Theme.text-small + 1px;
font-weight: 800;
color: white;
}
}
play-all-touch := TouchArea {
clicked => { root.play-all(); }
}
}
}
VerticalLayout {
alignment: start;
RoundButton {
label: "×";
size: 40px;
clicked => { root.close(); }
}
}
}
Flickable {
content-height: list.preferred-height;
content-width: self.width;
// Follow the keyboard through the list without measuring: every row is
// the same height, so the selected one's offset is arithmetic.
content-y: -max(0px, min(
max(0px, self.content-height - self.height),
root.sel-index * 52px - self.height / 2));
animate content-y { duration: 150ms; easing: ease-out; }
list := VerticalLayout {
spacing: 4px;
for track[i] in root.tracks: Rectangle {
height: 48px;
background: track.current ? Theme.accent.with-alpha(0.18) : transparent;
border-radius: 14px;
border-width: root.sel-index == i ? 3px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 30px;
height: 30px;
border-radius: 15px;
background: track.current ? Theme.accent : Theme.ink.with-alpha(0.12);
Text {
text: i + 1;
font-size: Theme.text-tiny;
font-weight: 800;
color: track.current ? white : Theme.ink;
vertical-alignment: center;
horizontal-alignment: center;
}
}
}
VerticalLayout {
alignment: center;
Text {
text: track.title;
font-size: Theme.text-small + 1px;
font-weight: 700;
color: Theme.ink;
overflow: elide;
}
}
VerticalLayout {
alignment: center;
Text {
text: track.duration;
font-family: Theme.mono;
font-size: Theme.text-tiny;
font-weight: 700;
color: Theme.ink.with-alpha(0.6);
}
}
}
TouchArea {
clicked => { root.play(i); }
}
}
}
}
}
}
}

View File

@@ -0,0 +1,81 @@
// The palette, converted once from the web front-end's oklch tokens
// (`web/src/styles/app.css`) into the sRGB hex Slint speaks. The oklch source value is
// kept beside each one, because that is the form the design is actually maintained in -
// if a colour needs adjusting, adjust the oklch and re-convert (`src/oklch.rs`), rather
// than nudging the hex and letting the two drift.
export global Theme {
// ------------------------------------------------------------------ colours --
out property <color> ink: #113339; // oklch(30% 0.04 210)
out property <color> paper: #eef7f9; // oklch(97% 0.01 210)
out property <color> accent: #de73bd; // oklch(70% 0.16 340)
out property <color> accent-dim: #f292d3; // oklch(78% 0.14 340)
out property <color> sea-deep: #001b21; // oklch(20% 0.045 210)
out property <color> shadow: #001017; // oklch(15% 0.05 210)
// The stage gradient, which in the web app sits under an animated canvas. There is
// no canvas here: a full-viewport repaint loop is the one cost a Pi cannot absorb,
// and `?pi=1` already removes it on the device this replaces.
out property <color> stage-top: #397d88; // oklch(55% 0.07 210)
out property <color> stage-mid: #0e4b54; // oklch(38% 0.06 210)
out property <color> text-dim: #c9dbdf; // oklch(88% 0.02 210)
out property <color> text-sub: #c0d2d5; // oklch(85% 0.02 210)
out property <color> count-pink: #7d0563; // oklch(40% 0.17 340)
out property <color> card-music: #e4f1f4; // oklch(95% 0.015 210)
out property <color> card-book: #ffd5ab; // oklch(92% 0.09 55)
out property <color> book-edge-1: #ffc298; // oklch(86% 0.09 55)
out property <color> book-edge-2: #e5a880; // oklch(78% 0.09 55)
out property <color> bar-bg: #00171e; // oklch(18% 0.05 210)
out property <color> sheet-bg: #e9f4f6; // oklch(96% 0.012 210)
out property <color> backdrop: #000e12; // oklch(15% 0.03 210)
// Two track colours, not one with an alpha: the seek bar sits on the pale stage in
// the play view and on the near-black player bar in the browse view, and a single
// translucent fill that reads correctly on one is invisible on the other.
out property <color> progress-track: #1b3236; // oklch(30% 0.03 210)
out property <color> progress-track-dark: #2e4f57;
out property <color> vol-empty: #c9dbdf; // oklch(88% 0.02 210)
// Glass, without the glass. `backdrop-filter` has no counterpart here, so these are
// exactly the web app's own `data-blur="off"` fallback: a flat translucent fill.
// That is a real loss of depth and the honest substitute for it - faking a blur by
// compositing a pre-blurred copy would reintroduce the per-frame cost the blur was
// dropped to avoid.
out property <brush> glass: @linear-gradient(160deg, #eef7f924 0%, #eef7f90d 100%);
out property <color> glass-border: #eef7f929;
out property <color> row-idle: #f7fcfd1a;
out property <color> row-active: #f7fcfd38;
// ------------------------------------------------------------------- metrics --
out property <length> radius-panel: 22px;
out property <length> radius-card: 16px;
out property <length> radius-row: 12px;
out property <length> radius-sheet: 26px;
out property <length> pill: 999px;
// The album grid's track width. The web grid is `minmax(180px, 1fr)`; keeping the
// same number keeps the same number of columns at 1920, which is what the shelf
// rows and the measured card height below were both tuned against.
out property <length> card-width: 180px;
out property <length> grid-gap: 24px;
out property <length> shelf-tile: 148px;
// How much of the right edge the page must leave clear for nothing to render
// underneath the corner controls.
out property <length> rail-clearance: 88px;
// ---------------------------------------------------------------- typography --
// Heavy throughout: 700/800/900. Durations and counters use the monospace family so
// a ticking clock does not reflow the row it sits in.
out property <string> font: "Nunito";
out property <string> mono: "monospace";
out property <length> text-hero: 44px;
out property <length> text-title: 26px;
out property <length> text-heading: 20px;
out property <length> text-body: 16px;
out property <length> text-small: 13px;
out property <length> text-tiny: 12px;
}

View File

@@ -0,0 +1,299 @@
import { Theme } from "theme.slint";
// A frosted surface wrapped around a shelf or a list, so it reads as something
// floating over the stage rather than text on a gradient.
export component GlassPanel inherits Rectangle {
background: Theme.glass;
border-width: 1px;
border-color: Theme.glass-border;
border-radius: Theme.radius-panel;
drop-shadow-color: #00101759;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
}
// Album art. `source` is whatever the cover store has decoded; until it has one, the
// Rust side hands over generated art built from the album's own three colours, so this
// never has an empty frame to show.
//
// The shape comes from the media type and from nowhere else: square for an album,
// taller than wide for an audiobook, everywhere either appears.
export component Cover inherits Rectangle {
in property <image> source;
in property <bool> is-book: false;
in property <length> radius: Theme.radius-card;
clip: true;
border-radius: root.radius;
// A book keeps a sliver of its generated spine showing past real square artwork, so
// the shelf metaphor survives contact with a photograph.
background: root.is-book ? Theme.book-edge-2 : transparent;
Image {
source: root.source;
image-fit: cover;
width: root.is-book ? parent.width * 1.05 : parent.width;
height: parent.height;
x: 0;
}
}
// Prev / next / play. Every glyph is drawn from rectangles and a clip, not an icon
// font: there is no icon font on the device, and a missing glyph is a blank button.
export component TransportButton inherits Rectangle {
in property <length> size: 48px;
in property <bool> primary: false;
in property <bool> playing: false;
in property <string> direction: "play"; // "play" | "next" | "previous"
callback clicked();
width: root.size;
height: root.size;
border-radius: root.size / 2;
background: root.primary
? (touch.pressed ? Theme.accent-dim : Theme.accent)
: (touch.pressed ? #2a4f57 : Theme.ink);
drop-shadow-color: root.primary ? #de73bd80 : #00101766;
drop-shadow-blur: root.primary ? 20px : 10px;
drop-shadow-offset-y: 4px;
animate background { duration: 100ms; }
// Pause: two bars. Play and the skips: a triangle, clipped out of a square.
if root.direction == "play" && root.playing: HorizontalLayout {
alignment: center;
spacing: root.size * 0.1;
Rectangle {
width: root.size * 0.1;
height: root.size * 0.34;
border-radius: 2px;
background: white;
}
Rectangle {
width: root.size * 0.1;
height: root.size * 0.34;
border-radius: 2px;
background: white;
}
}
if root.direction != "play" || !root.playing: HorizontalLayout {
alignment: center;
spacing: root.size * 0.06;
// "Previous" is the same triangle mirrored, plus the bar it butts against.
if root.direction == "previous": Rectangle {
width: root.size * 0.07;
height: root.size * 0.3;
border-radius: 1px;
background: white;
}
Rectangle {
width: root.size * 0.3;
height: root.size * 0.3;
Path {
width: 100%;
height: 100%;
fill: white;
viewbox-width: 100;
viewbox-height: 100;
commands: root.direction == "previous"
? "M 100 0 L 100 100 L 0 50 Z"
: "M 6 0 L 100 50 L 6 100 Z";
}
}
if root.direction == "next": Rectangle {
width: root.size * 0.07;
height: root.size * 0.3;
border-radius: 1px;
background: white;
}
}
touch := TouchArea {
clicked => { root.clicked(); }
}
}
export component Transport inherits HorizontalLayout {
in property <bool> playing;
in property <length> size: 48px;
callback toggle();
callback next();
callback previous();
alignment: center;
spacing: root.size * 0.21;
VerticalLayout {
alignment: center;
TransportButton {
direction: "previous";
size: root.size;
clicked => { root.previous(); }
}
}
VerticalLayout {
alignment: center;
TransportButton {
direction: "play";
primary: true;
playing: root.playing;
// The play button is the one target a child aims at from across the room.
size: root.size * 1.26;
clicked => { root.toggle(); }
}
}
VerticalLayout {
alignment: center;
TransportButton {
direction: "next";
size: root.size;
clicked => { root.next(); }
}
}
}
// The seek bar.
//
// `position` is only pushed twice a second, so the fill is *animated* between frames
// rather than recomputed on a client-side clock. That puts the interpolation on the
// render side, where it costs a property evaluation per frame instead of a layout pass,
// and it is why there is no equivalent of the web client's `usePlaybackClock` here.
//
// The animation is suppressed whenever `smooth` goes false - while dragging, and for
// the frame a track changes on - because sliding the fill across two unrelated
// positions is exactly the "it moves slightly backwards" artefact the web UI has.
export component ProgressBar inherits Rectangle {
in property <float> progress; // 0..1
in property <bool> interactive: true;
in property <length> bar-height: 10px;
in property <bool> smooth: true;
/// Set on the player bar, whose background is darker than the seek bar's own
/// track would otherwise show against.
in property <bool> on-dark: false;
callback seek(float);
height: max(root.bar-height, 28px);
track := Rectangle {
y: (parent.height - root.bar-height) / 2;
height: root.bar-height;
width: 100%;
border-radius: root.bar-height / 2;
background: root.on-dark ? Theme.progress-track-dark : Theme.progress-track.with-alpha(0.6);
clip: true;
fill := Rectangle {
x: 0;
width: parent.width * clamp(root.progress, 0.0, 1.0);
height: 100%;
border-radius: root.bar-height / 2;
background: Theme.accent;
animate width {
duration: root.smooth ? 500ms : 0ms;
easing: linear;
}
}
}
// A grab handle only while it is worth having one: at player-bar size the bar is
// the target, and a knob would be smaller than the thing it sits on.
if root.interactive && root.bar-height >= 12px: Rectangle {
x: max(0px, min(parent.width - self.width, track.width * clamp(root.progress, 0.0, 1.0) - self.width / 2));
y: (parent.height - self.height) / 2;
width: root.bar-height * 1.7;
height: root.bar-height * 1.7;
border-radius: self.width / 2;
background: Theme.paper;
drop-shadow-color: #00101773;
drop-shadow-blur: 8px;
}
if root.interactive: TouchArea {
width: 100%;
height: 100%;
// Both a click and a drag resolve to "the position under the finger", so
// scrubbing works without a separate drag state machine.
moved => {
if (self.pressed) { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
}
clicked => { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
}
}
// Volume as five discrete steps, not a continuous slider: a child aiming at a bar hits
// a level, where a slider would hand back whatever pixel they landed on.
export component VolumeBars inherits HorizontalLayout {
in property <int> volume; // 0..100
in property <length> bar-width: 11px;
in property <length> bar-height: 30px;
callback set-volume(int);
callback mute();
spacing: root.bar-width * 0.3;
alignment: center;
VerticalLayout {
alignment: center;
Rectangle {
width: root.bar-height * 0.8;
height: root.bar-height * 0.8;
border-radius: self.width / 2;
background: speaker.has-hover ? #ffffff26 : transparent;
Text {
text: root.volume == 0 ? "🔇" : "🔊";
font-size: root.bar-width * 1.6;
vertical-alignment: center;
horizontal-alignment: center;
}
speaker := TouchArea {
clicked => { root.mute(); }
}
}
}
for level[i] in [20, 40, 60, 80, 100]: VerticalLayout {
alignment: center;
Rectangle {
width: root.bar-width;
// Stepped heights, so the control reads as a volume ramp at a glance.
height: root.bar-height * (0.45 + 0.55 * (i + 1) / 5);
border-radius: 5px;
background: root.volume >= level ? Theme.accent : Theme.vol-empty.with-alpha(0.4);
animate background { duration: 120ms; }
TouchArea {
clicked => { root.set-volume(level); }
}
}
}
}
// A round icon button - back, close, and the fixed corner controls.
export component RoundButton inherits Rectangle {
in property <string> label;
in property <length> size: 44px;
in property <bool> highlighted: false;
callback clicked();
width: root.size;
height: root.size;
border-radius: root.size / 2;
background: root.highlighted
? Theme.accent
: (touch.pressed ? Theme.text-dim : Theme.paper.with-alpha(0.95));
drop-shadow-color: #00101766;
drop-shadow-blur: 18px;
drop-shadow-offset-y: 6px;
Text {
text: root.label;
font-size: root.size * 0.45;
font-weight: 900;
color: root.highlighted ? white : Theme.ink;
vertical-alignment: center;
horizontal-alignment: center;
}
touch := TouchArea {
clicked => { root.clicked(); }
}
}

View File

@@ -39,6 +39,14 @@ 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
@@ -52,7 +60,8 @@ 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/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. |
@@ -84,6 +93,42 @@ pointer. `F1` shows the same list in-app.
`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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
web/public/hiddenalbum.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -4,8 +4,9 @@
// cached answer for any of those would be a lie.
const SHELL = "musikdelphin-shell-v1";
const COVERS = "musikdelphin-covers-v1";
// There is no cover cache any more; `activate` below deletes every cache that is not
// SHELL, which retires the two generations of cover cache this worker used to keep.
self.addEventListener("install", (event) => {
event.waitUntil(
caches
@@ -29,7 +30,7 @@ self.addEventListener("activate", (event) => {
caches
.keys()
.then((names) =>
Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))),
Promise.all(names.filter((n) => n !== SHELL).map((n) => caches.delete(n))),
)
.then(() => self.clients.claim()),
);
@@ -42,19 +43,22 @@ self.addEventListener("fetch", (event) => {
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Cover art is immutable per album id and is the only heavy thing here.
if (url.pathname.startsWith("/api/albums/")) {
event.respondWith(
caches.open(COVERS).then(async (cache) => {
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}),
);
return;
}
// Cover art is NOT cached here, and that is deliberate. This worker only ever
// registers on localhost or over HTTPS (see the README), which on this setup means
// the kiosk on the device itself - where the backend is the same machine, and a
// Cache Storage lookup plus a revalidating fetch plus a cache write is strictly more
// work than just asking for the file.
//
// It was measured, after a stale-while-revalidate version of this handler made
// searching visibly worse. Typing "conni" over a 343-album library, keydown to
// painted, worst keystroke: 5580 ms through this worker against 461 ms with
// Network.setBypassServiceWorker on. The main thread was not running any JavaScript
// during those stalls - it sat waiting while ImageDecodeTask blocked on bytes this
// worker was serialising through a single thread.
//
// If the player ever moves behind a TLS proxy for the tablet, revisit: caching covers
// is worth something over wifi, and would want a budget rather than every cover on
// every load.
// Everything else under /api is live state. Never cache it, never serve it stale.
if (url.pathname.startsWith("/api/")) return;

View File

@@ -34,8 +34,8 @@ import { browseBackActions, GROUP_ORDER, handleKey, initialUiState, isRootShelf
import { playPop } from "./lib/pop";
import { targetForAlbum } from "./lib/remote";
import type { Group, Results, SongHit } from "./lib/search";
import { categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { SHOW_AMBIENCE, SHOW_GLASS_BLUR } from "./lib/theme";
import { buildSearchIndex, categoryMatches, groupOf, results as computeResults } from "./lib/search";
import { SHOW_AMBIENCE, SHOW_CARD_BLUR, SHOW_GLASS_BLUR } from "./lib/theme";
/** How long an armed "A" waits for the digit that completes the shortcut. */
const ASSIGN_PENDING_TIMEOUT_MS = 4000;
@@ -92,33 +92,38 @@ export function App() {
setUi((previous) => (previous.cols === columns ? previous : { ...previous, cols: columns }));
}, [columns]);
// Normalized search keys, group partitions and each shelf's category list, derived
// once per library payload instead of per keystroke - see lib/search.ts. The library
// only reloads when the backend says a rescan finished, so this is genuinely rare.
const index = useMemo(() => buildSearchIndex(library.albums), [library.albums]);
const results: Results = useMemo(
() =>
computeResults({
albums: library.albums,
index,
search: ui.search,
mode: ui.mode,
group: ui.group,
category: ui.category,
}),
[library.albums, ui.search, ui.mode, ui.group, ui.category],
[index, ui.search, ui.mode, ui.group, ui.category],
);
// The bare root's three shelves each compute their own categories independently
// (see BrowseView) - the keyboard handler needs the same lists, by key only, to know
// how far Ctrl+hjkl can move within a row and which category ENTER opens.
// The bare root's three shelves, by category key only: the keyboard handler needs
// these to know how far Ctrl+hjkl can move within a row and which category ENTER
// opens. BrowseView renders the same shelves from the same precomputed lists.
const rootShelves = useMemo(
() =>
GROUP_ORDER.map((group) =>
categoryMatches({
albums: library.albums,
index,
search: "",
mode: "albums",
category: null,
group,
}).map((entry) => entry.key),
),
[library.albums],
[index],
);
const byId = useMemo(
@@ -407,7 +412,11 @@ export function App() {
setUi((previous) => ({ ...previous, page: previous.page === page ? "music" : page }));
return (
<div className="stage" data-blur={SHOW_GLASS_BLUR ? undefined : "off"}>
<div
className="stage"
data-blur={SHOW_GLASS_BLUR ? undefined : "off"}
data-cardblur={SHOW_CARD_BLUR ? undefined : "off"}
>
{state && SHOW_AMBIENCE && (
<Ambience
album={currentAlbum}
@@ -432,7 +441,7 @@ export function App() {
<BrowseView
results={results}
group={ui.group}
albums={library.albums}
index={index}
mode={ui.mode}
search={ui.search}
category={ui.category}

View File

@@ -158,7 +158,7 @@ export interface LircConfig {
// Mirrors the `Tippen*` schemas in `musicmouse/services/web/schemas.py`.
export type TippenLessonKind = "letters" | "fragments" | "words" | "sentences";
export type TippenModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
export type TippenModeId = "dive" | "bubbles" | "feed" | "race";
/** What a lesson's `unlocks:` resolves to right now. `resolved: false` means the
* configured path matches nothing in the current library. */
@@ -236,7 +236,6 @@ export interface TippenSettings {
export interface TippenProgress {
lessons: Record<string, TippenLessonProgress>;
key_stats: Record<string, TippenKeyStat>;
pearls: number;
aquarium: string[];
streak: TippenStreak;
settings: TippenSettings;
@@ -257,7 +256,6 @@ export interface TippenRunInput {
animal: string;
points: number;
passed: boolean;
pearls: number;
strokes: TippenStroke[];
}

View File

@@ -41,6 +41,7 @@ import {
type Oklch,
} from "../lib/ambience";
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
import { AMBIENCE_QUALITY } from "../lib/lowPower";
import { groupOf, type Group } from "../lib/search";
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
@@ -247,16 +248,22 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
// can't feed back into the size it just measured (see the comment there). The
// clamp is a second line of defence against the same failure mode from anywhere
// else: an unclamped size can hit the browser's canvas allocation limit and throw.
const MAX_BACKING_STORE_PX = 4096;
//
// `resolutionScale` below 1 (the `?pi=1` profile) makes the backing store smaller
// than the canvas is on screen and lets the browser scale it back up. Everything
// in `tick` still works in CSS-pixel units, because the transform is derived from
// whatever the backing store ended up being rather than from `dpr`.
const quality = AMBIENCE_QUALITY;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const dpr = (window.devicePixelRatio || 1) * quality.resolutionScale;
const rect = canvas.getBoundingClientRect();
size.width = rect.width;
size.height = rect.height;
canvas.width = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.width * dpr)));
canvas.height = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.height * dpr)));
// Scale factors from the (possibly clamped) backing store, not raw `dpr`, so
// drawing in CSS-pixel units still lands correctly even when clamped.
const cap = quality.maxBackingStorePx;
canvas.width = Math.min(cap, Math.max(1, Math.round(rect.width * dpr)));
canvas.height = Math.min(cap, Math.max(1, Math.round(rect.height * dpr)));
// Scale factors from the (possibly clamped, possibly downscaled) backing store,
// not raw `dpr`, so drawing in CSS-pixel units still lands correctly.
const scaleX = canvas.width / Math.max(1, rect.width);
const scaleY = canvas.height / Math.max(1, rect.height);
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
@@ -312,7 +319,16 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
smoothedDrive,
);
// A frame budget, not a timer: rAF still drives everything, this just returns
// early until enough time has passed. The 1ms slack stops a 30fps budget from
// landing just inside a 60Hz vsync interval and silently halving again to 20.
const minFrameMs = quality.maxFps > 0 ? 1000 / quality.maxFps - 1 : 0;
const tick = (now: number) => {
if (now - lastFrame < minFrameMs) {
raf = requestAnimationFrame(tick);
return;
}
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
lastFrame = now;
@@ -386,6 +402,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
while (spawnAccumulator >= 1) {
spawnAccumulator -= 1;
// The cap drops the *newest* bubble rather than evicting an old one, so a
// loud passage thins the field out instead of making it flicker.
if (quality.maxBubbles > 0 && bubbles.length >= quality.maxBubbles) break;
bubbles.push(spawnBubble(size.width));
}
}
@@ -402,7 +421,9 @@ export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
});
ctx.clearRect(0, 0, size.width, size.height);
// No `clearRect`: the gradient below is fully opaque (`oklchString` defaults to
// alpha 1) and covers the whole canvas, so clearing first is a second
// full-screen write per frame that nothing can ever see.
const [top, mid, deep] = current.gradient;
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
gradient.addColorStop(0, oklchString(top));

View File

@@ -18,7 +18,7 @@ import {
unitLabel,
} from "../lib/covers";
import { clock } from "../lib/format";
import type { Category, Group, Results, SongHit } from "../lib/search";
import type { Category, Group, Results, SearchIndex, SongHit } from "../lib/search";
import { categoryMatches, groupOf } from "../lib/search";
import {
ANIMATE_VIEW_TRANSITIONS,
@@ -37,7 +37,7 @@ import { Cover } from "./Cover";
interface Props {
results: Results;
group: Group | null;
albums: Album[];
index: SearchIndex;
mode: "albums" | "tracks";
search: string;
category: string | null;
@@ -76,7 +76,7 @@ const GROUP_SECTION_LABEL: Record<Group, string> = {
export function BrowseView({
results,
group,
albums,
index,
mode,
search,
category,
@@ -114,14 +114,14 @@ export function BrowseView({
GROUPS.map((shelfGroup) => ({
group: shelfGroup,
categories: categoryMatches({
albums,
index,
search: "",
mode: "albums",
category: null,
group: shelfGroup,
}),
})),
[albums],
[index],
);
// Keep the selection on screen as the arrow keys walk past the fold.

View File

@@ -26,8 +26,8 @@ interface Props {
style?: CSSProperties;
/** Print the title over generated art. Off for thumbnails, where it would not fit. */
label?: boolean;
/** A reward-gated album/track not yet earned - shows a question mark instead of the
* real art or title, regardless of `has_cover`. */
/** A reward-gated album/track not yet earned - shows a placeholder cover instead of
* the real art or title, regardless of `has_cover`. */
locked?: boolean;
}
@@ -53,20 +53,18 @@ export function Cover({ album, size, fit = "width", radius, className, style, la
}}
>
{locked ? (
<div
<img
src={book ? "/hidden_audiobook_or_podcast.png" : "/hiddenalbum.png"}
alt=""
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: Math.max(22, Math.round(size / 2.2)),
color: "oklch(99% 0 0 / .85)",
textShadow: "0 2px 8px oklch(15% 0.05 210 / .6)",
width: "100%",
height: "100%",
objectFit: "cover",
clipPath: book ? "inset(0 5% 0 0)" : undefined,
}}
>
</div>
/>
) : (
<>
{!album.has_cover && label && (

View File

@@ -1,12 +1,13 @@
/** The typing game, as a tab of the music player rather than its own app.
*
* Ported from the standalone tippen app's App.tsx - same shape (one state object, one
* keydown listener for navigation, four screens) - with two changes: curriculum and
* keydown listener for navigation, screens) - with two changes: curriculum and
* progress are now fetched from the backend instead of a build-time YAML import and
* localStorage (see hooks/useTippenCurriculum.ts and hooks/useTippenProgress.ts), and
* `onExit` is the new base case for "back" - Escape/the map's own navigation peel one
* layer at a time, same as before, but the aquarium screen is no longer the floor: one
* more Escape leaves the tab entirely, back to the music player.
* `onExit` is the new base case for "back". The lesson map is the floor screen - the
* swimming aquarium creatures already show behind it, so there is no separate landing
* screen in front of it - and one Escape from the map leaves the tab entirely, back to
* the music player.
*
* The rule that still matters most: **while a run is going, every key belongs to the
* run**. This component's own keydown listener only ever intercepts Escape and F1 - the
@@ -18,36 +19,37 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTippenCurriculum } from "../hooks/useTippenCurriculum";
import { useTippenProgress } from "../hooks/useTippenProgress";
import { Aquarium } from "./tippen/Aquarium";
import { AppHeader } from "./tippen/AppHeader";
import { HelpOverlay } from "./tippen/HelpOverlay";
import { LessonMap } from "./tippen/LessonMap";
import { ResultSheet } from "./tippen/ResultSheet";
import { RewardUnlockOverlay } from "./tippen/RewardUnlockOverlay";
import { Stage } from "./tippen/Stage";
import { BubblesRun } from "./tippen/modes/BubblesRun";
import { DiveRun } from "./tippen/modes/DiveRun";
import { FeedRun } from "./tippen/modes/FeedRun";
import { JellyfishRun } from "./tippen/modes/JellyfishRun";
import { RaceRun } from "./tippen/modes/RaceRun";
import type { CreatureId } from "../lib/tippen/aquarium";
import { creatureById } from "../lib/tippen/aquarium";
import type { Lesson, ModeId } from "../lib/tippen/curriculum";
import { lessonById, nextLesson } from "../lib/tippen/curriculum";
import { letterStream, lineFor, lineText, mulberry32 } from "../lib/tippen/generator";
import { animalById } from "../lib/tippen/grading";
import type { RunResult } from "../lib/tippen/grading";
import { playFanfare, playPop } from "../lib/tippen/pop";
import { focusKeyFor, overallBestAnimal } from "../lib/tippen/progress";
import type { UnlockedReward } from "../lib/tippen/progress";
import { bubbleCountFor } from "../lib/tippen/theme";
type Screen = "aquarium" | "map" | "run";
type Screen = "map" | "run";
/** What a mode is handed to draw - see the original App.tsx for why there are only two
* shapes for five modes. */
* shapes for four modes. */
type RunTarget =
| { kind: "letters"; letters: readonly string[] }
| { kind: "text"; chunks: readonly string[]; text: string; spaceActive: boolean };
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles", "jellyfish"];
const LETTER_ONLY_MODES: readonly ModeId[] = ["bubbles"];
const SHARE_FOR_EMPHASIS: Record<"isolated" | "mixed", number> = { isolated: 0.75, mixed: 0.4 };
@@ -68,10 +70,14 @@ export function TippenApp({ onExit }: Props) {
const configured = curriculum !== null;
const { progress, loading: progressLoading, recordRun, saveSettings } = useTippenProgress(configured);
const [screen, setScreen] = useState<Screen>("aquarium");
const [screen, setScreen] = useState<Screen>("map");
const [lessonId, setLessonId] = useState<string | null>(null);
const [mode, setMode] = useState<ModeId>("dive");
const [outcome, setOutcome] = useState<Outcome | null>(null);
/** The unlock celebration, in front of the result sheet - see RewardUnlockOverlay.
* Separate from `outcome.unlockedReward` because it is dismissed on its own, leaving
* the result sheet (and its recap of the same reward) behind it. */
const [celebration, setCelebration] = useState<UnlockedReward | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [selected, setSelected] = useState(0);
/** Bumped to generate a fresh line - a new seed for the same lesson. */
@@ -118,6 +124,7 @@ export function TippenApp({ onExit }: Props) {
}, [lesson, mode, round]);
const start = useCallback((lesson: Lesson) => {
setCelebration(null);
setLessonId(lesson.id);
setMode(lesson.primaryMode);
setOutcome(null);
@@ -136,10 +143,11 @@ export function TippenApp({ onExit }: Props) {
isNewBest: recorded.isNewBest,
unlockedReward: recorded.unlockedReward,
});
if (
progress.settings.sound &&
(recorded.unlockedLessonId || recorded.newCreature || recorded.unlockedReward)
) {
// The unlock celebration brings its own sounds (and is the bigger moment), so
// the generic fanfare would only step on its opening. One or the other.
if (recorded.unlockedReward) {
setCelebration(recorded.unlockedReward);
} else if (progress.settings.sound && (recorded.unlockedLessonId || recorded.newCreature)) {
playFanfare();
}
});
@@ -148,6 +156,7 @@ export function TippenApp({ onExit }: Props) {
);
const retry = useCallback(() => {
setCelebration(null);
setOutcome(null);
setRound((r) => r + 1);
}, []);
@@ -156,6 +165,7 @@ export function TippenApp({ onExit }: Props) {
* unlock: `onFinished` still runs underneath, so a great bonus run can only improve
* the best score, not change what is unlocked. */
const playBonus = useCallback((bonusMode: ModeId) => {
setCelebration(null);
setMode(bonusMode);
setOutcome(null);
setRound((r) => r + 1);
@@ -163,22 +173,23 @@ export function TippenApp({ onExit }: Props) {
const continueAfterResult = useCallback(() => {
const next = lessonId && curriculum ? nextLesson(curriculum, lessonId) : null;
setCelebration(null);
setOutcome(null);
if (next && progress?.lessons[next.id]?.unlocked) start(next);
else setScreen("map");
}, [lessonId, curriculum, progress, start]);
const goBack = useCallback(() => {
if (celebration) return setCelebration(null);
if (outcome) return setOutcome(null);
if (screen === "run") return setScreen("map");
if (screen === "map") return setScreen("aquarium");
if (screen === "aquarium") return onExit();
}, [outcome, screen, onExit]);
if (screen === "map") return onExit();
}, [celebration, outcome, screen, onExit]);
// --- navigation keys -----------------------------------------------------
const latest = useRef({ screen, outcome, selected, goBack, retry, nextUp, start, curriculum });
latest.current = { screen, outcome, selected, goBack, retry, nextUp, start, curriculum };
const latest = useRef({ screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum });
latest.current = { screen, outcome, celebration, selected, goBack, retry, nextUp, start, curriculum };
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -196,6 +207,17 @@ export function TippenApp({ onExit }: Props) {
return;
}
// Enter dismisses the unlock celebration. It must be handled before the result
// sheet's own Enter below, or finishing a reward run would restart it instantly
// from behind the overlay.
if (current.celebration) {
if (event.key === "Enter") {
event.preventDefault();
setCelebration(null);
}
return;
}
// Enter repeats a finished run; the result sheet's own button has focus, so this
// is only a fallback for when focus has been lost.
if (current.outcome) {
@@ -212,12 +234,8 @@ export function TippenApp({ onExit }: Props) {
if (event.key === "Enter") {
event.preventDefault();
if (current.screen === "aquarium") {
if (current.nextUp) current.start(current.nextUp);
} else {
const lesson = current.curriculum.lessons[current.selected];
if (lesson) current.start(lesson);
}
const lesson = current.curriculum.lessons[current.selected];
if (lesson) current.start(lesson);
return;
}
@@ -239,6 +257,7 @@ export function TippenApp({ onExit }: Props) {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (screen === "run" && !outcome) return;
if (celebration) return;
if (!progress) return;
const key = event.key.toLowerCase();
if (key === "m") void saveSettings({ ...progress.settings, sound: !progress.settings.sound });
@@ -251,7 +270,7 @@ export function TippenApp({ onExit }: Props) {
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [screen, outcome, progress, saveSettings]);
}, [screen, outcome, celebration, progress, saveSettings]);
// --- render --------------------------------------------------------------
@@ -274,6 +293,7 @@ export function TippenApp({ onExit }: Props) {
}
const next = lessonId ? nextLesson(curriculum, lessonId) : null;
const bestAnimal = overallBestAnimal(progress);
// Every mode takes the same bundle; only the drawing differs.
const shared = { activeKeys: lesson?.activeKeys ?? [], progress, paused: outcome !== null, onFinished };
@@ -292,34 +312,58 @@ export function TippenApp({ onExit }: Props) {
compact={screen === "run"}
status={
<div style={{ display: "flex", gap: 12, alignItems: "center", color: "var(--paper)", fontWeight: 800 }}>
<span>🦪 {progress.pearls}</span>
{screen === "map" && (
<>
<div className="tp-map-creatures">
{curriculum.worlds.map((world) => {
const creature = creatureById(world.reward);
const owned = progress.aquarium.includes(creature.id);
return (
<img
key={creature.id}
src={creature.image}
alt=""
title={owned ? creature.name : `Welt ${world.number}`}
style={{
width: 22,
height: 22,
objectFit: "contain",
filter: owned ? "none" : "brightness(0) invert(1)",
opacity: owned ? 1 : 0.3,
}}
/>
);
})}
</div>
<span title="Tage am Stück">🔥 {progress.streak.days}</span>
{bestAnimal && (
<span title="Schnellstes Tier">
{animalById(bestAnimal).emoji} {animalById(bestAnimal).name}
</span>
)}
</>
)}
{!progress.settings.sound && <span title="Ton aus">🔇</span>}
</div>
}
/>
{screen === "aquarium" && (
<Aquarium
{screen === "map" && (
<LessonMap
worlds={curriculum.worlds}
lessons={curriculum.lessons}
progress={progress}
selected={selected}
onPick={start}
nextLesson={nextUp}
onContinue={() => nextUp && start(nextUp)}
onOpenMap={() => setScreen("map")}
/>
)}
{screen === "map" && (
<LessonMap worlds={curriculum.worlds} lessons={curriculum.lessons} progress={progress} selected={selected} onPick={start} />
)}
{screen === "run" && lesson && run && (
<>
{run.kind === "letters" ? (
mode === "jellyfish" ? (
<JellyfishRun {...letterProps(run.letters)} />
) : (
<BubblesRun {...letterProps(run.letters)} />
)
<BubblesRun {...letterProps(run.letters)} />
) : mode === "feed" ? (
<FeedRun {...textProps(run)} />
) : mode === "race" ? (
@@ -337,7 +381,7 @@ export function TippenApp({ onExit }: Props) {
newCreature={outcome.newCreature}
unlockedReward={outcome.unlockedReward}
isNewBest={outcome.isNewBest}
bestEver={overallBestAnimal(progress)}
bestEver={bestAnimal}
bonusModes={outcome.result.passed ? lesson.bonusModes : []}
onPlayBonus={playBonus}
onRetry={retry}
@@ -346,6 +390,14 @@ export function TippenApp({ onExit }: Props) {
/>
)}
{celebration && (
<RewardUnlockOverlay
reward={celebration}
sound={progress.settings.sound}
onClose={() => setCelebration(null)}
/>
)}
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</Stage>
);

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
interface Props {
title?: string;
/** Shown on the right - pearls, streak, a back hint. */
/** Shown on the right - streak, best animal, a back hint. */
status?: ReactNode;
/** Smaller header while a lesson is running, so the target line gets the room. */
compact?: boolean;

View File

@@ -1,136 +0,0 @@
/** The home screen: what she has collected, before she is asked to do anything.
*
* Deliberately the first thing on opening the app. The reward for finishing a world is a
* pet that moves in for good - it swims behind every screen from then on (see
* AquariumCreatures.tsx) - and a reward you can see before you start is worth more than
* one you are told about afterwards.
*
* The panel shows every pet there is to earn: the ones at home in colour, the rest as
* pale outlines. The same idea as the animal ladder - the next thing has to be visible to
* be worth aiming at - while the outline alone keeps a little surprise for the arrival. */
import { creatureById } from "../../lib/tippen/aquarium";
import type { Lesson, World } from "../../lib/tippen/curriculum";
import { animalById } from "../../lib/tippen/grading";
import { overallBestAnimal } from "../../lib/tippen/progress";
import type { Progress } from "../../lib/tippen/progress";
interface Props {
worlds: readonly World[];
progress: Progress;
/** The lesson the "Weiter üben" button jumps to - the first unfinished one. */
nextLesson: Lesson | null;
onContinue: () => void;
onOpenMap: () => void;
}
export function Aquarium({ worlds, progress, nextLesson, onContinue, onOpenMap }: Props) {
const bestAnimal = overallBestAnimal(progress);
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: "0 32px 32px",
minHeight: 0,
}}
>
<div
className="tp-glass-panel"
style={{ padding: "26px 34px", width: "min(680px, 100%)", textAlign: "center" }}
>
<div style={{ fontSize: 15, fontWeight: 900, color: "var(--paper)", opacity: 0.8 }}>
Dein Aquarium
</div>
<div
aria-label="Deine Tiere"
style={{
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
gap: 18,
margin: "16px 0 6px",
alignItems: "center",
}}
>
{worlds.map((world) => {
const creature = creatureById(world.reward);
const owned = progress.aquarium.includes(creature.id);
return (
<img
key={creature.id}
src={creature.image}
alt={owned ? creature.name : `Noch nicht da - Welt ${world.number}`}
title={owned ? creature.name : `Welt ${world.number}`}
style={{
width: 62,
height: 62,
objectFit: "contain",
// Not yet earned: a pale outline of the shape, no colours given away.
filter: owned ? "none" : "brightness(0) invert(1)",
opacity: owned ? 1 : 0.2,
animation: owned ? `dolphinBob ${3 + world.number * 0.4}s ease-in-out infinite` : undefined,
}}
/>
);
})}
</div>
{progress.aquarium.length === 0 && (
<div style={{ fontSize: 15, fontWeight: 800, color: "var(--paper)", opacity: 0.65 }}>
Schaffe eine ganze Welt und dein erstes Tier zieht ein!
</div>
)}
<div style={{ display: "flex", justifyContent: "center", gap: 30, marginTop: 14 }}>
<Stat label="Perlen" value={`🦪 ${progress.pearls}`} />
<Stat label="Tage am Stück" value={`🔥 ${progress.streak.days}`} />
<Stat
label="Schnellstes Tier"
value={bestAnimal ? `${animalById(bestAnimal).emoji} ${animalById(bestAnimal).name}` : "—"}
/>
</div>
</div>
<div style={{ display: "flex", gap: 12 }}>
{nextLesson && (
<button
onClick={onContinue}
style={{
border: "none",
borderRadius: 999,
padding: "15px 32px",
fontSize: 19,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
}}
>
{nextLesson.title}
</button>
)}
<button className="tp-pill" onClick={onOpenMap} style={{ fontSize: 16, padding: "15px 26px" }}>
🗺 Alle Lektionen
</button>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: 19, fontWeight: 900, color: "var(--paper)" }}>{value}</div>
<div style={{ fontSize: 11, fontWeight: 800, color: "var(--paper)", opacity: 0.6 }}>{label}</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@
import { useEffect, useRef } from "react";
import { LOW_POWER } from "../../lib/lowPower";
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
@@ -37,8 +38,11 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
useEffect(() => {
// Reduced motion: every pet is still placed and shown, it just holds still.
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Reduced motion: every pet is still placed and shown, it just holds still. `?pi=1`
// takes the same door: on that device the point is that nothing runs a frame loop,
// and the pets are the reward - they should be *there*, they just need not swim.
const reducedMotion =
LOW_POWER || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let frame = 0;
let lastTime = performance.now();

View File

@@ -1,15 +1,29 @@
/** The map: a single winding path, one world section at a time.
*
* The floor screen of the tippen tab - the swimming aquarium creatures already show
* behind it, so there is no separate landing screen in front of this one anymore.
*
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
* waiting is half the reason to finish the world that is open. Each node carries its own
* best animal, star count and a badge for which game it plays, so the map doubles as
* both a path forward and a trophy cabinet. */
* both a path forward and a trophy cabinet.
*
* A lesson that unlocks real music or an audiobook chapter carries a treasure chest,
* drawn *outside* its node so the dimming a locked node gets never touches it: the whole
* value of the chest is that it is visible from far off, on lessons she cannot play yet.
* Shut while the reward is still to be won, open with the cover art inside it once it has
* been - same reasoning as the dimmed-not-hidden lessons, except stronger here, because
* this is the one reward that reaches outside the game. */
import { Fragment } from "react";
import { coverUrl } from "../../api/client";
import type { Lesson, World } from "../../lib/tippen/curriculum";
import { animalById } from "../../lib/tippen/grading";
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
import { MODE_INFO } from "../../lib/tippen/modeInfo";
import type { Progress } from "../../lib/tippen/progress";
import { TreasureChest } from "./TreasureChest";
interface Props {
worlds: readonly World[];
@@ -18,9 +32,15 @@ interface Props {
/** Which card the keyboard selection is on. */
selected: number;
onPick: (lesson: Lesson) => void;
/** The lesson the floating "Weiter üben" button jumps to - the first unfinished one. */
nextLesson: Lesson | null;
onContinue: () => void;
}
const NODE_SIZE = 88;
/** Big enough to read as a treasure chest at a glance, scrolling past - the 15px 🎁 this
* replaced was invisible in practice. */
const CHEST_SIZE = 46;
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
const PATH_HALF_WIDTH = 160;
@@ -32,7 +52,7 @@ const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> =
sentences: "Sätze",
};
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
export function LessonMap({ worlds, lessons, progress, selected, onPick, nextLesson, onContinue }: Props) {
return (
<div
className="view-enter"
@@ -88,78 +108,90 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
const modeInfo = MODE_INFO[lesson.primaryMode];
return (
<button
key={lesson.id}
className="card tp-glass-panel"
data-selected={index === selected}
data-locked={locked}
disabled={locked}
onClick={() => onPick(lesson)}
title={lesson.title}
style={{
position: "absolute",
left: `calc(50% + ${point.x}px)`,
top: point.y,
transform: "translate(-50%, -50%)",
width: NODE_SIZE,
height: NODE_SIZE,
borderRadius: "50%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 0,
textAlign: "center",
}}
>
<span
aria-hidden
// The node and its chest are two siblings, both positioned against
// this world's own box - see RewardChest for why the chest must not
// be a child of the node.
<Fragment key={lesson.id}>
<button
className="card tp-glass-panel"
data-selected={index === selected}
data-locked={locked}
disabled={locked}
onClick={() => onPick(lesson)}
title={lesson.title}
style={{
position: "absolute",
top: -4,
right: -4,
fontSize: 15,
filter: locked ? "grayscale(1)" : "none",
opacity: locked ? 0.4 : 0.9,
}}
title={modeInfo.name}
>
{modeInfo.emoji}
</span>
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
{/* The keys themselves: for a pre-reader this is the real label, the
title is decoration. A drill has no new keys, so it says so with
a symbol instead of showing an empty line. */}
<span
style={{
fontSize: 11,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.8,
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
lineHeight: 1.1,
left: `calc(50% + ${point.x}px)`,
top: point.y,
transform: "translate(-50%, -50%)",
width: NODE_SIZE,
height: NODE_SIZE,
borderRadius: "50%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 0,
textAlign: "center",
}}
>
{lesson.isDrill
? "🔁"
: lesson.newKeys.length > 0
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
: lesson.kind === "letters"
? "üben"
: CONSOLIDATION_LABEL[lesson.kind]}
</span>
<span
aria-hidden
style={{
position: "absolute",
top: -4,
right: -4,
fontSize: 15,
filter: locked ? "grayscale(1)" : "none",
opacity: locked ? 0.4 : 0.9,
}}
title={modeInfo.name}
>
{modeInfo.emoji}
</span>
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
{[1, 2, 3].map((star) => (
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
</span>
))}
</span>
</button>
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
{/* The keys themselves: for a pre-reader this is the real label, the
title is decoration. A drill has no new keys, so it says so with
a symbol instead of showing an empty line. */}
<span
style={{
fontSize: 11,
fontWeight: 800,
color: "var(--paper)",
opacity: 0.8,
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
lineHeight: 1.1,
}}
>
{lesson.isDrill
? "🔁"
: lesson.newKeys.length > 0
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
: lesson.kind === "letters"
? "üben"
: CONSOLIDATION_LABEL[lesson.kind]}
</span>
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
{[1, 2, 3].map((star) => (
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
</span>
))}
</span>
</button>
{lesson.reward.resolved && (
<RewardChest
point={point}
earned={entry?.earned ?? false}
albumId={lesson.reward.hasCover ? lesson.reward.albumId : null}
/>
)}
</Fragment>
);
})}
</div>
@@ -167,6 +199,100 @@ export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props
);
})}
</div>
{nextLesson && (
<button
onClick={onContinue}
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
border: "none",
borderRadius: 999,
padding: "15px 32px",
fontSize: 19,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
}}
>
{nextLesson.title}
</button>
)}
</div>
);
}
/** A lesson's treasure chest, sitting on the rim of its node.
*
* A sibling of the node button rather than a child, for one reason that is easy to lose
* again later: `.card[data-locked="true"]` dims the whole button to 45% opacity, and the
* chest that most needs to be bright is exactly the one on a lesson three worlds away.
* `pointerEvents: none` hands clicks straight through to the node underneath, so the
* chest never becomes a second, dead target beside the real one.
*
* Open, with the cover art of what it gave inside it, once the lesson is earned: it stops
* being a promise and becomes the trophy, which makes scrolling the map also a way of
* seeing everything she has won. */
function RewardChest({
point,
earned,
albumId,
}: {
point: { x: number; y: number };
earned: boolean;
albumId: string | null;
}) {
return (
<div
style={{
position: "absolute",
// On the node's lower-right rim, overlapping it a little so the two read as one
// object rather than as a badge parked nearby.
left: `calc(50% + ${point.x + NODE_SIZE / 2 + 2}px)`,
top: point.y + NODE_SIZE / 2 - 6,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
zIndex: 1,
filter: "drop-shadow(0 3px 6px oklch(15% 0.05 175 / 0.55))",
}}
title={earned ? "Freigespielt! Im Musik-Player zu hören" : "Hier gibt es neue Musik zu gewinnen!"}
>
{/* Shut, the chest is drawn in one piece, because a shut lid has to cover the body's
top edge. Won, it is split around the cover art so the cover sits *in* the chest -
behind the front wall, under the thrown-back lid - rather than on top of it. See
TreasureChest's `layer`. */}
{earned && albumId ? (
<div style={{ position: "relative", width: CHEST_SIZE, height: CHEST_SIZE }}>
<TreasureChest size={CHEST_SIZE} open layer="back" style={{ position: "absolute", inset: 0 }} />
<img
src={coverUrl(albumId)}
alt=""
// A cover that fails to load must not leave a broken-image glyph in the chest:
// the open chest on its own still says "won", which is the part that matters.
onError={(event) => {
event.currentTarget.style.display = "none";
}}
style={{
position: "absolute",
left: "50%",
top: "34%",
transform: "translate(-50%, -50%)",
width: CHEST_SIZE * 0.46,
height: CHEST_SIZE * 0.46,
objectFit: "cover",
borderRadius: 4,
border: "1.5px solid oklch(92% 0.13 92 / 0.9)",
}}
/>
<TreasureChest size={CHEST_SIZE} open layer="front" style={{ position: "absolute", inset: 0 }} />
</div>
) : (
<TreasureChest size={CHEST_SIZE} open={earned} muted={!earned} />
)}
</div>
);
}

View File

@@ -93,7 +93,6 @@ export function ResultSheet({
<div style={{ display: "flex", justifyContent: "center", gap: 26, marginTop: 12 }}>
<Stat label="Richtig" value={`${Math.round(result.accuracy * 100)}%`} />
<Stat label="Zeichen/Min" value={String(Math.round(result.speed))} />
<Stat label="Perlen" value={`+${result.pearls}`} />
</div>
{/* How close the next animal is. A near miss is the strongest reason to press
@@ -149,34 +148,42 @@ export function ResultSheet({
{creatureById(newCreature).article} {creatureById(newCreature).name} ist ins Aquarium gezogen!
</div>
)}
{/* The recap, not the reveal. RewardUnlockOverlay has already given this its own
full-screen celebration by the time this sheet is visible, so here it only has
to stay on the page as a reminder of what she just won - which is also why it
is the one badge on this sheet that gets a tinted row of its own rather than a
line of text. */}
{unlockedReward && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--ink)",
fontWeight: 900,
marginTop: 8,
gap: 12,
marginTop: 12,
padding: "10px 14px",
borderRadius: 16,
textAlign: "left",
background: "oklch(93% 0.06 92)",
border: "1px solid oklch(80% 0.12 90)",
}}
>
{unlockedReward.hasCover ? (
<img
src={coverUrl(unlockedReward.albumId)}
alt=""
style={{
width: 52,
height: 52,
objectFit: "cover",
borderRadius: 8,
animation: "tierEnter 520ms ease-out",
}}
style={{ width: 52, height: 52, objectFit: "cover", borderRadius: 8, flexShrink: 0 }}
/>
) : (
<span style={{ fontSize: 40, animation: "tierEnter 520ms ease-out" }}>🎁</span>
<span style={{ fontSize: 40, flexShrink: 0 }}>🎵</span>
)}
🎁 Neu zum Anhören: {unlockedReward.title}
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 800, color: "var(--ink)", opacity: 0.7 }}>
Freigespielt ab jetzt im Musik-Player
</div>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--ink)" }}>
{unlockedReward.title}
</div>
</div>
</div>
)}
{!result.passed && !unlockedTitle && (

View File

@@ -0,0 +1,409 @@
/** The unlock celebration: a treasure chest that opens and hands back real music.
*
* This is the one moment the typing game exists for. Everything else it gives out -
* stars, animals, aquarium creatures - lives inside the game; this is the only reward
* that reaches outside it, into the music player. So it gets the whole screen, its own
* tune (`playRewardJingle`), and a sequence that has to be waited out, rather than being
* one more badge on the result sheet. The result sheet stays mounted underneath and is
* revealed when this closes, so nothing is lost by putting this in front of it.
*
* The staging, and why each beat is there:
*
* 0ms "drop" the chest falls in, shut, and rattles. The rattle is the beat that
* earns the opening - something inside wants out.
* 1150ms "open" the lid swings, light bursts, confetti starts, the tune starts.
* 1500ms "reveal" the cover art rises out of the chest; the title and the button
* arrive under it.
*
* Dismissal is deliberately not automatic. A six-year-old should get to look at the
* cover of the song she just won for as long as she likes, and pressing the button is
* itself part of the reward. It is, however, dismissible from the first frame: the
* sequence can always be cut short with Enter, Escape or a click. */
import { useEffect, useMemo, useRef, useState } from "react";
import { coverUrl } from "../../api/client";
import {
playChestOpen,
playChestThud,
playRewardJingle,
} from "../../lib/tippen/pop";
import type { UnlockedReward } from "../../lib/tippen/progress";
import { TreasureChest } from "./TreasureChest";
type Phase = "drop" | "open" | "reveal";
const OPEN_AT = 1150;
const REVEAL_AT = 1500;
/** The chest is drawn wider than the cover on purpose: the lid swings up across the
* middle, so a cover as wide as the chest hides it completely and the open chest reads as
* a shut one. Keeping the cover to roughly three-quarters of the chest's width leaves the
* lid standing clear beside it. */
const CHEST_SIZE = 196;
const COVER_SIZE = 148;
/** Headline and fallback glyph per reward kind. In German, and phrased as a gift rather
* than as a transaction: "freigeschaltet" is what the map's badge says, "für dich" is
* what this screen says. */
const COPY: Record<
UnlockedReward["kind"],
{ heading: string; lead: string; glyph: string }
> = {
album: {
heading: "Ein neues Lied für dich!",
lead: "Du hast Musik freigespielt",
glyph: "🎵",
},
book: {
heading: "Ein neues Hörbuch für dich!",
lead: "Du hast eine Geschichte freigespielt",
glyph: "📖",
},
podcast_episode: {
heading: "Eine neue Folge für dich!",
lead: "Du hast eine Folge freigespielt",
glyph: "🎙️",
},
};
const CONFETTI_COLOURS = [
"oklch(85% 0.17 88)",
"oklch(78% 0.16 340)",
"oklch(82% 0.15 150)",
"oklch(80% 0.14 220)",
"oklch(88% 0.13 60)",
"oklch(75% 0.17 300)",
];
const CONFETTI_COUNT = 34;
interface Props {
reward: UnlockedReward;
/** Honours her own sound setting - the same flag that silences every other sound in
* the app. A silent celebration still celebrates. */
sound: boolean;
onClose: () => void;
}
export function RewardUnlockOverlay({ reward, sound, onClose }: Props) {
const reducedMotion = usePrefersReducedMotion();
const [phase, setPhase] = useState<Phase>(reducedMotion ? "reveal" : "drop");
const button = useRef<HTMLButtonElement>(null);
const copy = COPY[reward.kind];
// Fixed per mount, so a re-render (the button focusing, say) never reshuffles the
// confetti mid-fall.
const confetti = useMemo(
() => (reducedMotion ? [] : makeConfetti()),
[reducedMotion],
);
useEffect(() => {
if (reducedMotion) {
if (sound) playRewardJingle();
return;
}
if (sound) playChestThud();
const timers = [
window.setTimeout(() => {
setPhase("open");
if (sound) {
playChestOpen();
playRewardJingle();
}
}, OPEN_AT),
window.setTimeout(() => setPhase("reveal"), REVEAL_AT),
];
return () => timers.forEach(window.clearTimeout);
}, [reducedMotion, sound]);
// The button only exists from "reveal" on, so this focuses it as it appears. Taking
// focus matters: the result sheet underneath grabbed it for its own "Nochmal" button
// when it mounted, and Enter must not restart the run from behind this screen.
useEffect(() => {
if (phase === "reveal") button.current?.focus();
}, [phase]);
const open = phase !== "drop";
const revealed = phase === "reveal";
return (
<div
className="tp-reward-overlay"
role="dialog"
aria-label={`${copy.heading} ${reward.title}`}
onClick={onClose}
>
{confetti.map((piece, i) => (
<span
key={i}
className="tp-confetti-piece"
style={{
left: piece.left,
width: piece.width,
height: piece.height,
background: piece.colour,
borderRadius: piece.round ? "50%" : 2,
animationDelay: `${OPEN_AT + piece.delay}ms`,
animationDuration: `${piece.duration}ms`,
["--tp-drift" as string]: piece.drift,
["--tp-spin" as string]: piece.spin,
}}
/>
))}
<div
style={{
fontSize: 15,
fontWeight: 800,
color: "var(--paper)",
opacity: revealed ? 0.75 : 0,
transition: "opacity 300ms",
}}
>
{copy.lead}
</div>
{/* The chest and the cover share one stacking box, and the cover is sandwiched
between the chest's two halves - see TreasureChest's `layer`. That is what makes
the cover come *out of* the chest rather than float in front of a picture of
one: it rises from behind the chest's front wall, under the thrown-back lid. */}
<div
style={{
position: "relative",
width: CHEST_SIZE,
height: CHEST_SIZE,
// Room above for the cover, which overhangs this box by most of its height.
marginTop: COVER_SIZE * 0.78,
animation: open
? undefined
: "chestDrop 520ms cubic-bezier(0.34, 1.4, 0.64, 1), chestRattle 420ms ease-in-out 540ms 2",
}}
>
<TreasureChest size={CHEST_SIZE} open={open} layer="back" style={{ position: "absolute", inset: 0 }} />
{open && !reducedMotion && <Rays />}
{revealed && (
<div
style={{
position: "absolute",
left: "50%",
// Deep enough that the cover's foot is hidden behind the front wall.
bottom: CHEST_SIZE * 0.5,
transform: "translateX(-50%)",
}}
>
<RewardArt reward={reward} glyph={copy.glyph} />
</div>
)}
<TreasureChest size={CHEST_SIZE} open={open} layer="front" style={{ position: "absolute", inset: 0 }} />
</div>
<div
style={{
fontSize: 27,
fontWeight: 900,
color: "var(--paper)",
marginTop: 16,
opacity: revealed ? 1 : 0,
animation: revealed
? "rewardTextEnter 360ms ease-out 80ms both"
: undefined,
}}
>
{copy.heading}
</div>
<div
style={{
fontSize: 19,
fontWeight: 800,
color: "oklch(90% 0.13 92)",
maxWidth: 460,
lineHeight: 1.3,
marginTop: 4,
opacity: revealed ? 1 : 0,
animation: revealed
? "rewardTextEnter 360ms ease-out 200ms both"
: undefined,
}}
>
{reward.title}
</div>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: "var(--paper)",
opacity: revealed ? 0.7 : 0,
marginTop: 8,
transition: "opacity 300ms 400ms",
}}
>
Ab jetzt im Musik-Player 🎧
</div>
{revealed && (
<button
ref={button}
onClick={(event) => {
event.stopPropagation();
onClose();
}}
style={{
border: "none",
borderRadius: 999,
padding: "14px 30px",
fontSize: 18,
fontWeight: 900,
cursor: "pointer",
background: "var(--accent)",
color: "var(--paper)",
boxShadow: "0 8px 24px var(--shadow)",
marginTop: 22,
animation: "rewardTextEnter 360ms ease-out 320ms both",
}}
>
Toll!
</button>
)}
</div>
);
}
/** The cover art, or a glyph when the album has none. */
function RewardArt({
reward,
glyph,
}: {
reward: UnlockedReward;
glyph: string;
}) {
const [failed, setFailed] = useState(false);
const shared: React.CSSProperties = {
width: COVER_SIZE,
height: COVER_SIZE,
borderRadius: 18,
border: "3px solid oklch(90% 0.13 92 / 0.9)",
animation:
"coverRise 760ms cubic-bezier(0.22, 1.2, 0.36, 1) both, coverHalo 2.6s ease-in-out 760ms infinite",
};
// A cover the backend said exists but that fails to load would otherwise leave a
// broken-image box as the centrepiece of the celebration.
if (!reward.hasCover || failed) {
return (
<div
style={{
...shared,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 92,
background:
"linear-gradient(160deg, oklch(70% 0.09 175), oklch(45% 0.08 175))",
}}
>
{glyph}
</div>
);
}
return (
<img
src={coverUrl(reward.albumId)}
alt=""
onError={() => setFailed(true)}
style={{
...shared,
objectFit: "cover",
background: "oklch(45% 0.08 175)",
}}
/>
);
}
/** The burst of light at the moment the lid lets go. Two counter-rotating stars of rays,
* so the burst reads as light rather than as a spinning shape. */
function Rays() {
return (
<div
aria-hidden
style={{
position: "absolute",
left: "50%",
top: "18%",
width: 260,
height: 260,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
}}
>
{[0, 1].map((layer) => (
<div
key={layer}
style={{
position: "absolute",
inset: 0,
animation: `rayBurst ${layer === 0 ? 900 : 1150}ms ease-out ${layer * 90}ms both`,
background: `repeating-conic-gradient(from ${layer * 11}deg, oklch(95% 0.14 92 / 0.55) 0deg 5deg, transparent 5deg 22deg)`,
maskImage:
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
WebkitMaskImage:
"radial-gradient(circle, transparent 12%, black 34%, transparent 72%)",
}}
/>
))}
</div>
);
}
interface ConfettiPiece {
left: string;
width: number;
height: number;
colour: string;
round: boolean;
delay: number;
duration: number;
drift: string;
spin: string;
}
function makeConfetti(): ConfettiPiece[] {
return Array.from({ length: CONFETTI_COUNT }, (_, i) => {
const round = i % 3 === 0;
const size = 7 + Math.random() * 8;
return {
left: `${Math.random() * 100}%`,
width: round ? size : size * 0.55,
height: size,
colour: CONFETTI_COLOURS[i % CONFETTI_COLOURS.length]!,
round,
// Spread over a second and a half, so it falls as a shower rather than as a line.
delay: Math.random() * 1500,
duration: 2400 + Math.random() * 2200,
drift: `${(Math.random() - 0.5) * 220}px`,
spin: `${(Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 720)}deg`,
};
});
}
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(
() =>
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false,
);
useEffect(() => {
const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
if (!query) return;
const onChange = () => setReduced(query.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
return reduced;
}

View File

@@ -1,5 +1,7 @@
/** The gradient stage every screen sits on - the app's one full-height container.
* `data-blur` is read by app.css to drop the backdrop filters wholesale.
* `data-blur` is read by app.css to drop the backdrop filters wholesale, and `data-anim`
* by tippen.css to stop the one CSS animation loop that is not a component's to switch
* off - the pulse on the next key to press.
*
* The pets swim here rather than on the home screen, so they stay with her on the lesson
* map and during a run too. */
@@ -7,6 +9,7 @@
import type { ReactNode } from "react";
import type { CreatureId } from "../../lib/tippen/aquarium";
import { LOW_POWER } from "../../lib/lowPower";
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
import { AquariumCreatures } from "./AquariumCreatures";
import { Bubbles } from "./Bubbles";
@@ -21,7 +24,11 @@ interface Props {
export function Stage({ children, creatures, dimmed }: Props) {
return (
<div className="tp-stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
<div
className="tp-stage"
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
data-anim={LOW_POWER ? "off" : "on"}
>
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
{SHOW_BUBBLES && <Bubbles />}
{children}

View File

@@ -0,0 +1,149 @@
/** A treasure chest whose lid actually opens.
*
* Drawn rather than set as an emoji for one reason: the lid has to be a separate group
* so it can swing on a hinge. A 🎁 can only ever pop and scale, which is exactly the
* animation the old reward badge had and exactly why nobody noticed it. It is also
* sharp at every size, which matters because the same component draws both the 46px
* badge on the lesson map and the 168px chest in the unlock overlay.
*
* `open` is a plain prop, not an animation trigger: the lid transitions to its open
* angle through CSS (`.tp-chest-lid`, tippen.css), so the map can render a permanently
* open chest for a claimed reward with no animation bookkeeping at all, and the overlay
* gets its swing for free just by flipping the prop mid-sequence.
*
* `layer` splits the drawing in two so that something can be put *inside* the chest. The
* lid swings up across the space above the chest, which is exactly where a rising cover
* wants to be; drawn as one piece, the lid crosses in front of the cover and the whole
* thing reads as a z-order bug. Drawn as "back" (lid, mouth, glow), then the cover, then
* "front" (the body), the cover emerges from behind the chest's own front wall - which is
* what coming out of a chest actually looks like. The map, which puts nothing inside, and
* whose shut chests need the lid painted over the body's top edge, takes the default. */
/** Instance-unique gradient ids. Two chests on one page (the map has many) must not share
* `<defs>` ids, or the second one silently paints with the first one's stops. */
let nextId = 0;
interface Props {
size: number;
open: boolean;
/** Which half to draw - see the header. "all" is the whole chest, in one element. */
layer?: "all" | "back" | "front";
/** Softened - a reward whose lesson is still ahead of her. Muted rather than greyed
* out: a shut chest still has to look like treasure, or the badge promises nothing. */
muted?: boolean;
className?: string;
style?: React.CSSProperties;
}
export function TreasureChest({ size, open, layer = "all", muted = false, className, style }: Props) {
const id = `tp-chest-${(nextId += 1)}`;
return (
<svg
width={size}
height={size}
viewBox="0 0 64 62"
className={className}
aria-hidden
style={{
overflow: "visible",
filter: muted ? "grayscale(0.35) brightness(0.9)" : undefined,
opacity: muted ? 0.9 : 1,
...style,
}}
>
<defs>
<linearGradient id={`${id}-wood`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(58% 0.11 55)" />
<stop offset="100%" stopColor="oklch(38% 0.09 45)" />
</linearGradient>
<linearGradient id={`${id}-lid`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(66% 0.11 58)" />
<stop offset="100%" stopColor="oklch(46% 0.1 48)" />
</linearGradient>
<linearGradient id={`${id}-gold`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="oklch(92% 0.14 95)" />
<stop offset="45%" stopColor="oklch(82% 0.17 88)" />
<stop offset="100%" stopColor="oklch(66% 0.15 80)" />
</linearGradient>
<radialGradient id={`${id}-glow`}>
<stop offset="0%" stopColor="oklch(97% 0.14 95 / 0.95)" />
<stop offset="55%" stopColor="oklch(90% 0.16 92 / 0.35)" />
<stop offset="100%" stopColor="oklch(90% 0.16 92 / 0)" />
</radialGradient>
</defs>
{/* What makes it read as *open* rather than as a chest with its lid drawn beside
it: a dark mouth, with light coming out of it. Both are painted before the body,
so the body's front wall covers their lower half and the opening looks like a
hole in the chest rather than a shape on it. */}
{layer !== "front" && open && (
<>
<ellipse cx="32" cy="27" rx="24" ry="7" fill="oklch(22% 0.03 45)" />
<circle cx="32" cy="25" r="24" fill={`url(#${id}-glow)`} />
</>
)}
{layer === "back" && <Lid id={id} open={open} />}
{layer !== "back" && (
<>
<rect
x="6"
y="26"
width="52"
height="30"
rx="5"
fill={`url(#${id}-wood)`}
stroke="oklch(28% 0.06 45)"
strokeWidth="2"
/>
{/* Two gold straps down the body, and the band along its foot. */}
<rect x="13" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
<rect x="46.5" y="26" width="4.5" height="30" fill={`url(#${id}-gold)`} opacity="0.85" />
<rect x="6" y="47" width="52" height="4" fill={`url(#${id}-gold)`} opacity="0.7" />
{/* Lock plate. It stays on the body - a real chest's hasp swings with the lid,
but a lock that leaves with the lid loses the "this was shut" read. */}
<rect
x="26"
y="30"
width="12"
height="14"
rx="2.5"
fill={`url(#${id}-gold)`}
stroke="oklch(45% 0.1 75)"
strokeWidth="1.2"
/>
<circle cx="32" cy="36" r="2.2" fill="oklch(32% 0.05 60)" />
<rect x="31" y="36" width="2" height="5" rx="1" fill="oklch(32% 0.05 60)" />
</>
)}
{/* In one piece, the lid goes last: shut, it has to cover the body's top edge. */}
{layer === "all" && <Lid id={id} open={open} />}
</svg>
);
}
/** A half-round lid, hinged at its left end - the cartoon flip-open, and the only hinge
* that works in a flat, front-on view. */
function Lid({ id, open }: { id: string; open: boolean }) {
return (
<g className="tp-chest-lid" data-open={open || undefined} style={{ transformOrigin: "8px 27px" }}>
<path d="M8 27 A 24 21 0 0 1 56 27 Z" fill={`url(#${id}-lid)`} stroke="oklch(28% 0.06 45)" strokeWidth="2" />
<rect
x="7"
y="22.5"
width="50"
height="5"
rx="2"
fill={`url(#${id}-gold)`}
stroke="oklch(45% 0.1 75)"
strokeWidth="0.8"
/>
<path d="M13 27 A 24 21 0 0 1 15.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
<path d="M51 27 A 24 21 0 0 0 48.5 15" stroke={`url(#${id}-gold)`} strokeWidth="3.5" fill="none" opacity="0.85" />
</g>
);
}

View File

@@ -1,144 +0,0 @@
/** Jellyfish mode - pure key location, nothing else.
*
* Six jellyfish drift in the water, each showing a letter. One of them glows: that is
* the one to zap. There is no line to read and no word to spell, so the only thing
* being exercised is "where does this letter live" - which is exactly the skill dive
* mode hides behind reading.
*
* The decoys matter. Showing only the target turns this into the bubble mode; showing
* five wrong letters next to it means she has to find *her* letter before she can type
* it, which is the searching step that eventually goes away. */
import { useMemo } from "react";
import { currentChar } from "../../../lib/tippen/engine";
import { fingerOf } from "../../../lib/tippen/fingers";
import { mulberry32 } from "../../../lib/tippen/generator";
import type { RunResult } from "../../../lib/tippen/grading";
import type { Progress } from "../../../lib/tippen/progress";
import { useRun } from "../../../hooks/useTippenRun";
import { Keyboard } from "../Keyboard";
interface Props {
letters: readonly string[];
activeKeys: readonly string[];
progress: Progress;
paused: boolean;
onFinished: (result: RunResult) => void;
}
/** How many jellyfish are in the water at once, target included. */
const JELLYFISH_COUNT = 6;
interface Jellyfish {
left: number;
top: number;
drift: number;
size: number;
}
export function JellyfishRun({ letters, activeKeys, progress, paused, onFinished }: Props) {
const text = letters.join("");
const { state, wrong } = useRun({
target: text,
sound: progress.settings.sound,
paused,
onFinished,
});
const next = currentChar(state);
// Fixed positions, seeded once: jellyfish that jump to a new spot on every keystroke
// would make the searching step impossible rather than merely hard.
const spots = useMemo<Jellyfish[]>(() => {
const rng = mulberry32(text.length * 31 + 7);
return Array.from({ length: JELLYFISH_COUNT }, () => ({
left: 10 + rng() * 76,
top: 6 + rng() * 66,
drift: 3 + rng() * 3,
size: 74 + rng() * 26,
}));
}, [text]);
/** The decoys shown alongside the target: other active keys, never the target itself,
* and stable for as long as the target is. */
const decoys = useMemo(() => {
if (!next) return [];
const rng = mulberry32(state.index * 101 + 13);
const others = activeKeys.filter((key) => key !== next && key !== " ");
const shuffled = [...others].sort(() => rng() - 0.5);
return shuffled.slice(0, JELLYFISH_COUNT - 1);
}, [next, state.index, activeKeys]);
// Which jellyfish carries the target. Moves around so it is not always the same one.
const targetSlot = next ? state.index % JELLYFISH_COUNT : -1;
return (
<div
className="view-enter"
style={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 16,
padding: "0 32px 8px",
minHeight: 0,
}}
>
<div style={{ position: "relative", flex: 1, width: "100%", minHeight: 0 }}>
{spots.map((spot, i) => {
const isTarget = i === targetSlot;
const letter = isTarget ? next : decoys[i > targetSlot ? i - 1 : i];
if (!letter) return null;
const finger = fingerOf(letter);
return (
<div
key={i}
className="tp-jellyfish"
style={{
position: "absolute",
left: `${spot.left}%`,
top: `${spot.top}%`,
width: spot.size,
height: spot.size,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: spot.size * (isTarget ? 0.4 : 0.3),
fontWeight: 900,
borderRadius: "50% 50% 42% 42%",
color: isTarget ? "oklch(25% 0.05 175)" : "var(--paper)",
background: isTarget
? "var(--paper)"
: `linear-gradient(160deg, oklch(70% 0.13 ${finger?.hue ?? 175} / .38), oklch(45% 0.09 ${finger?.hue ?? 175} / .16))`,
border: `2px solid oklch(88% 0.07 ${finger?.hue ?? 175} / ${isTarget ? 0.9 : 0.3})`,
boxShadow: isTarget
? "0 0 34px oklch(97% 0.01 175 / .55), 0 10px 26px var(--shadow)"
: "0 4px 14px var(--shadow)",
opacity: isTarget ? 1 : 0.55,
transform: isTarget ? "scale(1.12)" : "scale(1)",
animation: `dolphinBob ${spot.drift}s ease-in-out infinite`,
transition: "opacity 200ms ease, transform 200ms ease, background 200ms ease",
}}
>
{letter === " " ? "␣" : letter}
{isTarget && wrong && (
<div style={{ position: "absolute", inset: -6, borderRadius: "50%", animation: "wrongShake 260ms ease" }} />
)}
</div>
);
})}
</div>
<Keyboard
activeKeys={activeKeys}
nextKey={next}
progress={progress}
mode={progress.settings.keyboardHint}
size={36}
/>
</div>
);
}

View File

@@ -7,6 +7,13 @@ also gives beat-synced animation the frame-accurate clock it will need later.
import { useEffect, useRef, useState } from "react";
import { PLAYBACK_CLOCK_FPS } from "../lib/lowPower";
/** A render budget, not a timer - rAF still drives the loop, this only decides which
* frames are allowed to push state. The 1ms slack keeps a budget from landing just
* inside a vsync interval and silently halving the rate it asked for. */
const MIN_RENDER_MS = PLAYBACK_CLOCK_FPS > 0 ? 1000 / PLAYBACK_CLOCK_FPS - 1 : 0;
export function usePlaybackClock(position: number, playing: boolean): number {
const [interpolated, setInterpolated] = useState(position);
const anchor = useRef({ position, at: performance.now() });
@@ -20,10 +27,13 @@ export function usePlaybackClock(position: number, playing: boolean): number {
useEffect(() => {
if (!playing) return;
let frame = 0;
const tick = () => {
let lastRender = 0;
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
if (now - lastRender < MIN_RENDER_MS) return;
lastRender = now;
const { position: base, at } = anchor.current;
setInterpolated(base + (performance.now() - at) / 1000);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { Album } from "../../api/types";
import { handleKey, initialUiState, selectionAlbumAt, selectionAt, type UiState } from "../keyboard";
import { normalize, results as computeResults, type Results } from "../search";
import { buildSearchIndex, normalize, results as computeResults, type Results } from "../search";
function album(id: string, over: Partial<Album> = {}): Album {
return {
@@ -40,9 +40,11 @@ const key = (k: string, over: Partial<Parameters<typeof handleKey>[0]> = {}) =>
...over,
});
const INDEX = buildSearchIndex(ALBUMS);
const resultsFor = (ui: UiState) =>
computeResults({
albums: ALBUMS,
index: INDEX,
search: ui.search,
mode: ui.mode,
group: ui.group,

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import type { Album, Track } from "../../api/types";
import {
buildSearchIndex,
MAX_SONG_HITS,
normalize,
results as computeResults,
type Group,
} from "../search";
function track(title: string, over: Partial<Track> = {}): Track {
return { title, duration: 60, analysis: null, locked: false, unlock_hint: null, ...over };
}
function album(id: string, over: Partial<Album> = {}): Album {
return {
id,
section: "Musik",
kind: "music",
title: `Album ${id}`,
artist: "Kinderparty",
series: null,
figure: null,
category: "Kinderparty",
colors: ["#111111", "#222222", "#333333"],
has_cover: false,
duration: 120,
locked: false,
tracks: [track(`Lied ${id}`)],
...over,
};
}
const ALBUMS: Album[] = [
album("a", { title: "Conni lernt Rad fahren", artist: "Conni", kind: "book", category: "Conni" }),
album("b", { title: "Käpt'n Krabbe", artist: "Rolf", category: "Rolf" }),
album("c", {
section: "Kinderpodcasts",
title: "GEOlino Spezial",
artist: "WDR",
category: "GEOlino",
tracks: [track("Der Vater der Chemie"), track("Geheime Folge", { locked: true })],
}),
];
const INDEX = buildSearchIndex(ALBUMS);
const query = (over: Partial<Parameters<typeof computeResults>[0]> = {}) =>
computeResults({ index: INDEX, search: "", mode: "albums", group: null, category: null, ...over });
describe("buildSearchIndex", () => {
it("buckets albums onto the three shelves by section and kind", () => {
const groups = Object.fromEntries(
(["music", "audiobooks", "podcasts"] as Group[]).map((g) => [
g,
INDEX.byGroup[g].map((e) => e.album.id),
]),
);
expect(groups).toEqual({ music: ["b"], audiobooks: ["a"], podcasts: ["c"] });
});
it("sorts each shelf's categories, and only its own", () => {
expect(INDEX.categoriesByGroup.music.map((c) => c.key)).toEqual(["Rolf"]);
expect(INDEX.categoriesByGroup.audiobooks.map((c) => c.key)).toEqual(["Conni"]);
expect(INDEX.categoriesByGroup.podcasts.map((c) => c.key)).toEqual(["GEOlino"]);
});
it("leaves locked tracks out entirely - they have no title to match on", () => {
expect(INDEX.tracks.map((t) => t.title)).not.toContain("Geheime Folge");
expect(INDEX.tracks).toHaveLength(3);
});
it("stores the same normalized keys the matchers used to compute per keystroke", () => {
const conni = INDEX.entries.find((e) => e.album.id === "a")!;
expect(conni.haystack).toBe(normalize("Conni lernt Rad fahrenConni"));
});
});
describe("albumMatches", () => {
it("finds words in any order, across title and artist", () => {
expect(query({ search: "conni rad" }).albums.map((a) => a.id)).toEqual(["a"]);
expect(query({ search: "rad conni" }).albums.map((a) => a.id)).toEqual(["a"]);
});
it("ignores punctuation and diacritics", () => {
expect(query({ search: "kaptn" }).albums.map((a) => a.id)).toEqual(["b"]);
});
it("stays inside the shelf once one is entered", () => {
expect(query({ search: "conni", group: "music" }).albums).toEqual([]);
expect(query({ search: "conni", group: "audiobooks" }).albums.map((a) => a.id)).toEqual(["a"]);
});
it("lists a whole category with no search text", () => {
expect(query({ group: "music", category: "Rolf" }).albums.map((a) => a.id)).toEqual(["b"]);
});
it("shows nothing at the bare root - that screen is the three shelves", () => {
expect(query().albums).toEqual([]);
});
});
describe("songMatches", () => {
it("searches track titles, not album titles", () => {
const hits = query({ mode: "tracks", search: "vater" }).songs;
expect(hits.map((h) => [h.album.id, h.index, h.title])).toEqual([["c", 0, "Der Vater der Chemie"]]);
});
it("never returns a locked track", () => {
expect(query({ mode: "tracks", search: "geheime" }).songs).toEqual([]);
});
it("caps an empty query, which would otherwise be the whole library", () => {
const many = Array.from({ length: 60 }, (_, i) =>
album(`m${i}`, { tracks: [track(`Lied ${i}`)] }),
);
const index = buildSearchIndex(many);
const hits = computeResults({
index,
search: "",
mode: "tracks",
group: null,
category: null,
}).songs;
expect(hits).toHaveLength(MAX_SONG_HITS);
// Album order, then track order - the cap takes a prefix, it does not reshuffle.
expect(hits[0]!.title).toBe("Lied 0");
expect(hits.at(-1)!.title).toBe(`Lied ${MAX_SONG_HITS - 1}`);
});
});

99
web/src/lib/lowPower.ts Normal file
View File

@@ -0,0 +1,99 @@
/** `?pi=1` - the same app, cut down to what a Raspberry Pi 4 can actually paint.
*
* The device this exists for is musicdolphin: a Pi 4 driving a 1920x1080 monitor
* through a Firefox kiosk window. The GPU is fine there (`V3D 4.2`, accelerated), but
* Firefox on Linux rasterizes 2D canvas in the content process, and the ambient canvas
* repaints the full screen every frame. Idle - nothing playing, nothing moving but the
* gradient - that was half a core in the content process alone.
*
* Everything here was measured on the device rather than reasoned about, and the
* reasoning lost. 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 switched off 24.6% (no change; left on)
* + backdrop-filter glass blur switched off 118.1% <- 5x WORSE
*
* That last row is why this module is so much smaller than it started out. On this
* device `backdrop-filter` is load-bearing *for performance*: it promotes each glass
* panel to its own compositing layer, so a canvas frame underneath repaints only the
* canvas. Take the blur away and the canvas and everything stacked above it collapse
* into one layer, and every single frame repaints the whole page - all of the album
* grid included. The most expensive-looking CSS in the app is the thing keeping the
* rest of it cheap. Do not "optimize" it away without re-running the numbers above.
*
* That got it from 53.5% to 25%, and 25% was still not enough to use. The profile now
* goes further and stops the animation instead of thinning it: under `?pi=1` the
* ambient canvas is not rendered at all (`SHOW_AMBIENCE`), no bubbles rise anywhere,
* the decorative CSS loops are off, the view transitions are off, and the typing game's
* pets are placed but held still. A requestAnimationFrame loop that repaints the whole
* viewport is a floor you cannot get under while it runs at all, and on a Pi 4 that
* floor is too high. With it gone the browser has nothing to do between one keypress
* and the next. `.stage` keeps its own static CSS gradient, so the screen still has a
* sea behind it - it just no longer moves or follows the track.
*
* What stays: both blurs on the panels (see the table - dropping those was five times
* worse), and the card blur off, which is a count problem rather than a blur problem.
*
* Read once at module load, from the URL and nowhere else: no persistence, no
* auto-detection. The kiosk points Firefox at `http://localhost:8080/?pi=1` (see the
* `pi_kiosk_url` host var in the ansible repo) and a laptop opening the same page gets
* the full-fat version, which is what makes "is this the profile or the hardware?"
* answerable by editing the address bar.
*/
/** True when the page was loaded with `?pi=1`. */
export const LOW_POWER = readLowPowerFlag();
function readLowPowerFlag(): boolean {
// `location` is absent under vitest's default node environment.
if (typeof location === "undefined") return false;
const value = new URLSearchParams(location.search).get("pi");
return value === "1" || value === "true";
}
export interface AmbienceQuality {
/** Multiplies the canvas backing store relative to its on-screen size, before
* `devicePixelRatio`. At 0.5 the loop fills a quarter of the pixels and the browser
* scales the result back up. This is the one change that moved the number: 53.5% ->
* 25.0% idle. On a soft gradient with translucent circles drifting over it, the
* upscale is not something you can point at on the screen. */
resolutionScale: number;
/** Hard ceiling on either backing-store axis. Also a guard against a runaway
* measurement hitting the browser's canvas allocation limit. */
maxBackingStorePx: number;
/** Frames per second the loop will paint. 0 means "whatever the display offers".
* Worth nothing at idle once the resolution is halved - see the table above - and
* kept only for the playing case, where each frame also samples the mood curve and
* moves every bubble. The gradient eases over ~2s; nothing here has anything to say
* at 60fps it cannot say at 30. */
maxFps: number;
/** Ceiling on bubbles alive at once. 0 means unlimited. Spawn rate is driven by the
* music, so a loud track on a big screen can otherwise pile up more circles per
* frame than this device can draw. */
maxBubbles: number;
}
/** Unreachable under `?pi=1` as things stand, because `SHOW_AMBIENCE` switches the
* canvas off there entirely. Kept, and kept accurate, because it is the setting that
* matters the moment anyone turns the canvas back on for a weak device - the half-scale
* backing store is what took it from 53.5% to 25%. */
export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 }
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 };
/** How many times a second the interpolated playback position may push a new React
* render. 0 means "every animation frame", which is what it has always done.
*
* `usePlaybackClock` exists to stop the progress bar stepping twice a second, and it
* does that with a `setState` per animation frame - so `PlayView` and `PlayerBar` each
* re-render sixty times a second for the whole length of a track. A progress bar a
* thousand-odd pixels wide advances one pixel every few *hundred* milliseconds on a
* three-minute track, and the time label beside it only has one-second resolution, so
* ten updates a second is already more than either can show.
*
* Only bites while something is playing, so it is not in the idle table above.
*/
export const PLAYBACK_CLOCK_FPS = LOW_POWER ? 10 : 0;

View File

@@ -2,6 +2,18 @@
The whole index arrives in one response, so type-to-search has no round trip and feels
instant - which is the point of a keyboard-first UI. Ported from the design mockup.
The cost that matters here is per keystroke, not per library load. A real collection is
343 albums and 4969 tracks, and the naive version of this module re-derived its search
keys from scratch on every single one of them every time a letter was typed:
`normalize()` runs `toLowerCase` + Unicode NFD decomposition + two regex passes, so
track search meant five thousand NFD normalizations before a single comparison. That is
work whose answer cannot change until the library itself changes.
So it is done once, in `buildSearchIndex`, and a keystroke is reduced to
`String.includes` over strings that already exist. The same build also partitions the
albums by group and pre-computes each group's sorted category list - which `App` and
`BrowseView` were otherwise both deriving, independently, from the same albums.
*/
import type { Album } from "../api/types";
@@ -58,19 +70,118 @@ export function inGroup(album: Album, group: Group): boolean {
return groupOf(album) === group;
}
/** `null` means unrestricted - typing at the root searches every group at once. */
export function pool(albums: Album[], group: Group | null): Album[] {
return group === null ? albums : albums.filter((album) => inGroup(album, group));
// ------------------------------------------------------------------- index --
const GROUPS: readonly Group[] = ["music", "audiobooks", "podcasts"];
export interface AlbumEntry {
album: Album;
group: Group;
/** `normalize(title + artist)`, as `albumMatches` used to compute per keystroke. */
haystack: string;
}
export interface BrowseQuery {
export interface TrackEntry {
album: Album;
/** Index within `album.tracks`, which is what `SongHit` and playback both want. */
index: number;
title: string;
duration: number;
group: Group;
haystack: string;
}
/** Everything a search needs, derived once per library payload. */
export interface SearchIndex {
/** The albums exactly as the backend sent them, for callers that want the plain list. */
albums: Album[];
entries: AlbumEntry[];
byGroup: Record<Group, AlbumEntry[]>;
/** Each group's categories, already grouped and sorted. Same array identity on every
* read, so a `useMemo` downstream of it stays stable. */
categoriesByGroup: Record<Group, Category[]>;
/** Unlocked tracks only, album order then track order. A locked track shows as a
* question mark wherever it appears, so it has no title to match on and is left out
* of the index entirely rather than skipped at match time. A reward being earned
* reloads the library, which rebuilds this. */
tracks: TrackEntry[];
tracksByGroup: Record<Group, TrackEntry[]>;
}
const emptyByGroup = <T,>(): Record<Group, T[]> => ({ music: [], audiobooks: [], podcasts: [] });
export function buildSearchIndex(albums: Album[]): SearchIndex {
const entries: AlbumEntry[] = [];
const byGroup = emptyByGroup<AlbumEntry>();
const tracks: TrackEntry[] = [];
const tracksByGroup = emptyByGroup<TrackEntry>();
const categoryMaps: Record<Group, Map<string, Category>> = {
music: new Map(),
audiobooks: new Map(),
podcasts: new Map(),
};
for (const album of albums) {
const group = groupOf(album);
const entry: AlbumEntry = { album, group, haystack: normalize(album.title + album.artist) };
entries.push(entry);
byGroup[group].push(entry);
const categories = categoryMaps[group];
let category = categories.get(album.category);
if (!category) {
category = { key: album.category, albums: [] };
categories.set(album.category, category);
}
category.albums.push(album);
album.tracks.forEach((track, index) => {
if (track.locked) return;
const trackEntry: TrackEntry = {
album,
index,
title: track.title,
duration: track.duration,
group,
haystack: normalize(track.title),
};
tracks.push(trackEntry);
tracksByGroup[group].push(trackEntry);
});
}
const categoriesByGroup = emptyByGroup<Category>();
for (const group of GROUPS) {
categoriesByGroup[group] = [...categoryMaps[group].values()].sort((a, b) =>
a.key.localeCompare(b.key, "de"),
);
}
return { albums, entries, byGroup, categoriesByGroup, tracks, tracksByGroup };
}
/** For the moment before the library has loaded, and for tests that don't need one. */
export const EMPTY_INDEX: SearchIndex = buildSearchIndex([]);
// ----------------------------------------------------------------- queries --
export interface BrowseQuery {
index: SearchIndex;
search: string;
mode: Mode;
group: Group | null;
category: string | null;
}
/** `null` group means unrestricted - typing at the root searches every group at once. */
function albumPool(query: BrowseQuery): AlbumEntry[] {
return query.group === null ? query.index.entries : query.index.byGroup[query.group];
}
function trackPool(query: BrowseQuery): TrackEntry[] {
return query.group === null ? query.index.tracks : query.index.tracksByGroup[query.group];
}
/** Which of the three lists the browse view is showing. */
export function listMode(query: BrowseQuery): "tracks" | "albums" | "categories" {
if (query.mode === "tracks") return "tracks";
@@ -82,27 +193,17 @@ export function categoryMatches(query: BrowseQuery): Category[] {
// `group === null` is the bare root screen, rendered as three shelves instead of a
// flat category list - each shelf calls this again with its own group filled in.
if (listMode(query) !== "categories" || query.group === null) return [];
const map = new Map<string, Category>();
for (const album of pool(query.albums, query.group)) {
const key = album.category;
let entry = map.get(key);
if (!entry) {
entry = { key, albums: [] };
map.set(key, entry);
}
entry.albums.push(album);
}
return [...map.values()].sort((a, b) => a.key.localeCompare(b.key, "de"));
return query.index.categoriesByGroup[query.group];
}
export function albumMatches(query: BrowseQuery): Album[] {
if (query.mode === "tracks") return [];
const words = searchWords(query.search);
let candidates = pool(query.albums, query.group);
if (!words.length && !query.category) return [];
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
if (!words.length) return candidates;
return candidates.filter((a) => matchesWords(normalize(a.title + a.artist), words));
let candidates = albumPool(query);
if (query.category) candidates = candidates.filter((e) => e.album.category === query.category);
if (words.length) candidates = candidates.filter((e) => matchesWords(e.haystack, words));
return candidates.map((e) => e.album);
}
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
@@ -112,18 +213,19 @@ export function songMatches(query: BrowseQuery): SongHit[] {
if (query.mode !== "tracks") return [];
const words = searchWords(query.search);
const hits: SongHit[] = [];
for (const album of pool(query.albums, query.group)) {
album.tracks.forEach((track, index) => {
// A locked track has no real title to search by - it shows as a question mark
// wherever it appears, so it has nothing useful to match here either.
if (track.locked) return;
if (!words.length || matchesWords(normalize(track.title), words)) {
hits.push({ album, index, title: track.title, duration: track.duration });
}
for (const track of trackPool(query)) {
if (words.length && !matchesWords(track.haystack, words)) continue;
hits.push({
album: track.album,
index: track.index,
title: track.title,
duration: track.duration,
});
// Stopping at the cap rather than filling every hit and slicing is what keeps an
// empty query in tracks mode from walking all 4969 tracks for 40 rows.
if (hits.length >= MAX_SONG_HITS) break;
}
return hits.slice(0, MAX_SONG_HITS);
return hits;
}
export interface Results {

View File

@@ -11,6 +11,7 @@
import type { CSSProperties } from "react";
import { LOW_POWER } from "./lowPower";
import type { Group } from "./search";
import type { UiState } from "./keyboard";
@@ -32,24 +33,46 @@ export const ROW_SPACING = 25;
/** Fade the album/category grid in (with a slight upward slide) when a group or
* category is entered, and the album modal in when it opens. One animation per
* container, not per card, so cost stays flat regardless of grid size. */
export const ANIMATE_VIEW_TRANSITIONS = true;
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
/** Frost the glass panels/cards/rows with a real backdrop blur. `backdrop-filter` is
* one of the most GPU-expensive CSS effects in the app - a live backdrop copy+blur
* per element, repeated for every album card in the unvirtualized grid - so turn
* this off on weak hardware (e.g. an old Raspberry Pi) to fall back to a plain
* translucent background with no blur. */
/** Frost the glass panels/cards/rows with a real backdrop blur.
*
* The obvious thing to cut on weak hardware, and on the Pi that is exactly wrong: it
* measured five times *worse* with the blur off (118% of a core against 25%). The blur
* is what promotes each panel to its own compositing layer; without it the animated
* canvas and the whole album grid above it share one layer and every canvas frame
* repaints all of it. See the table in lib/lowPower.ts before touching this. */
export const SHOW_GLASS_BLUR = true;
/** Render the play view's animated canvas background (gradient + bubbles, both
* redrawn every frame at 60fps). Off falls back to `.stage`'s own static CSS
* gradient, which is still underneath the canvas either way. */
export const SHOW_AMBIENCE = true;
* redrawn every frame). Off falls back to `.stage`'s own static CSS gradient, which is
* still underneath the canvas either way - so the screen keeps a background, it just
* stops being a per-track one and stops moving.
*
* Off under `?pi=1`. Halving its resolution and its frame rate first (see
* `AMBIENCE_QUALITY`) was not enough on the device this exists for: a requestAnimationFrame
* loop that repaints the full viewport is a floor you cannot get under while it runs at
* all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop,
* so with this off the browser has nothing to do between one keypress and the next. */
export const SHOW_AMBIENCE = !LOW_POWER;
/** Frost the *repeated* glass surfaces - every album card in the grid, every row in a
* track list - as opposed to the handful of panels wrapped around them.
*
* Separate from `SHOW_GLASS_BLUR` because the two behave nothing alike, and lumping
* them together is what made the first attempt at a Pi profile five times slower. A
* panel is one live backdrop copy that buys its whole subtree a compositing layer. A
* card is one live backdrop copy out of three hundred sitting directly over the
* animated canvas, so every canvas frame re-blurs all of them. Measured on the Pi with
* a full album grid on screen, Chromium: see lib/lowPower.ts. */
export const SHOW_CARD_BLUR = !LOW_POWER;
/** Run the purely decorative CSS animation loops: the room page's bubble field and
* the dolphin mascot's bob/swim. Individually cheap (opacity/transform only) but
* free to cut on weak hardware. */
export const SHOW_DECORATIVE_ANIMATIONS = true;
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
* noise, but "nothing on this screen moves by itself" is a property worth having
* outright rather than a sum of small wins - a compositor with no animation to service
* has nothing to wake up for. */
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
// ------------------------------------------------------------------ colors --
@@ -85,7 +108,10 @@ export const TAB_RAIL_CLEARANCE = 88;
* smarthome tab only renders when Home Assistant is configured (see `App.tsx`); the
* other two always show. */
export const PAGE_ICON: Record<UiState["page"], string> = {
music: "🎵",
// A plain Unicode symbol, not the 🎵 emoji: that glyph's own built-in colour is a
// muted grey-blue on at least one real platform, which is illegible against the tab
// rail's own glass background and does not respond to `color` the way text does.
music: "♪",
room: "💡",
typing: "⌨️",
};

View File

@@ -67,9 +67,6 @@ describe("grade", () => {
expect(grade(state).errors).toBe(1);
});
it("awards pearls even for a bad run", () => {
expect(grade(run("a".repeat(20), [0, 1, 2, 3, 4, 5, 6, 7])).pearls).toBeGreaterThan(0);
});
});
describe("stars", () => {

View File

@@ -6,14 +6,14 @@
import { describe, expect, it } from "vitest";
import { focusKeyFor, mastery } from "../progress";
import { focusKeyFor, mastery, progressFromApi } from "../progress";
import type { Progress } from "../progress";
import type { TippenProgress as ApiProgress } from "../../../api/types";
function basicProgress(over: Partial<Progress> = {}): Progress {
return {
lessons: {},
keyStats: {},
pearls: 0,
aquarium: [],
streak: { days: 0, lastPlayed: null },
settings: { sound: true, keyboardHint: "auto" },
@@ -65,3 +65,26 @@ describe("mastery", () => {
expect(mastery(stat(300, 50), "a")).toBeCloseTo(0.5, 5);
});
});
describe("progressFromApi", () => {
/** `earned` was silently dropped here once, which is not a visible bug anywhere except
* on the lesson map, where it decides whether a reward's treasure chest is drawn open.
* A dropped field fails no type check - the mapping is written out by hand - so it
* needs a test that names it. */
it("keeps the backend's derived `earned` flag", () => {
const api = {
lessons: {
won: { unlocked: true, runs: 3, best_stars: 3, best_animal: "delfin", best_points: 9, earned: true, ghost: null },
open: { unlocked: true, runs: 1, best_stars: 1, best_animal: "krabbe", best_points: 2, earned: false, ghost: null },
},
key_stats: {},
aquarium: [],
streak: { days: 2, last_played: null },
settings: { sound: true, keyboard_hint: "auto" },
} as unknown as ApiProgress;
const progress = progressFromApi(api);
expect(progress.lessons.won?.earned).toBe(true);
expect(progress.lessons.open?.earned).toBe(false);
});
});

View File

@@ -13,7 +13,7 @@ import type {
import type { CreatureId } from "./aquarium";
export type LessonKind = "letters" | "fragments" | "words" | "sentences";
export type ModeId = "dive" | "bubbles" | "jellyfish" | "feed" | "race";
export type ModeId = "dive" | "bubbles" | "feed" | "race";
/** What this lesson unlocks in the music library, if anything - see `rewards.py`.
* `resolved: false` means the curriculum names a path that matches nothing right now. */

View File

@@ -117,7 +117,7 @@ export function press(state: RunState, key: string, now: number): [RunState, Run
}
/** Give up on the rest of the line - what Escape does. The run is still graded on what
* was typed, so a half-finished bubbles round still earns its pearls. */
* was typed, so a half-finished bubbles round still earns its stars. */
export function abandonRun(state: RunState, now: number): RunState {
if (isFinished(state) || state.startedAt === null) return state;
return { ...state, finishedAt: now };

View File

@@ -189,7 +189,7 @@ export function wordChunks(
}
/** The line a lesson should show, given what it can spell. Word lessons alternate:
* `preferWords` lets a mode ask for letters even in a late lesson (jellyfish mode is
* `preferWords` lets a mode ask for letters even in a late lesson (bubbles mode is
* always single letters) or for words wherever they exist (feed mode). */
export function lineFor(
lesson: { activeKeys: readonly string[]; words: readonly string[]; newKeys?: readonly string[] },
@@ -222,8 +222,8 @@ export function chunkOffsets(chunks: readonly string[], spaceActive: boolean): n
return offsets;
}
/** Single letters for the bubbles and jellyfish modes: one key per bubble, drawn from
* the same bag, so the arcade modes drill the same spread as the dive mode. */
/** Single letters for bubbles mode: one key per bubble, drawn from the same bag, so the
* arcade mode drills the same spread as the dive mode. */
export function letterStream(
activeKeys: readonly string[],
rng: Rng,

View File

@@ -35,8 +35,6 @@ export interface RunResult {
animal: AnimalId;
/** Whether this run unlocks the next lesson on its own. */
passed: boolean;
/** Pearls earned - the aquarium currency. */
pearls: number;
/** Kept for the race-mode ghost and the per-key stats. */
strokes: readonly Stroke[];
}
@@ -162,12 +160,6 @@ export function isPassed(accuracy: number): boolean {
return accuracy >= STAR_THRESHOLDS.two;
}
/** One pearl per five correct keys, plus a bonus per star. Small numbers that go up
* every single run, including a bad one - the aquarium should never stall. */
function pearlsFor(characters: number, stars: number): number {
return Math.floor(characters / 5) + stars * 2;
}
export function grade(state: RunState): RunResult {
const characters = state.strokes.filter((stroke) => stroke.correct).length;
const errors = state.missed.size;
@@ -196,7 +188,6 @@ export function grade(state: RunState): RunResult {
stars,
animal: animalFor(points).id,
passed: isPassed(accuracy),
pearls: pearlsFor(characters, stars),
strokes: state.strokes,
};
}

View File

@@ -6,7 +6,6 @@ import type { ModeId } from "./curriculum";
export const MODE_INFO: Record<ModeId, { emoji: string; name: string }> = {
dive: { emoji: "🤿", name: "Tauchgang" },
bubbles: { emoji: "🫧", name: "Blasenplatzen" },
jellyfish: { emoji: "🦑", name: "Quallenalarm" },
feed: { emoji: "🐟", name: "Fütterungszeit" },
race: { emoji: "🐬", name: "Delfinrennen" },
};

View File

@@ -54,3 +54,114 @@ export function playFanfare(): void {
window.setTimeout(() => blip(frequency, frequency * 1.5, 0.13, 0.28), i * 110);
});
}
/** One scheduled note of a tune. `at`/`duration` are seconds relative to the tune's
* own start, so a melody reads as a score rather than as nested `setTimeout`s - which
* also keeps the notes sample-accurate against each other instead of drifting by
* however late the event loop happened to be. */
interface Note {
/** Hz. */
hz: number;
at: number;
duration: number;
gain?: number;
type?: OscillatorType;
}
function playTune(notes: readonly Note[]): void {
try {
context ??= new AudioContext();
// Resuming matters here specifically: this tune plays at the end of a run, and on
// some browsers the context created during the run's first keystroke is suspended
// again by the time the result screen opens.
void context.resume?.();
const start = context.currentTime + 0.02;
for (const note of notes) {
const oscillator = context.createOscillator();
const gain = context.createGain();
const peak = note.gain ?? 0.12;
const from = start + note.at;
oscillator.type = note.type ?? "triangle";
oscillator.frequency.setValueAtTime(note.hz, from);
// A short attack rather than an instant one: a hard start on a triangle wave
// clicks, and a click in a reward jingle sounds like a fault.
gain.gain.setValueAtTime(0.0001, from);
gain.gain.exponentialRampToValueAtTime(peak, from + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, from + note.duration);
oscillator.connect(gain).connect(context.destination);
oscillator.start(from);
oscillator.stop(from + note.duration + 0.02);
}
} catch {
// Same as `blip`: no audio context, no sound, never an exception.
}
}
const C5 = 523.25;
const D5 = 587.33;
const E5 = 659.25;
const F5 = 698.46;
const G5 = 783.99;
const A5 = 880;
const C6 = 1046.5;
const E6 = 1318.5;
const G6 = 1568;
const C4 = 261.63;
const E4 = 329.63;
const G4 = 392;
const G3 = 196;
/** The unlock tune: about two seconds of unambiguous "you got something".
*
* Deliberately a whole melody rather than one more blip. `playFanfare` already marks
* every smaller win in this app - a new lesson, a new animal, a new aquarium creature -
* so if opening real music sounded like that too, the biggest reward in the game would
* be the one thing she could not hear coming. This one is longer, has a bass line under
* it and lands on a held major chord, which is what makes it read as an arrival.
*
* C major throughout: a rising C-E-G-C run, a little D-E-F-G turn over it, then the
* tonic triad plus its octave held together over a low C. */
export function playRewardJingle(): void {
playTune([
// The run up.
{ hz: C5, at: 0, duration: 0.16 },
{ hz: E5, at: 0.12, duration: 0.16 },
{ hz: G5, at: 0.24, duration: 0.16 },
{ hz: C6, at: 0.36, duration: 0.26 },
// The turn - the bit that makes it a tune instead of an arpeggio.
{ hz: A5, at: 0.62, duration: 0.13 },
{ hz: G5, at: 0.74, duration: 0.13 },
{ hz: A5, at: 0.86, duration: 0.13 },
{ hz: C6, at: 0.98, duration: 0.22 },
// The arrival: a held triad, with the bass under it.
{ hz: C5, at: 1.24, duration: 0.95, gain: 0.1 },
{ hz: E5, at: 1.24, duration: 0.95, gain: 0.09 },
{ hz: G5, at: 1.24, duration: 0.95, gain: 0.09 },
{ hz: C6, at: 1.24, duration: 0.95, gain: 0.08 },
{ hz: C4, at: 1.24, duration: 1, gain: 0.09, type: "sine" },
{ hz: G3, at: 1.24, duration: 1, gain: 0.07, type: "sine" },
// Sparkles over the held chord, as the cover comes out of the chest.
{ hz: E6, at: 1.4, duration: 0.2, gain: 0.05, type: "sine" },
{ hz: G6, at: 1.56, duration: 0.2, gain: 0.045, type: "sine" },
{ hz: C6 * 2, at: 1.72, duration: 0.3, gain: 0.04, type: "sine" },
]);
}
/** The chest landing on the screen, just before its lid opens: two low thuds. Pitched
* well below the jingle so it reads as a thing arriving, not as a note. */
export function playChestThud(): void {
playTune([
{ hz: 150, at: 0, duration: 0.12, gain: 0.1, type: "sine" },
{ hz: 110, at: 0.14, duration: 0.16, gain: 0.09, type: "sine" },
]);
}
/** The lid coming open: a bright upward creak-and-pop. */
export function playChestOpen(): void {
playTune([
{ hz: G4, at: 0, duration: 0.1, gain: 0.07 },
{ hz: E4 * 2, at: 0.06, duration: 0.12, gain: 0.07 },
{ hz: D5 * 2, at: 0.13, duration: 0.14, gain: 0.06 },
{ hz: F5 * 2, at: 0.2, duration: 0.2, gain: 0.05, type: "sine" },
]);
}

Some files were not shown because too many files have changed in this diff Show More