fix(qt): restore window lifecycle and improve grid artwork

Restore tray-aware close and quit confirmation, circular label covers, and missing label artist portraits. Publish artwork progressively, preload two grid rows, and skip redundant motion reports while preserving data refreshes.

Include regression tests and isolated artwork benchmarks. Verified Linux build, QML checks, and scrolling smoke tests.

cx
This commit is contained in:
vicrodh committed 2026-09-09 21:58:25 -06:00
1 parent 5fab503eb7
commit 2fc03d325d
61 files changed
+1591 -151

No files matched your search

@@ -446,7 +446,12 @@ Column {
/// Snapshot what is on screen NOW. Call it BEFORE the bridge call: the
/// bridge may republish synchronously.
readonly property bool immediateArtwork: QbzLocal.artworkImmediateEnabled()
&& root._dpr <= 1
onImmediateArtworkChanged: if (immediateArtwork) { tailFade.stop(); clearTailFade() }
function armTailFade() {
if (root.immediateArtwork) { root.clearTailFade(); return }
var seen = {}
root._addIds(seen, root.albums)
for (var g = 0; g < root.grouped.length; g++)
+32 -23
View File
@@ -149,7 +149,6 @@ Rectangle {
property var coverMap: ({})
property var _coverInbox: ({})
property var _asked: ({})
readonly property int coverBatchCap: 48
// Batched on a 16 ms one-shot: `libraryArtworkReady` fires once PER COVER,
// and assigning `coverMap` per signal would republish the whole map to
// every delegate 48 times for one screenful. One dirty frame instead of
@@ -167,6 +166,7 @@ Rectangle {
Connections {
target: QbzLibrary
function onLibraryArtworkReady(key, path) {
if (root._asked[key] !== true || root.coverMap[key] === path) return
root._coverInbox[key] = path
if (!coverFlush.running)
coverFlush.start()
@@ -174,7 +174,7 @@ Rectangle {
}
function requestCovers(urls) {
var out = []
for (var i = 0; i < urls.length && out.length < root.coverBatchCap; i++) {
for (var i = 0; i < urls.length; i++) {
var u = urls[i]
if (u === "" || root._asked[u] === true || root.coverMap[u])
continue
@@ -184,17 +184,25 @@ Rectangle {
if (out.length > 0)
QbzShell.sidebarArtworkWindow(JSON.stringify(out))
}
/// Ask for every portrait the current model wants, capped per call.
/// Driven by the document landing and by scrolling — `requestCovers`
/// self-dedupes, so an over-eager caller costs a loop, not a fetch.
function requestVisibleCovers() {
if (!root.isReady)
// The native ListView already mounts two rows around the viewport. Ask
// for that bounded range, including after scrolling past the first page.
function requestVisibleCovers(view) {
if (!root.isReady || !view || !view.visible || !root.visible)
return
var urls = []
var rows = root.rowModel
for (var i = 0; i < rows.length; i++) {
if (rows[i].type === "header")
continue
if (rows.length === 0) return
var first = view.indexAt(4, view.contentY + 1)
var last = view.indexAt(4, view.contentY + Math.max(1, view.height) - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(rows.length - 1,
first + Math.ceil(view.height / (root.cardH + root.rowGap)))
var start = first
first = Math.max(0, first - 2)
last = Math.min(rows.length - 1, last + 2)
var urls = []
for (var offset = 0; offset <= last - first; offset++) {
var i = start + offset
if (i > last) i = first + i - last - 1
var arts = rows[i].artists || []
for (var j = 0; j < arts.length; j++)
urls.push(String(arts[j].artUrl || ""))
@@ -381,17 +389,6 @@ Rectangle {
readonly property int columns: Math.max(1,
Math.floor((gridPane.width + root.cardGap) / (root.cardW + root.cardGap)))
// Ask for the portraits whenever the row model settles. `rowModel` is
// rebuilt by a new document, a page appended, a filter, a search keystroke
// and a resize — every one of which can bring artists into view that were
// not there before, and `requestCovers` self-dedupes, so a redundant call
// costs a loop over the model and no fetch.
//
// NOT a `Component.onCompleted`: the view mounts BEFORE the first document
// lands (the route is recorded by `QbzScene.open` and discovery is async),
// so at completion the model is empty and the one shot would be wasted.
onRowModelChanged: root.requestVisibleCovers()
readonly property var rowModel: {
var out = []
var showHeaders = root.groupingEnabled
@@ -1007,7 +1004,19 @@ Rectangle {
boundsBehavior: Flickable.StopAtBounds
model: root.rowModel
spacing: 0
cacheBuffer: 2 * (root.cardH + root.rowGap) // Grid:150 ±5 items
cacheBuffer: 2 * (root.cardH + root.rowGap)
function reportCovers() {
if (visible && !coverWindow.running) coverWindow.start()
}
onContentYChanged: reportCovers()
onModelChanged: reportCovers()
onHeightChanged: reportCovers()
onVisibleChanged: reportCovers()
Timer {
id: coverWindow
interval: 180
onTriggered: root.requestVisibleCovers(list)
}
NumberAnimation {
id: jumpAnim
+9 -3
View File
@@ -600,6 +600,7 @@ Rectangle {
height: 246
AlbumCard {
id: railAlbumCard
albumId: modelData.id
source: modelData.source || ""
sources: modelData.sources || []
@@ -645,7 +646,8 @@ Rectangle {
width: 200
height: 200
pending: (modelData.artUrl || "") !== ""
coverSource: root.sectionArtOf(modelData)
artworkItem: railAlbumCard.artworkImmediate ? railAlbumCard : null
coverSource: railAlbumCard.artworkImmediate ? "" : root.sectionArtOf(modelData)
phase: root.skelPhase
cellIndex: index
// A cover whose download fails republishes the
@@ -944,6 +946,7 @@ Rectangle {
}
}
Loader {
id: pinnedCardLoader
anchors.fill: parent
sourceComponent: modelData.itemKind === "artist" ? pArtist
: modelData.itemKind === "playlist" ? pPlaylist : pAlbum
@@ -958,7 +961,8 @@ Rectangle {
height: 200
pending: modelData.itemKind !== "artist"
&& (modelData.artUrl || "") !== ""
coverSource: modelData.artPath || ""
artworkItem: pinnedCardLoader.item && pinnedCardLoader.item.artworkImmediate ? pinnedCardLoader.item : null
coverSource: artworkItem && artworkItem.artworkImmediate ? "" : (modelData.artPath || "")
phase: root.skelPhase
cellIndex: index
settleMs: 6000
@@ -1061,6 +1065,7 @@ Rectangle {
height: 234
RadioCard {
id: radioArtworkCard
seedTitle: modelData.title
seedSubtitle: modelData.artist
label: QbzSession.tr("RADIO", QbzSession.trRev)
@@ -1086,7 +1091,8 @@ Rectangle {
y: 29
blockRadius: 4
pending: (modelData.artUrl || "") !== ""
coverSource: root.forYouArtOf(modelData)
artworkItem: radioArtworkCard.artworkImmediate ? radioArtworkCard : null
coverSource: radioArtworkCard.artworkImmediate ? "" : root.forYouArtOf(modelData)
phase: root.skelPhase
cellIndex: index
settleMs: 6000
+47 -13
View File
@@ -892,10 +892,12 @@ Rectangle {
// --------------------- windowed artwork -----------------------------
// Report the mounted window as artKeys; prune far-away covers.
function reportWindow(visibleArray, first, last) {
property var _artSent: ({})
function reportWindow(visibleArray, first, last, visibleFirst) {
if (visibleArray.length === 0) {
root._artKeep = ({})
root._artRequested = ({})
root._artSent = ({})
root._artInbox = ({})
root.artPending = 0
if (Object.keys(root.artMap).length > 0)
@@ -917,13 +919,25 @@ Rectangle {
var m = artMap
var inbox = root._artInbox
var pending = 0
for (i = first; i <= last; i++) {
var now = Date.now()
// Visible and forward rows enter the existing worker queue first.
var start = Math.max(first, Math.min(last, visibleFirst === undefined ? first : visibleFirst))
for (var offset = 0; offset <= last - first; offset++) {
i = start + offset
if (i > last) i = first + i - last - 1
if (visibleArray[i].kind === "group-header") continue
var k = visibleArray[i].artKey
if (visibleArray[i].imageUrl !== "") {
keys.push(k)
requested[k] = true
if (!m[k] && !inbox[k]) pending++
if (!m[k] && !inbox[k]) {
pending++
// Scrolling must not launch the same pending download
// again every 180 ms. Permit a later retry after failure.
if (!root._artSent[k] || now - root._artSent[k] >= 30000) {
root._artSent[k] = now
keys.push(k)
}
}
}
}
root._artKeep = keep
@@ -941,8 +955,11 @@ Rectangle {
for (key in inbox) {
if (!keep[key]) delete inbox[key]
}
for (key in root._artSent) {
if (!keep[key]) delete root._artSent[key]
}
if (changed) artMap = Object.assign({}, m)
QbzLibrary.libraryArtworkWindow(JSON.stringify(keys))
if (keys.length > 0) QbzLibrary.libraryArtworkWindow(JSON.stringify(keys))
}
Connections {
@@ -1041,17 +1058,21 @@ Rectangle {
id: windowDebounce
interval: 180
onTriggered: root.reportWindow(
pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast)
pendingRows ? pendingRows : root.visibleRows, pendingFirst, pendingLast, pendingVisibleFirst)
property int pendingFirst: 0
property int pendingLast: 0
property int pendingVisibleFirst: 0
property var pendingRows: null
}
/// `rows` is optional — omit it and the band is read off `visibleRows`.
function queueWindowReport(first, last, rows) {
function queueWindowReport(first, last, rows, visibleFirst) {
windowDebounce.pendingFirst = first
windowDebounce.pendingLast = last
windowDebounce.pendingVisibleFirst = visibleFirst === undefined ? first : visibleFirst
windowDebounce.pendingRows = rows === undefined ? null : rows
windowDebounce.restart()
// Coalesce to the latest window without postponing it until scrolling
// stops. The existing 180 ms cap still bounds bridge traffic.
if (!windowDebounce.running) windowDebounce.start()
}
// All-tab search settle -> re-report the first window so the filtered
@@ -1362,7 +1383,7 @@ Rectangle {
boundsBehavior: Flickable.StopAtBounds
model: root.visibleRows
onContentYChanged: root.gridWindowReport()
onContentYChanged: root.gridWindowReport(true)
onModelChanged: root.gridWindowReport()
onWidthChanged: root.gridWindowReport()
onVisibleChanged: root.gridWindowReport()
@@ -1705,13 +1726,26 @@ Rectangle {
// rail above all, since it is the one body whose band is NOT a
// `visibleRows` band. `onVisibleChanged` keeps the gate from swallowing
// the first report of a body that becomes visible after its model landed.
function gridWindowReport() {
property string _gridMotionBand: ""
property double _gridMotionReportedAt: 0
function gridWindowReport(motionOnly) {
if (!grid.visible) return
var cols = Math.max(1, Math.floor(grid.width / grid.cellWidth))
var firstRow = Math.max(0, Math.floor(grid.contentY / grid.cellHeight) - 1)
var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 1
var visibleFirst = Math.max(0, Math.floor(grid.contentY / grid.cellHeight))
// Match the two rows of delegates already held by cacheBuffer.
var firstRow = Math.max(0, visibleFirst - 2)
var lastRow = Math.ceil((grid.contentY + grid.height) / grid.cellHeight) + 2
var m = root.visibleRows
queueWindowReport(firstRow * cols, Math.min(m.length - 1, lastRow * cols - 1))
var band = firstRow + ":" + lastRow + ":" + visibleFirst + ":" + cols
var now = Date.now()
// Do not restart the bridge timer for every pixel in the same band.
// Non-motion notifications bypass this guard; a periodic motion
// refresh also preserves the existing failed-request retry behavior.
if (motionOnly === true && root._gridMotionBand === band
&& now - root._gridMotionReportedAt < 1000) return
root._gridMotionBand = band
root._gridMotionReportedAt = now
queueWindowReport(firstRow * cols, Math.min(m.length - 1, lastRow * cols - 1), undefined, visibleFirst * cols)
}
function listWindowReport() {
if (!list.visible) return
+58 -2
View File
@@ -537,6 +537,41 @@ Rectangle {
// Decoded-cover map {artKey: "file://…"} — fed by the id-keyed signal.
property var artMap: ({})
property var _artKeep: ({})
// Keep the high-density path unchanged until it passes the CPU budget.
// This remains reactive when the window moves between displays.
readonly property bool immediateArtwork: QbzLocal.artworkImmediateEnabled()
&& Screen.devicePixelRatio <= 1
readonly property bool artTimingEnabled: QbzLocal.artworkTimingEnabled()
property var _artTraceWindows: ({})
property int _artTraceSequence: 0
function traceArtReport(rows, first, last, surface) {
if (!root.artTimingEnabled) return
var keys = []
for (var i = Math.max(0, first); i <= Math.min(last, rows.length - 1); i++)
if (rows[i] && rows[i].artKey) keys.push(rows[i].artKey)
var signature = keys.join("|")
var old = root._artTraceWindows[surface]
if (old && old.signature === signature) return
if (old) {
for (var j = 0; j < old.keys.length; j++)
QbzLocal.artworkTiming("window-end", old.keys[j], old.id)
}
var id = surface + ":" + (++root._artTraceSequence)
root._artTraceWindows[surface] = {id: id, keys: keys, signature: signature}
for (var k = 0; k < keys.length; k++)
QbzLocal.artworkTiming("report", keys[k], id)
}
function traceArt(stage, key) {
if (!root.artTimingEnabled) return
for (var surface in root._artTraceWindows) {
var w = root._artTraceWindows[surface]
if (w.keys.indexOf(key) >= 0)
QbzLocal.artworkTiming(stage, key, w.id)
}
}
// Covers arrive ONE AT A TIME (src/local_artwork.rs phase 2 streams each
// thumbnail through the id-keyed signal the moment it resolves). Rebinding
@@ -577,6 +612,13 @@ Rectangle {
interval: 16
repeat: false
onTriggered: {
if (root.immediateArtwork) {
// One rebind for this batch, with no decorative reveal front.
root.artMap = Object.assign({}, root.artMap, root._artHeld, root._artInbox)
root._artHeld = ({})
root._artInbox = ({})
return
}
var k
// Everything that arrived this tick joins whatever is still held.
for (k in root._artInbox) root._artHeld[k] = root._artInbox[k]
@@ -611,10 +653,12 @@ Rectangle {
Connections {
target: QbzLocal
function onLocalArtworkReady(key, path) {
root.traceArt("path", key)
root.nativeTracksModel.setArtwork(key, path)
root.nativeAlbumsModel.setArtwork(key, path)
root.nativeArtistsModel.setArtwork(key, path)
root.nativeArtistAlbumsModel.setArtwork(key, path)
if (root.immediateArtwork && !root._artKeep[key]) return
root._artInbox[key] = path
if (!artFlush.running) artFlush.start()
// An arrival is live evidence that the pass is still running, so
@@ -1269,6 +1313,12 @@ Rectangle {
/// covers. Cheap no-op when the surface was never registered.
function releaseWindow(key) {
var k = key || "default"
if (root.artTimingEnabled && root._artTraceWindows[k]) {
var traced = root._artTraceWindows[k]
for (var i = 0; i < traced.keys.length; i++)
QbzLocal.artworkTiming("window-end", traced.keys[i], traced.id)
delete root._artTraceWindows[k]
}
if (root._windows[k] === undefined && root._pending[k] === undefined) return
delete root._windows[k]
delete root._pending[k]
@@ -1294,6 +1344,7 @@ Rectangle {
}
}
var m = root.artMap
root._artKeep = keep
var changed = false
for (k in m) if (!keep[k]) { delete m[k]; changed = true }
// The not-yet-flushed arrivals are evicted too, or a cover that landed
@@ -1318,7 +1369,7 @@ Rectangle {
ak = rows[i] ? rows[i].artKey : ""
if (!ak || seen[ak]) continue
seen[ak] = true
if (order[ak] === undefined) order[ak] = ord++
if (!root.immediateArtwork && order[ak] === undefined) order[ak] = ord++
if (m[ak] !== undefined || root._artInbox[ak] !== undefined) continue
keys.push(ak)
}
@@ -1326,7 +1377,11 @@ Rectangle {
// 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) {
if (root.artTimingEnabled)
for (var n = 0; n < keys.length; n++) root.traceArt("request", keys[n])
QbzLocal.artworkWindow(JSON.stringify(keys))
}
}
/// Immediate single-surface report — the leading edge and the rate-limit
@@ -1444,6 +1499,7 @@ Rectangle {
property real _lastReportMs: 0
function queueWindowReport(rows, first, last, key) {
var k = key || "default"
if (rows && root.artTimingEnabled) root.traceArtReport(rows, first, last, k)
if (!windowDebounce.running
&& Date.now() - root._lastReportMs >= windowDebounce.interval) {
root._lastReportMs = Date.now()
+6 -1
View File
@@ -268,7 +268,7 @@ Rectangle {
// that decode finishes. Gating on `artPath !== ""` (what this used to
// do) drops the placeholder while the card's canvas is still blank.
pending: (card.artUrl || "") !== ""
coverSource: resolvedSource
coverSource: artworkItem && artworkItem.artworkImmediate ? "" : resolvedSource
// A cover whose download fails republishes the document with an
// empty artPath — without this the tile would shimmer forever.
settleMs: 6000
@@ -438,6 +438,7 @@ Rectangle {
onLoaded: resultCell.restoreMutableBindings()
}
CardArtSkeleton {
artworkItem: cardLoader.item && cardLoader.item.artworkImmediate ? cardLoader.item : null
visible: resultGrid.kind !== "artist"
card: resultCell.cardData
resolvedSource: resultGrid.artOf(resultCell.cardData)
@@ -929,6 +930,7 @@ Rectangle {
width: 200
height: 246
AlbumCard {
id: searchAlbumCard
albumId: modelData.id
title: modelData.title
artist: modelData.artist
@@ -946,6 +948,7 @@ Rectangle {
artworkUrl: modelData.artUrl || ""
}
CardArtSkeleton {
artworkItem: searchAlbumCard.artworkImmediate ? searchAlbumCard : null
card: modelData
phase: root.skelPhase
cellIndex: index
@@ -1163,6 +1166,7 @@ Rectangle {
width: 200
height: 246
PlaylistCard {
id: searchPlaylistCard
// artworkUrl is not passed: the card
// defaults it to `item.artUrl`, which is
// this row's remote cover.
@@ -1171,6 +1175,7 @@ Rectangle {
isPinned: modelData.isPinned === true
}
CardArtSkeleton {
artworkItem: searchPlaylistCard.artworkImmediate ? searchPlaylistCard : null
card: modelData
phase: root.skelPhase
cellIndex: index
@@ -202,6 +202,8 @@ Item {
// (ArtistGridCard/LabelCard). A bare Rectangle: it does not take pointer
// events, so the card's hover/click areas keep working underneath.
QbzSkeleton {
artworkItem: cell.item.kind !== "group-header" && cardLoader.item
&& cardLoader.item.artworkImmediate ? cardLoader.item : null
variant: "art"
width: 200
height: 200
@@ -209,7 +211,8 @@ Item {
|| cell.item.kind === "track"
|| cell.item.kind === "playlist")
&& cell.item.imageUrl !== ""
&& (cell.view.artMap[cell.item.artKey] || "") === ""
&& (cardLoader.item && cardLoader.item.artworkImmediate
? opacity > 0.004 : (cell.view.artMap[cell.item.artKey] || "") === "")
// TrackCard's unavailable scrim is semantic content. A pending
// artwork skeleton must never paint over it and turn the honest
// state back into an ambiguous grey tile.
@@ -46,6 +46,7 @@ Rectangle {
tintName: "muted"
}
RoundedImage {
gridArtwork: true
id: subcardArt
anchors.fill: parent
source: root.artSource
@@ -17,6 +17,7 @@
// via the host's windowing helpers.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../../cards"
import "../../controls"
@@ -264,10 +265,15 @@ Item {
// moved the list by a pixel. Every state change that can alter the
// mounted band now reports: mount, becoming visible, the model rebuild,
// a viewport resize, and the scroll.
function report() {
// Only wheel/motion reports can be skipped. Model, layout and artwork
// notifications always refresh the window, including late native pages.
property string _motionBand: ""
property double _motionReportedAt: 0
function report(motionOnly) {
if (!view || !list) return
var count = nativeActive && nativeModel ? nativeModel.totalCount : entries.length
if (!list.visible || count === 0 || width <= 0) {
root._motionBand = ""
view.releaseWindow(root.surface)
return
}
@@ -275,9 +281,24 @@ Item {
var last = list.indexAt(4, list.contentY + Math.max(1, list.height) - 1)
if (first < 0) first = 0
if (last < 0) last = Math.min(count - 1, first + 8)
var visibleFirst = first
// These delegates already exist in cacheBuffer. Resolve their covers
// too, so Qt can decode them before the next wheel step reveals them.
if (root.viewMode === "grid") {
first = Math.max(0, first - 2)
last = Math.min(count - 1, last + 2)
}
var band = first + ":" + last + ":" + visibleFirst
var now = Date.now()
if (motionOnly === true && root._motionBand === band
&& now - root._motionReportedAt < 1000) return
root._motionBand = band
root._motionReportedAt = now
if (nativeActive && nativeModel) {
var resident = []
for (var entryIndex = first; entryIndex <= last; entryIndex++) {
for (var offset = 0; offset <= last - first; offset++) {
var entryIndex = visibleFirst + offset
if (entryIndex > last) entryIndex = first + entryIndex - last - 1
var entry = nativeModel.rowAt(entryIndex)
if (!entry || entry.loading || !entry.items) continue
for (var itemIndex = 0; itemIndex < entry.items.length; itemIndex++)
@@ -344,7 +365,7 @@ Item {
// lazy FIRST. In the other order this would install that bug.
reuseItems: true
model: root.nativeActive && root.nativeModel ? root.nativeModel : root.entries
onContentYChanged: root.report()
onContentYChanged: root.report(true)
onModelChanged: root.report()
onHeightChanged: root.report()
onVisibleChanged: if (visible) root.reportSoon()
@@ -432,6 +453,16 @@ Item {
/// slot is filled, `root.emptySlot` when it is not.
readonly property var slot:
cardCell.modelData || root.emptySlot
property string artTracePendingKey: ""
Connections {
target: root.view && root.view.artTimingEnabled
&& cardCell.artTracePendingKey !== "" ? root.Window.window : null
function onFrameSwapped() {
if (cardCell.slot.artKey === cardCell.artTracePendingKey)
root.view.traceArt("frame", cardCell.artTracePendingKey)
cardCell.artTracePendingKey = ""
}
}
visible: cardCell.modelData !== null
width: 200
height: 246
@@ -494,6 +525,18 @@ Item {
qualityDetail: cardCell.slot.qualityDetail || ""
artSource: cardCell.slot.artPath || (root.view
? (root.view.artMap[cardCell.slot.artKey] || "") : "")
artFadeMs: root.view && root.view.immediateArtwork ? 0 : 200
onArtworkReadyChanged: {
if (root.view && root.view.artTimingEnabled && artworkReady) {
root.view.traceArt("ready", cardCell.slot.artKey)
}
}
onArtworkRevealedChanged: {
if (root.view && root.view.artTimingEnabled && artworkRevealed) {
root.view.traceArt("revealed", cardCell.slot.artKey)
cardCell.artTracePendingKey = cardCell.slot.artKey
}
}
pinArtworkUrl: artSource
isPinned: QbzLibrary.pinState("album", albumId)
// LocalLibraryView.slint:1267 `show-source-badge`
@@ -565,15 +608,10 @@ Item {
root.enqueueRequested(cardCell.slot.id, m)
}
}
// Per-item cover placeholder, handed over to the
// art itself: AlbumCard seals its RoundedImage
// away, so this uses QbzSkeleton's probe arm —
// `coverSource` loads the SAME pixmap-cache entry
// the card is loading and retires the placeholder
// when that decode completes, not when the path
// appears. A bare Rectangle, so it does not take
// pointer events and the card's own areas keep
// working underneath.
// Observe the card's actual derivative, so the placeholder
// neither decodes the original again nor retires before
// the displayed image is ready. The source probe remains
// only for the diagnostic baseline comparison.
// settleMs is mandatory here: local artwork
// resolution drops keys with no cover, so an
// artless album would otherwise shimmer forever
@@ -584,7 +622,10 @@ Item {
height: 200
pending: root.view
? root.view.artWanted(cardCell.slot.artKey) : false
coverSource: root.view
coverReady: root.view && root.view.immediateArtwork
? albumCard.artworkReady : false
handoverFadeMs: root.view && root.view.immediateArtwork ? 0 : 180
coverSource: root.view && !root.view.immediateArtwork
? root.view.artPathOf(cardCell.slot.artKey) : ""
phase: root.view ? root.view.skelPhase : false
settleMs: root.view ? root.view.artSettleMs : 0
@@ -73,6 +73,7 @@ Rectangle {
spacing: 8
CollectionMosaic {
gridArtwork: true
size: 184
urls: root.item.cellUrls || []
paths: root.item.cellPaths || []
@@ -115,6 +116,7 @@ Rectangle {
spacing: 12
CollectionMosaic {
gridArtwork: true
anchors.verticalCenter: parent.verticalCenter
size: 48
urls: root.item.cellUrls || []
@@ -111,6 +111,7 @@ Rectangle {
Component {
id: desktopCover
PlaylistCollage {
gridArtwork: true
anchors.fill: parent
layout: "pm"
urls: root.item.covers || []
@@ -75,6 +75,7 @@ Item {
// per cell, no scissor that rounds nothing).
RoundedImage {
gridArtwork: true
id: art
anchors.fill: parent
source: root.artUrl
@@ -583,7 +583,7 @@ Item {
width: pane.gridW
height: parent.height
clip: true
cacheBuffer: root.cardH
cacheBuffer: 2 * root.cardH
boundsBehavior: Flickable.StopAtBounds
model: root.albumEntries
@@ -741,11 +741,9 @@ Item {
first = 0
if (last < 0)
last = Math.min(root.albumEntries.length - 1, first + 3)
// One screen of buffer either side, which is what keeps a fast scroll
// from showing placeholders — and the cap above is what keeps a fast
// scroll from queueing the whole discography.
first = Math.max(0, first - 1)
last = Math.min(root.albumEntries.length - 1, last + 1)
// Match the two buffered grid rows, not a whole extra screen.
first = Math.max(0, first - 2)
last = Math.min(root.albumEntries.length - 1, last + 2)
var urls = []
for (var i = first; i <= last; i++) {
var e = root.albumEntries[i]