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:
985
slint-frontend/src/main.rs
Normal file
985
slint-frontend/src/main.rs
Normal file
@@ -0,0 +1,985 @@
|
||||
//! A native front-end for the MusicMouse player.
|
||||
//!
|
||||
//! The same split the web front-end is built on: **what is playing** comes from the
|
||||
//! backend over a push-only websocket, **what you are looking at** lives here. The
|
||||
//! device has other front-ends - the buttons on the mouse, a figurine on the reader,
|
||||
//! Home Assistant - so this follows playback rather than owning it.
|
||||
//!
|
||||
//! Three threads and no async runtime. The UI thread does layout and nothing else; a
|
||||
//! websocket thread reads state; a cover thread fetches and downscales album art; a
|
||||
//! command thread makes the HTTP calls, because a blocking POST on the UI thread is
|
||||
//! exactly the stall this front-end exists to remove.
|
||||
|
||||
mod api;
|
||||
mod covers;
|
||||
mod format;
|
||||
mod keymap;
|
||||
mod library;
|
||||
mod oklch;
|
||||
mod ws;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{channel, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use slint::{ModelRc, SharedString, VecModel};
|
||||
|
||||
use api::{Album, Client, PlayerState};
|
||||
|
||||
use covers::Tier;
|
||||
use keymap::{Action, UiState};
|
||||
use library::{Group, Library, Query, Results, GROUPS};
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
// The application state lives on the UI thread and is reached from the event-loop
|
||||
// closures the worker relay posts. See `pump_workers`.
|
||||
thread_local! {
|
||||
static APP: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- commands --
|
||||
|
||||
/// Anything that talks to the backend, run off the UI thread. They all answer 204 with
|
||||
/// no body, so nothing comes back: the authoritative answer arrives as a pushed state
|
||||
/// frame, which is also what makes the optimistic updates below safe.
|
||||
enum Command {
|
||||
Play {
|
||||
album_id: String,
|
||||
track_index: usize,
|
||||
},
|
||||
Resume,
|
||||
Pause,
|
||||
Next,
|
||||
Previous,
|
||||
Seek(f64),
|
||||
Volume(i32),
|
||||
ReloadLibrary,
|
||||
}
|
||||
|
||||
enum FromWorker {
|
||||
Library(Result<Vec<Album>, String>),
|
||||
Cover(covers::Ready),
|
||||
Ws(WsEvent),
|
||||
}
|
||||
|
||||
enum WsEvent {
|
||||
Connected,
|
||||
Disconnected,
|
||||
State(Box<PlayerState>),
|
||||
Position { position: f64, duration: f64 },
|
||||
LibraryChanged,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ state --
|
||||
|
||||
struct App {
|
||||
commands: Sender<Command>,
|
||||
ui: UiState,
|
||||
player: PlayerState,
|
||||
library: Library,
|
||||
covers: covers::Store,
|
||||
online: bool,
|
||||
loaded: bool,
|
||||
error: Option<String>,
|
||||
/// Changes whenever the track itself changes, so the progress bar can stop
|
||||
/// animating across two unrelated positions instead of sliding between them.
|
||||
track_key: String,
|
||||
/// False for exactly one render: the frame a track changes on, and the frame a seek
|
||||
/// lands on.
|
||||
smooth: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn send(&self, command: Command) {
|
||||
let _ = self.commands.send(command);
|
||||
}
|
||||
|
||||
fn query(&self) -> Query<'_> {
|
||||
Query {
|
||||
search: &self.ui.search,
|
||||
tracks_mode: self.ui.tracks_mode,
|
||||
group: self.ui.group,
|
||||
category: self.ui.category.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_album(&self) -> Option<&Rc<Album>> {
|
||||
self.player
|
||||
.album_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.library.get(id))
|
||||
}
|
||||
|
||||
/// How long each of the three root shelves is, for the keyboard's column clamping.
|
||||
fn shelf_lengths(&self) -> [usize; 3] {
|
||||
[
|
||||
self.library.categories(Group::Music).len(),
|
||||
self.library.categories(Group::Audiobooks).len(),
|
||||
self.library.categories(Group::Podcasts).len(),
|
||||
]
|
||||
}
|
||||
|
||||
fn apply(&mut self, actions: Vec<Action>) {
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::Play {
|
||||
album_id,
|
||||
track_index,
|
||||
} => {
|
||||
// Starting something switches to the full-screen view and closes
|
||||
// the track list behind it, as the web front-end does: what you
|
||||
// just chose becomes the thing on screen. ESC and `/` come back.
|
||||
self.ui.play_view = true;
|
||||
self.ui.open_album = None;
|
||||
self.send(Command::Play {
|
||||
album_id,
|
||||
track_index,
|
||||
});
|
||||
}
|
||||
Action::Toggle => {
|
||||
// Optimistic: flip it here and let the pushed frame confirm. Safe
|
||||
// precisely because state only ever arrives pushed, never polled,
|
||||
// so there is no poll in flight to race with.
|
||||
self.player.playing = !self.player.playing;
|
||||
self.send(if self.player.playing {
|
||||
Command::Resume
|
||||
} else {
|
||||
Command::Pause
|
||||
});
|
||||
}
|
||||
Action::Next => self.send(Command::Next),
|
||||
Action::Previous => self.send(Command::Previous),
|
||||
Action::Volume(delta) => {
|
||||
self.player.volume = (self.player.volume + delta).clamp(0, 100);
|
||||
self.send(Command::Volume(self.player.volume));
|
||||
}
|
||||
Action::Seek(delta) => {
|
||||
let target = (self.player.position + delta).clamp(0.0, self.player.duration);
|
||||
self.player.position = target;
|
||||
self.smooth = false;
|
||||
self.send(Command::Seek(target));
|
||||
}
|
||||
Action::Mute => {
|
||||
// Unmuting restores a sensible level rather than whatever it was:
|
||||
// the level before a mute is not recorded anywhere the device
|
||||
// agrees on, and coming back at 8% reads as still broken.
|
||||
let target = if self.player.volume == 0 { 60 } else { 0 };
|
||||
self.player.volume = target;
|
||||
self.send(Command::Volume(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ presentation --
|
||||
|
||||
fn cover_of(store: &mut covers::Store, album: &Album, tier: Tier) -> slint::Image {
|
||||
let palette = album.palette();
|
||||
store
|
||||
.get(covers::Request {
|
||||
album_id: album.id.clone(),
|
||||
tier,
|
||||
has_cover: album.has_cover,
|
||||
is_book: album.is_book(),
|
||||
locked: album.locked,
|
||||
colors: [palette[0].into(), palette[1].into(), palette[2].into()],
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// "12 Songs · 45:03 · 🧸" - the line under an album's title.
|
||||
fn album_meta(album: &Album) -> String {
|
||||
let unit = if album.is_book() {
|
||||
"Kapitel"
|
||||
} else if album.tracks.len() == 1 {
|
||||
"Song"
|
||||
} else {
|
||||
"Songs"
|
||||
};
|
||||
let mut meta = format!(
|
||||
"{} {unit} · {}",
|
||||
album.tracks.len(),
|
||||
format::clock(album.duration)
|
||||
);
|
||||
if album.figure.is_some() {
|
||||
meta.push_str(" · 🧸");
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// "7 Hörbücher" - the line under a category tile.
|
||||
fn category_meta(group: Group, count: usize) -> String {
|
||||
let unit = match (group, count) {
|
||||
(Group::Music, 1) => "Album",
|
||||
(Group::Music, _) => "Alben",
|
||||
(Group::Audiobooks, _) => "Hörbücher",
|
||||
(Group::Podcasts, 1) => "Episode",
|
||||
(Group::Podcasts, _) => "Episoden",
|
||||
};
|
||||
format!("{count} {unit}")
|
||||
}
|
||||
|
||||
fn album_tile(store: &mut covers::Store, album: &Album, current: bool, nav: usize) -> AlbumTile {
|
||||
AlbumTile {
|
||||
id: album.id.as_str().into(),
|
||||
title: if album.locked {
|
||||
"❓ Geheimnis".into()
|
||||
} else {
|
||||
album.title.as_str().into()
|
||||
},
|
||||
// A podcast episode's artist is its show, which the title already says.
|
||||
artist: if album.artist == album.title {
|
||||
SharedString::new()
|
||||
} else {
|
||||
album.artist.as_str().into()
|
||||
},
|
||||
meta: album_meta(album).into(),
|
||||
cover: cover_of(store, album, Tier::Thumb),
|
||||
is_book: album.is_book(),
|
||||
locked: album.locked,
|
||||
current,
|
||||
nav: nav as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn category_tile(
|
||||
store: &mut covers::Store,
|
||||
group: Group,
|
||||
category: &library::Category,
|
||||
nav: usize,
|
||||
) -> CategoryTile {
|
||||
// Up to four covers in a 2x2 contact sheet, which is how a category reads as "a
|
||||
// stack of these" without a label saying so.
|
||||
let mut covers_out = [
|
||||
slint::Image::default(),
|
||||
slint::Image::default(),
|
||||
slint::Image::default(),
|
||||
slint::Image::default(),
|
||||
];
|
||||
for (slot, album) in covers_out.iter_mut().zip(category.albums.iter()) {
|
||||
*slot = cover_of(store, album, Tier::Thumb);
|
||||
}
|
||||
let [c1, c2, c3, c4] = covers_out;
|
||||
CategoryTile {
|
||||
key: category.key.as_str().into(),
|
||||
count_label: category_meta(group, category.albums.len()).into(),
|
||||
cover1: c1,
|
||||
cover2: c2,
|
||||
cover3: c3,
|
||||
cover4: c4,
|
||||
cover_count: category.albums.len().min(4) as i32,
|
||||
is_book: category.albums.first().is_some_and(|a| a.is_book()),
|
||||
nav: nav as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn track_row(
|
||||
store: &mut covers::Store,
|
||||
album: &Album,
|
||||
index: usize,
|
||||
title: &str,
|
||||
duration: f64,
|
||||
current: bool,
|
||||
nav: usize,
|
||||
) -> TrackRow {
|
||||
TrackRow {
|
||||
title: title.into(),
|
||||
subtitle: album.line().into(),
|
||||
duration: format::clock(duration).into(),
|
||||
cover: cover_of(store, album, Tier::Thumb),
|
||||
is_book: album.is_book(),
|
||||
current,
|
||||
locked: false,
|
||||
album_id: album.id.as_str().into(),
|
||||
index: index as i32,
|
||||
nav: nav as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_playing(app: &mut App) -> NowPlaying {
|
||||
let album = app.current_album().map(Rc::clone);
|
||||
let state = &app.player;
|
||||
let is_book = album.as_ref().is_some_and(|a| a.is_book());
|
||||
let is_podcast = album
|
||||
.as_ref()
|
||||
.is_some_and(|a| Group::of(a) == Group::Podcasts);
|
||||
|
||||
let (cover, hero) = match &album {
|
||||
Some(album) => (
|
||||
cover_of(&mut app.covers, album, Tier::Thumb),
|
||||
cover_of(&mut app.covers, album, Tier::Hero),
|
||||
),
|
||||
None => (slint::Image::default(), slint::Image::default()),
|
||||
};
|
||||
|
||||
let remaining_label = match &album {
|
||||
// "Noch ... im Hörbuch" means nothing for a podcast, where an episode is the
|
||||
// whole of what was chosen.
|
||||
Some(album) if !is_podcast => {
|
||||
let durations: Vec<f64> = album.tracks.iter().map(|t| t.duration).collect();
|
||||
let left = format::remaining_in_album(
|
||||
&durations,
|
||||
state.track_index.max(0) as usize,
|
||||
state.position,
|
||||
);
|
||||
let what = if is_book { "im Hörbuch" } else { "im Album" };
|
||||
format!("Noch {} {what}", format::clock(left))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
NowPlaying {
|
||||
title: state
|
||||
.track_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Wähl ein Album!".into())
|
||||
.into(),
|
||||
subtitle: album
|
||||
.as_ref()
|
||||
.map_or_else(|| "Tippen oder klicken".to_string(), |a| a.line())
|
||||
.into(),
|
||||
counter: format!(
|
||||
"{} {} von {}",
|
||||
if is_book { "Kapitel" } else { "Song" },
|
||||
state.track_index + 1,
|
||||
state.track_count.max(1)
|
||||
)
|
||||
.into(),
|
||||
cover,
|
||||
hero_cover: hero,
|
||||
is_book,
|
||||
has_album: album.is_some(),
|
||||
clock_label: if state.duration > 0.0 {
|
||||
format!("−{}", format::clock(state.duration - state.position)).into()
|
||||
} else {
|
||||
SharedString::new()
|
||||
},
|
||||
remaining_label: remaining_label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- rendering --
|
||||
|
||||
fn render(app: &mut App, window: &AppWindow) {
|
||||
window.set_loading(!app.loaded);
|
||||
window.set_splash_text(
|
||||
match &app.error {
|
||||
Some(error) => format!("Der Delfin ist nicht erreichbar …\n{error}"),
|
||||
None => "Einen Moment …".to_string(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_online(app.online);
|
||||
window.set_status_label(
|
||||
if !app.online {
|
||||
"Keine Verbindung".into()
|
||||
} else {
|
||||
match &app.player.active_figure {
|
||||
Some(figure) => format!("🧸 {figure}"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
// How many columns fit, from the width the window is actually giving the grid. The
|
||||
// keyboard needs the same number to know what "one row down" means.
|
||||
let gap = 24.0_f32;
|
||||
let card = 180.0_f32;
|
||||
let cols = (((window.get_grid_width() + gap) / (card + gap)).floor() as usize).max(1);
|
||||
app.ui.cols = cols;
|
||||
|
||||
window.set_play_view(app.ui.play_view);
|
||||
window.set_can_go_back(app.ui.can_go_back());
|
||||
window.set_playing(app.player.playing);
|
||||
window.set_volume(app.player.volume);
|
||||
window.set_smooth(app.smooth);
|
||||
window.set_progress(if app.player.duration > 0.0 {
|
||||
(app.player.position / app.player.duration).clamp(0.0, 1.0) as f32
|
||||
} else {
|
||||
0.0
|
||||
});
|
||||
|
||||
let now = now_playing(app);
|
||||
let has_album = now.has_album;
|
||||
window.set_now(now);
|
||||
window.set_has_bar(has_album && !app.ui.play_view);
|
||||
|
||||
let root_screen = app.ui.is_root_shelf();
|
||||
window.set_root_screen(root_screen);
|
||||
window.set_shelf_row(app.ui.shelf_row as i32);
|
||||
window.set_sel_index(app.ui.sel_index as i32);
|
||||
|
||||
if root_screen {
|
||||
render_shelves(app, window);
|
||||
} else {
|
||||
render_results(app, window, cols);
|
||||
}
|
||||
|
||||
render_sheet(app, window);
|
||||
}
|
||||
|
||||
fn render_shelves(app: &mut App, window: &AppWindow) {
|
||||
let mut shelves = Vec::with_capacity(GROUPS.len());
|
||||
for group in GROUPS {
|
||||
let categories: Vec<library::Category> = app.library.categories(group).to_vec();
|
||||
let tiles: Vec<CategoryTile> = categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, category)| category_tile(&mut app.covers, group, category, i))
|
||||
.collect();
|
||||
shelves.push(Shelf {
|
||||
label: group.label().into(),
|
||||
icon: group.icon().into(),
|
||||
tiles: ModelRc::new(VecModel::from(tiles)),
|
||||
empty_label: "Noch nichts hier".into(),
|
||||
});
|
||||
}
|
||||
window.set_shelves(ModelRc::new(VecModel::from(shelves)));
|
||||
// The shelves replace the flat result lists rather than sitting beside them.
|
||||
window.set_songs(ModelRc::new(VecModel::from(Vec::<TrackRow>::new())));
|
||||
window.set_grid(ModelRc::new(VecModel::from(Vec::<GridRow>::new())));
|
||||
window.set_category_grid(ModelRc::new(VecModel::from(Vec::<CategoryRow>::new())));
|
||||
}
|
||||
|
||||
fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
|
||||
let results: Results = app.library.results(&app.query());
|
||||
let total = results.total();
|
||||
let current_album = app.player.album_id.clone();
|
||||
let current_track = app.player.track_index.max(0) as usize;
|
||||
|
||||
// Selection indices run flat across songs, then categories, then albums.
|
||||
let mut nav = 0usize;
|
||||
|
||||
let songs: Vec<TrackRow> = results
|
||||
.songs
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
let row = track_row(
|
||||
&mut app.covers,
|
||||
&hit.album,
|
||||
hit.index,
|
||||
&hit.title,
|
||||
hit.duration,
|
||||
current_album.as_deref() == Some(hit.album.id.as_str())
|
||||
&& current_track == hit.index,
|
||||
nav,
|
||||
);
|
||||
nav += 1;
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
|
||||
let group = app.ui.group.unwrap_or(Group::Music);
|
||||
let categories: Vec<CategoryTile> = results
|
||||
.categories
|
||||
.iter()
|
||||
.map(|category| {
|
||||
let tile = category_tile(&mut app.covers, group, category, nav);
|
||||
nav += 1;
|
||||
tile
|
||||
})
|
||||
.collect();
|
||||
// Chunked like the album grid, and for the same reason: the view puts each row in
|
||||
// a virtualizing list rather than one ever-widening horizontal layout.
|
||||
let category_grid: Vec<CategoryRow> = categories
|
||||
.chunks(cols)
|
||||
.map(|chunk| CategoryRow {
|
||||
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tiles: Vec<AlbumTile> = results
|
||||
.albums
|
||||
.iter()
|
||||
.map(|album| {
|
||||
let tile = album_tile(
|
||||
&mut app.covers,
|
||||
album,
|
||||
current_album.as_deref() == Some(album.id.as_str()),
|
||||
nav,
|
||||
);
|
||||
nav += 1;
|
||||
tile
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Pre-chunked into rows: a ListView virtualizes its model, and a flat list of 343
|
||||
// cards has no rows for it to skip. The web grid reaches the same place with
|
||||
// `content-visibility: auto`.
|
||||
let first_album_nav = songs.len() + categories.len();
|
||||
let grid: Vec<GridRow> = tiles
|
||||
.chunks(cols)
|
||||
.map(|chunk| GridRow {
|
||||
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Which grid row the selection is in, so the scroller can follow it without
|
||||
// measuring anything - every row is the same height by construction.
|
||||
let sel_row = app
|
||||
.ui
|
||||
.sel_index
|
||||
.checked_sub(first_album_nav)
|
||||
.map_or(0, |within| within / cols.max(1));
|
||||
|
||||
let count_label = if app.ui.tracks_mode {
|
||||
format!("{} Titel gefunden", results.songs.len())
|
||||
} else if results.albums.is_empty() {
|
||||
"nichts gefunden".to_string()
|
||||
} else if results.albums.len() == 1 {
|
||||
"1 Album gefunden".to_string()
|
||||
} else {
|
||||
format!("{} Alben gefunden", results.albums.len())
|
||||
};
|
||||
|
||||
window.set_group_label(
|
||||
app.ui
|
||||
.group
|
||||
.map_or(SharedString::new(), |g| g.label().into()),
|
||||
);
|
||||
window.set_category_heading(group.category_label().into());
|
||||
window.set_album_heading(
|
||||
match app.ui.category.as_deref() {
|
||||
Some(category) => category.to_string(),
|
||||
None => group.section_label().to_string(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_show_search(!app.ui.search.is_empty() || app.ui.tracks_mode);
|
||||
window.set_search_text(
|
||||
if app.ui.search.is_empty() {
|
||||
"tippe …".to_string()
|
||||
} else {
|
||||
format!("\"{}\"", app.ui.search)
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_mode_label(if app.ui.tracks_mode {
|
||||
"♪ Titel".into()
|
||||
} else {
|
||||
"🔎 Alben".into()
|
||||
});
|
||||
window.set_count_label(count_label.into());
|
||||
window.set_no_results(total == 0);
|
||||
window.set_sel_row(sel_row as i32);
|
||||
|
||||
window.set_songs(ModelRc::new(VecModel::from(songs)));
|
||||
window.set_category_grid(ModelRc::new(VecModel::from(category_grid)));
|
||||
window.set_grid(ModelRc::new(VecModel::from(grid)));
|
||||
}
|
||||
|
||||
fn render_sheet(app: &mut App, window: &AppWindow) {
|
||||
let Some(album) = app
|
||||
.ui
|
||||
.open_album
|
||||
.clone()
|
||||
.and_then(|id| app.library.get(&id).map(Rc::clone))
|
||||
else {
|
||||
window.set_show_sheet(false);
|
||||
return;
|
||||
};
|
||||
|
||||
let playing_here = app.player.album_id.as_deref() == Some(album.id.as_str());
|
||||
let current = app.player.track_index.max(0) as usize;
|
||||
let rows: Vec<TrackRow> = album
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, track)| {
|
||||
let title = if track.locked {
|
||||
"❓".to_string()
|
||||
} else {
|
||||
track.title.clone()
|
||||
};
|
||||
track_row(
|
||||
&mut app.covers,
|
||||
&album,
|
||||
i,
|
||||
&title,
|
||||
track.duration,
|
||||
playing_here && current == i,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let kind = if album.is_book() {
|
||||
"📖 Hörbuch"
|
||||
} else {
|
||||
"🎵 Album"
|
||||
};
|
||||
let mut meta = format!("{kind} · {}", album_meta(&album));
|
||||
if let Some(figure) = &album.figure {
|
||||
meta.push_str(&format!(" · {figure}"));
|
||||
}
|
||||
|
||||
window.set_show_sheet(true);
|
||||
window.set_sheet_title(album.title.as_str().into());
|
||||
window.set_sheet_subtitle(album.artist.as_str().into());
|
||||
window.set_sheet_meta(meta.into());
|
||||
window.set_sheet_cover(cover_of(&mut app.covers, &album, Tier::Hero));
|
||||
window.set_sheet_is_book(album.is_book());
|
||||
window.set_sheet_index(app.ui.sheet_index.min(album.tracks.len().saturating_sub(1)) as i32);
|
||||
window.set_sheet_tracks(ModelRc::new(VecModel::from(rows)));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- wiring --
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let base = std::env::args()
|
||||
.skip_while(|a| a != "--server")
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
||||
println!("musicmouse-slint → {base}");
|
||||
|
||||
// Nunito is the face the design is drawn in. Slint takes one file as the primary
|
||||
// font through `SLINT_DEFAULT_FONT`; the dev shell sets it, and a deployment can
|
||||
// drop the TTF next to the binary instead. Neither present just means the system
|
||||
// sans - a missing font should change how this looks, not whether it runs.
|
||||
if std::env::var_os("SLINT_DEFAULT_FONT").is_none() {
|
||||
let local = std::path::Path::new("fonts/Nunito.ttf");
|
||||
if local.exists() {
|
||||
std::env::set_var("SLINT_DEFAULT_FONT", local);
|
||||
}
|
||||
}
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
let client = Client::new(&base);
|
||||
|
||||
let (to_ui, from_workers) = channel::<FromWorker>();
|
||||
let (cover_jobs, cover_rx) = channel::<covers::Job>();
|
||||
let (commands, command_rx) = channel::<Command>();
|
||||
|
||||
// Covers: fetch, downscale, cache to disk. Never on the UI thread.
|
||||
{
|
||||
let to_ui = to_ui.clone();
|
||||
covers::spawn(client.clone(), cover_rx, move |ready| {
|
||||
let _ = to_ui.send(FromWorker::Cover(ready));
|
||||
});
|
||||
}
|
||||
|
||||
// Commands, and the library fetch that seeds everything.
|
||||
{
|
||||
let client = client.clone();
|
||||
let to_ui = to_ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("commands".into())
|
||||
.spawn(move || {
|
||||
let _ = to_ui.send(FromWorker::Library(client.library()));
|
||||
for command in command_rx {
|
||||
let result = match command {
|
||||
Command::Play {
|
||||
album_id,
|
||||
track_index,
|
||||
} => client.play(&album_id, track_index as i32),
|
||||
Command::Resume => client.resume(),
|
||||
Command::Pause => client.pause(),
|
||||
Command::Next => client.next(),
|
||||
Command::Previous => client.previous(),
|
||||
Command::Seek(position) => client.seek(position),
|
||||
Command::Volume(percent) => client.volume(percent),
|
||||
Command::ReloadLibrary => {
|
||||
let _ = to_ui.send(FromWorker::Library(client.library()));
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
if let Err(error) = result {
|
||||
// A failed command is not fatal: the next pushed frame says
|
||||
// what actually happened, which is the only truth anyway.
|
||||
eprintln!("{error}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
// State.
|
||||
{
|
||||
let to_ui = to_ui.clone();
|
||||
let (ws_tx, ws_rx) = channel();
|
||||
ws::spawn(client.clone(), ws_tx);
|
||||
std::thread::Builder::new()
|
||||
.name("ws-relay".into())
|
||||
.spawn(move || {
|
||||
for update in ws_rx {
|
||||
let event = match update {
|
||||
ws::Update::Connected => WsEvent::Connected,
|
||||
ws::Update::Disconnected => WsEvent::Disconnected,
|
||||
ws::Update::State(state) => WsEvent::State(state),
|
||||
ws::Update::Position { position, duration } => {
|
||||
WsEvent::Position { position, duration }
|
||||
}
|
||||
ws::Update::LibraryChanged => WsEvent::LibraryChanged,
|
||||
};
|
||||
if to_ui.send(FromWorker::Ws(event)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
let app = Rc::new(RefCell::new(App {
|
||||
commands,
|
||||
ui: UiState::default(),
|
||||
player: PlayerState::default(),
|
||||
library: Library::default(),
|
||||
covers: covers::Store::new(cover_jobs),
|
||||
online: false,
|
||||
loaded: false,
|
||||
error: None,
|
||||
track_key: String::new(),
|
||||
smooth: true,
|
||||
}));
|
||||
|
||||
install_callbacks(&app, &window);
|
||||
pump_workers(&app, &window, from_workers);
|
||||
|
||||
render(&mut app.borrow_mut(), &window);
|
||||
window.run()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bridge the worker channels onto the Slint event loop.
|
||||
///
|
||||
/// `invoke_from_event_loop` takes a `Send` closure, and the application state is
|
||||
/// `Rc<RefCell<..>>` that never leaves the UI thread - so the state stays in a
|
||||
/// thread-local here and only the *messages* cross, which are `Send` by construction.
|
||||
///
|
||||
/// The queue is drained rather than handled one at a time, and that is not incidental:
|
||||
/// a cold start delivers a few hundred covers in a burst, and one re-render per cover
|
||||
/// would rebuild every model a few hundred times. Coalescing collapses each burst into
|
||||
/// a single render.
|
||||
fn pump_workers(
|
||||
app: &Rc<RefCell<App>>,
|
||||
window: &AppWindow,
|
||||
from_workers: std::sync::mpsc::Receiver<FromWorker>,
|
||||
) {
|
||||
APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(app)));
|
||||
|
||||
let queue: Arc<Mutex<VecDeque<FromWorker>>> = Arc::default();
|
||||
let weak = window.as_weak();
|
||||
let producer = Arc::clone(&queue);
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("relay".into())
|
||||
.spawn(move || {
|
||||
for message in from_workers {
|
||||
match producer.lock() {
|
||||
Ok(mut queue) => queue.push_back(message),
|
||||
// Only reachable if a UI-thread panic poisoned the lock, in which
|
||||
// case there is no UI left to deliver to.
|
||||
Err(_) => return,
|
||||
}
|
||||
let drain = Arc::clone(&producer);
|
||||
let posted = weak.upgrade_in_event_loop(move |window| {
|
||||
let messages: Vec<FromWorker> = match drain.lock() {
|
||||
Ok(mut queue) => queue.drain(..).collect(),
|
||||
Err(_) => return,
|
||||
};
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
APP.with(|slot| {
|
||||
let Some(app) = slot.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut app = app.borrow_mut();
|
||||
for message in messages {
|
||||
handle_worker_message(&mut app, message);
|
||||
}
|
||||
}
|
||||
render(&mut app.borrow_mut(), &window);
|
||||
});
|
||||
});
|
||||
if posted.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawning the worker relay");
|
||||
}
|
||||
|
||||
fn handle_worker_message(app: &mut App, message: FromWorker) {
|
||||
match message {
|
||||
FromWorker::Library(Ok(albums)) => {
|
||||
app.library = Library::build(albums);
|
||||
app.loaded = true;
|
||||
app.error = None;
|
||||
}
|
||||
FromWorker::Library(Err(error)) => {
|
||||
app.error = Some(error);
|
||||
}
|
||||
FromWorker::Cover(ready) => app.covers.insert(ready),
|
||||
FromWorker::Ws(WsEvent::Connected) => app.online = true,
|
||||
FromWorker::Ws(WsEvent::Disconnected) => app.online = false,
|
||||
FromWorker::Ws(WsEvent::State(state)) => {
|
||||
let key = format!("{:?}:{}", state.album_id, state.track_index);
|
||||
// A new track means the bar must jump, not slide: animating across two
|
||||
// unrelated positions is what makes a seek look like it drifts backwards.
|
||||
app.smooth = key == app.track_key;
|
||||
app.track_key = key;
|
||||
app.player = *state;
|
||||
}
|
||||
FromWorker::Ws(WsEvent::Position { position, duration }) => {
|
||||
app.player.position = position;
|
||||
if duration > 0.0 {
|
||||
app.player.duration = duration;
|
||||
}
|
||||
app.smooth = true;
|
||||
}
|
||||
FromWorker::Ws(WsEvent::LibraryChanged) => {
|
||||
// A rescan is the one moment the backend rewrites the art behind an
|
||||
// unchanged cover URL, so the thumbnails go with it.
|
||||
app.covers.clear();
|
||||
app.send(Command::ReloadLibrary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
|
||||
// Every callback does the same three things: mutate state, maybe queue a command,
|
||||
// re-render. `bind!` is that shape written once, so no individual handler has to
|
||||
// remember the third.
|
||||
macro_rules! bind {
|
||||
($setter:ident, |$a:ident $(, $arg:ident : $ty:ty)*| $body:block) => {
|
||||
let weak = window.as_weak();
|
||||
let handle = Rc::clone(app);
|
||||
window.$setter(move |$($arg: $ty),*| {
|
||||
let Some(window) = weak.upgrade() else { return };
|
||||
{
|
||||
let mut $a = handle.borrow_mut();
|
||||
$body
|
||||
}
|
||||
render(&mut handle.borrow_mut(), &window);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
bind!(on_key, |a, key: SharedString, ctrl: bool, shift: bool| {
|
||||
let results = a.library.results(&a.query());
|
||||
let shelves = a.shelf_lengths();
|
||||
let sheet_tracks =
|
||||
a.ui.open_album
|
||||
.as_deref()
|
||||
.and_then(|id| a.library.get(id))
|
||||
.map_or(0, |album| album.tracks.len());
|
||||
let mut ui = a.ui.clone();
|
||||
let actions = keymap::handle_key(
|
||||
key.as_str(),
|
||||
ctrl,
|
||||
shift,
|
||||
&mut ui,
|
||||
&results,
|
||||
&shelves,
|
||||
sheet_tracks,
|
||||
);
|
||||
a.ui = ui;
|
||||
a.apply(actions);
|
||||
});
|
||||
|
||||
bind!(on_toggle, |a| {
|
||||
a.apply(vec![Action::Toggle]);
|
||||
});
|
||||
bind!(on_next, |a| {
|
||||
a.apply(vec![Action::Next]);
|
||||
});
|
||||
bind!(on_previous, |a| {
|
||||
a.apply(vec![Action::Previous]);
|
||||
});
|
||||
bind!(on_mute, |a| {
|
||||
a.apply(vec![Action::Mute]);
|
||||
});
|
||||
|
||||
bind!(on_seek, |a, fraction: f32| {
|
||||
let target = (fraction as f64 * a.player.duration).clamp(0.0, a.player.duration);
|
||||
a.player.position = target;
|
||||
a.smooth = false;
|
||||
a.send(Command::Seek(target));
|
||||
});
|
||||
|
||||
bind!(on_set_volume, |a, percent: i32| {
|
||||
a.player.volume = percent.clamp(0, 100);
|
||||
a.send(Command::Volume(a.player.volume));
|
||||
});
|
||||
|
||||
bind!(on_go_back, |a| {
|
||||
if a.ui.open_album.is_some() {
|
||||
a.ui.open_album = None;
|
||||
} else if a.ui.play_view {
|
||||
a.ui.play_view = false;
|
||||
} else {
|
||||
a.ui.browse_back();
|
||||
}
|
||||
});
|
||||
|
||||
bind!(on_enter_group, |a, shelf: i32| {
|
||||
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
||||
a.ui.category = None;
|
||||
a.ui.sel_index = 0;
|
||||
});
|
||||
|
||||
bind!(on_enter_category, |a, shelf: i32, key: SharedString| {
|
||||
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
||||
a.ui.category = Some(key.to_string());
|
||||
a.ui.sel_index = 0;
|
||||
});
|
||||
|
||||
bind!(on_open_category, |a, key: SharedString| {
|
||||
a.ui.category = Some(key.to_string());
|
||||
a.ui.sel_index = 0;
|
||||
});
|
||||
|
||||
bind!(on_open_album, |a, id: SharedString| {
|
||||
// A podcast episode is a single track with nothing to choose between, so the
|
||||
// cover plays it rather than opening a list of one.
|
||||
match a.library.get(id.as_str()).map(Rc::clone) {
|
||||
Some(album) if Group::of(&album) == Group::Podcasts => {
|
||||
a.apply(vec![Action::Play {
|
||||
album_id: album.id.clone(),
|
||||
track_index: 0,
|
||||
}]);
|
||||
}
|
||||
Some(album) => {
|
||||
a.ui.open_album = Some(album.id.clone());
|
||||
a.ui.sheet_index = 0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
|
||||
bind!(on_play_album, |a, id: SharedString| {
|
||||
a.apply(vec![Action::Play {
|
||||
album_id: id.to_string(),
|
||||
track_index: 0,
|
||||
}]);
|
||||
});
|
||||
|
||||
bind!(on_play_song, |a, id: SharedString, index: i32| {
|
||||
a.apply(vec![Action::Play {
|
||||
album_id: id.to_string(),
|
||||
track_index: index.max(0) as usize,
|
||||
}]);
|
||||
});
|
||||
|
||||
bind!(on_open_play_view, |a| {
|
||||
a.ui.play_view = true;
|
||||
});
|
||||
bind!(on_close_sheet, |a| {
|
||||
a.ui.open_album = None;
|
||||
});
|
||||
|
||||
bind!(on_play_sheet_track, |a, index: i32| {
|
||||
if let Some(album_id) = a.ui.open_album.clone() {
|
||||
a.apply(vec![Action::Play {
|
||||
album_id,
|
||||
track_index: index.max(0) as usize,
|
||||
}]);
|
||||
a.ui.open_album = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user