perf(qt): make the local albums grid actually recycle, and stop it asking

for the whole library

The owner's report is precise and is what this targets: the grid OPENS
fine; scroll, scrollbar drag and search are what feel sluggish, and "the
first two keystrokes hurt" — the ones where the filter still matches
hundreds. That shape is a model swap, not a slow filter.

UNSMOKED. Committed to save progress; no binary matches this tree.

1. FIXED CELL COUNT. `reuseItems` was already on the ListView and bought
   nothing, because the pool recycles the row SHELL while the inner
   `Repeater { model: modelData.items }` got a new array on every recycle
   and rebuilt its AlbumCards anyway. The card is where the cost is: ~29
   items, a RoundedImage with TWO render targets (its own layer plus a
   separate mask layer), image probes, a skeleton. The shell was reused
   and the expensive part was not. The Repeater now runs over `root.cols`
   — a constant — so cells survive recycling and a scrolled or refiltered
   row only reassigns `item`.

2. THE HEADER WINDOW BUG. `loIdx` fell back to 0 whenever the first
   visible entry was a group header, because headers carried no `base`.
   In grouped mode, scrolling with a header at the top of the viewport
   asked `queueWindowReport` for artwork from album 0 through the current
   letter — hundreds of covers dispatched and later evicted, per scroll.
   Headers now carry the flat index their group starts at.

3. SEARCH DEBOUNCE on the albums box, which the Tracks box in the same
   file already had. `albumsSearch` is not a query, it is the filter
   input: writing it per keystroke refilters 1267 albums, rebuilds the
   entry list and hands the ListView a new model, tearing down every
   delegate. Note the tradeoff — this ADDS 200ms before any work, so the
   box may feel more deliberate even as it stutters less.

Also here, from the album pages (lower priority per the owner — their
cost is paid once and the view is fine in use afterwards): the disc
header, which carried a CardMenu per TRACK, is Loader-gated; a genuinely
quadratic multi-disc scan in LocalAlbumView is hoisted out of the
per-row call; and the track rows are windowed. That windowing was
measured broken twice (a band computed against its own parent's derived
height — a dependency cycle) and the fix is unverified.

Credit where due: 1 and 2 came out of an independent review that also
judged the previous round's lazy menus and `reuseItems` "mostly beside
the main point". That is a fair call.

cc
This commit is contained in:
vicrodh committed 2026-08-18 17:42:30 -06:00
1 parent 99722aedaf
commit bd9aec1440
5 files changed
+450 -146

No files matched your search

