perf(qt): stop republishing the Recommendations document for artwork

Covers that resolved after the document was published forced a second
`publish_snapshot`, and a republish hands QML a brand-new array:
`QQuickItemView::setModel()` then resets each rail's scroll offset and tears
down its QQmlDelegateModel. So every landing batch rebuilt all nine rails'
delegates, which is what a screen capture of a cold Recommendations load
shows — the rows visibly reassembling while covers trickle in, filling out of
order both between rows and within a row.

The fix is the pattern this port already applies twice: covers travel as a
url-keyed PATCH and the document is left alone. `foryou_qt::emit_foryou_art`
is the precedent for the For You rails, and `myqbz_bridge` cites it verbatim
for MyQBZ; the reco rails were the remaining holdout.

The download path is deliberately untouched. This changes only how a resolved
path is ANNOUNCED, never what is fetched or cached, so every other consumer of
the shared image cache — the now-playing bar, the queue, the `recent` rails —
sees exactly what it saw before.

Two traps this had to close, both already paid for once on the For You side:

  * `sectionArtOf()` falls back to `item.artPath`. `recoArt` only ever holds
    Recommendations urls, so for a Home / Editor's Picks / For You card the
    lookup misses and the binding returns what it always returned. No surface
    that reads a baked `artPath` can lose its cover to this — by construction,
    not by care. The pinned rail, fed by its own store, is untouched.
  * `anyItemArtPending` now consults the map. A patched cover never gets its
    `artPath` back through a republish, so the bare "artUrl set and artPath
    empty" predicate would be true forever and pin the 900ms skeleton pulse
    Timer on.

`refreshRecoArt()` on mount is the third: HomeView is destroyed on every
navigation while this document is published once per session, so a fresh mount
must be re-handed what already resolved or the covers come back blank.

Weekly's two rails still republish their own narrower property; their blast
radius is two rails, not nine, and widening this change was not worth it.

Verified by owner smoke: the three checks (nothing else lost its covers, the
rails stop reassembling, covers survive navigating away and back) all pass.

cc
This commit is contained in:
vicrodh committed 2026-08-18 20:25:22 -06:00
1 parent 101d0e25d4
commit c02e7713d9
3 files changed
+158 -11

No files matched your search

+55 -7
View File
@@ -200,7 +200,49 @@ Rectangle {
// never downloads, never republishes a document, and emits nothing at all
// when nothing is resolved yet — so the first, cold mount is a no-op and the
// in-flight download's own emit still fills the map.
Component.onCompleted: QbzHome.refreshForyouArt()
Component.onCompleted: { QbzHome.refreshForyouArt(); QbzHome.refreshRecoArt() }
// Recommendations' rails used to get their covers by REPUBLISHING
// `recoSectionsJson` once per landing batch, which hands `model:` a new JS
// array — `QQuickItemView::setModel()` then resets the rail's scroll and
// tears down its QQmlDelegateModel, so every batch rebuilt all nine rails'
// delegates (the same defect the For You rails were moved off, :161). Rust
// now emits a url-keyed patch instead and leaves the document alone.
property var recoArt: ({})
Connections {
target: QbzHome
function onRecoArtReady(patchJson) {
var patch
try {
patch = JSON.parse(patchJson)
} catch (e) {
return
}
var m = root.recoArt
var changed = false
for (var url in patch) {
if (m[url] !== patch[url]) {
m[url] = patch[url]
changed = true
}
}
if (changed)
root.recoArt = Object.assign({}, m) // rebind needs a NEW ref
}
}
/// Cover for a card that came out of a SECTIONS document: the patch map
/// first, then whatever the document itself carried.
///
/// The fallback is what makes this safe to put on rails that render the
/// other tabs too. `recoArt` only ever holds Recommendations urls, so for a
/// Home / Editor's Picks / For You card the lookup misses and the result is
/// `item.artPath` — byte for byte what the binding read before. No consumer
/// of a baked `artPath` can lose its cover to this change.
function sectionArtOf(item) {
return (item && item.artUrl && root.recoArt[item.artUrl])
|| (item ? (item.artPath || "") : "")
}
/// The patch map first, then whatever the document happened to carry (a
/// full Home reload publishes the paths it already has).
function forYouArtOf(item) {
@@ -295,9 +337,15 @@ Rectangle {
}
return false
}
// Consults the patch map, NOT a bare `artPath === ""`. A Recommendations
// cover that arrives as a patch never gets its `artPath` back through a
// republish any more, so the bare predicate would stay true forever and
// pin the 900ms skeleton pulse Timer on — the exact trap the For You
// rails hit (see anyForYouArtPending). Unchanged for every other tab,
// where `sectionArtOf` just returns `artPath`.
function anyItemArtPending(items) {
for (var i = 0; i < items.length; i++) {
if ((items[i].artUrl || "") !== "" && (items[i].artPath || "") === "")
if ((items[i].artUrl || "") !== "" && root.sectionArtOf(items[i]) === "")
return true
}
return false
@@ -425,7 +473,7 @@ Rectangle {
qualityTier: modelData.qualityTier
ribbon: modelData.ribbon
ribbonKind: modelData.ribbonKind
artSource: modelData.artPath
artSource: root.sectionArtOf(modelData)
isPinned: modelData.isPinned
// Snapshot url the pin payload persists (artPath is
// a local cache path — see AlbumCard.artworkUrl).
@@ -455,7 +503,7 @@ Rectangle {
width: 200
height: 200
pending: (modelData.artUrl || "") !== ""
coverSource: modelData.artPath || ""
coverSource: root.sectionArtOf(modelData)
phase: root.skelPhase
cellIndex: index
// A cover whose download fails republishes the
@@ -529,7 +577,7 @@ Rectangle {
clip: true
RoundedImage {
anchors.fill: parent
source: card.artPath || ""
source: root.sectionArtOf(card)
radius: 4
}
}
@@ -1184,7 +1232,7 @@ Rectangle {
model: plRail.sectionData.items
delegate: PlaylistCard {
item: modelData
artSource: modelData.artPath || ""
artSource: root.sectionArtOf(modelData)
// The card's own default is `false`; the
// pin state travels on the row (home_qt
// `map_playlist`), so it has to be handed
@@ -1236,7 +1284,7 @@ Rectangle {
model: arRail.sectionData.items
delegate: ArtistCard {
item: modelData
artSource: modelData.artPath || ""
artSource: root.sectionArtOf(modelData)
// Same hand-over as the playlist rail —
// home_qt `map_fav_artist` publishes the
// pin state and the card defaults to false.