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:
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++)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user