//! 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>>> = 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, String>), Cover(covers::Ready), Ws(WsEvent), } enum WsEvent { Connected, Disconnected, State(Box), Position { position: f64, duration: f64 }, LibraryChanged, } // ------------------------------------------------------------------------ state -- struct App { commands: Sender, ui: UiState, player: PlayerState, library: Library, covers: covers::Store, online: bool, loaded: bool, error: Option, /// 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> { 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) { 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 = 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 = app.library.categories(group).to_vec(); let tiles: Vec = 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::::new()))); window.set_grid(ModelRc::new(VecModel::from(Vec::::new()))); window.set_category_grid(ModelRc::new(VecModel::from(Vec::::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 = 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 = 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 = categories .chunks(cols) .map(|chunk| CategoryRow { tiles: ModelRc::new(VecModel::from(chunk.to_vec())), }) .collect(); let tiles: Vec = 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 = 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 = 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> { 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::(); let (cover_jobs, cover_rx) = channel::(); let (commands, command_rx) = channel::(); // 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>` 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>, window: &AppWindow, from_workers: std::sync::mpsc::Receiver, ) { APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(app))); let queue: Arc>> = 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 = 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>, 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; } }); }