Files
musicmouse/slint-frontend/ui/app.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

262 lines
9.8 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 { Theme } from "theme.slint";
import { RoundButton } from "widgets.slint";
import { ShelfView, ResultsView } from "browse.slint";
import { PlayerBar, PlayView, AlbumSheet } from "player.slint";
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying } from "models.slint";
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, Theme }
export component AppWindow inherits Window {
title: "Musik Delfin";
default-font-family: Theme.font;
default-font-size: Theme.text-body;
// The stage. In the web app a per-track animated canvas sits on top of this; here
// it is the whole background, on purpose. A full-viewport repaint loop is a floor
// you cannot get under while it runs at all, and it is the first thing `?pi=1`
// already removes on the device this is meant to replace.
background: @linear-gradient(180deg,
Theme.stage-top 0%,
Theme.stage-mid 45%,
Theme.sea-deep 100%);
// ------------------------------------------------------------------- inputs --
in property <bool> loading: true;
in property <string> splash-text;
in property <string> status-label;
in property <bool> online: false;
in property <bool> play-view: false;
in property <bool> show-sheet: false;
in property <bool> can-go-back: false;
in property <[Shelf]> shelves;
in property <bool> root-screen: true;
in property <int> shelf-row: 0;
in property <string> group-label;
in property <string> category-heading;
in property <string> album-heading;
in property <bool> show-search: false;
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 <bool> no-results: false;
in property <int> sel-index: 0;
in property <int> sel-row: 0;
in property <NowPlaying> now;
in property <bool> playing: false;
in property <float> progress: 0.0;
in property <bool> smooth: true;
in property <int> volume: 60;
in property <bool> has-bar: false;
in property <string> sheet-title;
in property <string> sheet-subtitle;
in property <string> sheet-meta;
in property <image> sheet-cover;
in property <bool> sheet-is-book: false;
in property <[TrackRow]> sheet-tracks;
in property <int> sheet-index: 0;
// How wide the grid may be. Rust reads this back to decide the column count, so the
// chunking into `GridRow`s and the layout agree about how many fit.
out property <length> grid-width: self.width - 32px - Theme.rail-clearance;
// ---------------------------------------------------------------- callbacks --
// The special keys are named here rather than in Rust because this is where the
// `Key.*` constants exist; Rust receives "escape"/"left"/"enter"/… or the literal
// character that was typed.
callback key(string, bool, bool);
callback toggle();
callback next();
callback previous();
callback seek(float);
callback set-volume(int);
callback mute();
callback go-back();
callback enter-group(int);
callback enter-category(int, string);
callback open-category(string);
callback open-album(string);
callback play-album(string);
callback play-song(string, int);
callback open-play-view();
callback close-sheet();
callback play-sheet-track(int);
forward-focus: keys;
keys := FocusScope {
key-pressed(event) => {
root.key(
event.text == Key.Escape ? "escape"
: event.text == Key.Return ? "enter"
: event.text == Key.Backspace ? "backspace"
: event.text == Key.Tab ? "tab"
: event.text == Key.LeftArrow ? "left"
: event.text == Key.RightArrow ? "right"
: event.text == Key.UpArrow ? "up"
: event.text == Key.DownArrow ? "down"
: event.text == Key.F1 ? "f1"
: event.text == Key.Space ? "space"
: event.text,
event.modifiers.control,
event.modifiers.shift);
accept
}
VerticalLayout {
// ------------------------------------------------------------ header --
if !root.play-view: HorizontalLayout {
alignment: center;
spacing: 16px;
padding-top: 18px;
padding-bottom: 6px;
Text {
text: "Musik Delfin";
font-size: 34px;
font-weight: 900;
color: Theme.paper;
}
if root.status-label != "": VerticalLayout {
alignment: center;
Rectangle {
background: Theme.paper.with-alpha(0.16);
border-radius: Theme.pill;
width: status.preferred-width;
height: status.preferred-height;
status := HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 6px;
padding-bottom: 6px;
Text {
text: root.status-label;
font-size: Theme.text-small + 1px;
font-weight: 800;
color: root.online ? Theme.paper : #ffb4a2;
}
}
}
}
}
// -------------------------------------------------------------- body --
if !root.play-view && root.root-screen: ShelfView {
shelves: root.shelves;
shelf-row: root.shelf-row;
sel-index: root.sel-index;
enter-group(s) => { root.enter-group(s); }
enter-category(s, key) => { root.enter-category(s, key); }
}
if !root.play-view && !root.root-screen: ResultsView {
group-label: root.group-label;
category-heading: root.category-heading;
album-heading: root.album-heading;
show-search: root.show-search;
search-text: root.search-text;
mode-label: root.mode-label;
count-label: root.count-label;
songs: root.songs;
category-grid: root.category-grid;
grid: root.grid;
sel-index: root.sel-index;
sel-row: root.sel-row;
empty: root.no-results;
play-song(id, i) => { root.play-song(id, i); }
open-category(key) => { root.open-category(key); }
open-album(id) => { root.open-album(id); }
play-album(id) => { root.play-album(id); }
}
if root.play-view: PlayView {
now: root.now;
playing: root.playing;
progress: root.progress;
smooth: root.smooth;
volume: root.volume;
toggle => { root.toggle(); }
next => { root.next(); }
previous => { root.previous(); }
seek(f) => { root.seek(f); }
set-volume(v) => { root.set-volume(v); }
mute => { root.mute(); }
open-album => { root.open-album(root.now.title); }
}
}
}
// The player bar floats over the browse screen rather than displacing it, which is
// why the scrollers above reserve bottom padding for it. Geometry is set here
// rather than inside the component: a plain element takes its *preferred* width
// from its content, not its parent's width, so a bar that only declared a height
// left gaps for the grid behind it to show through.
if !root.play-view && root.has-bar: PlayerBar {
x: 0;
y: parent.height - self.height;
width: parent.width;
now: root.now;
playing: root.playing;
progress: root.progress;
smooth: root.smooth;
volume: root.volume;
toggle => { root.toggle(); }
next => { root.next(); }
previous => { root.previous(); }
seek(f) => { root.seek(f); }
set-volume(v) => { root.set-volume(v); }
mute => { root.mute(); }
open-play-view => { root.open-play-view(); }
}
// One fixed round button in the top-left: back, everywhere it applies, so "top
// left" always means the same thing.
if root.can-go-back: RoundButton {
x: 24px;
y: 18px;
label: "";
clicked => { root.go-back(); }
}
if root.show-sheet: AlbumSheet {
width: 100%;
height: 100%;
title: root.sheet-title;
subtitle: root.sheet-subtitle;
meta: root.sheet-meta;
cover: root.sheet-cover;
is-book: root.sheet-is-book;
tracks: root.sheet-tracks;
sel-index: root.sheet-index;
close => { root.close-sheet(); }
play(i) => { root.play-sheet-track(i); }
play-all => { root.play-sheet-track(0); }
}
if root.loading: Rectangle {
background: Theme.sea-deep.with-alpha(0.92);
TouchArea { }
VerticalLayout {
alignment: center;
spacing: 18px;
Text {
text: "🐬";
font-size: 96px;
horizontal-alignment: center;
}
Text {
text: root.splash-text;
font-size: 22px;
font-weight: 800;
color: Theme.paper;
horizontal-alignment: center;
}
}
}
}