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:
2026-09-20 22:26:09 +02:00
parent 83d16e0058
commit 6ccb3e458f
21 changed files with 11270 additions and 0 deletions

985
slint-frontend/src/main.rs Normal file
View 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;
}
});
}