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:
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user