fix(qt): silence the TypeError flood the constant-cell grid introduced

Regression from the previous commit's first fix. Pinning the inner Repeater
to `root.cols` is what made recycling free, but it also means the tail slots
of a partly-filled row are now real, live cells with no album behind them.
The delegate anticipated that — `modelData` resolves to null and the cell
sets `visible: false` — and that is not enough: in QML `visible: false` stops
an item PAINTING, it does not stop its bindings EVALUATING. Every
`cardCell.modelData.id`, `.title`, `.artKey` on an empty slot therefore threw
once per evaluation, which during a scroll is a continuous log flood (the
same ten line numbers, over and over, seen in a smoke run).

`modelData` keeps its meaning — "is this slot filled?" — so `visible` still
works. The bindings now read `cardCell.slot`, which is the same object when
the slot is filled and a frozen all-empty object when it is not.
`artWanted("")` is false and `artPathOf("")` is "", so an empty slot stays
silent through the skeleton's probe arm too.

Deliberately NOT a Loader gated on the data: `active` would flip every time a
full row recycled into a partial one, rebuilding the AlbumCard — precisely
the construction churn the constant cell count exists to remove.

cc
This commit is contained in:
vicrodh committed 2026-08-18 18:41:17 -06:00
1 parent bd9aec1440
commit c0fe8a80d3
1 file changed
+38 -18
@@ -56,6 +56,20 @@ Item {
readonly property int cols: viewMode === "grid"
? Math.max(1, Math.floor(width / cellW)) : 1
/// Stand-in for a grid slot with no album — the last row of a group is
/// rarely full, and since the cell count is now CONSTANT (that is what
/// makes recycling free) those tail slots are real, live cells with no
/// data behind them. `visible: false` does not stop a binding from
/// evaluating, so every `modelData.x` on an empty slot threw a TypeError
/// once per evaluation — a log flood during any scroll. Binding against
/// this frozen object instead keeps the slot silent AND avoids gating the
/// card behind a Loader, which would reintroduce exactly the construction
/// churn the constant cell count removed.
readonly property var emptySlot: ({
"id": "", "title": "", "artist": "", "year": "",
"qualityTier": "", "artKey": "", "source": "", "sourceRaw": ""
})
// ---------------------------------------------------------------------
// Chunk model
// ---------------------------------------------------------------------
@@ -255,6 +269,12 @@ Item {
(cardRow.rowEntry && cardRow.rowEntry.items)
? (cardRow.rowEntry.items[cardCell.index] || null)
: null
/// `modelData` answers "is this slot filled?" and
/// stays null so `visible` keeps working. `slot`
/// is what the bindings read: same object when the
/// slot is filled, `root.emptySlot` when it is not.
readonly property var slot:
cardCell.modelData || root.emptySlot
visible: cardCell.modelData !== null
width: 200
height: 246
@@ -284,9 +304,9 @@ Item {
{ "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)
if (a === "open") root.openRequested(cardCell.slot.id)
else if (a === "play") root.playRequested(cardCell.slot.id)
else root.enqueueRequested(cardCell.slot.id, a)
}
}
}
@@ -298,15 +318,15 @@ Item {
AlbumCard {
localMode: true
albumId: cardCell.modelData.id
title: cardCell.modelData.title
artist: cardCell.modelData.artist
albumId: cardCell.slot.id
title: cardCell.slot.title
artist: cardCell.slot.artist
artistId: ""
genre: ""
year: cardCell.modelData.year
qualityTier: cardCell.modelData.qualityTier
year: cardCell.slot.year
qualityTier: cardCell.slot.qualityTier
artSource: root.view
? (root.view.artMap[cardCell.modelData.artKey] || "") : ""
? (root.view.artMap[cardCell.slot.artKey] || "") : ""
// LocalLibraryView.slint:1267 `show-source-badge`
// — the card takes the raw source word and the
// BADGE is what the flag switches (blanking
@@ -321,8 +341,8 @@ Item {
// fires off the raw word. All FOUR badge sites
// do this — grid and list must not disagree
// about the same album.
source: cardCell.modelData.sourceRaw
|| cardCell.modelData.source
source: cardCell.slot.sourceRaw
|| cardCell.slot.source
showSourceBadge: root.showSource
// SELECT MODE IS THE CARD'S (AlbumCard's
// `selectMode`/`selected`/`selectToggled`, the
@@ -340,15 +360,15 @@ Item {
// which is exactly what a host overlay cannot
// do. Do not re-add one.
selectMode: root.selectMode
selected: root.selected[cardCell.modelData.id] === true
onSelectToggled: root.toggleSelect(cardCell.modelData.id)
selected: root.selected[cardCell.slot.id] === true
onSelectToggled: root.toggleSelect(cardCell.slot.id)
// Non-select mode only — the card routes a
// select-mode click to `selectToggled` and never
// emits this while ticking.
onOpenRequested: root.openRequested(cardCell.modelData.id)
onPlayRequested: root.playRequested(cardCell.modelData.id)
onOpenRequested: root.openRequested(cardCell.slot.id)
onPlayRequested: root.playRequested(cardCell.slot.id)
onEnqueueRequested: function (m) {
root.enqueueRequested(cardCell.modelData.id, m)
root.enqueueRequested(cardCell.slot.id, m)
}
}
// Per-item cover placeholder, handed over to the
@@ -369,9 +389,9 @@ Item {
width: 200
height: 200
pending: root.view
? root.view.artWanted(cardCell.modelData.artKey) : false
? root.view.artWanted(cardCell.slot.artKey) : false
coverSource: root.view
? root.view.artPathOf(cardCell.modelData.artKey) : ""
? root.view.artPathOf(cardCell.slot.artKey) : ""
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
settleHold: root.view ? root.view.artPulse : false