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 },
|
||||
/// 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<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
|
||||
/// from disk, a miss downloads and stores. HTTP(S) only.
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 } => {
|
||||
let model = window.global::<crate::FavoritesState>().get_artists();
|
||||
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
|
||||
//! 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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
_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::<LocalLibraryState>().set_active_tab(tab_id.into());
|
||||
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
|
||||
@@ -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,
|
||||
// recording it as a history entry (mirrors the favorites branch
|
||||
// of on_header_menu_navigate).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -344,6 +344,25 @@ export global LocalLibraryState {
|
||||
in property <int> artist-count: 0;
|
||||
in property <int> track-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 {
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user