Files
musicmouse/slint-frontend/src/main.rs
Martin Bauer 6ccb3e458f Add a native Slint front-end for the music player
The React UI is sluggish on the kiosk and the reasons are browser-shaped: a compositor
deciding what gets its own layer, a requestAnimationFrame loop repainting the viewport,
and a JPEG decode per album card per cold start. The last three commits chased that
through the service worker and the cover pipeline. This is the other direction - the
same backend, the same layout, the same German copy, without a browser.

Browse and play only. The typing game, the room-lights page, parent mode and the
IR-remote assignment stay web-only, and nothing here touches python-backend.

Four things carry the performance claim, in rough order of how much they should matter:

Covers are downscaled once, ever. The backend serves one size - 640px on the long edge,
no ?size= - and a grid card is 180. src/covers.rs fetches each cover once on a worker
thread, resizes it, and writes a JPEG thumbnail to ~/.cache/musicmouse-slint/covers/.
Two tiers and only two, which is what makes the cache worth keeping: a per-widget pixel
size would give each album a dozen near-identical files and a fresh decode for each. The
cache is dropped when the websocket announces a rescan, because that is the one moment
the backend rewrites the art behind an unchanged cover URL - the trap sw.js had to learn
about the hard way.

The grid is virtualized. Rust hands the UI the album list pre-chunked into rows and the
view puts those in a ListView, which instantiates only what is on screen. A flat list of
660 cards gives it no rows to skip, hence the chunking. Same purpose as
content-visibility: auto on .grid > .card.

The progress bar animates between the 2 Hz pushes rather than running a clock, so
interpolation costs a property evaluation per frame on the render side and there is no
equivalent of usePlaybackClock. It is suppressed for the frame a track changes on, so a
new track jumps instead of sliding across two unrelated positions.

Nothing on screen animates by itself. The ambient canvas is not ported, for the reason
lib/lowPower.ts already gives: a loop repainting the viewport is a floor you cannot get
under while it runs at all.

The device must not use FemtoVG. On Mesa V3D it draws every runtime-loaded Image as
solid black (slint-ui/slint#11785, open), which here means every album cover; Skia and
the software renderer are unaffected. So `kiosk` is linuxkms + Skia and FemtoVG stays
the default only for desktop development. Both profiles are verified to build; the kiosk
binary links libinput/libgbm/libdrm and no X11 or Wayland at all.

lib/search.ts, lib/keyboard.ts and lib/format.ts were already pure functions with their
own tests, so they port across as pure Rust with theirs: 43 tests, no window required.
The .slint files are layout only - nothing in them formats a number or picks a word.

Verified against the real 660-album library rather than a fixture. tools/headless-shots.sh
renders the UI inside a nested headless compositor and grabs a frame per screen, which
is how that was checked on a machine whose session was locked; a Slint window needs a
real compositor, and under bare Xvfb nothing maps at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:26:09 +02:00

986 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
}
});
}