perf: viewport windowing for the Local Library albums grid (phase 1)

Covers and card elements now scale with the viewport instead of the
library: the shared AlbumGrid gains opt-in windowing (row band = visible
rows +/- one viewport, computed in .slint, row-granular change events),
cards outside the band render as same-footprint placeholders (scroll
geometry unchanged), and the Rust side dispatches artwork only for the
band, evicting covers outside a retention span back to the placeholder.

- New id-keyed ArtworkTarget::LocalAlbumById (replaces the index-keyed
  LocalAlbumCard): immune to derive re-sorts between dispatch and apply;
  reloads still drop stale covers via the generation guard. The enum
  loses Copy (String variant).
- In-flight dedupe set freed on apply AND on fetch failure, cleared on
  generation bumps.
- Flat list view and grouped mode keep the pre-windowing full dispatch
  (they don't fire window events); switching grid->list re-dispatches
  covers the window may have evicted.
- Windowing is off by default in the shared grid: Discover, Labels and
  Favorites are untouched (phase 2).

A 2,000-album library drops from ~540 MB of resident cover RGBA to a
viewport-sized working set, and scrolling repaints ~40 mounted cards
instead of the whole collection.

cc
This commit is contained in:
vicrodh committed 2026-07-01 19:46:14 -06:00
1 parent a050e6394a
commit 23b572e5c9
5 files changed
+345 -47

No files matched your search

@@ -21,9 +21,23 @@ component AlbumGrid inherits Rectangle {
in property <bool> show-favorite: true;
in property <bool> show-source-badge: false;
in property <bool> select-mode: false;
// --- Viewport windowing (opt-in; default OFF = identical to before) ---
// With `windowed` on, only cards within ~one viewport of the visible band
// mount a real AlbumCard; the rest are same-footprint placeholders, so
// the height formula below (the scroll geometry) never changes.
// `visible-top`/`visible-height` arrive in the surrounding Flickable's
// CONTENT coordinates; `content-offset` is this grid's approximate y
// inside that content — small errors are absorbed by the margin.
in property <bool> windowed: false;
in property <length> visible-top: 0px;
in property <length> visible-height: 0px;
in property <length> content-offset: 0px;
callback open-album(string);
callback open-artist(string);
callback media-action(string, string, string);
// Fired (windowed only) when the row band changes — [first, last] item
// indices, prefetch margin included. The consumer dispatches artwork.
callback window-changed(int /* first index */, int /* last index */);
property <int> columns: Math.max(
1,
@@ -33,23 +47,58 @@ component AlbumGrid inherits Rectangle {
? self.rows * root.card-height + Math.max(0, self.rows - 1) * root.card-gap
: 0px;
for album[i] in root.albums: AlbumCard {
// Row band = visible rows ± one full viewport. Row-granular ints, so the
// `changed` handlers only fire when a row boundary is crossed (~every
// 270px of scroll), not per scrolled pixel.
property <length> row-stride: root.card-height + root.card-gap;
property <length> local-top: root.visible-top - root.content-offset;
property <int> first-window-row: root.windowed
? Math.max(0, Math.floor((root.local-top - root.visible-height) / root.row-stride))
: 0;
property <int> last-window-row: root.windowed
? Math.max(0, Math.floor((root.local-top + 2 * root.visible-height) / root.row-stride))
: 0;
changed first-window-row => { root.notify-window(); }
changed last-window-row => { root.notify-window(); }
// Column count changes on first layout and on window resize — the band's
// ITEM indices depend on it, so re-notify.
changed columns => { root.notify-window(); }
init => { root.notify-window(); }
function notify-window() {
if (root.windowed) {
root.window-changed(
root.first-window-row * root.columns,
(root.last-window-row + 1) * root.columns - 1);
}
}
for album[i] in root.albums: card-slot := Rectangle {
x: Math.mod(i, root.columns) * (root.card-width + root.card-gap);
y: Math.floor(i / root.columns) * (root.card-height + root.card-gap);
width: root.card-width;
height: root.card-height;
album: album;
show-favorite: root.show-favorite;
show-source-badge: root.show-source-badge;
select-mode: root.select-mode;
clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
if (kind == "artist") {
root.open-artist(id);
} else {
root.media-action(kind, id, action);
property <bool> in-window: !root.windowed
|| (Math.floor(i / root.columns) >= root.first-window-row
&& Math.floor(i / root.columns) <= root.last-window-row);
// Placeholder tile while the real card is unmounted (fast scroll).
border-radius: 8px;
background: card-slot.in-window ? transparent : Theme.surface-elevated;
if card-slot.in-window: AlbumCard {
width: root.card-width;
height: root.card-height;
album: album;
show-favorite: root.show-favorite;
show-source-badge: root.show-source-badge;
select-mode: root.select-mode;
clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
if (kind == "artist") {
root.open-artist(id);
} else {
root.media-action(kind, id, action);
}
}
}
}
@@ -74,6 +123,14 @@ export component AlbumCollectionView inherits VerticalLayout {
in property <length> card-height: 246px;
in property <length> card-gap: 24px;
in property <length> list-row-gap: 4px;
// Viewport windowing — forwarded to the FLAT grid only (grouped sections
// keep full mounts; their per-section offsets are unknown here). OFF by
// default so Discover Browse / Labels / Favorites are untouched.
in property <bool> windowed: false;
in property <length> visible-top: 0px;
in property <length> visible-height: 0px;
in property <length> content-offset: 0px;
callback window-changed(int /* first index */, int /* last index */);
callback open-album(string);
callback open-artist(string);
callback media-action(string, string, string);
@@ -147,6 +204,13 @@ export component AlbumCollectionView inherits VerticalLayout {
card-width: root.card-width;
card-height: root.card-height;
card-gap: root.card-gap;
windowed: root.windowed;
visible-top: root.visible-top;
visible-height: root.visible-height;
content-offset: root.content-offset;
window-changed(first, last) => {
root.window-changed(first, last);
}
open-album(id) => {
root.open-album(id);
}
@@ -1169,6 +1169,18 @@ export component LocalLibraryView inherits Rectangle {
show-favorite: false;
// Always-visible source badge (local | qobuz-offline | plex).
show-source-badge: true;
// Viewport windowing (Phase 1): only cards near the
// visible band mount + get artwork dispatched. The
// offset is the page's padding-top (16px); the grid's
// one-viewport margin absorbs the small extras
// (multi-select bar) above the grid.
windowed: true;
visible-top: -albums-flick.viewport-y;
visible-height: albums-flick.height;
content-offset: 16px;
window-changed(first, last) => {
LocalLibraryActions.albums-window-changed(first, last);
}
open-album(id) => {
LocalLibraryActions.open-album(id);
}
+24 -13
View File
@@ -31,7 +31,8 @@ pub const MAX_CACHE_BYTES: u64 = 200 * 1024 * 1024;
pub type ImageCache = Arc<Mutex<Option<ImageCacheService>>>;
/// Which card an artwork download targets.
#[derive(Clone, Copy)]
/// (`Clone` only — `LocalAlbumById` carries a `String`, so no `Copy`.)
#[derive(Clone)]
pub enum ArtworkTarget {
/// A card in a Discover descriptor list's embedded album section
/// (`DiscoverState.home-sections` / `editor-sections`
@@ -123,10 +124,11 @@ 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 Local Library album cover, addressed BY ID (windowed dispatch over
/// `albums-visible` — id-keyed delivery is immune to derive re-sorts
/// between dispatch and apply). `gen` is the albums generation at fetch
/// time; a stale cover (the model was replaced by a reload) is dropped.
LocalAlbumById { id: String, gen: u64 },
/// A card in LocalLibraryState.folders[index] (Folders-flat grid).
LocalFolderCard { index: usize },
/// A subfolder cover card in LocalLibraryState.folder-detail-subfolders[index]
@@ -448,7 +450,17 @@ pub fn spawn_local_or_plex_loads(
} else {
ArtworkRef::LocalFile(job.url.clone())
};
let (pixels, width, height) = fetch_and_decode_ref(&art, &cache, decode_size).await?;
let Some((pixels, width, height)) =
fetch_and_decode_ref(&art, &cache, decode_size).await
else {
// Failed fetch/decode never reaches apply_artwork — free the
// windowed-dispatch dedupe slot here so a later band pass can
// retry this cover instead of skipping it for the session.
if let ArtworkTarget::LocalAlbumById { id, .. } = &job.target {
crate::local_library::album_artwork_job_done(id);
}
return None;
};
let target = job.target;
let url = job.url;
let _ = window.upgrade_in_event_loop(move |w| {
@@ -1232,17 +1244,16 @@ fn apply_artwork(
.global::<crate::PurchaseDetailState>()
.set_artwork(image);
}
ArtworkTarget::LocalAlbumCard { index, gen } => {
ArtworkTarget::LocalAlbumById { id, gen } => {
// The job is done either way — free its in-flight slot so the
// window dispatcher can re-request it after an eviction.
crate::local_library::album_artwork_job_done(&id);
// Drop the cover if a reload superseded the set it belongs to.
if !crate::local_library::albums_gen_current(gen) {
return;
}
let model = window.global::<crate::LocalLibraryState>().get_albums();
if let Some(item) = model.row_data(index) {
// Dual-set by id onto the full set + visible + grouped sections.
let id = item.id.to_string();
crate::local_library::set_local_album_artwork(window, &id, image);
}
// Set by id onto the full set + visible + grouped sections.
crate::local_library::set_local_album_artwork(window, &id, image);
}
ArtworkTarget::LocalFolderCard { index } => {
let model = window.global::<crate::LocalLibraryState>().get_folders();
+212 -21
View File
@@ -273,17 +273,103 @@ fn album_matches_filters(a: &qbz_library::LocalAlbum, f: &AlbumFilter) -> bool {
passes_q && passes_f && passes_s
}
/// Build LocalAlbumCard artwork jobs for the full `albums` set (gen-stamped).
fn album_artwork_jobs(cards: &[crate::album_map::AlbumCard], gen: u64) -> Vec<ArtworkJob> {
cards
.iter()
.enumerate()
.filter(|(_, c)| !c.artwork_url.is_empty())
.map(|(i, c)| ArtworkJob {
target: ArtworkTarget::LocalAlbumCard { index: i, gen },
url: c.artwork_url.clone(),
})
.collect()
/// Last row band reported by the windowed albums grid (item indices into
/// `albums-visible`, prefetch margin already included by the grid). Kept so
/// model rebuilds (load/derive) can re-dispatch — the grid only fires its
/// callback when the band CHANGES, not when the rows under it change.
static ALBUMS_WINDOW: std::sync::Mutex<(usize, usize)> = std::sync::Mutex::new((0, 59));
/// Cover ids currently in the artwork pipeline for the albums window
/// (dedupe during fast scroll). Freed on apply; cleared on reloads.
fn albums_inflight() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
static S: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
S.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
/// Plex params + image-cache handle captured at load time so the window
/// dispatcher can spawn artwork jobs outside the load path.
fn albums_dispatch_ctx() -> &'static std::sync::Mutex<Option<(String, String, ImageCache)>> {
static S: std::sync::OnceLock<std::sync::Mutex<Option<(String, String, ImageCache)>>> =
std::sync::OnceLock::new();
S.get_or_init(|| std::sync::Mutex::new(None))
}
/// A windowed artwork job finished (applied or dropped) — free its slot so
/// the dispatcher can request the id again after an eviction.
pub fn album_artwork_job_done(id: &str) {
albums_inflight().lock().unwrap().remove(id);
}
/// The windowed grid reported a new visible row band.
pub fn albums_window_changed(window: &AppWindow, first: i32, last: i32) {
let first = first.max(0) as usize;
let last = last.max(first as i32) as usize;
*ALBUMS_WINDOW.lock().unwrap() = (first, last);
dispatch_albums_window(window);
}
/// Dispatch covers for the current albums window (over `albums-visible`) and
/// evict decoded covers far outside it back to the placeholder, so cover RAM
/// scales with the viewport instead of the library. Delivery is id-keyed
/// (`LocalAlbumById`), so a derive re-sort between dispatch and apply cannot
/// land a cover on the wrong card; a full reload bumps `ALBUMS_GEN` and the
/// apply arm drops the stale image.
pub fn dispatch_albums_window(window: &AppWindow) {
let (first, last) = *ALBUMS_WINDOW.lock().unwrap();
let Some((base_url, token, image_cache)) = albums_dispatch_ctx().lock().unwrap().clone()
else {
return;
};
let gen = ALBUMS_GEN.load(Ordering::SeqCst);
let s = window.global::<LocalLibraryState>();
let visible = s.get_albums_visible();
let len = visible.row_count();
if len == 0 {
return;
}
let last = last.min(len - 1);
if first > last {
return;
}
// Retention = the window plus one window-span on each side. Beyond it,
// covers return to the placeholder; re-entry is cheap (byte-budgeted
// decoded cache, else a bounded 264px re-decode from the source file).
let span = last - first + 1;
let keep_lo = first.saturating_sub(span);
let keep_hi = (last + span).min(len - 1);
let mut jobs = Vec::new();
{
let mut inflight = albums_inflight().lock().unwrap();
for vi in first..=last {
let Some(item) = visible.row_data(vi) else { continue };
if item.artwork.size().width > 0 || item.artwork_url.is_empty() {
continue;
}
let id = item.id.to_string();
if inflight.insert(id.clone()) {
jobs.push(ArtworkJob {
target: ArtworkTarget::LocalAlbumById { id, gen },
url: item.artwork_url.to_string(),
});
}
}
}
for vi in (0..keep_lo).chain(keep_hi + 1..len) {
let Some(item) = visible.row_data(vi) else { continue };
if item.artwork.size().width > 0 {
set_local_album_artwork(window, item.id.as_str(), slint::Image::default());
}
}
if !jobs.is_empty() {
crate::artwork::spawn_local_or_plex_loads(
jobs,
base_url,
token,
window.as_weak(),
image_cache,
);
}
}
/// Set a freshly-decoded local-album cover (by id) on every rendered model:
@@ -380,6 +466,16 @@ pub fn derive_albums(window: &AppWindow) {
s.set_albums_visible(ModelRc::new(VecModel::from(filtered)));
s.set_albums_grouped(empty_sections());
s.set_albums_alpha(ModelRc::new(VecModel::from(Vec::<AlphaJump>::new())));
if s.get_albums_view_mode() == "list" {
// The LIST view renders the same albums-visible model but is NOT
// windowed (only AlbumGrid fires window-changed) — dispatch every
// missing cover and do no eviction, like pre-windowing.
dispatch_albums_all_visible(window);
} else {
// The rows under the window band changed (search/sort/filter) but
// the band itself didn't — the grid won't re-fire, so re-dispatch.
dispatch_albums_window(window);
}
return;
}
@@ -426,6 +522,100 @@ pub fn derive_albums(window: &AppWindow) {
s.set_albums_grouped(ModelRc::new(VecModel::from(sections)));
s.set_albums_alpha(ModelRc::new(VecModel::from(jumps)));
s.set_albums_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
dispatch_albums_all_grouped(window);
}
/// Flat LIST-view artwork: same `albums-visible` model as the grid, but the
/// list is not windowed (only `AlbumGrid` fires `window-changed`) — dispatch
/// every missing cover, no eviction. Phase 1 limit.
fn dispatch_albums_all_visible(window: &AppWindow) {
let Some((base_url, token, image_cache)) = albums_dispatch_ctx().lock().unwrap().clone()
else {
return;
};
let gen = ALBUMS_GEN.load(Ordering::SeqCst);
let s = window.global::<LocalLibraryState>();
let visible = s.get_albums_visible();
let mut jobs = Vec::new();
{
let mut inflight = albums_inflight().lock().unwrap();
for vi in 0..visible.row_count() {
let Some(item) = visible.row_data(vi) else { continue };
if item.artwork.size().width > 0 || item.artwork_url.is_empty() {
continue;
}
let id = item.id.to_string();
if inflight.insert(id.clone()) {
jobs.push(ArtworkJob {
target: ArtworkTarget::LocalAlbumById { id, gen },
url: item.artwork_url.to_string(),
});
}
}
}
if !jobs.is_empty() {
crate::artwork::spawn_local_or_plex_loads(
jobs,
base_url,
token,
window.as_weak(),
image_cache,
);
}
}
/// The Albums grid/list view-mode toggled. Switching TO the list needs a full
/// dispatch (the list is not windowed and the grid's window may have evicted
/// covers the list now shows); switching to the grid is handled by AlbumGrid's
/// own `init => notify-window()` on mount.
pub fn albums_view_mode_changed(window: &AppWindow) {
let s = window.global::<LocalLibraryState>();
if s.get_albums_group() == "off" && s.get_albums_view_mode() == "list" {
dispatch_albums_all_visible(window);
}
}
/// Grouped-mode artwork: `albums-visible` is EMPTY there (the sections render
/// from `albums-grouped`), so the viewport window doesn't apply — keep the
/// pre-windowing behavior and dispatch every missing cover. Phase 1 limit;
/// per-section windowing needs the sections' content offsets.
fn dispatch_albums_all_grouped(window: &AppWindow) {
let Some((base_url, token, image_cache)) = albums_dispatch_ctx().lock().unwrap().clone()
else {
return;
};
let gen = ALBUMS_GEN.load(Ordering::SeqCst);
let s = window.global::<LocalLibraryState>();
let grouped = s.get_albums_grouped();
let mut jobs = Vec::new();
{
let mut inflight = albums_inflight().lock().unwrap();
for gi in 0..grouped.row_count() {
let Some(sec) = grouped.row_data(gi) else { continue };
for i in 0..sec.albums.row_count() {
let Some(item) = sec.albums.row_data(i) else { continue };
if item.artwork.size().width > 0 || item.artwork_url.is_empty() {
continue;
}
let id = item.id.to_string();
if inflight.insert(id.clone()) {
jobs.push(ArtworkJob {
target: ArtworkTarget::LocalAlbumById { id, gen },
url: item.artwork_url.to_string(),
});
}
}
}
}
if !jobs.is_empty() {
crate::artwork::spawn_local_or_plex_loads(
jobs,
base_url,
token,
window.as_weak(),
image_cache,
);
}
}
/// Clear all quality/format/source filters, then re-derive.
@@ -568,23 +758,21 @@ fn spawn_albums_load(
match loaded {
Some((albums, cards)) => {
*local_albums() = albums;
let jobs = album_artwork_jobs(&cards, gen);
let items: Vec<AlbumCardItem> =
cards.into_iter().map(crate::album_map::to_item).collect();
s.set_albums(ModelRc::new(VecModel::from(items.clone())));
s.set_album_count(items.len() as i32);
s.set_albums_loading(false);
s.set_albums_load_failed(false);
// WINDOWED artwork (was: a job for every album in the
// library). Stash the dispatch context BEFORE derive —
// derive dispatches the covers itself (flat = viewport
// band via dispatch_albums_window; grouped = full set).
// The spawn is Plex-aware: /library/... → PlexThumb,
// else LocalFile.
*albums_dispatch_ctx().lock().unwrap() =
Some((plex.base_url.clone(), plex.token.clone(), image_cache));
derive_albums(&w);
// Route covers through the Plex-aware spawn: Plex rows carry
// a /library/... path → PlexThumb; local rows → LocalFile.
crate::artwork::spawn_local_or_plex_loads(
jobs,
plex.base_url.clone(),
plex.token.clone(),
w.as_weak(),
image_cache,
);
}
None => {
s.set_albums_loading(false);
@@ -603,6 +791,9 @@ pub fn reload_albums(
) {
let _ = weak.upgrade_in_event_loop(move |w| {
let gen = ALBUMS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
// The gen bump orphans every in-flight windowed job (dropped on
// apply) — free their dedupe slots so the new set can re-request.
albums_inflight().lock().unwrap().clear();
let s = w.global::<LocalLibraryState>();
s.set_albums_loading(true);
s.set_albums_load_failed(false);
+20
View File
@@ -5829,6 +5829,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
log::warn!("[qbz-slint] select_bundled_translation('{lang}') failed: {e:?}");
}
reseed_i18n_labels(&window);
// Shader scenes need the wgpu underlay: on the femtovg-GL / software tiers
// they would render black, so hide them from the immersive picker + `g`.
window
.global::<ImmersiveState>()
.set_shader_scenes_available(use_gpu_renderer);
// Tell the user their renderer override was rolled back (set in
// renderer_tier_from_prefs when the previous start died before painting).
if RENDERER_REVERTED.load(std::sync::atomic::Ordering::Relaxed) {
@@ -14867,6 +14872,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
let weak = window.as_weak();
window
.global::<LocalLibraryActions>()
.on_albums_window_changed(move |first, last| {
// Windowed albums grid: dispatch covers for the reported row
// band and evict the ones far outside it.
if let Some(w) = weak.upgrade() {
local_library::albums_window_changed(&w, first, last);
}
});
}
{
let weak = window.as_weak();
window
@@ -14896,6 +14913,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.on_albums_set_view(move |mode| {
if let Some(w) = weak.upgrade() {
w.global::<LocalLibraryState>().set_albums_view_mode(mode);
// Switching to the (non-windowed) list view needs covers
// the grid's window may have evicted.
local_library::albums_view_mode_changed(&w);
}
});
}