perf(qt): one rebuild per query in the local grid, and reveal covers in order

Two changes to the local albums grid, both BUILT BUT UNSMOKED — Local Library
is best-effort for the next release, so this is committed to preserve it
rather than because it was validated.

1. ONE REBUILD PER QUERY. `rows` and `groups` are two bindings over one query:
   the host derives `albumsVisible` from the search box and `albumsGrouped`
   FROM `albumsVisible` (LocalLibraryView.qml:378-380). With a handler each,
   a single keystroke rebuilt twice, and the first of the two was WRONG as
   well as wasted — it chunked the new rows against the previous groups.
   Turning grouping off did not save it either, because `groupRows` returns a
   fresh `[]` every time and a new array is still a change. Coalesced onto a
   zero-interval Timer: one rebuild per event-loop turn, reading both new
   values. `Component.onCompleted` stays synchronous so a mount does not show
   a frame of empty grid.

   `[coltiming]` logs every rebuild with its cost, so a smoke run can falsify
   the coalescing: one line per keystroke means it holds, two means it does
   not. Timed around the work, never from a deferred callback.

2. ORDERED COVER REVEAL. The cold-thumbnail pool starts work in window order
   but finishes out of order, since decode cost varies per cover, so covers
   appeared scattered across the grid. Ordering the emission in Rust would
   have made it worse: one slow decode at index 3 would hold 4..N behind it,
   adding latency instead of hiding it. Instead arrivals are applied behind a
   front that walks the window at a fixed rate; a cover landing behind the
   front is applied at once. The front advances every tick unconditionally,
   which is what makes a stall impossible, and a key with no ordinal is
   released immediately.

Note for whoever picks this up: this reveal covers the LOCAL path only. The
uneven fill visible in ordinary navigation is HomeView's For You rails, which
take a different route (`forYouArt` via `onForyouArtReady`).

cc
This commit is contained in:
vicrodh committed 2026-08-18 19:15:06 -06:00
1 parent ed081cac6b
commit 101d0e25d4
2 files changed
+105 -8

No files matched your search

