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

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