Files
musicmouse/slint-frontend/ui/browse.slint
Martin Bauer 6ccb3e458f 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>
2026-09-20 22:26:09 +02:00

568 lines
20 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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); }
}
}
}
}