diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index e40173dae..043de07c3 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -79,6 +79,10 @@ pub enum ArtworkTarget { FavoriteAlbum { index: usize }, /// A card in DiscoverBrowseState.albums[index]. DiscoverBrowseAlbum { index: usize }, + /// A card in LocalLibraryState.albums[index] (Local Library grid). `gen` + /// is the albums generation at fetch time; a stale cover (the model was + /// replaced by a search/sort/retry) is dropped on apply. + LocalAlbumCard { index: usize, gen: u64 }, /// A card in FavoritesState.artists[index]. FavoriteArtist { index: usize }, /// A card in FavoritesState.labels[index]. @@ -226,6 +230,29 @@ pub fn spawn_loads(jobs: Vec, window: slint::Weak, cache: } } +/// Like `spawn_loads`, but each job's `url` is a LOCAL filesystem path +/// (Local Library covers) rather than an HTTP URL. Decodes via the +/// source-aware `ArtworkRef::LocalFile` instead of the HTTP cache path. +pub fn spawn_local_loads(jobs: Vec, window: slint::Weak, cache: ImageCache) { + let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT)); + for job in jobs { + let semaphore = semaphore.clone(); + let window = window.clone(); + let cache = cache.clone(); + tokio::spawn(async move { + let _permit = semaphore.acquire().await.ok()?; + let decode_size = job.target.decode_size(); + let art = ArtworkRef::LocalFile(job.url.clone()); + let (pixels, width, height) = fetch_and_decode_ref(&art, &cache, decode_size).await?; + let target = job.target; + let _ = window.upgrade_in_event_loop(move |w| { + apply_artwork(&w, target, &pixels, width, height); + }); + Some(()) + }); + } +} + /// Resolve a remote URL to raw bytes via the shared disk cache: a hit reads /// from disk, a miss downloads and stores. HTTP(S) only. async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option> { @@ -575,6 +602,17 @@ fn apply_artwork( model.set_row_data(index, item); } } + ArtworkTarget::LocalAlbumCard { index, gen } => { + // Drop the cover if a reload superseded the page it belongs to. + if !crate::local_library::albums_gen_current(gen) { + return; + } + let model = window.global::().get_albums(); + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } ArtworkTarget::FavoriteArtist { index } => { let model = window.global::().get_artists(); if let Some(mut item) = model.row_data(index) { diff --git a/crates/qbz-slint/src/local_library.rs b/crates/qbz-slint/src/local_library.rs index 58ac43840..5edadd770 100644 --- a/crates/qbz-slint/src/local_library.rs +++ b/crates/qbz-slint/src/local_library.rs @@ -11,6 +11,13 @@ //! this view — they belong under Settings > Local Library. The view's gear //! button routes there. +use std::sync::atomic::{AtomicU64, Ordering}; + +use slint::{ComponentHandle, Model, ModelRc, VecModel}; + +use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache}; +use crate::{AlbumCardItem, AppWindow, LocalLibraryState}; + /// The four browse tabs. Order mirrors Tauri's default tab order /// (`tracks / folders / albums / artists`); the visible order is a user /// preference layered on top later. @@ -57,6 +64,329 @@ impl LibTab { } } +// =============================== Albums tab =============================== +// +// The Albums tab browses the metadata-grouped local albums via the +// server-paginated `get_albums_metadata_page` (the performant path that +// scales past the documented 16K-row freeze). Sort + search are pushed to +// SQL; the grid pages on scroll. The shared `AlbumCollectionView` renders +// the result, covers load from the local filesystem via the source-aware +// artwork pipeline. + +/// Albums fetched per page (chunk). Mirrors Tauri's chunked store (100). +const ALBUMS_PAGE: u64 = 100; + +/// Generation guard, bumped on every page-1 (re)load. A stale in-flight +/// fetch (older search/sort) is discarded on apply, and an in-flight +/// load-more is dropped once a reload supersedes it. +static ALBUMS_GEN: AtomicU64 = AtomicU64::new(0); + +/// True if `gen` is still the current albums generation. The artwork +/// pipeline calls this before applying a decoded cover so an in-flight job +/// from a superseded page (a search/sort/retry replaced the model) doesn't +/// land on a stale row index. +pub fn albums_gen_current(gen: u64) -> bool { + ALBUMS_GEN.load(Ordering::SeqCst) == gen +} + +/// Map one local album row to the shared plain `AlbumCard`. Kept as the +/// Send-safe plain struct (NOT `AlbumCardItem`, which holds a non-Send +/// `slint::Image`) so it can cross the `spawn_blocking` boundary; the +/// conversion to `AlbumCardItem` happens on the UI thread via +/// `album_map::to_item`. Genre is intentionally empty (the local DB carries +/// genre per-track, not per-album); the cover PATH rides on `artwork_url` +/// as the artwork-job carrier (the grid renders `artwork`, not the url). +pub fn map_local_album(a: qbz_library::LocalAlbum) -> crate::album_map::AlbumCard { + let tier = match a.bit_depth { + Some(b) if b >= 24 => "hires", + Some(_) => "cd", + None => "", + }; + let (quality_detail, quality_label) = local_quality(a.bit_depth, a.sample_rate); + let year = a.year.map(|y| y.to_string()).unwrap_or_default(); + let track_count = if a.track_count > 0 { + a.track_count.to_string() + } else { + String::new() + }; + crate::album_map::AlbumCard { + id: a.id, + title: a.title, + artist: a.artist, + artist_id: String::new(), + genre: String::new(), + year: year.clone(), + quality_tier: tier.to_string(), + quality_label, + artwork_url: a.artwork_path.unwrap_or_default(), + release_type: crate::album_map::classify_release_type(Some(a.track_count)).to_string(), + quality_detail, + track_count, + plain_year: year, + } +} + +/// Format a local album's quality. `sample_rate_hz` is Hz (44100.0); the +/// detail is the bare "24-bit / 96 kHz" (QualityBadgeFull) and the label is +/// the grid badge tooltip "Hi-Res: 24-bit / 96 kHz". +fn local_quality(bit_depth: Option, sample_rate_hz: f64) -> (String, String) { + let Some(bd) = bit_depth else { + return (String::new(), String::new()); + }; + let khz = if sample_rate_hz >= 1000.0 { + sample_rate_hz / 1000.0 + } else { + sample_rate_hz + }; + let khz_str = if khz.fract().abs() < 0.05 { + format!("{}", khz.round() as i64) + } else { + format!("{khz:.1}") + }; + let prefix = if bd >= 24 { "Hi-Res" } else { "CD" }; + ( + format!("{bd}-bit / {khz_str} kHz"), + format!("{prefix}: {bd}-bit / {khz_str} kHz"), + ) +} + +/// Map the toolbar sort key to the DB's validated (sort_by, sort_dir). +fn sort_params(sort: &str) -> (&'static str, &'static str) { + match sort { + "title-asc" => ("title", "asc"), + "title-desc" => ("title", "desc"), + "year-desc" => ("year", "desc"), + "year-asc" => ("year", "asc"), + "artist-desc" => ("artist", "desc"), + // Covers all six toolbar options; unknown input defaults to artist-asc. + _ => ("artist", "asc"), + } +} + +/// Fetch one albums page off the UI thread (rusqlite is blocking). Returns +/// (cards, total, has_more), or None if the DB can't be opened. Returns the +/// plain `AlbumCard` (Send) — conversion to `AlbumCardItem` is UI-thread. +fn fetch_albums_page( + offset: u64, + search: String, + sort: String, +) -> Option<(Vec, u64, bool)> { + let (sort_by, sort_dir) = sort_params(&sort); + let trimmed = search.trim(); + let search_opt = if trimmed.is_empty() { + None + } else { + Some(trimmed) + }; + let page = crate::library_db::with_db(|db| { + db.get_albums_metadata_page( + offset, + ALBUMS_PAGE, + search_opt, + sort_by, + sort_dir, + // include_qobuz_downloads: mirrors the `show_in_library` + // download-setting default (true). The user toggle lands with + // the Settings > Local Library integration. + true, + // exclude_network_folders / plex_cache_path: offline-network + // filtering and Plex are their own later slices. + false, + None, + ) + })?; + let total = page.total; + let has_more = offset + (page.albums.len() as u64) < total; + let cards = page.albums.into_iter().map(map_local_album).collect(); + Some((cards, total, has_more)) +} + +/// Build local-cover artwork jobs for a page of cards. The job url carries +/// the local file path; `spawn_local_loads` decodes via `ArtworkRef::LocalFile`. +/// `gen` is the albums generation at fetch time — a reload bumps it, so a +/// cover decoded after the model was replaced is dropped on apply. +fn album_artwork_jobs( + cards: &[crate::album_map::AlbumCard], + offset: usize, + gen: u64, +) -> Vec { + cards + .iter() + .enumerate() + .filter(|(_, c)| !c.artwork_url.is_empty()) + .map(|(i, c)| ArtworkJob { + target: ArtworkTarget::LocalAlbumCard { + index: offset + i, + gen, + }, + url: c.artwork_url.clone(), + }) + .collect() +} + +/// Replace the albums set with page 1 (UI thread). Converts the plain cards +/// to `AlbumCardItem` here, where the non-Send `slint::Image` is allowed. +fn apply_albums( + window: &AppWindow, + cards: Vec, + total: u64, + has_more: bool, +) { + let items: Vec = cards.into_iter().map(crate::album_map::to_item).collect(); + let s = window.global::(); + let n = items.len() as i32; + s.set_albums(ModelRc::new(VecModel::from(items))); + s.set_albums_total(total as i32); + s.set_album_count(total as i32); + s.set_albums_next_offset(n); + s.set_albums_has_more(has_more); + s.set_albums_loading(false); + s.set_albums_loading_more(false); + s.set_albums_load_failed(false); +} + +/// Append a fetched page onto the existing grid (UI thread). +fn append_albums( + window: &AppWindow, + cards: Vec, + total: u64, + has_more: bool, +) { + let new_items: Vec = + cards.into_iter().map(crate::album_map::to_item).collect(); + let s = window.global::(); + let model = s.get_albums(); + let mut combined: Vec = (0..model.row_count()) + .filter_map(|i| model.row_data(i)) + .collect(); + let added = new_items.len() as i32; + combined.extend(new_items); + s.set_albums(ModelRc::new(VecModel::from(combined))); + s.set_albums_next_offset(s.get_albums_next_offset() + added); + s.set_albums_total(total as i32); + s.set_album_count(total as i32); + s.set_albums_has_more(has_more); + s.set_albums_loading_more(false); +} + +/// Spawn the page-1 fetch and apply it (gen-guarded). Reads search/sort + +/// spawns from the UI thread; the caller has set `albums-loading` + `gen`. +fn spawn_albums_page_load( + window: &AppWindow, + handle: tokio::runtime::Handle, + image_cache: ImageCache, + gen: u64, +) { + let s = window.global::(); + let search = s.get_albums_search().to_string(); + let sort = s.get_albums_sort().to_string(); + let weak = window.as_weak(); + handle.spawn(async move { + let result = tokio::task::spawn_blocking(move || fetch_albums_page(0, search, sort)) + .await + .ok() + .flatten(); + let _ = weak.upgrade_in_event_loop(move |w| { + if ALBUMS_GEN.load(Ordering::SeqCst) != gen { + return; + } + match result { + Some((cards, total, has_more)) => { + let jobs = album_artwork_jobs(&cards, 0, gen); + apply_albums(&w, cards, total, has_more); + crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache); + } + None => { + let s = w.global::(); + s.set_albums_loading(false); + s.set_albums_load_failed(true); + } + } + }); + }); +} + +/// (Re)load page 1 with the current search + sort. Bumps the generation so +/// any older in-flight fetch is discarded on arrival. +pub fn reload_albums( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, +) { + let _ = weak.upgrade_in_event_loop(move |w| { + let gen = ALBUMS_GEN.fetch_add(1, Ordering::SeqCst) + 1; + let s = w.global::(); + s.set_albums_loading(true); + s.set_albums_load_failed(false); + spawn_albums_page_load(&w, handle, image_cache, gen); + }); +} + +/// Load page 1 only if the albums set is empty and not already loading — the +/// lazy per-tab fetch on first visit (re-entry keeps the loaded set + scroll). +pub fn ensure_albums_loaded( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, +) { + let _ = weak.upgrade_in_event_loop(move |w| { + let s = w.global::(); + if s.get_albums().row_count() == 0 && !s.get_albums_loading() { + let gen = ALBUMS_GEN.fetch_add(1, Ordering::SeqCst) + 1; + s.set_albums_loading(true); + s.set_albums_load_failed(false); + spawn_albums_page_load(&w, handle, image_cache, gen); + } + }); +} + +/// Fetch + append the next page (driven by scroll-near-bottom). No-ops while +/// a page is in flight or the catalog is exhausted; the in-flight result is +/// dropped if a reload supersedes it. +pub fn load_more_albums( + weak: slint::Weak, + handle: tokio::runtime::Handle, + image_cache: ImageCache, +) { + let _ = weak.upgrade_in_event_loop(move |w| { + let s = w.global::(); + if !s.get_albums_has_more() || s.get_albums_loading_more() || s.get_albums_loading() { + return; + } + let offset = s.get_albums_next_offset().max(0) as u64; + let search = s.get_albums_search().to_string(); + let sort = s.get_albums_sort().to_string(); + let gen = ALBUMS_GEN.load(Ordering::SeqCst); + s.set_albums_loading_more(true); + let weak2 = w.as_weak(); + handle.spawn(async move { + let result = + tokio::task::spawn_blocking(move || fetch_albums_page(offset, search, sort)) + .await + .ok() + .flatten(); + let _ = weak2.upgrade_in_event_loop(move |w| { + let s = w.global::(); + if ALBUMS_GEN.load(Ordering::SeqCst) != gen { + s.set_albums_loading_more(false); + return; + } + match result { + Some((cards, total, has_more)) => { + let base = s.get_albums().row_count(); + let jobs = album_artwork_jobs(&cards, base, gen); + append_albums(&w, cards, total, has_more); + crate::artwork::spawn_local_loads(jobs, w.as_weak(), image_cache); + } + None => { + s.set_albums_loading_more(false); + } + } + }); + }); + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 9ce916c75..999ce7f04 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -899,14 +899,14 @@ fn navigate_favorites( /// Open a Local Library browse tab (Albums / Artists / Folders / Tracks). /// -/// Sets the active tab + switches the view. Per-tab data loading lands with -/// each tab slice (Albums first); for now the view renders its shell + an -/// empty placeholder, so the navigation is verifiable end to end. +/// Sets the active tab + switches the view, then lazily loads the tab's data +/// on first visit. Albums is the first slice (chunked grid); the other tabs +/// render their shell + a placeholder until their slices land. fn navigate_local_library( _runtime: Arc>, weak: slint::Weak, - _handle: &tokio::runtime::Handle, - _image_cache: artwork::ImageCache, + handle: &tokio::runtime::Handle, + image_cache: artwork::ImageCache, tab: local_library::LibTab, ) { let tab_id = tab.tab_id().to_string(); @@ -914,6 +914,10 @@ fn navigate_local_library( w.global::().set_active_tab(tab_id.into()); w.global::().set_view(ContentView::LocalLibrary); }); + // Lazy per-tab load — Albums fetches page 1 on first visit. + if tab == local_library::LibTab::Albums { + local_library::ensure_albums_loaded(weak, handle.clone(), image_cache); + } } /// Open a Qobuz mix detail view (daily / weekly / fav / top) and load @@ -3944,6 +3948,78 @@ fn main() -> Result<(), Box> { }); } + // Local Library — Albums tab controls (search / sort re-query page 1; + // load-more pages on scroll; retry) + the shared AlbumCollectionView's + // open / per-card actions (album-detail + playback land with later slices). + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_albums_search(move |_query| { + // The query is two-way bound to albums-search; reload page 1. + local_library::reload_albums(weak.clone(), handle.clone(), image_cache.clone()); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_albums_set_sort(move |sort| { + if let Some(w) = weak.upgrade() { + w.global::().set_albums_sort(sort); + } + local_library::reload_albums(weak.clone(), handle.clone(), image_cache.clone()); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_albums_load_more(move || { + local_library::load_more_albums(weak.clone(), handle.clone(), image_cache.clone()); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_albums_retry(move || { + local_library::reload_albums(weak.clone(), handle.clone(), image_cache.clone()); + }); + } + { + window + .global::() + .on_open_album(move |id| { + // TODO(locallibrary): open the local album-detail view. + log::debug!("[qbz-slint] local album open (album-detail slice pending): {id}"); + }); + } + { + window + .global::() + .on_open_artist(move |id| { + // TODO(locallibrary): open the local Artists tab / artist. + log::debug!("[qbz-slint] local artist open (Artists slice pending): {id}"); + }); + } + { + window + .global::() + .on_album_action(move |id, action| { + // TODO(locallibrary): play / queue local albums. + log::debug!("[qbz-slint] local album action (playback slice pending): {id} {action}"); + }); + } + // Discover "View all" — open the full-list page for a section, // recording it as a history entry (mirrors the favorites branch // of on_header_menu_navigate). diff --git a/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint index 149abe5e4..4c58f0769 100644 --- a/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint +++ b/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint @@ -21,6 +21,11 @@ import { Typography } from "../foundation/typography.slint"; import { LocalLibraryState, LocalLibraryActions } from "../state.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint"; +import { ExpandableSearch } from "../primitives/ExpandableSearch.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; +import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; +import { ListScrollbar } from "../primitives/ListScrollbar.slint"; // One tab in the Local Library tab menu — underline style (ADR-008: NO // pills). 1:1 with Favorites' FavTab so the chrome reads identically. @@ -184,15 +189,65 @@ export component LocalLibraryView inherits Rectangle { Rectangle { height: 12px; } // Row 2 — per-tab action buttons (left) + toolbar (right). Filled as - // each tab slice lands (Albums: play/shuffle + search/sort/view). - // vertical-stretch: 0 is load-bearing (see FavoritesView): without it - // an empty row grabs leftover height and inflates the sub-header. + // each tab slice lands. vertical-stretch: 0 is load-bearing (see + // FavoritesView): without it an empty row grabs leftover height and + // inflates the sub-header. HorizontalLayout { vertical-stretch: 0; padding-left: 32px; padding-right: 32px; spacing: 8px; + Rectangle { horizontal-stretch: 1; } + + // Albums toolbar — search + sort (both server-side). The + // grid/list toggle, group-by and filters land with the next slice. + if LocalLibraryState.active-tab == "albums" + && (LocalLibraryState.albums.length > 0 + || LocalLibraryState.albums-search != ""): HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + ExpandableSearch { + placeholder: @tr("Search"); + sm: true; + query <=> LocalLibraryState.albums-search; + edited(q) => { + LocalLibraryActions.albums-search(q); + } + } + } + VerticalLayout { + alignment: center; + QbzSelect { + menu-width: 180px; + options: [ + @tr("Artist A-Z"), + @tr("Artist Z-A"), + @tr("Title A-Z"), + @tr("Title Z-A"), + @tr("Year (newest)"), + @tr("Year (oldest)"), + ]; + sm: true; + current-index: LocalLibraryState.albums-sort == "artist-asc" ? 0 + : LocalLibraryState.albums-sort == "artist-desc" ? 1 + : LocalLibraryState.albums-sort == "title-asc" ? 2 + : LocalLibraryState.albums-sort == "title-desc" ? 3 + : LocalLibraryState.albums-sort == "year-desc" ? 4 + : 5; + selected(i) => { + LocalLibraryActions.albums-set-sort( + i == 0 ? "artist-asc" + : i == 1 ? "artist-desc" + : i == 2 ? "title-asc" + : i == 3 ? "title-desc" + : i == 4 ? "year-desc" + : "year-asc"); + } + } + } + } } Rectangle { height: 14px; } @@ -206,8 +261,139 @@ export component LocalLibraryView inherits Rectangle { vertical-stretch: 1; clip: true; - // Placeholder until each tab slice lands (Albums is next). - VerticalLayout { + // -------- Albums tab — chunked grid -------------------------- + if LocalLibraryState.active-tab == "albums": Rectangle { + // Initial page load. + if LocalLibraryState.albums-loading: VerticalLayout { + alignment: center; + HorizontalLayout { + alignment: center; + LoadingSpinner { size: 36px; } + } + } + // Load error + retry. + if !LocalLibraryState.albums-loading + && LocalLibraryState.albums-load-failed: VerticalLayout { + alignment: center; + spacing: 10px; + Text { + text: @tr("Couldn't load your local albums."); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.semibold; + horizontal-alignment: center; + } + HorizontalLayout { + alignment: center; + Rectangle { + width: retry-label.preferred-width + 28px; + height: 32px; + border-radius: 6px; + background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + retry-label := Text { + text: @tr("Retry"); + color: Theme.text-primary; + font-size: Typography.legal; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + retry-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + LocalLibraryActions.albums-retry(); + } + } + } + } + } + // Empty — no albums indexed yet. + if !LocalLibraryState.albums-loading + && !LocalLibraryState.albums-load-failed + && LocalLibraryState.albums.length == 0 + && LocalLibraryState.albums-search == "": VerticalLayout { + alignment: center; + Text { + text: @tr("No albums in your local library yet."); + color: Theme.text-muted; + font-size: Typography.body; + horizontal-alignment: center; + } + } + // Empty — search matched nothing. + if !LocalLibraryState.albums-loading + && !LocalLibraryState.albums-load-failed + && LocalLibraryState.albums.length == 0 + && LocalLibraryState.albums-search != "": VerticalLayout { + alignment: center; + Text { + text: @tr("No albums match your search."); + color: Theme.text-muted; + font-size: Typography.body; + horizontal-alignment: center; + } + } + // The grid — self-scrolling, paged on scroll-near-bottom. + if !LocalLibraryState.albums-loading + && !LocalLibraryState.albums-load-failed + && LocalLibraryState.albums.length > 0: Rectangle { + albums-flick := Flickable { + viewport-width: self.width; + viewport-height: albums-page.preferred-height; + changed viewport-y => { + if (-self.viewport-y + self.height >= self.viewport-height - 600px + && LocalLibraryState.albums-has-more + && !LocalLibraryState.albums-loading-more + && !LocalLibraryState.albums-loading) { + LocalLibraryActions.albums-load-more(); + } + } + albums-page := VerticalLayout { + width: parent.width; + padding-left: 32px; + padding-right: 32px; + padding-top: 16px; + padding-bottom: 100px; + spacing: 0px; + alignment: start; + AlbumCollectionView { + albums: LocalLibraryState.albums; + is-grouped: false; + view-mode: "grid"; + show-source: false; + // Local albums aren't Qobuz-favoritable — hide the heart. + show-favorite: false; + open-album(id) => { + LocalLibraryActions.open-album(id); + } + open-artist(id) => { + LocalLibraryActions.open-artist(id); + } + media-action(kind, id, action) => { + LocalLibraryActions.album-action(id, action); + } + } + if LocalLibraryState.albums-loading-more: HorizontalLayout { + alignment: center; + padding-top: 16px; + LoadingSpinner { size: 24px; } + } + } + } + ListScrollbar { + x: parent.width - self.width - 4px; + width: 14px; + height: 100%; + viewport-height: albums-flick.viewport-height; + visible-height: albums-flick.height; + viewport-y <=> albums-flick.viewport-y; + } + } + } + + // -------- Other tabs — placeholder until their slices land ---- + if LocalLibraryState.active-tab != "albums": VerticalLayout { alignment: center; spacing: 6px; Text { @@ -218,7 +404,7 @@ export component LocalLibraryView inherits Rectangle { font-weight: Typography.semibold; } Text { - text: @tr("Building the browse tabs slice by slice."); + text: @tr("This tab is coming next."); horizontal-alignment: center; color: Theme.text-muted; font-size: Typography.legal; diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 7b064122b..92ce65089 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -344,6 +344,25 @@ export global LocalLibraryState { in property artist-count: 0; in property track-count: 0; in property folder-count: 0; + + // --- Albums tab (server-paginated, metadata-grouped local albums) ---- + // `albums` is the accumulated paged set (also the artwork target). Sort + // + search are pushed to SQL (the performant path); paging fetches the + // next chunk on scroll. List / group / A-Z / filters land with the next + // slice — this slice ships the chunked grid. + in property <[AlbumCardItem]> albums: []; + in property albums-total: 0; + in property albums-loading: false; // initial page fetch + in property albums-loading-more: false; // subsequent pages + in property albums-has-more: false; + in property albums-load-failed: false; + in-out property albums-next-offset: 0; + in-out property albums-search: ""; + // "artist-asc" | "artist-desc" | "title-asc" | "title-desc" + // | "year-desc" | "year-asc" + in-out property albums-sort: "artist-asc"; + // "grid" | "list" — the list view toggle lands with the next slice. + in-out property albums-view-mode: "grid"; } export global LocalLibraryActions { @@ -352,6 +371,23 @@ export global LocalLibraryActions { callback select-tab(string /* "albums"|"artists"|"folders"|"tracks" */); // Open Settings > Local Library (the gear button — management lives there). callback open-settings(); + + // --- Albums tab --- + // Re-query page 1 with the current search text (server-side LIKE). + callback albums-search(string /* query */); + // Set the sort key + re-query page 1 (server-side ORDER BY). + callback albums-set-sort(string /* sort key */); + // Fetch + append the next page (driven by scroll-near-bottom). + callback albums-load-more(); + // Retry after a load error. + callback albums-retry(); + // Open a local album's detail view (lands with the album-detail slice). + callback open-album(string /* album id */); + // Open an artist (lands with the Artists slice). + callback open-artist(string /* artist id */); + // Per-card action (play / queue / play-next) — lands with the playback + // slice. Wired now so the shared AlbumCollectionView contract is honored. + callback album-action(string /* album id */, string /* action */); } // Global toast state, driven by crate::toast. The Toast overlay (mounted