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:
2026-09-20 22:26:09 +02:00
parent 83d16e0058
commit 6ccb3e458f
21 changed files with 11270 additions and 0 deletions

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;
}
}
}
}