perf(library): virtualize large album details

Render only the visible track slice for expanded box sets and share physical version caches without cloning every source row.

cx
This commit is contained in:
vicrodh committed 2026-08-25 18:22:32 -06:00
1 parent 720e6d33de
commit e0dc288659
4 files changed
+83 -53

No files matched your search

@@ -320,6 +320,20 @@ Item {
readonly property int headerH: 72
readonly property int discHeaderH: 46
readonly property int loadingBodyH: 50
readonly property real bodyHeight: loaded
? visibleTracks * 50 + discHeaders * discHeaderH : 0
// Keep the album itself as one variable-height outer delegate, but
// give its tracks a real viewport. The former nested Repeater
// constructed one wrapper and two Loaders for all 247 tracks in a
// box set before the first frame could paint. Moving this small
// ListView through the visible slice preserves the exact outer
// geometry while Qt only instantiates the rows on screen.
readonly property real trackViewportStart: Math.max(
0, list.contentY - y - headerH - 300)
readonly property real trackViewportEnd: Math.min(
bodyHeight, list.contentY + list.height - y - headerH + 300)
readonly property real trackViewportHeight: Math.max(
0, trackViewportEnd - trackViewportStart)
function ensureCurrent() {
// Delegate creation is only a visibility hint. Let the view's
// bounded scheduler choose foreground albums after ListView
@@ -329,7 +343,7 @@ Item {
width: list.width
height: headerH + (expanded
? (loaded
? visibleTracks * 50 + discHeaders * discHeaderH + 12
? bodyHeight + 12
: loadingBodyH)
: 0)
@@ -391,45 +405,39 @@ Item {
size: 18
}
Column {
ListView {
id: trackList
visible: albumBlock.expanded && albumBlock.loaded
&& albumBlock.trackViewportHeight > 0
x: 4
y: albumBlock.headerH
y: albumBlock.headerH + albumBlock.trackViewportStart
width: parent.width - 8
Repeater {
model: albumBlock.tracks
delegate: Item {
id: trackBlock
required property var modelData
required property int index
width: parent ? parent.width : 0
readonly property bool showDisc: albumBlock.startsDisc(index)
readonly property int discNumber: Number(modelData.disc || 1)
readonly property bool discFolded:
albumBlock.discIsCollapsed(discNumber)
height: (showDisc ? albumBlock.discHeaderH : 0)
+ (discFolded ? 0 : 50)
// The outer ListView virtualizes by ALBUM. A 150-track
// box is one delegate, so its nested Repeater would
// otherwise build 150 full TrackRows even though only
// ~18 fit on screen. Keep cheap spacers for exact
// geometry and mount heavy rows in a six-row lookahead.
readonly property real contentTop:
albumBlock.y + albumBlock.headerH + y
readonly property bool inViewportBand:
contentTop + height >= list.contentY - 300
&& contentTop <= list.contentY + list.height + 300
height: albumBlock.trackViewportHeight
model: albumBlock.tracks
contentY: albumBlock.trackViewportStart
interactive: false
clip: true
reuseItems: true
cacheBuffer: 300
boundsBehavior: Flickable.StopAtBounds
delegate: Item {
id: trackBlock
required property var modelData
required property int index
width: parent ? parent.width : 0
readonly property bool showDisc: albumBlock.startsDisc(index)
readonly property int discNumber: Number(modelData.disc || 1)
readonly property bool discFolded:
albumBlock.discIsCollapsed(discNumber)
height: (showDisc ? albumBlock.discHeaderH : 0)
+ (discFolded ? 0 : 50)
// A divider used to be an always-instantiated Item
// whose `visible` flag was false on ordinary tracks.
// That still constructed its art, six controls and
// popup tree once PER TRACK. A 100-track box therefore
// paid for 100 dividers to paint five. Loader makes the
// object count match the number of actual boundaries.
Loader {
// A divider exists only on disc boundaries and only
// while this virtualized delegate is near the viewport.
Loader {
width: parent.width
height: trackBlock.showDisc ? albumBlock.discHeaderH : 0
active: trackBlock.showDisc && trackBlock.inViewportBand
active: trackBlock.showDisc
sourceComponent: Item {
id: discHeader
width: parent.width
@@ -542,14 +550,13 @@ Item {
color: theme.borderSubtle
}
}
}
}
Loader {
Loader {
y: trackBlock.showDisc ? albumBlock.discHeaderH : 0
width: parent.width
height: trackBlock.discFolded ? 0 : 50
active: !trackBlock.discFolded
&& trackBlock.inViewportBand
sourceComponent: LocalTrackRow {
view: root.view
item: trackBlock.modelData
@@ -565,7 +572,6 @@ Item {
QbzLocal.enqueue("track", trackBlock.modelData.id, mode)
}
}
}
}
}
}
+5 -3
View File
@@ -452,7 +452,7 @@ pub(crate) fn genre_select_version(album_id: &str, index: i32) -> Option<GenreAl
state(|s| {
s.genre_detail_raw.insert(album_id.to_string(), selected);
});
Some(genre_detail_doc(album_id, &versions, index))
Some(genre_detail_doc(album_id, versions.as_slice(), index))
}
/// The header for ONE version — recomputed per version (the Slint recomputes
@@ -1149,8 +1149,10 @@ mod version_tests {
];
state(|s| {
s.genre_detail_raw.insert(album.to_string(), hires);
s.genre_detail_versions
.insert(album.to_string(), versions.clone());
s.genre_detail_versions.insert(
album.to_string(),
std::sync::Arc::new(versions.clone()),
);
});
let doc = genre_select_version(album, 1).expect("second version");
+21 -4
View File
@@ -1635,12 +1635,18 @@ impl qbz_local::QbzLocal {
.cloned()
.collect::<Vec<_>>();
let filtered_rows = tracks.len();
let versions = crate::local_album_actions::split_versions(tracks);
let versions = std::sync::Arc::new(
crate::local_album_actions::split_versions(tracks),
);
let selected = versions
.first()
.map(|(_, rows)| rows.clone())
.unwrap_or_default();
let doc = crate::local_album_actions::genre_detail_doc(&request_id, &versions, 0);
let doc = crate::local_album_actions::genre_detail_doc(
&request_id,
versions.as_slice(),
0,
);
let json = crate::local_rows::to_json(&doc);
let project_elapsed = project_started.elapsed();
let versions_empty = versions.is_empty();
@@ -1726,6 +1732,7 @@ impl qbz_local::QbzLocal {
}
pub fn genre_album_select_version(self: Pin<&mut Self>, album_id: QString, index: i32) {
let started = std::time::Instant::now();
let album_id = album_id.to_string();
let generation = crate::local_state::state(|state| {
let generation = state
@@ -1754,8 +1761,11 @@ impl qbz_local::QbzLocal {
))
})?;
let selected = versions.get(index)?.1.clone();
let doc =
crate::local_album_actions::genre_detail_doc(&request_id, &versions, index);
let doc = crate::local_album_actions::genre_detail_doc(
&request_id,
versions.as_slice(),
index,
);
Some((
request_id,
selected,
@@ -1767,6 +1777,7 @@ impl qbz_local::QbzLocal {
.ok()
.flatten();
if let Some((request_id, selected, json, filter_json)) = selection {
let bytes = json.len();
let current = crate::local_state::state(|state| {
if state
.genre_detail_version_generations
@@ -1785,6 +1796,12 @@ impl qbz_local::QbzLocal {
if !current {
return;
}
log::info!(
"[qbz-qt][perf] genre album detail phase=version id={} bytes={} elapsed={:?}",
request_id,
bytes,
started.elapsed(),
);
ui(move |mut bridge| {
bridge.as_mut().local_genre_album_ready(
QString::from(request_id.as_str()),
+13 -8
View File
@@ -125,7 +125,7 @@ pub struct LocalState {
/// Every physical version behind the same expanded albums. The QML
/// version picker changes `genre_detail_raw` from this cache, without a
/// DB round-trip and without retargeting another expanded album.
pub genre_detail_versions: HashMap<String, Vec<(String, Vec<LocalTrack>)>>,
pub genre_detail_versions: HashMap<String, Arc<Vec<(String, Vec<LocalTrack>)>>>,
/// Last serialized selected-version document for the same bounded set.
/// A Genres view destroyed by navigation can republish this immediately
/// instead of re-querying every authoritative source on Back.
@@ -615,18 +615,23 @@ mod phase_a_tests {
#[test]
fn genres_details_virtualizes_the_nested_box_set_rows() {
// The outer ListView is album-granular: a 150-track box is one visible
// delegate. A plain nested Repeater therefore creates every TrackRow,
// plus one hidden disc-header tree per track. Keep only cheap geometry
// items outside the viewport and instantiate controls in a look-ahead
// band. Menus are click-cold for the same reason.
// delegate. A plain nested Repeater therefore creates every wrapper and
// Loader before the first frame. Keep the album's exact variable-height
// geometry, but move a non-interactive inner ListView through the visible
// slice so Qt constructs only the rows in its look-ahead band. Menus are
// click-cold for the same reason.
let details = include_str!("../qml/views/local/LocalGenreDetails.qml");
let column = include_str!("../qml/views/local/LocalGenreColumn.qml");
let tab = include_str!("../qml/views/local/LocalGenresTab.qml");
let versions = include_str!("../qml/views/local/VersionPicker.qml");
let album_row = include_str!("../qml/views/local/LocalAlbumRow.qml");
assert!(details.contains("readonly property bool inViewportBand:"));
assert!(details.contains("active: trackBlock.showDisc && trackBlock.inViewportBand"));
assert!(details.contains("&& trackBlock.inViewportBand"));
assert!(details.contains("readonly property real trackViewportStart:"));
assert!(details.contains("readonly property real trackViewportHeight:"));
assert!(details.contains("id: trackList"));
assert!(details.contains("contentY: albumBlock.trackViewportStart"));
assert!(details.contains("interactive: false"));
assert!(details.contains("reuseItems: true"));
assert!(!details.contains("readonly property bool inViewportBand:"));
assert!(!details.contains("visible: trackBlock.showDisc"));
// A cold album is one compact loading row. `trackCount` is metadata,
// never permission to reserve hundreds or thousands of blank pixels