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>
This commit is contained in:
261
slint-frontend/ui/app.slint
Normal file
261
slint-frontend/ui/app.slint
Normal file
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user