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>
568 lines
20 KiB
Plaintext
568 lines
20 KiB
Plaintext
import { ListView } from "std-widgets.slint";
|
||
import { Theme } from "theme.slint";
|
||
import { GlassPanel, Cover } from "widgets.slint";
|
||
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow } from "models.slint";
|
||
|
||
// Heights are fixed rather than derived from content, and that is the whole reason the
|
||
// grid can be virtualized: a `ListView` that has to measure a row before it can decide
|
||
// whether to skip it has not skipped anything. The web grid does the same thing with
|
||
// `contain-intrinsic-size` for the same reason.
|
||
//
|
||
// A book is taller than an album, so the cover *slot* is sized for the taller of the
|
||
// two and each cover is centred in it. That keeps every row identical without
|
||
// flattening the shape difference that tells the two media apart.
|
||
export global Metrics {
|
||
out property <length> cover-slot: 200px;
|
||
out property <length> card-text: 78px;
|
||
out property <length> card-height: 278px; // cover-slot + card-text
|
||
out property <length> row-height: 302px; // card-height + grid-gap
|
||
out property <length> shelf-height: 236px;
|
||
out property <length> category-row-height: 260px;
|
||
}
|
||
|
||
component SelectionRing inherits Rectangle {
|
||
in property <bool> selected;
|
||
in property <bool> current;
|
||
|
||
// Selection outranks "currently playing": when the keyboard is on the album that is
|
||
// also playing, the ring it needs to see is the one that moves.
|
||
border-width: root.selected ? 5px : (root.current ? 4px : 0px);
|
||
border-color: root.selected ? Theme.paper : Theme.accent;
|
||
border-radius: Theme.radius-card + 6px;
|
||
}
|
||
|
||
component AlbumCard inherits Rectangle {
|
||
in property <AlbumTile> tile;
|
||
in property <bool> selected;
|
||
callback open();
|
||
callback play();
|
||
|
||
height: Metrics.card-height;
|
||
|
||
ring := SelectionRing {
|
||
selected: root.selected;
|
||
current: root.tile.current;
|
||
x: -7px;
|
||
y: -7px;
|
||
width: parent.width + 14px;
|
||
height: parent.height + 14px;
|
||
}
|
||
|
||
card := Rectangle {
|
||
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
|
||
border-radius: Theme.radius-card;
|
||
drop-shadow-color: #00101759;
|
||
drop-shadow-blur: 18px;
|
||
drop-shadow-offset-y: 6px;
|
||
// The lift on selection: 120ms, the same as the web card's transition.
|
||
y: root.selected ? -3px : 0px;
|
||
animate y { duration: 120ms; easing: ease-out; }
|
||
|
||
VerticalLayout {
|
||
padding: 10px;
|
||
spacing: 6px;
|
||
|
||
// The cover, centred in a slot tall enough for the taller media type.
|
||
Rectangle {
|
||
height: Metrics.cover-slot - 20px;
|
||
HorizontalLayout {
|
||
alignment: center;
|
||
Cover {
|
||
source: root.tile.cover;
|
||
is-book: root.tile.is-book;
|
||
height: parent.height;
|
||
width: root.tile.is-book ? parent.height * 0.82 : parent.height;
|
||
}
|
||
}
|
||
TouchArea {
|
||
// Cover opens the track list; the title below starts it playing.
|
||
// Two targets, because "show me what is in this" and "play this"
|
||
// are different intentions and a child has both.
|
||
clicked => { root.open(); }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
height: Metrics.card-text - 26px;
|
||
VerticalLayout {
|
||
spacing: 1px;
|
||
Text {
|
||
text: root.tile.title;
|
||
font-size: Theme.text-body - 1px;
|
||
font-weight: 800;
|
||
color: Theme.ink;
|
||
wrap: word-wrap;
|
||
overflow: elide;
|
||
// Two lines always, whether or not the title needs them, so
|
||
// every card in the row ends at the same place.
|
||
height: (Theme.text-body - 1px) * 2.4;
|
||
}
|
||
Text {
|
||
text: root.tile.artist;
|
||
font-size: Theme.text-small - 1px;
|
||
font-weight: 700;
|
||
color: Theme.ink.with-alpha(0.65);
|
||
overflow: elide;
|
||
}
|
||
}
|
||
TouchArea {
|
||
clicked => { root.play(); }
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: root.tile.meta;
|
||
font-size: Theme.text-tiny;
|
||
font-weight: 800;
|
||
color: Theme.count-pink;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
component CategoryCard inherits Rectangle {
|
||
in property <CategoryTile> tile;
|
||
in property <bool> selected;
|
||
in property <length> tile-width: Theme.shelf-tile;
|
||
callback open();
|
||
|
||
width: root.tile-width;
|
||
height: Metrics.shelf-height;
|
||
|
||
SelectionRing {
|
||
selected: root.selected;
|
||
current: false;
|
||
x: -7px;
|
||
y: -7px;
|
||
width: parent.width + 14px;
|
||
height: parent.height + 14px;
|
||
}
|
||
|
||
Rectangle {
|
||
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
|
||
border-radius: Theme.radius-card;
|
||
drop-shadow-color: #00101759;
|
||
drop-shadow-blur: 18px;
|
||
drop-shadow-offset-y: 6px;
|
||
y: root.selected ? -3px : 0px;
|
||
animate y { duration: 120ms; easing: ease-out; }
|
||
|
||
VerticalLayout {
|
||
padding: 8px;
|
||
spacing: 5px;
|
||
|
||
// One album fills the tile; several show as a 2x2 contact sheet, which is
|
||
// how a category reads as "a stack of these" without a label saying so.
|
||
Rectangle {
|
||
height: root.tile-width - 16px;
|
||
clip: true;
|
||
if root.tile.cover-count <= 1: Cover {
|
||
source: root.tile.cover1;
|
||
is-book: root.tile.is-book;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
if root.tile.cover-count > 1: VerticalLayout {
|
||
spacing: 4px;
|
||
HorizontalLayout {
|
||
spacing: 4px;
|
||
Cover { source: root.tile.cover1; radius: 6px; }
|
||
Cover { source: root.tile.cover2; radius: 6px; }
|
||
}
|
||
HorizontalLayout {
|
||
spacing: 4px;
|
||
Cover { source: root.tile.cover3; radius: 6px; }
|
||
Cover { source: root.tile.cover4; radius: 6px; }
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: root.tile.key;
|
||
font-size: Theme.text-body;
|
||
font-weight: 800;
|
||
color: Theme.ink;
|
||
overflow: elide;
|
||
}
|
||
Text {
|
||
text: root.tile.count-label;
|
||
font-size: Theme.text-tiny;
|
||
font-weight: 800;
|
||
color: Theme.count-pink;
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
TouchArea {
|
||
clicked => { root.open(); }
|
||
}
|
||
}
|
||
}
|
||
|
||
component TrackListRow inherits Rectangle {
|
||
in property <TrackRow> track;
|
||
in property <bool> selected;
|
||
callback play();
|
||
|
||
height: 58px;
|
||
background: root.track.current ? Theme.row-active : Theme.row-idle;
|
||
border-radius: Theme.radius-row;
|
||
border-width: root.selected ? 3px : 1px;
|
||
border-color: root.selected ? Theme.paper : (root.track.current ? Theme.accent : Theme.glass-border);
|
||
|
||
HorizontalLayout {
|
||
padding-left: 12px;
|
||
padding-right: 16px;
|
||
spacing: 12px;
|
||
|
||
VerticalLayout {
|
||
alignment: center;
|
||
Cover {
|
||
source: root.track.cover;
|
||
is-book: root.track.is-book;
|
||
radius: 8px;
|
||
height: 40px;
|
||
width: root.track.is-book ? 33px : 40px;
|
||
}
|
||
}
|
||
|
||
VerticalLayout {
|
||
alignment: center;
|
||
Text {
|
||
text: root.track.title;
|
||
font-size: Theme.text-body;
|
||
font-weight: 800;
|
||
color: Theme.paper;
|
||
overflow: elide;
|
||
}
|
||
Text {
|
||
text: root.track.subtitle;
|
||
font-size: Theme.text-tiny;
|
||
font-weight: 700;
|
||
color: Theme.text-sub.with-alpha(0.75);
|
||
overflow: elide;
|
||
}
|
||
}
|
||
|
||
VerticalLayout {
|
||
alignment: center;
|
||
Text {
|
||
text: root.track.duration;
|
||
font-family: Theme.mono;
|
||
font-size: Theme.text-small;
|
||
font-weight: 700;
|
||
color: Theme.text-dim.with-alpha(0.8);
|
||
}
|
||
}
|
||
}
|
||
|
||
TouchArea {
|
||
clicked => { root.play(); }
|
||
}
|
||
}
|
||
|
||
// The root screen: three shelves, each scrolling sideways.
|
||
export component ShelfView inherits Flickable {
|
||
in property <[Shelf]> shelves;
|
||
in property <int> shelf-row;
|
||
in property <int> sel-index;
|
||
callback enter-group(int); // shelf index
|
||
callback enter-category(int, string); // shelf index, category key
|
||
|
||
content-height: layout.preferred-height;
|
||
content-width: self.width;
|
||
|
||
layout := VerticalLayout {
|
||
padding-left: 32px;
|
||
padding-right: Theme.rail-clearance;
|
||
padding-top: 14px;
|
||
// Clear of the player bar, which floats over this rather than displacing it.
|
||
padding-bottom: 210px;
|
||
spacing: 25px;
|
||
|
||
for shelf[s] in root.shelves: GlassPanel {
|
||
height: Metrics.shelf-height + 74px;
|
||
// The panel itself is a target: it opens the whole group.
|
||
TouchArea {
|
||
clicked => { root.enter-group(s); }
|
||
}
|
||
|
||
VerticalLayout {
|
||
padding: 14px;
|
||
spacing: 10px;
|
||
|
||
HorizontalLayout {
|
||
spacing: 10px;
|
||
alignment: start;
|
||
// A round frosted badge behind the emoji, which otherwise renders
|
||
// thin and low-contrast straight on the stage gradient.
|
||
Rectangle {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 17px;
|
||
background: Theme.paper.with-alpha(0.2);
|
||
border-width: 1px;
|
||
border-color: Theme.paper.with-alpha(0.4);
|
||
Text {
|
||
text: shelf.icon;
|
||
font-size: 17px;
|
||
vertical-alignment: center;
|
||
horizontal-alignment: center;
|
||
}
|
||
}
|
||
VerticalLayout {
|
||
alignment: center;
|
||
Text {
|
||
text: shelf.label;
|
||
font-size: Theme.text-heading;
|
||
font-weight: 900;
|
||
color: Theme.paper;
|
||
}
|
||
}
|
||
VerticalLayout {
|
||
alignment: center;
|
||
Text {
|
||
text: "›";
|
||
font-size: 17px;
|
||
font-weight: 900;
|
||
color: Theme.paper.with-alpha(0.6);
|
||
}
|
||
}
|
||
}
|
||
|
||
if shelf.tiles.length == 0: Text {
|
||
text: shelf.empty-label;
|
||
font-size: Theme.text-small;
|
||
font-weight: 700;
|
||
color: Theme.text-dim.with-alpha(0.6);
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
|
||
if shelf.tiles.length > 0: Flickable {
|
||
content-width: shelf.tiles.length * (Theme.shelf-tile + 12px) + 20px;
|
||
content-height: self.height;
|
||
// Keep the focused tile in view as the arrow keys walk past the fold.
|
||
content-x: root.shelf-row != s
|
||
? 0px
|
||
: -max(0px, min(
|
||
self.content-width - self.width,
|
||
root.sel-index * (Theme.shelf-tile + 12px) - self.width / 2 + Theme.shelf-tile / 2));
|
||
animate content-x { duration: 180ms; easing: ease-out; }
|
||
|
||
HorizontalLayout {
|
||
spacing: 12px;
|
||
padding: 10px;
|
||
alignment: start;
|
||
for tile[i] in shelf.tiles: CategoryCard {
|
||
tile: tile;
|
||
selected: root.shelf-row == s && root.sel-index == i;
|
||
open => { root.enter-category(s, tile.key); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Inside a group, or showing search results.
|
||
//
|
||
// Exactly one of the three lists is ever populated, and that is a property of the query
|
||
// model rather than a coincidence worth defending against: track hits only exist in
|
||
// track-search mode, which forces the album list empty; the category list only exists
|
||
// when there is neither a query nor a chosen category, which is exactly when the album
|
||
// list is empty. So this shows one list filling the page, instead of stacking three
|
||
// inside a scroller and nesting a second scroller in it.
|
||
export component ResultsView inherits VerticalLayout {
|
||
in property <string> group-label;
|
||
in property <string> category-heading;
|
||
in property <string> album-heading;
|
||
in property <bool> show-search;
|
||
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 <int> sel-index;
|
||
/// Which row of the visible grid holds the selection. Every row is the same height
|
||
/// by construction, so following the keyboard is arithmetic, not measurement.
|
||
in property <int> sel-row;
|
||
in property <bool> empty;
|
||
callback play-song(string, int);
|
||
callback open-category(string);
|
||
callback open-album(string);
|
||
callback play-album(string);
|
||
|
||
spacing: 6px;
|
||
padding-left: 32px;
|
||
padding-right: Theme.rail-clearance;
|
||
|
||
if root.group-label != "": Text {
|
||
text: root.group-label;
|
||
font-size: 15px;
|
||
font-weight: 800;
|
||
color: Theme.paper;
|
||
horizontal-alignment: center;
|
||
}
|
||
|
||
if root.show-search: HorizontalLayout {
|
||
alignment: center;
|
||
spacing: 12px;
|
||
padding-bottom: 4px;
|
||
|
||
Rectangle {
|
||
background: Theme.paper;
|
||
border-radius: Theme.pill;
|
||
drop-shadow-color: #00101759;
|
||
drop-shadow-blur: 14px;
|
||
drop-shadow-offset-y: 4px;
|
||
width: chip.preferred-width;
|
||
height: chip.preferred-height;
|
||
|
||
chip := HorizontalLayout {
|
||
padding-left: 10px;
|
||
padding-right: 20px;
|
||
padding-top: 10px;
|
||
padding-bottom: 10px;
|
||
spacing: 10px;
|
||
|
||
Rectangle {
|
||
background: Theme.ink;
|
||
border-radius: Theme.pill;
|
||
width: label.preferred-width;
|
||
label := HorizontalLayout {
|
||
padding-left: 10px;
|
||
padding-right: 10px;
|
||
padding-top: 4px;
|
||
padding-bottom: 4px;
|
||
Text {
|
||
text: root.mode-label;
|
||
font-size: Theme.text-small + 1px;
|
||
font-weight: 800;
|
||
color: white;
|
||
}
|
||
}
|
||
}
|
||
Text {
|
||
text: root.search-text;
|
||
font-size: Theme.text-heading;
|
||
font-weight: 800;
|
||
color: Theme.ink;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
VerticalLayout {
|
||
alignment: center;
|
||
Text {
|
||
text: root.count-label;
|
||
font-size: Theme.text-small + 1px;
|
||
font-weight: 700;
|
||
color: Theme.text-dim.with-alpha(0.85);
|
||
}
|
||
}
|
||
}
|
||
|
||
if root.empty: VerticalLayout {
|
||
alignment: center;
|
||
Text {
|
||
text: "Nichts gefunden — probier andere Buchstaben!";
|
||
font-size: Theme.text-heading;
|
||
font-weight: 800;
|
||
color: Theme.paper;
|
||
horizontal-alignment: center;
|
||
}
|
||
}
|
||
|
||
// --- track hits -----------------------------------------------------------
|
||
// Capped at 40 rows upstream, so this one does not need virtualizing. Held to a
|
||
// readable column rather than the full width, which is what lets a long album
|
||
// line elide instead of running the width of a 1080p screen. The heading rides
|
||
// inside that column so the two are aligned with each other, not with the page.
|
||
if root.songs.length > 0: Flickable {
|
||
content-height: songs-column.preferred-height + 210px;
|
||
content-width: self.width;
|
||
HorizontalLayout {
|
||
alignment: center;
|
||
songs-column := VerticalLayout {
|
||
// Without this the column is centred in whatever vertical space is
|
||
// left over, which on a short result list leaves it floating.
|
||
alignment: start;
|
||
width: 860px;
|
||
spacing: 8px;
|
||
Text {
|
||
text: "Titel";
|
||
font-size: Theme.text-heading;
|
||
font-weight: 900;
|
||
color: Theme.paper;
|
||
}
|
||
GlassPanel {
|
||
height: song-rows.preferred-height;
|
||
song-rows := VerticalLayout {
|
||
padding: 12px;
|
||
spacing: 6px;
|
||
for song in root.songs: TrackListRow {
|
||
track: song;
|
||
selected: root.sel-index == song.nav;
|
||
play => { root.play-song(song.album-id, song.index); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- categories -----------------------------------------------------------
|
||
if root.category-grid.length > 0: Text {
|
||
text: root.category-heading;
|
||
font-size: Theme.text-heading;
|
||
font-weight: 900;
|
||
color: Theme.paper;
|
||
}
|
||
if root.category-grid.length > 0: ListView {
|
||
for row in root.category-grid: HorizontalLayout {
|
||
height: Metrics.category-row-height;
|
||
spacing: Theme.grid-gap;
|
||
alignment: start;
|
||
for tile in row.tiles: CategoryCard {
|
||
tile: tile;
|
||
tile-width: Theme.card-width;
|
||
selected: root.sel-index == tile.nav;
|
||
open => { root.open-category(tile.key); }
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- albums ---------------------------------------------------------------
|
||
if root.grid.length > 0: Text {
|
||
text: root.album-heading;
|
||
font-size: Theme.text-heading;
|
||
font-weight: 900;
|
||
color: Theme.paper;
|
||
}
|
||
if root.grid.length > 0: album-list := ListView {
|
||
// Follow the keyboard without measuring anything.
|
||
content-y: -max(0px, min(
|
||
max(0px, self.content-height - self.height),
|
||
root.sel-row * Metrics.row-height));
|
||
animate content-y { duration: 180ms; easing: ease-out; }
|
||
|
||
for row in root.grid: HorizontalLayout {
|
||
height: Metrics.row-height;
|
||
spacing: Theme.grid-gap;
|
||
alignment: start;
|
||
for tile in row.tiles: AlbumCard {
|
||
width: Theme.card-width;
|
||
tile: tile;
|
||
selected: root.sel-index == tile.nav;
|
||
open => { root.open-album(tile.id); }
|
||
play => { root.play-album(tile.id); }
|
||
}
|
||
}
|
||
}
|
||
}
|