+23 -8
View File
@@ -183,6 +183,12 @@ Rectangle {
QbzTheme { id: theme }
/// Build the context popup on first use, then open it.
function openAlbumMenu(anchor, x, y) {
albumMenuLoader.active = true
albumMenuLoader.item.openAtCursor(anchor, x, y)
}
width: 200
// 246 normally; +20 for the "{} plays" line (see `plays`) — the same
// 266 -> 286 step MostPlayedAlbumsView.slint:23 takes for its grid.
@@ -386,7 +392,7 @@ Rectangle {
// button carries the play affordance).
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
albumMenu.openAtCursor(artArea, mouse.x, mouse.y)
root.openAlbumMenu(artArea, mouse.x, mouse.y)
return
}
// .slint:169 — in select mode the card click TOGGLES.
@@ -509,7 +515,7 @@ Rectangle {
// opens under the ⋯ (worst case 18px off the pointer).
// Stays correct if the signal ever forwards the event.
onClicked: function (mouse) {
albumMenu.openAtCursor(moreBtn,
root.openAlbumMenu(moreBtn,
mouse ? mouse.x : moreBtn.width / 2,
mouse ? mouse.y : moreBtn.height / 2)
}
@@ -518,11 +524,20 @@ Rectangle {
// Context menu (AlbumCard.slint's album-menu) — the shared
// CardMenu surface, not a second copy of its delegate.
CardMenu {
id: albumMenu
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
// LAZY. A CardMenu is a QtQuick.Controls Popup with a Repeater
// over its entries, and this card is the unit the catalog surfaces
// are built from: Discover Home mounts ~423 of them, and the Local
// Albums grid recycles a screenful of them on every scroll step.
// Constructing a popup per card — for a menu that only ever opens
// on a click — is paid on every one of those.
Loader {
id: albumMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: root.menuModel()
onPicked: function (a) { root.menuAction(a) }
}
}
// Award ribbon — content-width, capped at the card width.
@@ -636,7 +651,7 @@ Rectangle {
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function (mouse) {
if (mouse.button === Qt.RightButton) {
albumMenu.openAtCursor(titleArea, mouse.x, mouse.y)
root.openAlbumMenu(titleArea, mouse.x, mouse.y)
return
}
// .slint:465 — the title carries the SAME target
+154 -91
View File
@@ -1043,111 +1043,174 @@ Rectangle {
}
// Rows (with Disc / work headers).
//
// WINDOWED, for the reason spelled out in the twin
// (LocalAlbumView): a box set is BOUNDED but not cheap,
// and the search field filters `visibleTracks`, so every
// keystroke handed this Repeater a new model and rebuilt
// every row. The slot keeps its height; only the row is
// windowed. The disc / work headers stay gated on DATA,
// not on the band, so their contribution to the slot's
// height is always real.
Repeater {
id: trackList
model: root.visibleTracks
/// This list's top in the page Flickable's content
/// coordinates — one mapToItem for the whole list.
readonly property real topInFlick:
trackList.parent
? trackList.parent.mapToItem(pageFlick.contentItem, 0, 0).y
: 0
delegate: Column {
id: trackBlock
required property var modelData
required property int index
property var hdr: root.headerFor(index)
width: parent ? parent.width : 0
Rectangle {
visible: hdr && hdr.disc > 0
// LOADER, not `visible:` — this header is declared per TRACK and was
// built for every one of them so a couple of disc
// boundaries could show. Measured on a 247-track album:
// `localalbum built=39ms` / `to-idle=2242ms`, the mount
// instant and the settle 2.2 s. See LocalAlbumView for
// the twin (whose header also carried a Popup).
Loader {
width: parent.width
height: 40
color: "transparent"
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Disc", QbzSession.trRev) + " " + hdr.disc
color: theme.textMuted
font.pixelSize: 13
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
}
Row {
visible: hdr && hdr.work !== ""
width: parent.width
leftPadding: 12
rightPadding: 12
topPadding: 14
bottomPadding: 4
spacing: 0
Text {
text: hdr.work
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
Text {
visible: modelData.workComposerName !== ""
text: " ("
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
Text {
visible: modelData.workComposerName !== ""
text: modelData.workComposerName
color: composerArea.containsMouse && modelData.workComposerId !== "" ? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
MouseArea {
id: composerArea
anchors.fill: parent
enabled: modelData.workComposerId !== ""
hoverEnabled: true
cursorShape: modelData.workComposerId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzArtist.openArtist(modelData.workComposerId)
active: hdr && hdr.disc > 0
visible: active
sourceComponent: Rectangle {
visible: hdr && hdr.disc > 0
width: parent.width
height: 40
color: "transparent"
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Disc", QbzSession.trRev) + " " + hdr.disc
color: theme.textMuted
font.pixelSize: 13
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
}
Text {
visible: modelData.workComposerName !== ""
text: ")"
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
// Same gate, same reason as the disc header above.
Loader {
width: parent.width
active: hdr && hdr.work !== ""
visible: active
sourceComponent: Row {
visible: hdr && hdr.work !== ""
width: parent.width
leftPadding: 12
rightPadding: 12
topPadding: 14
bottomPadding: 4
spacing: 0
Text {
text: hdr.work
color: theme.textPrimary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
Text {
visible: modelData.workComposerName !== ""
text: " ("
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
Text {
visible: modelData.workComposerName !== ""
text: modelData.workComposerName
color: composerArea.containsMouse && modelData.workComposerId !== "" ? theme.textPrimary : theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
MouseArea {
id: composerArea
anchors.fill: parent
enabled: modelData.workComposerId !== ""
hoverEnabled: true
cursorShape: modelData.workComposerId !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: QbzArtist.openArtist(modelData.workComposerId)
}
}
Text {
visible: modelData.workComposerName !== ""
text: ")"
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightBold
}
}
}
TrackRow {
item: modelData
number: index + 1
zebra: true
clickPlays: false
artistLink: true
qualityStyle: "text"
showDownload: true
downloadGlyph: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id)
menuShowLater: false
menuShowGoTo: false
onPlayRequested: QbzPlayer.playAlbumFrom(header.id, item.id)
onEnqueueRequested: function (m) {
QbzPlayer.enqueueAlbumTrack(header.id, item.id, m === "next" ? "next" : "later")
// 50 px reserved whether or not the row exists,
// so windowing cannot move the page under the user.
Item {
id: rowSlot
width: parent.width
height: 50
/// `rowSlot.y` — NOT `trackBlock.height`.
/// The Column's height is derived FROM its
/// children, so measuring this slot against it
/// made the position depend on the thing being
/// positioned. QML broke that cycle, `topY`
/// came out garbage, every row read as in-band
/// and all 247 were built — the counter said
/// `filas construidas=247`, which is how this
/// was caught rather than guessed at again.
/// `y` is assigned by the Column from the
/// PRECEDING siblings only, and the header
/// above is gated on DATA, so nothing here
/// depends on the band.
readonly property real topY:
trackList.topInFlick + trackBlock.y + rowSlot.y
readonly property bool inBand:
rowSlot.topY > pageFlick.contentY - pageFlick.height
&& rowSlot.topY < pageFlick.contentY + 2 * pageFlick.height
Loader {
anchors.fill: parent
active: rowSlot.inBand
sourceComponent: TrackRow {
item: modelData
number: index + 1
zebra: true
clickPlays: false
artistLink: true
qualityStyle: "text"
showDownload: true
downloadGlyph: true
selectMode: root.multiSelect
checked: root.selected[item.id] === true
onToggleSelect: root.toggleSelected(item.id)
menuShowLater: false
menuShowGoTo: false
onPlayRequested: QbzPlayer.playAlbumFrom(header.id, item.id)
onEnqueueRequested: function (m) {
QbzPlayer.enqueueAlbumTrack(header.id, item.id, m === "next" ? "next" : "later")
}
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// SOURCE: this view never shows a local album.
// `open_album` (main.rs:538-548) routes any id
// that `is_local_feed_id("album", …)` accepts
// — a Plex `plex:` key, a group key, a path —
// to the LocalAlbum view instead, so what
// reaches `album_qt::TrackRow` here is always
// a `/album/get` response. `item.id` is a
// Qobuz catalog id by construction of the
// route, not by assumption at this call site.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
}
// MyQBZ "Add to mixtape" — the HOST builds the
// AddItem array (TrackRow does not know
// itemType/source).
//
// SOURCE: this view never shows a local album.
// `open_album` (main.rs:538-548) routes any id
// that `is_local_feed_id("album", …)` accepts
// — a Plex `plex:` key, a group key, a path —
// to the LocalAlbum view instead, so what
// reaches `album_qt::TrackRow` here is always
// a `/album/get` response. `item.id` is a
// Qobuz catalog id by construction of the
// route, not by assumption at this call site.
onMixtapeRequested: QbzMyQbzAdd.open(JSON.stringify([{
"itemType": "track", "source": "qobuz",
"sourceItemId": item.id, "title": item.title || "",
"subtitle": item.artist || "", "artworkUrl": "",
"year": null, "trackCount": null
}]))
}
}
}
+162 -26
View File
@@ -70,7 +70,25 @@ Rectangle {
property string trackQuery: ""
// Client-side track search: an album's track list is bounded, so the
// Slint's LocalAlbumActions.search is a pure view filter here.
property real _deriveMs: 0
// Cuánto tarda la vista en existir SIN sus filas: si el costo no está en
// las filas ni en el derive, tiene que estar aquí (portada, versiones,
// lista de artistas, toolbar).
property double _mountedAt: Date.now()
Component.onCompleted: console.info(albTiming,
"[albtiming] arbol de la vista construido en "
+ (Date.now() - root._mountedAt) + "ms")
readonly property var visibleTracks: {
var _dt = Date.now()
var _r = root._deriveImpl()
// El trabajo, medido AQUÍ. La versión anterior guardaba el tiempo en
// un Qt.callLater, que corre en la siguiente vuelta del event loop —
// así que medía "cuánto tardó el loop en desocuparse", no el filtro.
// Salía pegado a to-idle porque era el mismo número con otro nombre.
root._deriveMs = Date.now() - _dt
return _r
}
function _deriveImpl() {
if (trackQuery === "") return tracks
var q = trackQuery.toLowerCase()
var out = []
@@ -146,14 +164,68 @@ Rectangle {
// Disc divider before the first row of each disc on a multi-disc album
// (0 = flat list, as the Slint's disc-header-number).
// ---- INSTRUMENTACIÓN (qbz.nav.timing) -------------------------------
// Dos intentos fallaron en esta vista: la ventana en las filas bajó
// 2242 -> 1485 ms, y quitar el O(n²) del disc-header no movió nada
// (1417 ms). Deja de razonar y mide: cuántas filas se construyen de
// verdad, cuánto cuesta el derive, y cuánto tarda cada tramo.
LoggingCategory {
id: albTiming
name: "qbz.nav.timing"
defaultLogLevel: LoggingCategory.Warning
}
property double _t0: Date.now()
property int _rowsBuilt: 0
property int _hdrsBuilt: 0
// El contador anterior era ACUMULATIVO y nunca se reiniciaba, así que
// "253 filas sobre 247 tracks" podía significar dos cosas opuestas: banda
// rota construyendo todo, o banda buena con el usuario recorriendo el
// álbum entero. No discriminaba nada. Ahora se reinicia con cada lista y
// se reporta en una ventana FIJA, antes de que nadie haga scroll.
onVisibleTracksChanged: {
root._rowsBuilt = 0
root._hdrsBuilt = 0
settleProbe.restart()
}
Timer {
id: settleProbe
interval: 900
repeat: false
onTriggered: console.info(albTiming,
"[albtiming] tracks=" + root.visibleTracks.length
+ " derive=" + root._deriveMs + "ms"
+ " filas en 900ms=" + root._rowsBuilt
+ " encabezados=" + root._hdrsBuilt
+ " multiDisc=" + root.multiDisc
+ " versiones=" + (root.versions ? root.versions.length : -1))
}
/// Does this album span more than one disc? Computed ONCE per track list
/// instead of once per row.
///
/// It used to live inside `discHeader(i)`, which is called for every row —
/// so the scan ran N times over N tracks. O(n²), and the early `break`
/// hid it on exactly the albums that did not matter: a multi-disc album
/// bails on the first track of disc 2, while a SINGLE-disc album never
/// bails and walks the whole list every time. On a 247-track single-disc
/// album that is ~61,000 iterations, and it was most of the 1485 ms this
/// view still spent settling after its rows were already windowed.
///
/// The local `list` binding matters too: `visibleTracks` is a `var` on the
/// root, so each `visibleTracks[j]` in the old loop was a property lookup
/// through the QML object rather than a plain array index.
readonly property bool multiDisc: {
var list = root.visibleTracks
for (var j = 0; j < list.length; j++) {
if ((list[j].disc || 1) > 1) return true
}
return false
}
function discHeader(i) {
var t = visibleTracks[i]
if (!t) return 0
var multi = false
for (var j = 0; j < visibleTracks.length; j++) {
if ((visibleTracks[j].disc || 1) > 1) { multi = true; break }
}
if (!multi) return 0
if (!root.multiDisc) return 0
if (i === 0) return t.disc || 1
return (visibleTracks[i - 1].disc || 1) !== (t.disc || 1) ? (t.disc || 1) : 0
}
@@ -456,11 +528,32 @@ Rectangle {
}
// ---- Track list ----
// An album's track count is bounded (the windowed views are for
// the library-scale surfaces), so this mounts like the Slint.
//
// WINDOWED. The header here used to say an album's track count is
// "bounded, so this mounts like the Slint" — and it IS bounded,
// but bounded is not cheap. Measured on a 247-track album:
// `localalbum built=39ms` with `to-idle=2242ms`.
//
// The reference gets away with one element per track because a
// Slint element is cheap; a QML delegate is not, which is the
// recurring mistranslation in this port. Worse than the open cost:
// the search field filters `visibleTracks`, so every KEYSTROKE
// handed the Repeater a new model and rebuilt all 247 rows.
//
// The slot keeps its full height so the page's scroll geometry is
// unchanged whether or not the row inside exists; only the row is
// windowed.
Column {
id: trackList
width: parent.width
spacing: 0
/// This list's top in the page Flickable's content coordinates.
/// One mapToItem for the whole list, re-evaluated on layout —
/// the per-row test below is then plain arithmetic on `y`,
/// which the Column has already computed exactly (disc headers
/// included).
readonly property real topInFlick:
trackList.mapToItem(flick.contentItem, 0, 0).y
Repeater {
model: root.visibleTracks
delegate: Column {
@@ -471,10 +564,28 @@ Rectangle {
spacing: 0
// Disc divider + its per-disc ⋯ menu.
Item {
visible: root.discHeader(trackBlock.index) > 0
//
// LOADER, not `visible:`. This block carries a
// CardMenu — a Popup with its own Repeater — and it
// was built for EVERY track so that two or three disc
// boundaries could show one. On a 247-track album that
// is 247 popups constructed to display none.
//
// Measured on the owner's largest local album:
// `localalbum built=39ms` but `to-idle=2242ms`, i.e.
// the mount was instant and the settle took 2.2 s. The
// header above this list says an album's track count
// is "bounded" so it can mount like the Slint — and it
// is bounded, but bounded is not the same as cheap,
// which is what that assumption missed.
Loader {
width: parent.width
height: visible ? 40 : 0
active: root.discHeader(trackBlock.index) > 0
visible: active
onLoaded: root._hdrsBuilt++ // INSTRUM.
sourceComponent: Item {
width: parent.width
height: 40
Text {
x: 12
anchors.verticalCenter: parent.verticalCenter
@@ -522,26 +633,51 @@ Rectangle {
}
}
}
}
}
LocalTrackRow {
// 50 px reserved whether or not the row is built, so
// windowing cannot move the page under the user.
Item {
id: rowSlot
width: page.width
item: trackBlock.modelData
number: trackBlock.modelData.number > 0
? trackBlock.modelData.number : trackBlock.index + 1
showAlbum: false
showArtwork: false
// The checkbox LocalTrackRow already draws over
// the number cell for the Tracks tab — no fork,
// no new component (rule 5).
selectMode: root.multiSelect
checked: root.selected[trackBlock.modelData.id] === true
onPlayRequested: QbzLocal.playAlbumTrack(
root.album.id, trackBlock.modelData.id)
onEnqueueRequested: function (m) {
QbzLocal.enqueue("track", trackBlock.modelData.id, m)
height: 50
/// This row's top in Flickable content coords.
/// `rowSlot.y` — NOT `trackBlock.height`; see
/// the twin in AlbumView. Measuring the slot
/// against its own parent's derived height was a
/// dependency cycle, and every row read as in-band.
readonly property real topY:
trackList.topInFlick + trackBlock.y + rowSlot.y
/// One screenful of slack each way, so a flick
/// reveals rows that already exist.
readonly property bool inBand:
rowSlot.topY > flick.contentY - flick.height
&& rowSlot.topY < flick.contentY + 2 * flick.height
Loader {
anchors.fill: parent
active: rowSlot.inBand
onLoaded: root._rowsBuilt++ // INSTRUM.
sourceComponent: LocalTrackRow {
width: page.width
item: trackBlock.modelData
number: trackBlock.modelData.number > 0
? trackBlock.modelData.number : trackBlock.index + 1
showAlbum: false
showArtwork: false
// The checkbox LocalTrackRow already draws over
// the number cell for the Tracks tab — no fork,
// no new component (rule 5).
selectMode: root.multiSelect
checked: root.selected[trackBlock.modelData.id] === true
onPlayRequested: QbzLocal.playAlbumTrack(
root.album.id, trackBlock.modelData.id)
onEnqueueRequested: function (m) {
QbzLocal.enqueue("track", trackBlock.modelData.id, m)
}
onToggleSelect: root.toggleSelected(trackBlock.modelData.id)
}
}
onToggleSelect: root.toggleSelected(trackBlock.modelData.id)
}
}
}
@@ -85,7 +85,15 @@ Item {
var g = groups[i]
if (!g.items || g.items.length === 0) continue
jumps.push({ "letter": g.letter, "index": out.length })
out.push({ "t": 0, "label": g.letter })
// The header carries the flat index its group STARTS at.
// Without it the window report below fell back to 0 whenever
// the first visible entry was a header — so scrolling in
// grouped mode asked for artwork from album 0 through the
// current letter, hundreds of covers at a time, and then
// evicted them again. (Found by an independent review; the
// fallback reads as harmless until you notice what `0` means
// to `queueWindowReport`.)
out.push({ "t": 0, "label": g.letter, "base": flatOut.length })
pushChunk(g.items)
}
} else {
@@ -127,8 +135,13 @@ Item {
if (last < 0) last = Math.min(entries.length - 1, first + 8)
var lo = entries[first]
var hi = entries[last]
var loIdx = lo ? (lo.t === 1 ? lo.base : 0) : 0
var hiIdx = hi ? (hi.t === 1 ? hi.base + hi.items.length - 1 : loIdx) : loIdx
// Both entry kinds now carry `base`, so a header at either edge of
// the viewport reports ITS group's position instead of collapsing to
// the top of the library.
var loIdx = lo ? (lo.base || 0) : 0
var hiIdx = hi
? (hi.t === 1 ? hi.base + hi.items.length - 1 : Math.max(loIdx, hi.base || 0))
: loIdx
view.queueWindowReport(flat, loIdx, hiIdx, root.surface)
}
@@ -168,6 +181,12 @@ Item {
clip: true
cacheBuffer: root.viewMode === "grid" ? root.cellH * 2 : root.listRowH * 10
boundsBehavior: Flickable.StopAtBounds
// Recycle rows instead of destroying and rebuilding them on every
// scroll step. Safe only NOW: the project's own note (PmListRow /
// PmFolderMenu) is that `reuseItems` plus a per-row menu gives every
// pooled delegate its own Popup — so the menus above had to become
// lazy FIRST. In the other order this would install that bug.
reuseItems: true
model: root.entries
onContentYChanged: root.report()
onModelChanged: root.report()
@@ -200,12 +219,43 @@ Item {
Component {
id: cardRowComp
Row {
id: cardRow
/// The row entry from the ListView delegate (the Loader's
/// `modelData`), captured here because the Repeater below
/// shadows that name inside its own delegates.
readonly property var rowEntry: modelData
spacing: root.cellW - 200
// FIXED CELL COUNT — `root.cols`, a NUMBER, not the row's
// item array.
//
// This is what makes the ListView's `reuseItems` actually
// pay. Pooling recycles the row SHELL, but the old
// `model: modelData.items` handed this Repeater a new
// array on every recycle, so it destroyed and rebuilt its
// AlbumCards anyway — and the card is where the cost is:
// ~29 items, a RoundedImage with TWO render targets (its
// own layer plus the mask), image probes, a skeleton.
// The shell was reused and the expensive part was not.
//
// With a constant count the cells are created once and
// survive recycling; a scrolled or re-filtered row only
// reassigns `item`, which is a binding update instead of a
// construction. Same shape as the reference, where cheap
// slots stay alive and only the data moves.
Repeater {
model: modelData.items
model: root.cols
delegate: Item {
id: cardCell
required property var modelData
required property int index
/// The album for this slot, or `null` on the last
/// row when it is not full. A null slot renders
/// nothing and keeps its width, so the row's
/// spacing does not collapse.
readonly property var modelData:
(cardRow.rowEntry && cardRow.rowEntry.items)
? (cardRow.rowEntry.items[cardCell.index] || null)
: null
visible: cardCell.modelData !== null
width: 200
height: 246
@@ -215,22 +265,36 @@ Item {
// The card's own ⋯ overlay button opens AlbumCard's
// built-in menu; see the GLUE note about aligning
// that one's entries with these.
CardMenu {
id: cardMenu
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.openRequested(cardCell.modelData.id)
else if (a === "play") root.playRequested(cardCell.modelData.id)
else root.enqueueRequested(cardCell.modelData.id, a)
// LAZY, like AlbumCard's own. This cell carried a
// SECOND popup on top of the card's, so a grid of
// 1267 local albums paid two Popup constructions
// per cell — and the ListView recycles a screenful
// of cells on every scroll step, which is what the
// owner reports as heavy scrolling here.
Loader {
id: cardMenuLoader
active: false
sourceComponent: CardMenu {
menuWidth: 196
entries: [
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
]
onPicked: function (a) {
if (a === "open") root.openRequested(cardCell.modelData.id)
else if (a === "play") root.playRequested(cardCell.modelData.id)
else root.enqueueRequested(cardCell.modelData.id, a)
}
}
}
/// Build the popup on first use, then open it.
function openCardMenu(anchor, x, y) {
cardMenuLoader.active = true
cardMenuLoader.item.openAtCursor(anchor, x, y)
}
AlbumCard {
localMode: true
@@ -319,7 +383,7 @@ Item {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: function (mouse) {
cardMenu.openAtCursor(cardRc, mouse.x, mouse.y)
cardCell.openCardMenu(cardRc, mouse.x, mouse.y)
}
}
// (The multi-select tick that used to sit here is
+27 -1
View File
@@ -81,9 +81,35 @@ Row {
height: parent.height
LocalSearchBox {
id: albumsSearchBox
anchors.verticalCenter: parent.verticalCenter
placeholder: QbzSession.tr("Search", QbzSession.trRev)
onEdited: function (v) { root.view.albumsSearch = v }
// DEBOUNCED, like the Tracks box below — which had it and this one
// did not, in the same file.
//
// `albumsSearch` is not a query, it is the FILTER INPUT: writing it
// re-filters 1267 albums, rebuilds the collection's entry list, and
// hands the ListView a new model — which tears down every delegate
// and builds them again. Per keystroke.
//
// The owner's report names the shape exactly: "the first two
// keystrokes hurt", i.e. while the filter still matches hundreds.
// By the time it matches ten there is nothing left to rebuild, so
// the tail of the word feels fine. That is a model swap, not a
// slow filter.
//
// The box keeps showing what was typed immediately; only the
// filter waits.
property string pending: ""
onEdited: function (v) {
albumsSearchBox.pending = v
albumsSearchDebounce.restart()
}
Timer {
id: albumsSearchDebounce
interval: 200
onTriggered: root.view.albumsSearch = albumsSearchBox.pending
}
}
LocalIconSelect {
anchors.verticalCenter: parent.verticalCenter