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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
567
slint-frontend/ui/browse.slint
Normal file
567
slint-frontend/ui/browse.slint
Normal file
@@ -0,0 +1,567 @@
|
||||
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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
slint-frontend/ui/models.slint
Normal file
91
slint-frontend/ui/models.slint
Normal file
@@ -0,0 +1,91 @@
|
||||
// The shapes that cross between Rust and the UI.
|
||||
//
|
||||
// Everything here is already *presentation*: labels are formatted, durations are
|
||||
// strings, covers are decoded images. Nothing in the .slint files formats a number or
|
||||
// picks a word, so the German copy and the duration format live in one place (Rust)
|
||||
// rather than being split across two languages.
|
||||
//
|
||||
// `nav` is each item's position in the single flat selection order that runs songs ->
|
||||
// categories -> albums, so one pair of arrow keys walks the whole page. It is assigned
|
||||
// in Rust, where the three lists are built together.
|
||||
|
||||
export struct AlbumTile {
|
||||
id: string,
|
||||
title: string,
|
||||
artist: string,
|
||||
// "12 Songs · 45:03 · 🧸" - already assembled, including the figurine marker.
|
||||
meta: string,
|
||||
cover: image,
|
||||
is-book: bool,
|
||||
locked: bool,
|
||||
// This album is the one playing; drawn with the accent ring rather than the
|
||||
// selection ring, and outranked by it when they coincide.
|
||||
current: bool,
|
||||
nav: int,
|
||||
}
|
||||
|
||||
// The grid is handed to the UI pre-chunked into rows, because a `ListView` virtualizes
|
||||
// its model and a flat list of 343 cards has no rows to skip. This is the direct
|
||||
// counterpart of the web app's `content-visibility: auto` on `.grid > .card`, which
|
||||
// exists for exactly the same measurement: 340 live cards saturated the Pi's renderer.
|
||||
export struct GridRow {
|
||||
tiles: [AlbumTile],
|
||||
}
|
||||
|
||||
export struct CategoryTile {
|
||||
key: string,
|
||||
// "7 Hörbücher"
|
||||
count-label: string,
|
||||
// Up to four covers in a 2x2, or one filling the tile when the category holds a
|
||||
// single album. Four fields rather than an array: the count is fixed and small, and
|
||||
// this keeps the tile a plain struct.
|
||||
cover1: image,
|
||||
cover2: image,
|
||||
cover3: image,
|
||||
cover4: image,
|
||||
cover-count: int,
|
||||
is-book: bool,
|
||||
nav: int,
|
||||
}
|
||||
|
||||
export struct CategoryRow {
|
||||
tiles: [CategoryTile],
|
||||
}
|
||||
|
||||
// One of the three shelves on the root screen.
|
||||
export struct Shelf {
|
||||
label: string,
|
||||
icon: string,
|
||||
tiles: [CategoryTile],
|
||||
empty-label: string,
|
||||
}
|
||||
|
||||
export struct TrackRow {
|
||||
title: string,
|
||||
subtitle: string,
|
||||
duration: string,
|
||||
cover: image,
|
||||
is-book: bool,
|
||||
current: bool,
|
||||
locked: bool,
|
||||
album-id: string,
|
||||
index: int,
|
||||
nav: int,
|
||||
}
|
||||
|
||||
// What the player bar and the full-screen view both say. Shared so the two cannot
|
||||
// drift apart on a copy change.
|
||||
export struct NowPlaying {
|
||||
title: string,
|
||||
subtitle: string,
|
||||
// "Song 3 von 8" / "Kapitel 3 von 8"
|
||||
counter: string,
|
||||
cover: image,
|
||||
hero-cover: image,
|
||||
is-book: bool,
|
||||
has-album: bool,
|
||||
// "−4:12", the time left in this track.
|
||||
clock-label: string,
|
||||
// "Noch 38:20 im Album" - empty for a podcast, where it means nothing.
|
||||
remaining-label: string,
|
||||
}
|
||||
413
slint-frontend/ui/player.slint
Normal file
413
slint-frontend/ui/player.slint
Normal file
@@ -0,0 +1,413 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { Cover, Transport, ProgressBar, VolumeBars, RoundButton, GlassPanel } from "widgets.slint";
|
||||
import { NowPlaying, TrackRow } from "models.slint";
|
||||
|
||||
// The bar along the bottom of the browse screen.
|
||||
export component PlayerBar inherits Rectangle {
|
||||
in property <NowPlaying> now;
|
||||
in property <bool> playing;
|
||||
in property <float> progress;
|
||||
in property <bool> smooth;
|
||||
in property <int> volume;
|
||||
callback toggle();
|
||||
callback next();
|
||||
callback previous();
|
||||
callback seek(float);
|
||||
callback set-volume(int);
|
||||
callback mute();
|
||||
callback open-play-view();
|
||||
|
||||
height: 96px;
|
||||
background: Theme.bar-bg;
|
||||
|
||||
// The accent rule along the top is what separates the bar from the stage without a
|
||||
// shadow, which over a gradient reads as a smudge.
|
||||
Rectangle {
|
||||
y: 0;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
background: Theme.accent.with-alpha(0.5);
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
spacing: 18px;
|
||||
|
||||
// Cover: the way back into the full-screen view.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: root.now.is-book ? 46px : 56px;
|
||||
height: 56px;
|
||||
Cover {
|
||||
source: root.now.cover;
|
||||
is-book: root.now.is-book;
|
||||
radius: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
TouchArea {
|
||||
clicked => { root.open-play-view(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
width: 240px;
|
||||
Text {
|
||||
text: root.now.title;
|
||||
font-size: Theme.text-body;
|
||||
font-weight: 800;
|
||||
color: Theme.paper;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: root.now.subtitle;
|
||||
font-size: Theme.text-small;
|
||||
font-weight: 600;
|
||||
color: Theme.text-sub.with-alpha(0.75);
|
||||
overflow: elide;
|
||||
}
|
||||
if root.now.has-album: Text {
|
||||
text: root.now.counter;
|
||||
font-size: Theme.text-tiny;
|
||||
font-weight: 800;
|
||||
color: Theme.accent-dim;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
ProgressBar {
|
||||
progress: root.progress;
|
||||
smooth: root.smooth;
|
||||
on-dark: true;
|
||||
bar-height: 10px;
|
||||
seek(fraction) => { root.seek(fraction); }
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Transport {
|
||||
playing: root.playing;
|
||||
size: 48px;
|
||||
toggle => { root.toggle(); }
|
||||
next => { root.next(); }
|
||||
previous => { root.previous(); }
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
VolumeBars {
|
||||
volume: root.volume;
|
||||
bar-width: 11px;
|
||||
bar-height: 30px;
|
||||
set-volume(v) => { root.set-volume(v); }
|
||||
mute => { root.mute(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The full-screen now-playing view.
|
||||
export component PlayView inherits VerticalLayout {
|
||||
in property <NowPlaying> now;
|
||||
in property <bool> playing;
|
||||
in property <float> progress;
|
||||
in property <bool> smooth;
|
||||
in property <int> volume;
|
||||
callback toggle();
|
||||
callback next();
|
||||
callback previous();
|
||||
callback seek(float);
|
||||
callback set-volume(int);
|
||||
callback mute();
|
||||
callback open-album();
|
||||
|
||||
alignment: center;
|
||||
spacing: 18px;
|
||||
padding: 32px;
|
||||
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
height: 320px;
|
||||
width: root.now.is-book ? 262px : 320px;
|
||||
drop-shadow-color: #0016208c;
|
||||
drop-shadow-blur: 40px;
|
||||
drop-shadow-offset-y: 20px;
|
||||
|
||||
if root.now.has-album: Cover {
|
||||
source: root.now.hero-cover;
|
||||
is-book: root.now.is-book;
|
||||
radius: 28px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
if !root.now.has-album: Rectangle {
|
||||
background: #2a4f57;
|
||||
border-radius: 28px;
|
||||
}
|
||||
TouchArea {
|
||||
clicked => { root.open-album(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: root.now.title;
|
||||
font-size: Theme.text-hero;
|
||||
font-weight: 900;
|
||||
color: Theme.paper;
|
||||
horizontal-alignment: center;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Text {
|
||||
text: root.now.subtitle;
|
||||
font-size: Theme.text-heading;
|
||||
font-weight: 700;
|
||||
color: Theme.text-dim.with-alpha(0.8);
|
||||
horizontal-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
if root.now.has-album: Text {
|
||||
text: root.now.counter;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: Theme.accent-dim;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
VerticalLayout {
|
||||
width: 680px;
|
||||
spacing: 8px;
|
||||
ProgressBar {
|
||||
progress: root.progress;
|
||||
smooth: root.smooth;
|
||||
bar-height: 14px;
|
||||
seek(fraction) => { root.seek(fraction); }
|
||||
}
|
||||
HorizontalLayout {
|
||||
Text {
|
||||
text: root.now.clock-label;
|
||||
font-family: Theme.mono;
|
||||
font-size: Theme.text-small + 1px;
|
||||
font-weight: 800;
|
||||
color: Theme.text-dim.with-alpha(0.8);
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
Text {
|
||||
text: root.now.remaining-label;
|
||||
font-size: Theme.text-small + 1px;
|
||||
font-weight: 800;
|
||||
color: Theme.text-dim.with-alpha(0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Transport {
|
||||
playing: root.playing;
|
||||
size: 72px;
|
||||
toggle => { root.toggle(); }
|
||||
next => { root.next(); }
|
||||
previous => { root.previous(); }
|
||||
}
|
||||
|
||||
VolumeBars {
|
||||
volume: root.volume;
|
||||
bar-width: 18px;
|
||||
bar-height: 44px;
|
||||
set-volume(v) => { root.set-volume(v); }
|
||||
mute => { root.mute(); }
|
||||
}
|
||||
}
|
||||
|
||||
// The track list for one album, over a dimmed stage.
|
||||
export component AlbumSheet inherits Rectangle {
|
||||
in property <string> title;
|
||||
in property <string> subtitle;
|
||||
in property <string> meta;
|
||||
in property <image> cover;
|
||||
in property <bool> is-book;
|
||||
in property <[TrackRow]> tracks;
|
||||
in property <int> sel-index;
|
||||
callback close();
|
||||
callback play(int);
|
||||
callback play-all();
|
||||
|
||||
background: Theme.backdrop.with-alpha(0.6);
|
||||
|
||||
// Catches every click that misses the sheet, so tapping the dimmed area closes it.
|
||||
TouchArea {
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 640px;
|
||||
height: min(root.height - 80px, 720px);
|
||||
background: Theme.sheet-bg;
|
||||
border-radius: Theme.radius-sheet;
|
||||
drop-shadow-color: #0016208c;
|
||||
drop-shadow-blur: 60px;
|
||||
drop-shadow-offset-y: 24px;
|
||||
|
||||
// Without this the backdrop's TouchArea above would receive clicks aimed at the
|
||||
// sheet's own background and close it.
|
||||
TouchArea { }
|
||||
|
||||
VerticalLayout {
|
||||
padding: 26px;
|
||||
spacing: 16px;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 18px;
|
||||
Cover {
|
||||
source: root.cover;
|
||||
is-book: root.is-book;
|
||||
width: root.is-book ? 123px : 150px;
|
||||
height: 150px;
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
spacing: 6px;
|
||||
Text {
|
||||
text: root.title;
|
||||
font-size: Theme.text-title;
|
||||
font-weight: 900;
|
||||
color: Theme.ink;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Text {
|
||||
text: root.subtitle;
|
||||
font-size: Theme.text-body;
|
||||
font-weight: 700;
|
||||
color: Theme.ink.with-alpha(0.7);
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: root.meta;
|
||||
font-size: Theme.text-small;
|
||||
font-weight: 800;
|
||||
color: Theme.count-pink;
|
||||
overflow: elide;
|
||||
}
|
||||
Rectangle {
|
||||
height: 8px;
|
||||
}
|
||||
Rectangle {
|
||||
background: play-all-touch.pressed ? Theme.accent-dim : Theme.accent;
|
||||
border-radius: Theme.pill;
|
||||
width: play-all.preferred-width;
|
||||
height: play-all.preferred-height;
|
||||
play-all := HorizontalLayout {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
Text {
|
||||
text: "▶ Alle abspielen";
|
||||
font-size: Theme.text-small + 1px;
|
||||
font-weight: 800;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
play-all-touch := TouchArea {
|
||||
clicked => { root.play-all(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
RoundButton {
|
||||
label: "×";
|
||||
size: 40px;
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
content-height: list.preferred-height;
|
||||
content-width: self.width;
|
||||
// Follow the keyboard through the list without measuring: every row is
|
||||
// the same height, so the selected one's offset is arithmetic.
|
||||
content-y: -max(0px, min(
|
||||
max(0px, self.content-height - self.height),
|
||||
root.sel-index * 52px - self.height / 2));
|
||||
animate content-y { duration: 150ms; easing: ease-out; }
|
||||
|
||||
list := VerticalLayout {
|
||||
spacing: 4px;
|
||||
for track[i] in root.tracks: Rectangle {
|
||||
height: 48px;
|
||||
background: track.current ? Theme.accent.with-alpha(0.18) : transparent;
|
||||
border-radius: 14px;
|
||||
border-width: root.sel-index == i ? 3px : 0px;
|
||||
border-color: Theme.accent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
spacing: 12px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
background: track.current ? Theme.accent : Theme.ink.with-alpha(0.12);
|
||||
Text {
|
||||
text: i + 1;
|
||||
font-size: Theme.text-tiny;
|
||||
font-weight: 800;
|
||||
color: track.current ? white : Theme.ink;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: track.title;
|
||||
font-size: Theme.text-small + 1px;
|
||||
font-weight: 700;
|
||||
color: Theme.ink;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Text {
|
||||
text: track.duration;
|
||||
font-family: Theme.mono;
|
||||
font-size: Theme.text-tiny;
|
||||
font-weight: 700;
|
||||
color: Theme.ink.with-alpha(0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
clicked => { root.play(i); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
slint-frontend/ui/theme.slint
Normal file
81
slint-frontend/ui/theme.slint
Normal file
@@ -0,0 +1,81 @@
|
||||
// The palette, converted once from the web front-end's oklch tokens
|
||||
// (`web/src/styles/app.css`) into the sRGB hex Slint speaks. The oklch source value is
|
||||
// kept beside each one, because that is the form the design is actually maintained in -
|
||||
// if a colour needs adjusting, adjust the oklch and re-convert (`src/oklch.rs`), rather
|
||||
// than nudging the hex and letting the two drift.
|
||||
|
||||
export global Theme {
|
||||
// ------------------------------------------------------------------ colours --
|
||||
out property <color> ink: #113339; // oklch(30% 0.04 210)
|
||||
out property <color> paper: #eef7f9; // oklch(97% 0.01 210)
|
||||
out property <color> accent: #de73bd; // oklch(70% 0.16 340)
|
||||
out property <color> accent-dim: #f292d3; // oklch(78% 0.14 340)
|
||||
out property <color> sea-deep: #001b21; // oklch(20% 0.045 210)
|
||||
out property <color> shadow: #001017; // oklch(15% 0.05 210)
|
||||
|
||||
// The stage gradient, which in the web app sits under an animated canvas. There is
|
||||
// no canvas here: a full-viewport repaint loop is the one cost a Pi cannot absorb,
|
||||
// and `?pi=1` already removes it on the device this replaces.
|
||||
out property <color> stage-top: #397d88; // oklch(55% 0.07 210)
|
||||
out property <color> stage-mid: #0e4b54; // oklch(38% 0.06 210)
|
||||
|
||||
out property <color> text-dim: #c9dbdf; // oklch(88% 0.02 210)
|
||||
out property <color> text-sub: #c0d2d5; // oklch(85% 0.02 210)
|
||||
out property <color> count-pink: #7d0563; // oklch(40% 0.17 340)
|
||||
|
||||
out property <color> card-music: #e4f1f4; // oklch(95% 0.015 210)
|
||||
out property <color> card-book: #ffd5ab; // oklch(92% 0.09 55)
|
||||
out property <color> book-edge-1: #ffc298; // oklch(86% 0.09 55)
|
||||
out property <color> book-edge-2: #e5a880; // oklch(78% 0.09 55)
|
||||
|
||||
out property <color> bar-bg: #00171e; // oklch(18% 0.05 210)
|
||||
out property <color> sheet-bg: #e9f4f6; // oklch(96% 0.012 210)
|
||||
out property <color> backdrop: #000e12; // oklch(15% 0.03 210)
|
||||
// Two track colours, not one with an alpha: the seek bar sits on the pale stage in
|
||||
// the play view and on the near-black player bar in the browse view, and a single
|
||||
// translucent fill that reads correctly on one is invisible on the other.
|
||||
out property <color> progress-track: #1b3236; // oklch(30% 0.03 210)
|
||||
out property <color> progress-track-dark: #2e4f57;
|
||||
out property <color> vol-empty: #c9dbdf; // oklch(88% 0.02 210)
|
||||
|
||||
// Glass, without the glass. `backdrop-filter` has no counterpart here, so these are
|
||||
// exactly the web app's own `data-blur="off"` fallback: a flat translucent fill.
|
||||
// That is a real loss of depth and the honest substitute for it - faking a blur by
|
||||
// compositing a pre-blurred copy would reintroduce the per-frame cost the blur was
|
||||
// dropped to avoid.
|
||||
out property <brush> glass: @linear-gradient(160deg, #eef7f924 0%, #eef7f90d 100%);
|
||||
out property <color> glass-border: #eef7f929;
|
||||
out property <color> row-idle: #f7fcfd1a;
|
||||
out property <color> row-active: #f7fcfd38;
|
||||
|
||||
// ------------------------------------------------------------------- metrics --
|
||||
out property <length> radius-panel: 22px;
|
||||
out property <length> radius-card: 16px;
|
||||
out property <length> radius-row: 12px;
|
||||
out property <length> radius-sheet: 26px;
|
||||
out property <length> pill: 999px;
|
||||
|
||||
// The album grid's track width. The web grid is `minmax(180px, 1fr)`; keeping the
|
||||
// same number keeps the same number of columns at 1920, which is what the shelf
|
||||
// rows and the measured card height below were both tuned against.
|
||||
out property <length> card-width: 180px;
|
||||
out property <length> grid-gap: 24px;
|
||||
out property <length> shelf-tile: 148px;
|
||||
|
||||
// How much of the right edge the page must leave clear for nothing to render
|
||||
// underneath the corner controls.
|
||||
out property <length> rail-clearance: 88px;
|
||||
|
||||
// ---------------------------------------------------------------- typography --
|
||||
// Heavy throughout: 700/800/900. Durations and counters use the monospace family so
|
||||
// a ticking clock does not reflow the row it sits in.
|
||||
out property <string> font: "Nunito";
|
||||
out property <string> mono: "monospace";
|
||||
|
||||
out property <length> text-hero: 44px;
|
||||
out property <length> text-title: 26px;
|
||||
out property <length> text-heading: 20px;
|
||||
out property <length> text-body: 16px;
|
||||
out property <length> text-small: 13px;
|
||||
out property <length> text-tiny: 12px;
|
||||
}
|
||||
299
slint-frontend/ui/widgets.slint
Normal file
299
slint-frontend/ui/widgets.slint
Normal file
@@ -0,0 +1,299 @@
|
||||
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(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user