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);
}