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>
92 lines
2.7 KiB
Plaintext
92 lines
2.7 KiB
Plaintext
// 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,
|
||
}
|