feat(locallibrary): Albums tab — server-paginated chunked grid
First Local Library data slice. The Albums tab browses the metadata-grouped local albums via the performant server-paginated path (the same query Tauri's chunked store uses), reusing the shared AlbumCollectionView grid — no new rendering. Scales past the documented ~16K-row Tracks freeze by never materializing the full set. - local_library.rs: map_local_album (LocalAlbum -> the plain album_map AlbumCard, Send-safe; the non-Send slint::Image conversion via to_item happens on the UI thread), local_quality (Hz -> kHz), sort_params; a blocking fetch_albums_page over LibraryDatabase::get_albums_metadata_page (spawn_blocking); apply/append + reload/ensure/load_more driven by an ALBUMS_GEN generation guard so a search/sort/retry can't be clobbered by a stale in-flight page. - LocalLibraryView.slint: Albums body reusing AlbumCollectionView (grid, show-favorite: false), a search + sort toolbar (both server-side), auto load-more on scroll-near-bottom (DiscoverBrowse pattern), and loading / error+retry / empty states. - Local covers: ArtworkTarget::LocalAlbumCard + spawn_local_loads resolve the on-disk cover path through the source-aware ArtworkRef::LocalFile pipeline; the target carries the generation so a cover decoded after a reload is dropped instead of landing on a replaced row. - state.slint: LocalLibraryState album fields + LocalLibraryActions callbacks. - main.rs: navigate_local_library lazily loads page 1 on first visit; the on_albums_* callbacks wire search/sort/load-more/retry (open-album / open-artist / album-action are TODO no-ops until the detail + playback slices). List/group/A-Z/quality filters, album detail, and playback are later slices. include_qobuz_downloads mirrors the show_in_library setting default (true). Built green (cargo check -p qbz-slint); runtime smoke-test pending. cc
This commit is contained in:
5 files changed
+677
-11
No files matched your search
@@ -79,6 +79,10 @@ pub enum ArtworkTarget {
|
|||||||
FavoriteAlbum { index: usize },
|
FavoriteAlbum { index: usize },
|
||||||
/// A card in DiscoverBrowseState.albums[index].
|
/// A card in DiscoverBrowseState.albums[index].
|
||||||
DiscoverBrowseAlbum { index: usize },
|
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].
|
/// A card in FavoritesState.artists[index].
|
||||||
FavoriteArtist { index: usize },
|
FavoriteArtist { index: usize },
|
||||||
/// A card in FavoritesState.labels[index].
|
/// A card in FavoritesState.labels[index].
|
||||||
@@ -226,6 +230,29 @@ pub fn spawn_loads(jobs: Vec<ArtworkJob>, window: slint::Weak<AppWindow>, 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<ArtworkJob>, window: slint::Weak<AppWindow>, 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
|
/// 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.
|
/// from disk, a miss downloads and stores. HTTP(S) only.
|
||||||
async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option<Vec<u8>> {
|
async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option<Vec<u8>> {
|
||||||
@@ -575,6 +602,17 @@ fn apply_artwork(
|
|||||||
model.set_row_data(index, item);
|
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::<crate::LocalLibraryState>().get_albums();
|
||||||
|
if let Some(mut item) = model.row_data(index) {
|
||||||
|
item.artwork = image;
|
||||||
|
model.set_row_data(index, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
ArtworkTarget::FavoriteArtist { index } => {
|
ArtworkTarget::FavoriteArtist { index } => {
|
||||||
let model = window.global::<crate::FavoritesState>().get_artists();
|
let model = window.global::<crate::FavoritesState>().get_artists();
|
||||||
if let Some(mut item) = model.row_data(index) {
|
if let Some(mut item) = model.row_data(index) {
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
//! this view — they belong under Settings > Local Library. The view's gear
|
//! this view — they belong under Settings > Local Library. The view's gear
|
||||||
//! button routes there.
|
//! 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
|
/// The four browse tabs. Order mirrors Tauri's default tab order
|
||||||
/// (`tracks / folders / albums / artists`); the visible order is a user
|
/// (`tracks / folders / albums / artists`); the visible order is a user
|
||||||
/// preference layered on top later.
|
/// 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<u32>, 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<crate::album_map::AlbumCard>, 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<ArtworkJob> {
|
||||||
|
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<crate::album_map::AlbumCard>,
|
||||||
|
total: u64,
|
||||||
|
has_more: bool,
|
||||||
|
) {
|
||||||
|
let items: Vec<AlbumCardItem> = cards.into_iter().map(crate::album_map::to_item).collect();
|
||||||
|
let s = window.global::<LocalLibraryState>();
|
||||||
|
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<crate::album_map::AlbumCard>,
|
||||||
|
total: u64,
|
||||||
|
has_more: bool,
|
||||||
|
) {
|
||||||
|
let new_items: Vec<AlbumCardItem> =
|
||||||
|
cards.into_iter().map(crate::album_map::to_item).collect();
|
||||||
|
let s = window.global::<LocalLibraryState>();
|
||||||
|
let model = s.get_albums();
|
||||||
|
let mut combined: Vec<AlbumCardItem> = (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::<LocalLibraryState>();
|
||||||
|
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::<LocalLibraryState>();
|
||||||
|
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<AppWindow>,
|
||||||
|
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::<LocalLibraryState>();
|
||||||
|
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<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
image_cache: ImageCache,
|
||||||
|
) {
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
let s = w.global::<LocalLibraryState>();
|
||||||
|
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<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
image_cache: ImageCache,
|
||||||
|
) {
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
let s = w.global::<LocalLibraryState>();
|
||||||
|
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::<LocalLibraryState>();
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -899,14 +899,14 @@ fn navigate_favorites(
|
|||||||
|
|
||||||
/// Open a Local Library browse tab (Albums / Artists / Folders / Tracks).
|
/// Open a Local Library browse tab (Albums / Artists / Folders / Tracks).
|
||||||
///
|
///
|
||||||
/// Sets the active tab + switches the view. Per-tab data loading lands with
|
/// Sets the active tab + switches the view, then lazily loads the tab's data
|
||||||
/// each tab slice (Albums first); for now the view renders its shell + an
|
/// on first visit. Albums is the first slice (chunked grid); the other tabs
|
||||||
/// empty placeholder, so the navigation is verifiable end to end.
|
/// render their shell + a placeholder until their slices land.
|
||||||
fn navigate_local_library(
|
fn navigate_local_library(
|
||||||
_runtime: Arc<AppRuntime<SlintAdapter>>,
|
_runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||||
weak: slint::Weak<AppWindow>,
|
weak: slint::Weak<AppWindow>,
|
||||||
_handle: &tokio::runtime::Handle,
|
handle: &tokio::runtime::Handle,
|
||||||
_image_cache: artwork::ImageCache,
|
image_cache: artwork::ImageCache,
|
||||||
tab: local_library::LibTab,
|
tab: local_library::LibTab,
|
||||||
) {
|
) {
|
||||||
let tab_id = tab.tab_id().to_string();
|
let tab_id = tab.tab_id().to_string();
|
||||||
@@ -914,6 +914,10 @@ fn navigate_local_library(
|
|||||||
w.global::<LocalLibraryState>().set_active_tab(tab_id.into());
|
w.global::<LocalLibraryState>().set_active_tab(tab_id.into());
|
||||||
w.global::<NavState>().set_view(ContentView::LocalLibrary);
|
w.global::<NavState>().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
|
/// Open a Qobuz mix detail view (daily / weekly / fav / top) and load
|
||||||
@@ -3944,6 +3948,78 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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::<LocalLibraryActions>()
|
||||||
|
.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::<LocalLibraryActions>()
|
||||||
|
.on_albums_set_sort(move |sort| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.global::<LocalLibraryState>().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::<LocalLibraryActions>()
|
||||||
|
.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::<LocalLibraryActions>()
|
||||||
|
.on_albums_retry(move || {
|
||||||
|
local_library::reload_albums(weak.clone(), handle.clone(), image_cache.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
window
|
||||||
|
.global::<LocalLibraryActions>()
|
||||||
|
.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::<LocalLibraryActions>()
|
||||||
|
.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::<LocalLibraryActions>()
|
||||||
|
.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,
|
// Discover "View all" — open the full-list page for a section,
|
||||||
// recording it as a history entry (mirrors the favorites branch
|
// recording it as a history entry (mirrors the favorites branch
|
||||||
// of on_header_menu_navigate).
|
// of on_header_menu_navigate).
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import { Typography } from "../foundation/typography.slint";
|
|||||||
import { LocalLibraryState, LocalLibraryActions } from "../state.slint";
|
import { LocalLibraryState, LocalLibraryActions } from "../state.slint";
|
||||||
import { NavButtons } from "../shell/NavButtons.slint";
|
import { NavButtons } from "../shell/NavButtons.slint";
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.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
|
// One tab in the Local Library tab menu — underline style (ADR-008: NO
|
||||||
// pills). 1:1 with Favorites' FavTab so the chrome reads identically.
|
// pills). 1:1 with Favorites' FavTab so the chrome reads identically.
|
||||||
@@ -184,15 +189,65 @@ export component LocalLibraryView inherits Rectangle {
|
|||||||
Rectangle { height: 12px; }
|
Rectangle { height: 12px; }
|
||||||
|
|
||||||
// Row 2 — per-tab action buttons (left) + toolbar (right). Filled as
|
// Row 2 — per-tab action buttons (left) + toolbar (right). Filled as
|
||||||
// each tab slice lands (Albums: play/shuffle + search/sort/view).
|
// each tab slice lands. vertical-stretch: 0 is load-bearing (see
|
||||||
// vertical-stretch: 0 is load-bearing (see FavoritesView): without it
|
// FavoritesView): without it an empty row grabs leftover height and
|
||||||
// an empty row grabs leftover height and inflates the sub-header.
|
// inflates the sub-header.
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
vertical-stretch: 0;
|
vertical-stretch: 0;
|
||||||
padding-left: 32px;
|
padding-left: 32px;
|
||||||
padding-right: 32px;
|
padding-right: 32px;
|
||||||
spacing: 8px;
|
spacing: 8px;
|
||||||
|
|
||||||
Rectangle { horizontal-stretch: 1; }
|
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; }
|
Rectangle { height: 14px; }
|
||||||
@@ -206,8 +261,139 @@ export component LocalLibraryView inherits Rectangle {
|
|||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
clip: true;
|
clip: true;
|
||||||
|
|
||||||
// Placeholder until each tab slice lands (Albums is next).
|
// -------- Albums tab — chunked grid --------------------------
|
||||||
VerticalLayout {
|
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;
|
alignment: center;
|
||||||
spacing: 6px;
|
spacing: 6px;
|
||||||
Text {
|
Text {
|
||||||
@@ -218,7 +404,7 @@ export component LocalLibraryView inherits Rectangle {
|
|||||||
font-weight: Typography.semibold;
|
font-weight: Typography.semibold;
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
text: @tr("Building the browse tabs slice by slice.");
|
text: @tr("This tab is coming next.");
|
||||||
horizontal-alignment: center;
|
horizontal-alignment: center;
|
||||||
color: Theme.text-muted;
|
color: Theme.text-muted;
|
||||||
font-size: Typography.legal;
|
font-size: Typography.legal;
|
||||||
|
|||||||
@@ -344,6 +344,25 @@ export global LocalLibraryState {
|
|||||||
in property <int> artist-count: 0;
|
in property <int> artist-count: 0;
|
||||||
in property <int> track-count: 0;
|
in property <int> track-count: 0;
|
||||||
in property <int> folder-count: 0;
|
in property <int> 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 <int> albums-total: 0;
|
||||||
|
in property <bool> albums-loading: false; // initial page fetch
|
||||||
|
in property <bool> albums-loading-more: false; // subsequent pages
|
||||||
|
in property <bool> albums-has-more: false;
|
||||||
|
in property <bool> albums-load-failed: false;
|
||||||
|
in-out property <int> albums-next-offset: 0;
|
||||||
|
in-out property <string> albums-search: "";
|
||||||
|
// "artist-asc" | "artist-desc" | "title-asc" | "title-desc"
|
||||||
|
// | "year-desc" | "year-asc"
|
||||||
|
in-out property <string> albums-sort: "artist-asc";
|
||||||
|
// "grid" | "list" — the list view toggle lands with the next slice.
|
||||||
|
in-out property <string> albums-view-mode: "grid";
|
||||||
}
|
}
|
||||||
|
|
||||||
export global LocalLibraryActions {
|
export global LocalLibraryActions {
|
||||||
@@ -352,6 +371,23 @@ export global LocalLibraryActions {
|
|||||||
callback select-tab(string /* "albums"|"artists"|"folders"|"tracks" */);
|
callback select-tab(string /* "albums"|"artists"|"folders"|"tracks" */);
|
||||||
// Open Settings > Local Library (the gear button — management lives there).
|
// Open Settings > Local Library (the gear button — management lives there).
|
||||||
callback open-settings();
|
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
|
// Global toast state, driven by crate::toast. The Toast overlay (mounted
|
||||||
|
|||||||
Reference in new issue
Block a user