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>
300 lines
10 KiB
Plaintext
300 lines
10 KiB
Plaintext
import { Theme } from "theme.slint";
|
|
|
|
// A frosted surface wrapped around a shelf or a list, so it reads as something
|
|
// floating over the stage rather than text on a gradient.
|
|
export component GlassPanel inherits Rectangle {
|
|
background: Theme.glass;
|
|
border-width: 1px;
|
|
border-color: Theme.glass-border;
|
|
border-radius: Theme.radius-panel;
|
|
drop-shadow-color: #00101759;
|
|
drop-shadow-blur: 24px;
|
|
drop-shadow-offset-y: 8px;
|
|
}
|
|
|
|
// Album art. `source` is whatever the cover store has decoded; until it has one, the
|
|
// Rust side hands over generated art built from the album's own three colours, so this
|
|
// never has an empty frame to show.
|
|
//
|
|
// The shape comes from the media type and from nowhere else: square for an album,
|
|
// taller than wide for an audiobook, everywhere either appears.
|
|
export component Cover inherits Rectangle {
|
|
in property <image> source;
|
|
in property <bool> is-book: false;
|
|
in property <length> radius: Theme.radius-card;
|
|
|
|
clip: true;
|
|
border-radius: root.radius;
|
|
// A book keeps a sliver of its generated spine showing past real square artwork, so
|
|
// the shelf metaphor survives contact with a photograph.
|
|
background: root.is-book ? Theme.book-edge-2 : transparent;
|
|
|
|
Image {
|
|
source: root.source;
|
|
image-fit: cover;
|
|
width: root.is-book ? parent.width * 1.05 : parent.width;
|
|
height: parent.height;
|
|
x: 0;
|
|
}
|
|
}
|
|
|
|
// Prev / next / play. Every glyph is drawn from rectangles and a clip, not an icon
|
|
// font: there is no icon font on the device, and a missing glyph is a blank button.
|
|
export component TransportButton inherits Rectangle {
|
|
in property <length> size: 48px;
|
|
in property <bool> primary: false;
|
|
in property <bool> playing: false;
|
|
in property <string> direction: "play"; // "play" | "next" | "previous"
|
|
callback clicked();
|
|
|
|
width: root.size;
|
|
height: root.size;
|
|
border-radius: root.size / 2;
|
|
background: root.primary
|
|
? (touch.pressed ? Theme.accent-dim : Theme.accent)
|
|
: (touch.pressed ? #2a4f57 : Theme.ink);
|
|
drop-shadow-color: root.primary ? #de73bd80 : #00101766;
|
|
drop-shadow-blur: root.primary ? 20px : 10px;
|
|
drop-shadow-offset-y: 4px;
|
|
animate background { duration: 100ms; }
|
|
|
|
// Pause: two bars. Play and the skips: a triangle, clipped out of a square.
|
|
if root.direction == "play" && root.playing: HorizontalLayout {
|
|
alignment: center;
|
|
spacing: root.size * 0.1;
|
|
Rectangle {
|
|
width: root.size * 0.1;
|
|
height: root.size * 0.34;
|
|
border-radius: 2px;
|
|
background: white;
|
|
}
|
|
Rectangle {
|
|
width: root.size * 0.1;
|
|
height: root.size * 0.34;
|
|
border-radius: 2px;
|
|
background: white;
|
|
}
|
|
}
|
|
|
|
if root.direction != "play" || !root.playing: HorizontalLayout {
|
|
alignment: center;
|
|
spacing: root.size * 0.06;
|
|
// "Previous" is the same triangle mirrored, plus the bar it butts against.
|
|
if root.direction == "previous": Rectangle {
|
|
width: root.size * 0.07;
|
|
height: root.size * 0.3;
|
|
border-radius: 1px;
|
|
background: white;
|
|
}
|
|
Rectangle {
|
|
width: root.size * 0.3;
|
|
height: root.size * 0.3;
|
|
Path {
|
|
width: 100%;
|
|
height: 100%;
|
|
fill: white;
|
|
viewbox-width: 100;
|
|
viewbox-height: 100;
|
|
commands: root.direction == "previous"
|
|
? "M 100 0 L 100 100 L 0 50 Z"
|
|
: "M 6 0 L 100 50 L 6 100 Z";
|
|
}
|
|
}
|
|
if root.direction == "next": Rectangle {
|
|
width: root.size * 0.07;
|
|
height: root.size * 0.3;
|
|
border-radius: 1px;
|
|
background: white;
|
|
}
|
|
}
|
|
|
|
touch := TouchArea {
|
|
clicked => { root.clicked(); }
|
|
}
|
|
}
|
|
|
|
export component Transport inherits HorizontalLayout {
|
|
in property <bool> playing;
|
|
in property <length> size: 48px;
|
|
callback toggle();
|
|
callback next();
|
|
callback previous();
|
|
|
|
alignment: center;
|
|
spacing: root.size * 0.21;
|
|
|
|
VerticalLayout {
|
|
alignment: center;
|
|
TransportButton {
|
|
direction: "previous";
|
|
size: root.size;
|
|
clicked => { root.previous(); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
TransportButton {
|
|
direction: "play";
|
|
primary: true;
|
|
playing: root.playing;
|
|
// The play button is the one target a child aims at from across the room.
|
|
size: root.size * 1.26;
|
|
clicked => { root.toggle(); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
TransportButton {
|
|
direction: "next";
|
|
size: root.size;
|
|
clicked => { root.next(); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// The seek bar.
|
|
//
|
|
// `position` is only pushed twice a second, so the fill is *animated* between frames
|
|
// rather than recomputed on a client-side clock. That puts the interpolation on the
|
|
// render side, where it costs a property evaluation per frame instead of a layout pass,
|
|
// and it is why there is no equivalent of the web client's `usePlaybackClock` here.
|
|
//
|
|
// The animation is suppressed whenever `smooth` goes false - while dragging, and for
|
|
// the frame a track changes on - because sliding the fill across two unrelated
|
|
// positions is exactly the "it moves slightly backwards" artefact the web UI has.
|
|
export component ProgressBar inherits Rectangle {
|
|
in property <float> progress; // 0..1
|
|
in property <bool> interactive: true;
|
|
in property <length> bar-height: 10px;
|
|
in property <bool> smooth: true;
|
|
/// Set on the player bar, whose background is darker than the seek bar's own
|
|
/// track would otherwise show against.
|
|
in property <bool> on-dark: false;
|
|
callback seek(float);
|
|
|
|
height: max(root.bar-height, 28px);
|
|
|
|
track := Rectangle {
|
|
y: (parent.height - root.bar-height) / 2;
|
|
height: root.bar-height;
|
|
width: 100%;
|
|
border-radius: root.bar-height / 2;
|
|
background: root.on-dark ? Theme.progress-track-dark : Theme.progress-track.with-alpha(0.6);
|
|
clip: true;
|
|
|
|
fill := Rectangle {
|
|
x: 0;
|
|
width: parent.width * clamp(root.progress, 0.0, 1.0);
|
|
height: 100%;
|
|
border-radius: root.bar-height / 2;
|
|
background: Theme.accent;
|
|
animate width {
|
|
duration: root.smooth ? 500ms : 0ms;
|
|
easing: linear;
|
|
}
|
|
}
|
|
}
|
|
|
|
// A grab handle only while it is worth having one: at player-bar size the bar is
|
|
// the target, and a knob would be smaller than the thing it sits on.
|
|
if root.interactive && root.bar-height >= 12px: Rectangle {
|
|
x: max(0px, min(parent.width - self.width, track.width * clamp(root.progress, 0.0, 1.0) - self.width / 2));
|
|
y: (parent.height - self.height) / 2;
|
|
width: root.bar-height * 1.7;
|
|
height: root.bar-height * 1.7;
|
|
border-radius: self.width / 2;
|
|
background: Theme.paper;
|
|
drop-shadow-color: #00101773;
|
|
drop-shadow-blur: 8px;
|
|
}
|
|
|
|
if root.interactive: TouchArea {
|
|
width: 100%;
|
|
height: 100%;
|
|
// Both a click and a drag resolve to "the position under the finger", so
|
|
// scrubbing works without a separate drag state machine.
|
|
moved => {
|
|
if (self.pressed) { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
|
|
}
|
|
clicked => { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
|
|
}
|
|
}
|
|
|
|
// Volume as five discrete steps, not a continuous slider: a child aiming at a bar hits
|
|
// a level, where a slider would hand back whatever pixel they landed on.
|
|
export component VolumeBars inherits HorizontalLayout {
|
|
in property <int> volume; // 0..100
|
|
in property <length> bar-width: 11px;
|
|
in property <length> bar-height: 30px;
|
|
callback set-volume(int);
|
|
callback mute();
|
|
|
|
spacing: root.bar-width * 0.3;
|
|
alignment: center;
|
|
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: root.bar-height * 0.8;
|
|
height: root.bar-height * 0.8;
|
|
border-radius: self.width / 2;
|
|
background: speaker.has-hover ? #ffffff26 : transparent;
|
|
Text {
|
|
text: root.volume == 0 ? "🔇" : "🔊";
|
|
font-size: root.bar-width * 1.6;
|
|
vertical-alignment: center;
|
|
horizontal-alignment: center;
|
|
}
|
|
speaker := TouchArea {
|
|
clicked => { root.mute(); }
|
|
}
|
|
}
|
|
}
|
|
|
|
for level[i] in [20, 40, 60, 80, 100]: VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: root.bar-width;
|
|
// Stepped heights, so the control reads as a volume ramp at a glance.
|
|
height: root.bar-height * (0.45 + 0.55 * (i + 1) / 5);
|
|
border-radius: 5px;
|
|
background: root.volume >= level ? Theme.accent : Theme.vol-empty.with-alpha(0.4);
|
|
animate background { duration: 120ms; }
|
|
TouchArea {
|
|
clicked => { root.set-volume(level); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A round icon button - back, close, and the fixed corner controls.
|
|
export component RoundButton inherits Rectangle {
|
|
in property <string> label;
|
|
in property <length> size: 44px;
|
|
in property <bool> highlighted: false;
|
|
callback clicked();
|
|
|
|
width: root.size;
|
|
height: root.size;
|
|
border-radius: root.size / 2;
|
|
background: root.highlighted
|
|
? Theme.accent
|
|
: (touch.pressed ? Theme.text-dim : Theme.paper.with-alpha(0.95));
|
|
drop-shadow-color: #00101766;
|
|
drop-shadow-blur: 18px;
|
|
drop-shadow-offset-y: 6px;
|
|
|
|
Text {
|
|
text: root.label;
|
|
font-size: root.size * 0.45;
|
|
font-weight: 900;
|
|
color: root.highlighted ? white : Theme.ink;
|
|
vertical-alignment: center;
|
|
horizontal-alignment: center;
|
|
}
|
|
|
|
touch := TouchArea {
|
|
clicked => { root.clicked(); }
|
|
}
|
|
}
|