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:
vicrodh committed 2026-05-28 22:44:18 -06:00
1 parent 6974721b7a
commit 835a6cad85
5 files changed
+677 -11

No files matched your search

+38
View File
@@ -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) {
+330
View File
@@ -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::*;
+81 -5
View File
@@ -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;
+36
View File
@@ -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