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>
This commit is contained in:
91
slint-frontend/ui/models.slint
Normal file
91
slint-frontend/ui/models.slint
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user