+63 -3
View File
@@ -154,16 +154,65 @@ Rectangle {
// coalesced into ONE rebind per frame instead — the covers still appear // coalesced into ONE rebind per frame instead — the covers still appear
// progressively (16ms granularity is invisible), at O(n) total cost. // progressively (16ms granularity is invisible), at O(n) total cost.
property var _artInbox: ({}) property var _artInbox: ({})
// ORDERED REVEAL. The cold-thumbnail pool (local_artwork.rs:163) starts
// work in window order but FINISHES out of order, because decode cost
// varies per cover — so covers used to pop in scattered across the grid.
// Ordering the emission in Rust instead would be a mistake: one slow
// decode at index 3 would hold 4..N behind it, which ADDS latency rather
// than hiding it.
//
// So arrivals are applied behind a reveal FRONT that walks down the
// window at a fixed rate. A cover that lands ahead of the front waits for
// it; a cover that lands behind it (the slow ones) is applied at once,
// having already been passed. The front advances every tick unconditionally,
// which is what makes a stall impossible: a key is delayed at most
// (ordinal / step) ticks no matter what the pool does, and a key with no
// ordinal at all is released immediately.
//
// `_artOrder` is rebuilt per window request (see flushWindows), so the
// wipe restarts from the top of whatever is on screen now.
property var _artOrder: ({})
property var _artHeld: ({})
property int _artFront: 0
/// Keys the front passes per 16ms tick — about one grid row, which reads
/// as a wipe rather than a sweep. A ~50-cover window resolves in ~130ms.
property int artRevealStep: 6
Timer { Timer {
id: artFlush id: artFlush
interval: 16 interval: 16
repeat: false repeat: false
onTriggered: { onTriggered: {
var m = Object.assign({}, root.artMap, root._artInbox) var k
// Everything that arrived this tick joins whatever is still held.
for (k in root._artInbox) root._artHeld[k] = root._artInbox[k]
root._artInbox = ({}) root._artInbox = ({})
root._artFront += root.artRevealStep
var m = Object.assign({}, root.artMap)
var released = false
var stillHeld = false
for (k in root._artHeld) {
var ord = root._artOrder[k]
// No ordinal means this key is not part of the current window
// (a late arrival from a window we have scrolled past). Holding
// it would be holding it forever.
if (ord === undefined || ord < root._artFront) {
m[k] = root._artHeld[k]
delete root._artHeld[k]
released = true
} else {
stillHeld = true
}
}
// A rebind needs a NEW object reference (same-ref assignment is // A rebind needs a NEW object reference (same-ref assignment is
// not a change in QML). // not a change in QML).
root.artMap = m if (released) root.artMap = m
// Keep ticking while anything waits, or the front stops moving and
// the covers behind it never land.
if (stillHeld) artFlush.start()
} }
} }
Connections { Connections {
@@ -555,8 +604,15 @@ Rectangle {
// for a row we have just scrolled away from would be re-added by the // for a row we have just scrolled away from would be re-added by the
// next flush and defeat the eviction. // next flush and defeat the eviction.
for (k in root._artInbox) if (!keep[k]) delete root._artInbox[k] for (k in root._artInbox) if (!keep[k]) delete root._artInbox[k]
for (k in root._artHeld) if (!keep[k]) delete root._artHeld[k]
if (changed) root.artMap = Object.assign({}, m) if (changed) root.artMap = Object.assign({}, m)
// Ordinals for the ordered reveal, assigned over the window in DISPLAY
// order so the front walks top to bottom. Built for every key in the
// window, not just the missing ones, so a cover that resolves while
// the wipe is mid-flight still knows where it sits.
var order = {}
var ord = 0
var keys = [] var keys = []
var seen = {} var seen = {}
for (k in root._windows) { for (k in root._windows) {
@@ -565,11 +621,15 @@ Rectangle {
for (i = w.first; i <= w.last; i++) { for (i = w.first; i <= w.last; i++) {
ak = rows[i] ? rows[i].artKey : "" ak = rows[i] ? rows[i].artKey : ""
if (!ak || seen[ak]) continue if (!ak || seen[ak]) continue
if (m[ak] !== undefined || root._artInbox[ak] !== undefined) continue
seen[ak] = true seen[ak] = true
if (order[ak] === undefined) order[ak] = ord++
if (m[ak] !== undefined || root._artInbox[ak] !== undefined) continue
keys.push(ak) keys.push(ak)
} }
} }
// Restart the wipe at the top of what is on screen NOW.
root._artOrder = order
root._artFront = 0
if (keys.length > 0) QbzLocal.artworkWindow(JSON.stringify(keys)) if (keys.length > 0) QbzLocal.artworkWindow(JSON.stringify(keys))
} }
@@ -92,7 +92,18 @@ Item {
/// header — what AlphaStrip needs to scroll here. /// header — what AlphaStrip needs to scroll here.
property var alphaJumps: [] property var alphaJumps: []
// Counts and times every rebuild so a smoke run can FALSIFY the coalescing:
// one line per keystroke means it holds, two means it does not. Measured
// around the work itself, never from a deferred callback.
LoggingCategory {
id: colTiming
name: "qbz.nav.timing"
defaultLogLevel: LoggingCategory.Warning
}
property int rebuildCount: 0
function rebuild() { function rebuild() {
var _t0 = Date.now()
var out = [] var out = []
var flatOut = [] var flatOut = []
var jumps = [] var jumps = []
@@ -127,13 +138,39 @@ Item {
flat = flatOut flat = flatOut
entries = out entries = out
alphaJumps = jumps alphaJumps = jumps
root.rebuildCount += 1
console.info(colTiming, "[coltiming] rebuild #" + root.rebuildCount
+ " surface=" + root.surface + " grouped=" + root.grouped
+ " albums=" + flatOut.length + " entries=" + out.length
+ " in " + (Date.now() - _t0) + "ms")
report() report()
} }
onRowsChanged: rebuild() // `rows` and `groups` are TWO bindings over ONE query: the host derives
onGroupsChanged: rebuild() // `albumsVisible` from the search box, then `albumsGrouped` FROM
onGroupedChanged: rebuild() // `albumsVisible` (LocalLibraryView.qml:378-380). So a single keystroke
onViewModeChanged: rebuild() // changes both, and with one handler each this rebuilt twice — and the
onColsChanged: rebuild() // first of the two was WRONG as well as wasted, because it chunked the new
// rows against the previous groups. Grouping being off does not save it:
// `groupRows` returns a FRESH `[]` every time, which is still a change.
//
// Coalesced into one rebuild per event-loop turn. A zero-interval Timer
// rather than `Qt.callLater`, because dedup then depends on the callback
// being the same function object every time, and `restart()` needs no such
// assumption — same shape as `artFlush` in LocalLibraryView.
Timer {
id: rebuildCoalescer
interval: 0
repeat: false
onTriggered: root.rebuild()
}
function scheduleRebuild() { rebuildCoalescer.restart() }
onRowsChanged: scheduleRebuild()
onGroupsChanged: scheduleRebuild()
onGroupedChanged: scheduleRebuild()
onViewModeChanged: scheduleRebuild()
onColsChanged: scheduleRebuild()
// The first build stays synchronous — deferring it would show one frame of
// empty grid on every mount.
Component.onCompleted: { rebuild(); reportSoon() } Component.onCompleted: { rebuild(); reportSoon() }
Component.onDestruction: if (view) view.releaseWindow(root.surface) Component.onDestruction: if (view) view.releaseWindow(root.surface)