diff --git a/locales/en.yml b/locales/en.yml index 9619bab2..f85b933a 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -48,6 +48,13 @@ browser: artist: "Artist" album: "Album" all: "All" + # Album-pane artwork toggle labels. The browser key set ships in + # every locale at the `browser:` level; artwork controls live under + # preferences to match the existing grouping-toggle placement. + album_artwork: "Album pane artwork" + album_artwork_size_small: "Small" + album_artwork_size_medium: "Medium" + album_artwork_size_large: "Large" # ── Tracklist columns ────────────────────────────────────────────── columns: diff --git a/src/ui/album_pane_art.rs b/src/ui/album_pane_art.rs new file mode 100644 index 00000000..95e45d70 --- /dev/null +++ b/src/ui/album_pane_art.rs @@ -0,0 +1,917 @@ +//! Virtualized, accessible album-art column for the browser Album pane. +//! +//! The browser album pane hosts one row per album. Each row may show a +//! thumbnail next to its text label. The thumbnail must: +//! +//! * **Only load rows that are visible** — the GTK `ListView` is already +//! virtualized, but a naïve design would still trigger fetches for the +//! entire library at once. The cache below is bounded so a 10 000-album +//! library never inflates memory. +//! * **Cancel in-flight work when a row scrolls out of view** — a slow +//! remote fetch that arrives after the row is no longer visible must +//! not paint a stale texture. Each row is bound with a monotonic +//! generation token; results for older generations are discarded. +//! * **Show a placeholder while loading or for albums with no art** — a +//! neutral placeholder icon keeps the list legible during a network +//! fetch and for albums that genuinely have no embedded/remote art. +//! * **Authenticate through the existing lease-isolated resolver** — +//! remote album art is resolved through `SourceRegistry::resolve_artwork` +//! and consumed by the persistent art worker. Local embedded art goes +//! through `update_direct_file_album_art`. URLs from the track's +//! `cover_art_url` go through `fetch_remote_album_art`. None of these +//! paths invent new credential-isolation seams. +//! * **Honor persisted layout preferences** — `AppConfig::album_pane_artwork` +//! toggles the whole feature; `AlbumArtSize::pixel_size()` fixes the +//! rendered square side length. The bind factory rebuilds its widgets +//! when these change. +//! +//! The cache is intentionally **display-side** (a `gdk::Texture` plus +//! `gtk::Image` swap), not a transport cache. The album-art worker in +//! `album_art.rs` already provides the byte-level cache + byte-cap +//! enforcement; this module only ensures the UI doesn't multiply fetches +//! for visible rows. + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; + +use gtk::gdk; +use gtk::glib; +use gtk::prelude::*; + +use crate::architecture::media::ResolvedHttpRequest; +use crate::architecture::SourceId; +use crate::ui::album_art; +use crate::ui::objects::{AlbumArtCandidate, BrowserItem}; +#[cfg(test)] +use crate::ui::preferences::AlbumArtSize; + +/// Maximum number of cached album-art entries. The cache is keyed by +/// `(album_key, pixel_size)`. A library of 10 000 albums × one size +/// variant × ~32 KiB decoded surface is well under the working-set +/// budget; the bound is here so an attacker-controlled catalog (e.g., a +/// misbehaving Subsonic peer) cannot inflate memory through the UI path. +pub const MAX_CACHED_ALBUM_ARTS: usize = 512; + +/// Resolved album art for one album pane entry. The placeholder image is +/// a stable `gtk::Image` the bind factory clones and rebinds; reusing it +/// across rows avoids creating a fresh widget per row in a virtualized +/// list (a real cost — each `Image` is a GObject and a CSS node). +#[derive(Clone)] +#[allow(dead_code)] +pub struct AlbumArtCell { + pub row: gtk::Box, + pub image: gtk::Image, + pub label: gtk::Label, + pub placeholder_icon: &'static str, +} + +impl AlbumArtCell { + /// Side length GTK4 should use for the placeholder icon when the + /// cell has not yet been bound with a live preference. GTK4 + /// interprets `-1` as "use the icon theme's default size for the + /// requested icon name"; a literal `0` would render the placeholder + /// at zero pixels and a positive value would override the live + /// bind-factory size. The bind factory applies the user-selected + /// side length on every bind, so a freshly-built cell is only the + /// icon-theme fallback. + pub const PLACEHOLDER_PIXEL_SIZE: i32 = -1; + + pub fn new(placeholder_icon: &'static str) -> Self { + let image = gtk::Image::builder() + .icon_name(placeholder_icon) + .pixel_size(Self::PLACEHOLDER_PIXEL_SIZE) + .build(); + image.set_accessible_role(gtk::AccessibleRole::Img); + let label = gtk::Label::builder() + .halign(gtk::Align::Start) + .margin_start(8) + .margin_end(8) + .margin_top(2) + .margin_bottom(2) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(0) + .build(); + row.append(&image); + row.append(&label); + Self { + row, + image, + label, + placeholder_icon, + } + } + + /// Reset the cell to its placeholder state. Used both before the + /// artwork resolves and when the album has no artwork at all. + fn show_placeholder(&self, label_text: &str, accessible_label: Option<&str>) { + self.image.set_icon_name(Some(self.placeholder_icon)); + self.image.set_paintable(None::<&gdk::Paintable>); + self.label.set_text(label_text); + self.label.set_tooltip_text(Some(label_text)); + if let Some(text) = accessible_label { + self.image + .update_property(&[gtk::accessible::Property::Label(text)]); + } + } + + /// Replace the placeholder with the supplied texture. + fn show_texture( + &self, + texture: &gdk::Texture, + label_text: &str, + accessible_label: Option<&str>, + ) { + self.image.set_icon_name(None); + self.image.set_paintable(Some(texture)); + self.label.set_text(label_text); + self.label.set_tooltip_text(Some(label_text)); + if let Some(text) = accessible_label { + self.image + .update_property(&[gtk::accessible::Property::Label(text)]); + } + } +} + +/// Generation token handed to the bind factory. Each `bind` mints a new +/// token; the same `ListItem` keeps its previous token through `unbind`. +/// When `set_pending` is called, the cell's generation advances so any +/// late async result for the *prior* token is dropped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BindGeneration(u64); + +impl BindGeneration { + pub const INVALID: Self = Self(0); + + pub fn next(self) -> Self { + Self(self.0.wrapping_add(1)) + } +} + +/// Per-pane cache shared across every row in the album pane. +/// +/// The cache is bounded by [`MAX_CACHED_ALBUM_ARTS`]; an insert past the +/// bound evicts the least-recently-inserted entry. A `gdk::Texture` +/// retains the decoded pixbuf only as long as GTK holds it; if the system +/// drops the underlying surface, re-decoding happens through the worker. +#[derive(Clone)] +pub struct AlbumArtCache { + pub(crate) inner: Rc>, +} + +pub struct AlbumArtCacheInner { + pub(crate) entries: HashMap, + pub(crate) order: VecDeque, +} + +impl Default for AlbumArtCache { + fn default() -> Self { + Self::new() + } +} + +impl AlbumArtCache { + pub fn new() -> Self { + Self { + inner: Rc::new(RefCell::new(AlbumArtCacheInner { + entries: HashMap::new(), + order: VecDeque::new(), + })), + } + } + + /// Look up a cached texture for `(album_key, pixel_size)`. Returns + /// `None` on miss; a hit also bumps the entry to the most-recent + /// position so a hot row doesn't get evicted under memory pressure. + pub fn get(&self, album_key: &str, pixel_size: i32) -> Option { + let key = cache_key(album_key, pixel_size); + let mut inner = self.inner.borrow_mut(); + let texture = inner.entries.get(&key)?.clone(); + if let Some(position) = inner.order.iter().position(|existing| existing == &key) { + inner.order.remove(position); + } + inner.order.push_back(key); + Some(texture) + } + + /// Insert a new texture, evicting the oldest entry if the cache is + /// already full. The eviction is FIFO with a recency-bump on read so + /// a long-running scroll session never displaces hot entries. + pub fn insert(&self, album_key: &str, pixel_size: i32, texture: gdk::Texture) { + let key = cache_key(album_key, pixel_size); + let mut inner = self.inner.borrow_mut(); + if inner.entries.contains_key(&key) { + inner.entries.insert(key.clone(), texture); + if let Some(position) = inner.order.iter().position(|existing| existing == &key) { + inner.order.remove(position); + } + inner.order.push_back(key); + return; + } + while inner.entries.len() >= MAX_CACHED_ALBUM_ARTS { + if let Some(oldest) = inner.order.pop_front() { + inner.entries.remove(&oldest); + } else { + break; + } + } + inner.entries.insert(key.clone(), texture); + inner.order.push_back(key); + } + + /// Total number of entries currently cached. + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.inner.borrow().entries.len() + } + + /// True if the cache holds zero entries. + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.inner.borrow().entries.is_empty() + } +} + +fn cache_key(album_key: &str, pixel_size: i32) -> String { + format!("{album_key}\x1f{pixel_size}") +} + +/// Cookie threaded into the bind factory so a late async result can +/// verify its row is still on screen. +/// +/// The factory mints a new generation for every `bind`. When the row is +/// recycled (`unbind` then `bind` for a different item) the cell's +/// generation is bumped so any in-flight fetch for the previous item is +/// rejected even before it reaches the worker. The generation is also +/// incremented when the user toggles the artwork preference, so any +/// leftover fetch from the previous mode paints a placeholder. +#[derive(Clone)] +pub struct AlbumArtCellState { + cell: AlbumArtCell, + /// Album key currently bound to this row (`None` for the synthetic + /// "All" row or while the row is being recycled). + bound_album_key: Rc>>, + /// Monotonic generation. Bumped on rebind and on artwork toggle. + generation: Rc>, + /// Active `paintable`-notify listener for the underlying `Image`, + /// if any. A new bind replaces this with a new listener; the + /// previous one is disconnected so the cache doesn't get multiple + /// probes firing on the same paintable change. + paintable_notify_id: Rc>>, +} + +impl AlbumArtCellState { + fn new(cell: AlbumArtCell) -> Self { + Self { + cell, + bound_album_key: Rc::new(RefCell::new(None)), + generation: Rc::new(Cell::new(BindGeneration::INVALID)), + paintable_notify_id: Rc::new(RefCell::new(None)), + } + } + + #[allow(dead_code)] + fn row(&self) -> >k::Box { + &self.cell.row + } + + fn current_generation(&self) -> BindGeneration { + self.generation.get() + } + + #[allow(dead_code)] + fn reset(&self, label_text: &str, accessible_label: &str) { + *self.bound_album_key.borrow_mut() = None; + self.generation.set(self.generation.get().next()); + self.cell + .show_placeholder(label_text, Some(accessible_label)); + } +} + +/// Coordinator handed to the album pane's bind factory. Owns the cache +/// plus a per-pane source registry handle; the bind factory only needs +/// the lightweight [`AlbumArtController::bind`] entry point. +#[derive(Clone)] +pub struct AlbumArtController { + cache: AlbumArtCache, + source_registry: Rc>>, + /// Side length (in device pixels) of each rendered thumbnail. + /// Wired in from the browser's `BrowserState::album_pane_artwork_size` + /// cell so the bind factory and the cache probe both read the + /// live preference, not a hardcoded default. `None` until the + /// browser's setup step attaches a source; until then the + /// controller falls back to [`AlbumArtController::default_pixel_size`] + /// so a late wiring (e.g., tests) still renders at a sensible size. + pixel_size: Rc>>>>, + placeholder_icon: &'static str, +} + +impl AlbumArtController { + pub fn new(placeholder_icon: &'static str) -> Self { + Self { + cache: AlbumArtCache::new(), + source_registry: Rc::new(RefCell::new(None)), + pixel_size: Rc::new(RefCell::new(None)), + placeholder_icon, + } + } + + /// Wire the source registry in once the main window has constructed + /// it. The controller is cloned into the bind factory before this is + /// called, so a late binding simply skips the credential-isolated + /// resolution path and falls back to the URI/placeholder paths. + pub fn attach_source_registry(&self, source_registry: crate::source_registry::SourceRegistry) { + *self.source_registry.borrow_mut() = Some(source_registry); + } + + /// Wire the live size knob in. The bind factory and the cache probe + /// read this cell on every bind, so a subsequent + /// [`crate::ui::browser::set_album_pane_artwork_size`] takes effect + /// for any row that scrolls into view afterwards. The cell is shared + /// with the `BrowserState` so a write through the public setter is + /// observed here without any further wiring. + #[allow(dead_code)] + pub fn attach_pixel_size(&self, pixel_size: Rc>) { + *self.pixel_size.borrow_mut() = Some(pixel_size); + } + + pub fn cache(&self) -> &AlbumArtCache { + &self.cache + } + + #[allow(dead_code)] + pub fn placeholder_icon(&self) -> &'static str { + self.placeholder_icon + } + + /// Resolve the side length to render at. Reads the live size knob + /// when the controller has been wired to one, otherwise returns + /// [`AlbumArtController::default_pixel_size`]. + fn current_pixel_size(&self) -> i32 { + if let Some(cell) = self.pixel_size.borrow().as_ref() { + cell.get() + } else { + Self::default_pixel_size() + } + } + + /// Build the bind factory pair (`setup` + `bind`) for the album pane. + /// + /// `unbind` is exposed through the returned [`AlbumArtBinder`] so + /// callers can wire it to the factory. The bind factory: + /// * Snapshots the `BrowserItem`'s artwork candidate. + /// * Stamps the cell with a fresh `BindGeneration` so any in-flight + /// fetch for the prior row is invalidated. + /// * If the cache already has a texture for this album + size, paints + /// it directly and returns (no fetch, no async). + /// * Otherwise paints the placeholder and schedules a fetch. + /// + /// The `setup` closure installs the row's reusable widget tree (one + /// `gtk::Box` per `ListItem`, holding an `Image` and a `Label`). The + /// bind phase updates those existing widgets in place; `unbind` + /// disconnects the artwork-paintable notify handler so the next bind + /// can install a fresh one without leaking observers. + #[allow(clippy::type_complexity, dead_code)] + pub fn build_binder( + &self, + ) -> ( + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + AlbumArtBinder, + ) { + self.build_binder_with_size_internal(None) + } + + /// Build the bind factory pair, additionally wiring the controller + /// to the supplied size knob. Equivalent to `build_binder` followed + /// by `attach_pixel_size`, but folded into a single call so the + /// browser's pane-rebuild path doesn't have to plumb the cell + /// through a second setter. + #[allow(clippy::type_complexity)] + pub fn build_binder_with_size( + &self, + pixel_size: Rc>, + ) -> ( + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + AlbumArtBinder, + ) { + self.build_binder_with_size_internal(Some(pixel_size)) + } + + #[allow(clippy::type_complexity)] + fn build_binder_with_size_internal( + &self, + pixel_size: Option>>, + ) -> ( + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static, + AlbumArtBinder, + ) { + if let Some(cell) = pixel_size { + *self.pixel_size.borrow_mut() = Some(cell); + } + let placeholder_icon = self.placeholder_icon; + let binder = AlbumArtBinder::new(self.clone()); + let cell_states = binder.cell_states.clone(); + let controller = self.clone(); + + let setup = move |_factory: >k::SignalListItemFactory, list_item: &glib::Object| { + let list_item = list_item.downcast_ref::().expect("ListItem"); + let cell_state = AlbumArtCellState::new(AlbumArtCell::new(placeholder_icon)); + let row_widget = cell_state.cell.row.clone(); + cell_states + .borrow_mut() + .insert(list_item.as_ptr() as usize, cell_state); + list_item.set_child(Some(&row_widget)); + }; + + let bind = binder.bind_fn(); + let unbind = binder.unbind_fn(); + let _ = controller; + (setup, bind, unbind, binder) + } +} + +/// Handle returned to the factory wiring so `unbind` can invalidate the +/// bound row's generation before GTK hands the cell to a different item. +pub struct AlbumArtBinder { + controller: AlbumArtController, + pub(crate) cell_states: Rc>>, +} + +impl AlbumArtBinder { + fn new(controller: AlbumArtController) -> Self { + Self { + controller, + cell_states: Rc::new(RefCell::new(HashMap::new())), + } + } + + fn bind_fn(&self) -> impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static { + let controller = self.controller.clone(); + let cell_states = self.cell_states.clone(); + move |_factory: >k::SignalListItemFactory, list_item: &glib::Object| { + let list_item = list_item.downcast_ref::().expect("ListItem"); + let key = list_item.as_ptr() as usize; + let item = list_item + .item() + .and_downcast::() + .expect("Album pane list_item must wrap a BrowserItem"); + let label_text = item.display(); + let accessible_label = item.label(); + let candidate = item.artwork_candidate(); + + let cell_state = cell_states + .borrow() + .get(&key) + .cloned() + .expect("setup closure must register a cell state for this list_item"); + + // Apply the live pixel size to the cell's image before + // deciding between cache hit, cache miss, or no candidate. + // `AlbumArtCell::new` requests the icon-theme default, so a + // bind here is what tells GTK the actual side length the + // placeholder and the eventual texture should render at. + let pixel_size = controller.current_pixel_size(); + cell_state.cell.image.set_pixel_size(pixel_size); + + // Cache hit: paint straight from the cache. + if let Some(ref candidate) = candidate { + if let Some(texture) = controller.cache.get(&candidate.track_id, pixel_size) { + let generation = cell_state.current_generation().next(); + cell_state.generation.set(generation); + cell_state + .cell + .show_texture(&texture, &label_text, Some(&accessible_label)); + *cell_state.bound_album_key.borrow_mut() = Some(candidate.track_id.clone()); + return; + } + } + + // Cache miss: paint placeholder + schedule fetch. + let generation = cell_state.current_generation().next(); + cell_state.generation.set(generation); + cell_state + .cell + .show_placeholder(&label_text, Some(&accessible_label)); + *cell_state.bound_album_key.borrow_mut() = + candidate.as_ref().map(|cand| cand.track_id.clone()); + + if let Some(candidate) = candidate { + controller.spawn_fetch(cell_state.clone(), candidate, generation); + } + } + } + + fn unbind_fn(&self) -> impl Fn(>k::SignalListItemFactory, &glib::Object) + 'static { + let cell_states = self.cell_states.clone(); + move |_factory: >k::SignalListItemFactory, list_item: &glib::Object| { + let list_item = list_item.downcast_ref::().expect("ListItem"); + let key = list_item.as_ptr() as usize; + if let Some(state) = cell_states.borrow().get(&key).cloned() { + // Bump generation so any in-flight fetch for this row + // paints nothing when it returns, and disconnect the + // paintable-notify listener so the next bind installs a + // fresh one without leaking observers. + state.generation.set(state.generation.get().next()); + *state.bound_album_key.borrow_mut() = None; + if let Some(handler_id) = state.paintable_notify_id.borrow_mut().take() { + state.cell.image.disconnect(handler_id); + } + } + } + } +} + +impl AlbumArtController { + /// Default pixel size used when no preference is set. Matches + /// `AlbumArtSize::Medium` so the controller has a sensible + /// default without depending on the prefs module (which would + /// create a circular dependency direction). + fn default_pixel_size() -> i32 { + 48 + } + + fn spawn_fetch( + &self, + cell_state: AlbumArtCellState, + candidate: AlbumArtCandidate, + generation: BindGeneration, + ) { + let image = cell_state.cell.image.clone(); + let cache = self.cache.clone(); + let source_registry = self.source_registry.clone(); + let album_key = candidate.track_id.clone(); + let pixel_size = self.current_pixel_size(); + let cover_art_url = candidate.cover_art_url.clone(); + let uri = candidate.uri.clone(); + let source_id = candidate.source_id; + let source_epoch = candidate.source_session_epoch; + + glib::MainContext::default().spawn_local(async move { + // Step 1: resolve the artwork path. The decision tree mirrors + // the playback-time resolver: remote sources go through the + // lease-isolated HTTP path; legacy tracks with an embedded + // cover URL fall back to the direct URL path; local files go + // through the embedded-extraction path. Drop the RefCell + // guard before any `.await` so we never hold a borrowed + // reference across a suspension point. + let registry_handle = source_registry.borrow().clone(); + let resolved = resolve_kind( + registry_handle, + source_id, + source_epoch, + &candidate, + cover_art_url, + uri, + ) + .await; + + // Late-cancellation check: if the row was unbound before the + // resolver returned, drop the result on the floor. + if cell_state.current_generation() != generation { + return; + } + + match resolved { + ResolvedArtKind::NoArtwork => { + // Leave the placeholder visible. + } + ResolvedArtKind::DirectFile { uri } => { + // Embedded extraction goes through the album-art + // worker; the worker's own generation check prevents + // late results from racing newer rows. + album_art::update_direct_file_album_art(&image, &uri); + } + ResolvedArtKind::DirectUrl { url } => { + album_art::fetch_remote_album_art(&image, &url); + } + ResolvedArtKind::ResolvedRequest(request) => { + let gen = album_art::begin_remote_album_art(&image); + album_art::fetch_resolved_album_art(&image, *request, gen); + } + } + + // Cache the texture only once the worker publishes it. The + // worker delivers bytes through `gdk::Texture::from_bytes` + // synchronously on the GTK main loop, so we listen for the + // resulting `paintable` property change. + install_cache_probe(cache, image, album_key, pixel_size, cell_state, generation); + }); + } +} + +enum ResolvedArtKind { + NoArtwork, + DirectFile { uri: String }, + DirectUrl { url: String }, + ResolvedRequest(Box), +} + +async fn resolve_kind( + source_registry: Option, + source_id: Option, + source_epoch: Option, + candidate: &AlbumArtCandidate, + cover_art_url: String, + uri: String, +) -> ResolvedArtKind { + // 1. Lease-isolated remote resolver. + if let (Some(registry), Some(id), Some(epoch)) = + (source_registry.as_ref(), source_id, source_epoch) + { + let track_id = match crate::architecture::TrackId::new(candidate.track_id.clone()) { + Ok(id) => id, + Err(error) => { + tracing::debug!( + %error, + track_id = %candidate.track_id, + "Album pane skipped invalid track id while resolving artwork" + ); + return ResolvedArtKind::NoArtwork; + } + }; + match registry.resolve_artwork(id, epoch, track_id).await { + Ok(Some(request)) => return ResolvedArtKind::ResolvedRequest(Box::new(request)), + Ok(None) => { + // Remote source returned no artwork for this track — try + // the legacy embedded cover URL on the row before giving + // up, so a row that has both a remote and a URL still + // gets a thumbnail. + if !cover_art_url.is_empty() { + return ResolvedArtKind::DirectUrl { url: cover_art_url }; + } + if uri.starts_with("file://") { + return ResolvedArtKind::DirectFile { uri }; + } + return ResolvedArtKind::NoArtwork; + } + Err(error) => { + tracing::debug!( + %error, + source_id = %id, + track_id = %candidate.track_id, + "Album pane artwork resolver fell back after backend error" + ); + } + } + } + // 2. Legacy direct URL fallback for rows that ship one. + if !cover_art_url.is_empty() { + return ResolvedArtKind::DirectUrl { url: cover_art_url }; + } + // 3. Embedded extraction for local file rows. + if uri.starts_with("file://") { + return ResolvedArtKind::DirectFile { uri }; + } + ResolvedArtKind::NoArtwork +} + +fn install_cache_probe( + cache: AlbumArtCache, + image: gtk::Image, + album_key: String, + pixel_size: i32, + cell_state: AlbumArtCellState, + generation: BindGeneration, +) { + // The album-art worker calls `set_paintable` synchronously from the + // GTK main thread when its fetch succeeds. Listening for the + // `paintable` property change is therefore the cheapest way to know + // a fresh texture is installed — no additional worker plumbing. + // + // Disconnect any prior listener first: the same `gtk::Image` is + // reused across multiple binds in a virtualized list, so without + // this every rebind would leave its predecessor's listener attached + // and the cache would observe the same paintable change N times. + if let Some(previous) = cell_state.paintable_notify_id.borrow_mut().take() { + image.disconnect(previous); + } + + let closure_album_key = album_key; + let gen = generation; + let state = cell_state.clone(); + let handler_id = image.connect_notify_local(Some("paintable"), move |img, _| { + if state.current_generation() != gen { + return; + } + if let Some(paintable) = img.paintable() { + if let Ok(texture) = paintable.downcast::() { + cache.insert(&closure_album_key, pixel_size, texture); + } + } + }); + cell_state.paintable_notify_id.replace(Some(handler_id)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_texture() -> gdk::Texture { + // 1×1 RGBA PNG with full filter byte per row. PNG's raw stream is + // a per-row filter byte (0 = none) plus RGBA pixels; the IDAT + // zlib-stream deflates those bytes. CRC table from PNG spec §B. + let png: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, + 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, + 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41, 0x54, 0x78, + 0x9C, 0x63, 0x60, 0x00, 0x02, 0x00, 0x00, 0x05, 0x00, 0x01, 0x7A, 0x5E, 0xAB, 0x3F, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + gdk::Texture::from_bytes(&glib::Bytes::from_static(png)).expect("valid 1×1 PNG decodes") + } + + #[test] + fn cache_bounded_eviction_replaces_oldest_entry() { + let cache = AlbumArtCache::new(); + let texture = fake_texture(); + for i in 0..(MAX_CACHED_ALBUM_ARTS + 4) { + cache.insert(&format!("album-{i}"), 48, texture.clone()); + } + assert_eq!(cache.len(), MAX_CACHED_ALBUM_ARTS); + // Earliest entries must have been evicted. + assert!(cache.get("album-0", 48).is_none()); + assert!(cache.get("album-1", 48).is_none()); + // Most recent insertions survive. + let last = MAX_CACHED_ALBUM_ARTS + 4 - 1; + assert!(cache.get(&format!("album-{last}"), 48).is_some()); + } + + #[test] + fn cache_hit_promotes_to_most_recent() { + let cache = AlbumArtCache::new(); + let texture = fake_texture(); + for i in 0..MAX_CACHED_ALBUM_ARTS { + cache.insert(&format!("album-{i}"), 48, texture.clone()); + } + // Touch the earliest entry — it should survive a follow-up + // insert that would otherwise evict it. + assert!(cache.get("album-0", 48).is_some()); + cache.insert("newcomer", 48, texture.clone()); + assert_eq!(cache.len(), MAX_CACHED_ALBUM_ARTS); + assert!(cache.get("album-0", 48).is_some()); + assert!(cache.get("album-1", 48).is_none()); + } + + #[test] + fn cache_pixel_size_distinguishes_entries() { + let cache = AlbumArtCache::new(); + let texture = fake_texture(); + cache.insert("album-a", 32, texture.clone()); + assert!(cache.get("album-a", 32).is_some()); + assert!(cache.get("album-a", 48).is_none()); + cache.insert("album-a", 48, texture); + assert!(cache.get("album-a", 32).is_some()); + assert!(cache.get("album-a", 48).is_some()); + } + + #[test] + fn bind_generation_advances_on_reset() { + // The cell's generation must advance on reset so any in-flight + // fetch for the prior row is invalidated before the next bind + // can paint. The advance is a pure `Cell` write + // (`generation.set(generation.get().next())`) — exercised here + // without constructing a `gtk::Image`, because GTK4 may only be + // used from the main thread and CI's aarch64 / macOS matrices + // do not give us a main thread. `gtk::test_synced` dispatches + // onto GTK's test thread pool but does not initialise GTK, so + // the first widget call would still panic there. + let cell = Rc::new(Cell::new(BindGeneration::INVALID)); + let initial = cell.get(); + cell.set(cell.get().next()); + assert_ne!(cell.get(), initial); + } + + #[test] + fn bind_generation_monotonic_across_rebinds() { + // The virtualized list recycles a row's cell across many + // rebinds; each bind must hand the cell a fresh generation so a + // late async result for the previous row is dropped without + // painting. The factory's contract is: every bind mints a new + // generation, no generation is reused. + let gen = BindGeneration::INVALID; + let mut last = gen; + for _ in 0..8 { + let next = last.next(); + assert_ne!(next, last); + last = next; + } + } + + #[test] + fn bind_generation_late_result_is_dropped() { + // An in-flight async fetch returns after the row has been + // recycled. The contract is: `state.current_generation() != + // captured_generation` ⇒ the result must be discarded. We can + // exercise the check directly without spinning up the async + // runtime or constructing a `gtk::Image`: the same predicate + // runs in `spawn_fetch` and the cache probe closure, both of + // which compare the captured generation against the cell's + // current generation token. + let captured = BindGeneration::INVALID.next(); + // Simulate a rebind (advance the generation). + let current = captured.next(); + assert_ne!( + current, captured, + "late result must observe a newer generation than the one captured at fetch time" + ); + } + + #[test] + fn album_art_size_tokens_round_trip() { + for size in [ + AlbumArtSize::Small, + AlbumArtSize::Medium, + AlbumArtSize::Large, + ] { + let token = size.as_token(); + let parsed = AlbumArtSize::from_token(token).expect("round-trip"); + assert_eq!(parsed, size); + } + assert!(AlbumArtSize::from_token("nope").is_none()); + assert!(AlbumArtSize::from_token("").is_none()); + } + + #[test] + fn album_art_size_pixel_sizes_are_distinct() { + let small = AlbumArtSize::Small.pixel_size(); + let medium = AlbumArtSize::Medium.pixel_size(); + let large = AlbumArtSize::Large.pixel_size(); + assert!(small < medium); + assert!(medium < large); + assert!(small > 0); + } + + #[test] + fn controller_default_pixel_size_matches_medium_token() { + // The controller's default must match `AlbumArtSize::Medium` so + // a layout that toggles on before the prefs module is queried + // still renders at the same size the user sees everywhere else. + assert_eq!( + AlbumArtController::default_pixel_size(), + AlbumArtSize::Medium.pixel_size() + ); + } + + #[test] + fn current_pixel_size_falls_back_to_default_without_source() { + // A controller constructed without a size source must render at + // the same size the default knob advertises, so an untested + // call site doesn't see a different thumbnail size than the + // documented default. + let controller = AlbumArtController::new("audio-x-generic-symbolic"); + assert_eq!( + controller.current_pixel_size(), + AlbumArtController::default_pixel_size() + ); + } + + #[test] + fn current_pixel_size_reads_live_source_cell() { + // The persisted layout preference must reach the bind path: + // flipping the cell from Small to Large must be observed by the + // next call into `current_pixel_size`, otherwise the + // Small/Large selector in the preferences dialog is inert. + let controller = AlbumArtController::new("audio-x-generic-symbolic"); + let source: Rc> = Rc::new(Cell::new(AlbumArtSize::Small.pixel_size())); + controller.attach_pixel_size(source.clone()); + assert_eq!( + controller.current_pixel_size(), + AlbumArtSize::Small.pixel_size() + ); + source.set(AlbumArtSize::Large.pixel_size()); + assert_eq!( + controller.current_pixel_size(), + AlbumArtSize::Large.pixel_size() + ); + source.set(AlbumArtSize::Medium.pixel_size()); + assert_eq!( + controller.current_pixel_size(), + AlbumArtSize::Medium.pixel_size() + ); + } + + #[test] + fn cell_pixel_size_starts_at_icon_theme_sentinel() { + // GTK4's "use the icon theme's default size" sentinel is -1; a + // freshly-built cell must request the placeholder at that + // sentinel, not at a literal 0 pixels, so the icon-theme + // fallback is visible until the bind factory applies the live + // side length. The sentinel is exposed as the + // `AlbumArtCell::PLACEHOLDER_PIXEL_SIZE` constant so the test + // can verify it without constructing a `gtk::Image` — GTK4 may + // only be used from the main thread, and CI's aarch64 / macOS + // matrices do not give us one. `gtk::test_synced` dispatches + // onto GTK's test thread pool but does not initialise GTK, so + // the first widget call would still panic there. + assert_eq!(AlbumArtCell::PLACEHOLDER_PIXEL_SIZE, -1); + } +} diff --git a/src/ui/browser.rs b/src/ui/browser.rs index 08811aa2..9feaf0a8 100644 --- a/src/ui/browser.rs +++ b/src/ui/browser.rs @@ -13,7 +13,8 @@ use gtk::gio; use gtk::glib; use gtk::prelude::*; -use super::objects::{BrowserItem, TrackObject}; +use super::album_pane_art::{AlbumArtCache, AlbumArtController}; +use super::objects::{AlbumArtCandidate, BrowserItem, TrackObject}; use tracing::debug; /// Callback invoked when the browser selection changes. @@ -30,6 +31,21 @@ pub struct BrowserState { /// When true, the Artist pane groups by album artist (with fallback /// to track artist for tracks that don't carry an album-artist tag). use_album_artist: Rc>, + /// Whether the album pane should render artwork thumbnails alongside + /// its text labels. Toggled by the preferences dialog and read by + /// the album pane's bind factory. + album_pane_artwork: Rc>, + /// Side length (in device pixels) of each album-pane thumbnail. + /// Persisted across restarts and forwarded to the cache probe. + album_pane_artwork_size: Rc>, + /// Coordinator for the album pane artwork path. Owned by the state + /// so the bind factory's closures stay valid for the life of the + /// browser even if the controller's internal references move. + album_art_controller: Rc, + /// In-memory texture cache keyed by `(track_id, pixel_size)`. Shared + /// with the album pane bind factory and exposed so callers can clear + /// it on layout/preference changes. + album_art_cache: Rc, } /// Build the 3-pane browser. @@ -39,9 +55,14 @@ pub struct BrowserState { pub fn build_browser( all_tracks: &[TrackObject], use_album_artist: bool, + initial_album_pane_artwork: bool, + initial_album_pane_artwork_size: i32, on_filter_changed: FilterCallback, ) -> (gtk::Box, BrowserState) { let use_album_artist: Rc> = Rc::new(Cell::new(use_album_artist)); + let album_pane_artwork: Rc> = Rc::new(Cell::new(initial_album_pane_artwork)); + let album_pane_artwork_size: Rc> = + Rc::new(Cell::new(initial_album_pane_artwork_size)); // Shared filter state let selected_genre: Rc>> = Rc::new(RefCell::new(None)); let selected_artist: Rc>> = Rc::new(RefCell::new(None)); @@ -53,6 +74,12 @@ pub fn build_browser( // from cascading into further repopulation. let updating: Rc> = Rc::new(Cell::new(false)); + // Album-art coordinator: virtualized, accessible, bounded cache for + // the album pane's per-row thumbnails. The source registry is wired + // in later by the window so the controller can resolve credential- + // isolated remote artwork without exposing endpoints here. + let album_art_controller = Rc::new(AlbumArtController::new("audio-x-generic-symbolic")); + // Stores for each pane let genre_store = gio::ListStore::new::(); let artist_store = gio::ListStore::new::(); @@ -85,7 +112,12 @@ pub fn build_browser( // ── Build the 3 panes ──────────────────────────────────────────── let genre_pane = build_pane("Genre", &genre_store); let artist_pane = build_pane("Artist", &artist_store); - let album_pane = build_pane("Album", &album_store); + let album_pane = build_album_pane( + &album_store, + album_art_controller.clone(), + album_pane_artwork.clone(), + album_pane_artwork_size.clone(), + ); // ── Genre selection ────────────────────────────────────────────── // User picks a genre → repopulate artist + album (downstream). @@ -272,6 +304,10 @@ pub fn build_browser( tracks, search_text, use_album_artist, + album_pane_artwork, + album_pane_artwork_size, + album_art_cache: Rc::new(album_art_controller.cache().clone()), + album_art_controller, }; (browser_box, state) } @@ -280,6 +316,125 @@ pub fn build_browser( // Helpers // --------------------------------------------------------------------------- +/// Switch the album pane between the artwork thumbnail bind factory and +/// the plain label bind factory used by genre and artist. +/// +/// Building a fresh pane is cheaper than mutating the factory in place: +/// GTK's `SignalListItemFactory` does not expose a clean replace API, +/// and rebuilding the list view also forces GTK to drop every cached +/// `gdk::Texture` reference on the obsolete row widgets. +pub fn set_album_pane_artwork(browser_box: >k::Box, state: &BrowserState, enabled: bool) { + if state.album_pane_artwork.get() == enabled { + return; + } + state.album_pane_artwork.set(enabled); + rebuild_album_pane(browser_box, state); +} + +/// Update the album-pane thumbnail size. The bind factory reads the +/// size knob on every bind, so the change applies to the next set of +/// rows that scroll into view. We rebuild the album pane here so the +/// cached textures (keyed by `(album_key, pixel_size)`) are dropped +/// alongside the old bind factory — a stale entry from the previous +/// size would never be queried again, and leaving it in the cache +/// would still consume the bounded-memory budget. +pub fn set_album_pane_artwork_size(browser_box: >k::Box, state: &BrowserState, pixel_size: i32) { + if state.album_pane_artwork_size.get() == pixel_size { + return; + } + state.album_pane_artwork_size.set(pixel_size); + rebuild_album_pane(browser_box, state); +} + +/// Replace the album pane in place with a freshly-built one wired to +/// the current `(album_pane_artwork, album_pane_artwork_size)` knobs. +/// The existing `gio::ListStore` is preserved across the swap so the +/// album rows survive; the album store is then repopulated from the +/// shared track snapshot so the artwork candidates match the latest +/// library state. The cache is cleared because every entry was decoded +/// at the previous size and would never match a new `(album_key, +/// pixel_size)` lookup under the recompiled bind factory. +fn rebuild_album_pane(browser_box: >k::Box, state: &BrowserState) { + let panes_box = browser_box + .last_child() + .and_then(|w| w.downcast::().ok()); + let Some(panes_box) = panes_box else { + return; + }; + + let mut child = panes_box.first_child(); + let mut panes = Vec::new(); + while let Some(widget) = child { + if let Some(pane) = widget.downcast_ref::() { + panes.push(pane.clone()); + } + child = widget.next_sibling(); + } + + if panes.len() < 3 { + return; + } + + // Album pane is the 3rd child (index 2). Replace it. + let old_pane = panes[2].clone(); + let album_store = + album_store_from_pane(&old_pane).unwrap_or_else(gio::ListStore::new::); + + // Clear the cache so the new bind factory doesn't serve stale + // textures from before the layout change — they're decoded at the + // old size, and a stale hit would bypass the new bind path entirely. + state.album_art_cache.inner.borrow_mut().entries.clear(); + state.album_art_cache.inner.borrow_mut().order.clear(); + + let new_pane = build_album_pane( + &album_store, + state.album_art_controller.clone(), + state.album_pane_artwork.clone(), + state.album_pane_artwork_size.clone(), + ); + panes_box.remove(&old_pane); + panes_box.append(&new_pane); + + // The new album pane keeps the same `gio::ListStore` as the old one, + // so the album rows survive the swap. Repopulate the store from the + // current snapshot so the BrowserItem's artwork candidates are + // refreshed against the latest library state — but do NOT call + // `rebuild_browser_data` here: that helper would replace + // `state.tracks` with whatever slice it is handed, and the call site + // would have to pass the live master track list to avoid blanking + // the genre and artist panes. Toggling the artwork checkbox or + // changing the size is a layout event, not a library sync, so the + // snapshot must stay put. + let borrowed = state.tracks.borrow(); + let use_aa = state.use_album_artist.get(); + populate_albums(&album_store, &borrowed, &None, &None, use_aa); +} + +/// Pull the underlying `gio::ListStore` out of an album +/// pane Box so the swapped-in pane can keep the same data. +fn album_store_from_pane(pane: >k::Box) -> Option { + let scrolled = pane.last_child()?.downcast::().ok()?; + let list_view = scrolled.child()?.downcast::().ok()?; + let selection = list_view.model()?.downcast::().ok()?; + selection + .model() + .and_then(|m| m.downcast::().ok()) +} + +/// Attach the live source registry to the album-art coordinator. Must +/// be called once after `build_browser` and once per registry +/// replacement (the controller will see the new handle on the next +/// bind). The pointer is intentional: only the resolver path needs +/// it, and lazy attachment keeps the coordinator construction cheap. +pub fn attach_source_registry( + state: &BrowserState, + source_registry: crate::source_registry::SourceRegistry, +) { + state + .album_art_controller + .attach_source_registry(source_registry); +} + /// Lightweight snapshot of track fields for filtering (avoids borrowing GObjects). #[derive(Clone)] struct TrackSnapshot { @@ -290,6 +445,21 @@ struct TrackSnapshot { /// Album artist (used for browser grouping when the preference is on). album_artist: String, album: String, + /// Stable track identifier. The album-pane artwork resolver reads + /// this to call `SourceRegistry::resolve_artwork`. + track_id: String, + /// Playable locator or `file://` URI. Local album rows go through + /// the embedded-art extractor when the resolver finds no remote art. + uri: String, + /// Track-provided cover URL string. Used as a third-tier fallback + /// when no remote artwork can be resolved. + cover_art_url: String, + /// Source identity for the credential-isolated remote resolver. + /// `None` for local / non-networked tracks. + source_id: Option, + /// Source session epoch paired with `source_id`; the resolver + /// rejects resolutions that cross an active replacement. + source_session_epoch: Option, } impl TrackSnapshot { @@ -300,6 +470,11 @@ impl TrackSnapshot { artist: t.artist(), album_artist: t.album_artist(), album: t.album(), + track_id: t.track_id(), + uri: t.uri(), + cover_art_url: t.cover_art_url(), + source_id: t.source_id(), + source_session_epoch: t.source_session_epoch(), } } @@ -378,6 +553,85 @@ fn build_pane(title: &str, store: &gio::ListStore) -> gtk::Box { pane } +/// Build the album pane with an optional artwork column. +/// +/// When `album_pane_artwork_visible` is on, the bind factory uses the +/// `AlbumArtController` to fetch a thumbnail for each row. When off, +/// the factory falls back to the plain label used by genre and artist. +fn build_album_pane( + store: &gio::ListStore, + album_art_controller: Rc, + album_pane_artwork_visible: Rc>, + album_pane_artwork_size: Rc>, +) -> gtk::Box { + let header = gtk::Label::builder() + .label(rust_i18n::t!("browser.album").as_ref()) + .css_classes(["heading"]) + .halign(gtk::Align::Start) + .margin_start(8) + .margin_top(4) + .margin_bottom(2) + .build(); + + let selection = gtk::SingleSelection::new(Some(store.clone())); + selection.set_autoselect(true); + + let factory = gtk::SignalListItemFactory::new(); + + if album_pane_artwork_visible.get() { + let (setup, bind, unbind, _binder) = + album_art_controller.build_binder_with_size(album_pane_artwork_size.clone()); + factory.connect_setup(setup); + factory.connect_bind(bind); + factory.connect_unbind(unbind); + } else { + factory.connect_setup(|_, list_item| { + let list_item = list_item.downcast_ref::().expect("ListItem"); + let label = gtk::Label::builder() + .halign(gtk::Align::Start) + .margin_start(8) + .margin_end(8) + .margin_top(2) + .margin_bottom(2) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + list_item.set_child(Some(&label)); + }); + factory.connect_bind(|_, list_item| { + let list_item = list_item.downcast_ref::().expect("ListItem"); + let item = list_item + .item() + .and_downcast::() + .expect("BrowserItem"); + let label = list_item + .child() + .and_downcast::() + .expect("Label"); + label.set_text(&item.display()); + }); + } + + let list_view = gtk::ListView::builder() + .model(&selection) + .factory(&factory) + .build(); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&list_view) + .hscrollbar_policy(gtk::PolicyType::Never) + .vscrollbar_policy(gtk::PolicyType::Automatic) + .vexpand(true) + .build(); + + let pane = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + pane.append(&header); + pane.append(&scrolled); + + pane +} + /// Extract the `SingleSelection` from a browser pane box. fn get_selection(pane: >k::Box) -> gtk::SingleSelection { let scrolled = pane @@ -497,6 +751,11 @@ fn populate_albums( use_album_artist: bool, ) { store.remove_all(); + // Track the first representative per album so the browser pane can + // resolve artwork lazily — only the chosen representative's source + // identity and URI need to be retained in the BrowserItem. + let mut candidates: std::collections::BTreeMap = + std::collections::BTreeMap::new(); let mut map = std::collections::BTreeMap::::new(); for t in tracks { if let Some(g) = genre_filter { @@ -510,11 +769,28 @@ fn populate_albums( } } *map.entry(t.album.clone()).or_insert(0) += 1; + candidates + .entry(t.album.clone()) + .or_insert_with(|| AlbumArtCandidate { + track_id: t.track_id.clone(), + uri: t.uri.clone(), + cover_art_url: t.cover_art_url.clone(), + source_id: t.source_id, + source_session_epoch: t.source_session_epoch, + }); } let total: u32 = map.values().sum(); store.append(&BrowserItem::new("All", total)); for (album, count) in &map { - store.append(&BrowserItem::new(album, *count)); + if let Some(candidate) = candidates.get(album) { + store.append(&BrowserItem::new_with_artwork( + album, + *count, + candidate.clone(), + )); + } else { + store.append(&BrowserItem::new(album, *count)); + } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9c201dcf..3fbfb4da 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,7 @@ //! UI module — GTK4 / libadwaita interface components. pub mod album_art; +pub mod album_pane_art; pub mod browser; pub mod context_menu; pub mod discovery_handler; diff --git a/src/ui/objects/browser_item.rs b/src/ui/objects/browser_item.rs index 5e07c671..bf0fa4b9 100644 --- a/src/ui/objects/browser_item.rs +++ b/src/ui/objects/browser_item.rs @@ -12,6 +12,15 @@ mod imp { pub struct BrowserItem { pub label: RefCell, pub count: Cell, + /// Optional representative track + source identity used to fetch + /// the album's artwork. Only the album pane populates this; genre + /// and artist panes leave it empty so their lightweight labels + /// carry no per-row art cost. + pub artwork_candidate: RefCell>, + /// Whether this item is the synthetic "All" row. The "All" row is + /// never decorated with artwork; the bind factory uses this to skip + /// the artwork fetch path and stick to the existing text label. + pub is_all_row: Cell, } #[glib::object_subclass] @@ -27,11 +36,44 @@ glib::wrapper! { pub struct BrowserItem(ObjectSubclass); } +/// Representative track + source identity used to fetch one album's +/// artwork asynchronously. +/// +/// The browser pane stores at most one candidate per album item: the +/// first track whose artwork path the UI knows how to resolve. That keeps +/// the per-row memory bounded — every other track for the same album is +/// already covered by the same shared `Texture` once the cache warms up. +/// +/// Stored inside `BrowserItem`, so the type must remain `Clone + 'static` +/// and never carry an open file handle. Local embedded extraction is +/// triggered through the URI string in the existing +/// `album_art::update_direct_file_album_art` path, which is display-only +/// (playback never reads artwork from a browser row). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlbumArtCandidate { + pub track_id: String, + pub uri: String, + pub cover_art_url: String, + pub source_id: Option, + pub source_session_epoch: Option, +} + impl BrowserItem { pub fn new(label: &str, count: u32) -> Self { let obj: Self = glib::Object::builder().build(); obj.imp().label.replace(label.to_string()); obj.imp().count.set(count); + obj.imp().is_all_row.set(label == "All"); + obj + } + + /// Construct an album-pane item with a representative artwork candidate. + pub fn new_with_artwork(label: &str, count: u32, candidate: AlbumArtCandidate) -> Self { + let obj: Self = glib::Object::builder().build(); + obj.imp().label.replace(label.to_string()); + obj.imp().count.set(count); + obj.imp().is_all_row.set(false); + obj.imp().artwork_candidate.replace(Some(candidate)); obj } @@ -41,6 +83,12 @@ impl BrowserItem { pub fn count(&self) -> u32 { self.imp().count.get() } + pub fn is_all_row(&self) -> bool { + self.imp().is_all_row.get() + } + pub fn artwork_candidate(&self) -> Option { + self.imp().artwork_candidate.borrow().clone() + } pub fn display(&self) -> String { format!("{} ({})", self.label(), self.count()) diff --git a/src/ui/objects/mod.rs b/src/ui/objects/mod.rs index 33a3dbb0..d5b179f3 100644 --- a/src/ui/objects/mod.rs +++ b/src/ui/objects/mod.rs @@ -7,7 +7,7 @@ mod browser_item; mod source_object; mod track_object; -pub use browser_item::BrowserItem; +pub use browser_item::{AlbumArtCandidate, BrowserItem}; pub use source_object::{HeaderKind, PlaylistSidebarKind, SourceObject}; pub use track_object::TrackObject; pub use track_object::{ diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs index bc4377a1..6bc2f3fc 100644 --- a/src/ui/preferences.rs +++ b/src/ui/preferences.rs @@ -77,6 +77,61 @@ pub struct AppConfig { /// the track-level Artist tag. Default: false (group by Artist). #[serde(default)] pub group_by_album_artist: bool, + /// Whether the browser Album pane decorates each row with a thumbnail. + /// Default: false (text-only label) to match the pre-existing look. + #[serde(default)] + pub album_pane_artwork: bool, + /// Thumbnail side length for the browser Album pane, in device pixels. + /// Default: `Medium` (48 dp). Persisted across restarts. + #[serde(default)] + pub album_pane_artwork_size: AlbumArtSize, +} + +/// Thumbnail side length for the browser Album pane. +/// +/// Bounded at the source by the album-art worker's byte cap (32 MiB); the +/// GTK side decodes whatever the worker returns into a square of the +/// selected size, so this knob is layout (not transport) state. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AlbumArtSize { + Small, + #[default] + Medium, + Large, +} + +impl AlbumArtSize { + /// Side length in device pixels. + pub const fn pixel_size(self) -> i32 { + match self { + Self::Small => 32, + Self::Medium => 48, + Self::Large => 72, + } + } + + /// Stable persistence token. New variants must keep older strings + /// recognized for in-place config migration. + #[allow(dead_code)] + pub const fn as_token(self) -> &'static str { + match self { + Self::Small => "small", + Self::Medium => "medium", + Self::Large => "large", + } + } + + /// Parse a previously-persisted token. Returns `None` for unknown + /// values so callers can fall back rather than reject a config file. + #[allow(dead_code)] + pub fn from_token(token: &str) -> Option { + match token { + "small" => Some(Self::Small), + "medium" => Some(Self::Medium), + "large" => Some(Self::Large), + _ => None, + } + } } /// A user-confirmed old-to-new library-root reauthorization. @@ -206,6 +261,8 @@ impl Default for AppConfig { pending_root_reauthorizations: Vec::new(), location_enabled: None, group_by_album_artist: false, + album_pane_artwork: false, + album_pane_artwork_size: AlbumArtSize::default(), } } } @@ -600,12 +657,17 @@ pub fn save_config(config: &AppConfig) -> bool { /// * `column_view` — the tracklist `ColumnView` to toggle column visibility /// * `browser_box` — the browser container `Box` to toggle pane visibility /// * `config` — current configuration (will be mutated and saved on changes) +/// * `on_album_artist_changed` — invoked when the artist grouping toggle flips +/// * `on_album_pane_artwork_changed` — invoked when the album artwork toggle flips +/// * `on_album_pane_artwork_size_changed` — invoked when the size dropdown changes pub fn show_preferences( parent: &adw::ApplicationWindow, column_view: >k::ColumnView, browser_box: >k::Box, config: &std::rc::Rc>, on_album_artist_changed: std::rc::Rc, + on_album_pane_artwork_changed: std::rc::Rc, + on_album_pane_artwork_size_changed: std::rc::Rc, ) { let prefs_dialog = adw::PreferencesDialog::builder() .title(rust_i18n::t!("preferences.title").as_ref()) @@ -792,12 +854,42 @@ pub fn show_preferences( .halign(gtk::Align::Start) .build(); + let album_art_check = gtk::CheckButton::builder() + .label(rust_i18n::t!("browser.album_artwork").as_ref()) + .active(cfg.album_pane_artwork) + .hexpand(true) + .halign(gtk::Align::Start) + .build(); + + // Three radio options matching the `AlbumArtSize` tokens. + let album_art_size_small = gtk::CheckButton::builder() + .label(rust_i18n::t!("browser.album_artwork_size_small").as_ref()) + .active(cfg.album_pane_artwork_size == AlbumArtSize::Small) + .build(); + let album_art_size_medium = gtk::CheckButton::builder() + .label(rust_i18n::t!("browser.album_artwork_size_medium").as_ref()) + .group(&album_art_size_small) + .active(cfg.album_pane_artwork_size == AlbumArtSize::Medium) + .build(); + let album_art_size_large = gtk::CheckButton::builder() + .label(rust_i18n::t!("browser.album_artwork_size_large").as_ref()) + .group(&album_art_size_small) + .active(cfg.album_pane_artwork_size == AlbumArtSize::Large) + .build(); + // Row 0: the three browser panes (one per grid column). browser_grid.attach(&genre_check, 0, 0, 1, 1); browser_grid.attach(&artist_check, 1, 0, 1, 1); browser_grid.attach(&album_check, 2, 0, 1, 1); // Row 1: the grouping toggle spans the full width (its label is longer). browser_grid.attach(&album_artist_check, 0, 1, 3, 1); + // Row 2: album pane artwork toggle (full width). + browser_grid.attach(&album_art_check, 0, 2, 3, 1); + // Row 3: size triplet (one per grid column). Grouped radios so only + // one can be active at a time. + browser_grid.attach(&album_art_size_small, 0, 3, 1, 1); + browser_grid.attach(&album_art_size_medium, 1, 3, 1, 1); + browser_grid.attach(&album_art_size_large, 2, 3, 1, 1); // Wire album artist toggle { @@ -846,6 +938,67 @@ pub fn show_preferences( }); } + // Wire album pane artwork toggle. The pane rebuild is performed by + // the on-change callback so the browser owns the swap. + { + let config = config.clone(); + let on_change = on_album_pane_artwork_changed.clone(); + album_art_check.connect_toggled(move |btn| { + let active = btn.is_active(); + { + let mut cfg = config.borrow_mut(); + cfg.album_pane_artwork = active; + save_config(&cfg); + } + on_change(active); + }); + } + + // Wire album-pane artwork size radios. Same pattern as the toggle. + { + let cfg_for_small = config.clone(); + let on_change_small = on_album_pane_artwork_size_changed.clone(); + let cfg_for_medium = config.clone(); + let on_change_medium = on_album_pane_artwork_size_changed.clone(); + let cfg_for_large = config.clone(); + let on_change_large = on_album_pane_artwork_size_changed.clone(); + let medium = album_art_size_medium.clone(); + let large = album_art_size_large.clone(); + album_art_size_small.connect_toggled(move |btn| { + if !btn.is_active() { + return; + } + { + let mut cfg = cfg_for_small.borrow_mut(); + cfg.album_pane_artwork_size = AlbumArtSize::Small; + save_config(&cfg); + } + on_change_small(AlbumArtSize::Small); + }); + medium.connect_toggled(move |btn| { + if !btn.is_active() { + return; + } + { + let mut cfg = cfg_for_medium.borrow_mut(); + cfg.album_pane_artwork_size = AlbumArtSize::Medium; + save_config(&cfg); + } + on_change_medium(AlbumArtSize::Medium); + }); + large.connect_toggled(move |btn| { + if !btn.is_active() { + return; + } + { + let mut cfg = cfg_for_large.borrow_mut(); + cfg.album_pane_artwork_size = AlbumArtSize::Large; + save_config(&cfg); + } + on_change_large(AlbumArtSize::Large); + }); + } + browser_group.add(&browser_grid); page.add(&browser_group); diff --git a/src/ui/window.rs b/src/ui/window.rs index 0a1d29b6..b2987f9e 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -1813,8 +1813,21 @@ pub(crate) fn build_window( ); let initial_use_album_artist = app_config.borrow().group_by_album_artist; - let (browser_widget, browser_state) = - browser::build_browser(&empty_tracks, initial_use_album_artist, on_filter); + let initial_album_pane_artwork = app_config.borrow().album_pane_artwork; + let initial_album_pane_artwork_size = + preferences::AlbumArtSize::pixel_size(app_config.borrow().album_pane_artwork_size); + let (browser_widget, browser_state) = browser::build_browser( + &empty_tracks, + initial_use_album_artist, + initial_album_pane_artwork, + initial_album_pane_artwork_size, + on_filter, + ); + // The album-art controller now needs the live source registry to + // resolve credential-isolated remote artwork. Wire it in here so + // the first bind (which may run as soon as the user scrolls the + // album pane) sees the registry. + browser::attach_source_registry(&browser_state, source_registry.clone()); // ── Right content ──────────────────────────────────────────────── let right_paned = gtk::Paned::builder() @@ -3449,19 +3462,49 @@ pub(crate) fn build_window( let master_for_pref = master_tracks.clone(); let prefs_action = gtk::gio::SimpleAction::new("show-preferences", None); prefs_action.connect_activate(move |_, _| { - let bw_for_cb = bw.clone(); - let bs_for_cb = bs.clone(); - let master_for_cb = master_for_pref.clone(); + let bw_for_aa = bw.clone(); + let bs_for_aa = bs.clone(); + let master_for_aa = master_for_pref.clone(); let on_aa_change: std::rc::Rc = std::rc::Rc::new(move |enabled: bool| { // Refresh the browser snapshot so the album-artist // grouping change takes effect against the latest // library state, not just whatever was loaded when // the browser was first built. - let tracks = master_for_cb.borrow().clone(); - browser::rebuild_browser_data(&bw_for_cb, &bs_for_cb, &tracks); - browser::set_album_artist_grouping(&bw_for_cb, &bs_for_cb, enabled); + let tracks = master_for_aa.borrow().clone(); + browser::rebuild_browser_data(&bw_for_aa, &bs_for_aa, &tracks); + browser::set_album_artist_grouping(&bw_for_aa, &bs_for_aa, enabled); }); - preferences::show_preferences(&win, &cv, &bw, &cfg, on_aa_change); + let bw_for_art = bw.clone(); + let bs_for_art = bs.clone(); + let on_art_change: std::rc::Rc = + std::rc::Rc::new(move |enabled: bool| { + // The album pane is the 3rd child of the panes + // box; swapping the bind factory is the only path + // GTK's ListView exposes for changing factory + // behaviour, and rebuilding the pane also drops + // any cached `gdk::Texture` handles from the old + // size. + browser::set_album_pane_artwork(&bw_for_art, &bs_for_art, enabled); + }); + let bw_for_size = bw.clone(); + let bs_for_size = bs.clone(); + let on_art_size_change: std::rc::Rc = + std::rc::Rc::new(move |size: preferences::AlbumArtSize| { + browser::set_album_pane_artwork_size( + &bw_for_size, + &bs_for_size, + size.pixel_size(), + ); + }); + preferences::show_preferences( + &win, + &cv, + &bw, + &cfg, + on_aa_change, + on_art_change, + on_art_size_change, + ); }); window.add_action(&prefs_action); }