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>
414 lines
14 KiB
Plaintext
414 lines
14 KiB
Plaintext
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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|