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
707 lines
36 KiB
QML
707 lines
36 KiB
QML
// The Local Library album collection — the Qt equivalent of
|
|
// discover/AlbumCollectionView.slint as LocalLibraryView.slint mounts it
|
|
// (:1255 Albums, :1601 Folders-flat, :2409 Artists detail): grid or list,
|
|
// grouped or flat, with the source badge and multi-select.
|
|
//
|
|
// WHY A CHUNK MODEL AND NOT A GridView + Repeater
|
|
// The Slint calls this a "chunked grid" and it is the one surface with the
|
|
// documented 16k-row freeze. Here EVERY arm is a single windowing ListView
|
|
// over a flat "entry" array:
|
|
// { t: 0, label } -> group header (34px)
|
|
// { t: 1, items: [...], base: n } -> one visual row of cards, or one
|
|
// list row; `base` is the row's
|
|
// first index in the flat album array
|
|
// so grouping costs one O(n) rebuild when the inputs change (never per
|
|
// frame) and the mounted delegate count stays proportional to the viewport,
|
|
// not to the library. Artwork is reported from the mounted entry range only,
|
|
// via the host's windowing helpers.
|
|
|
|
import QtQuick
|
|
import QtQuick.Window
|
|
import com.blitzfc.qbz
|
|
import "../../cards"
|
|
import "../../controls"
|
|
import "../../theme"
|
|
|
|
Item {
|
|
id: root
|
|
|
|
/// The LocalLibraryView root — artMap + queueWindowReport live there.
|
|
property var view: null
|
|
/// Stable id for this cover surface in the host's window registry. Three
|
|
/// instances of this component can be alive at once (Albums, Folders-flat,
|
|
/// the Artists detail grid), and they must not share a slot.
|
|
property string surface: "collection"
|
|
/// Scroll-memory scope for this list ("local:albums" / "local:folders"),
|
|
/// empty when the host is not the page (the artist detail pane).
|
|
property string scrollScope: ""
|
|
/// Flat album rows (already searched/sorted/filtered by the host).
|
|
property var rows: []
|
|
/// [{ letter, items: [...] }] — only read when `grouped`.
|
|
property var groups: []
|
|
property bool grouped: false
|
|
property string viewMode: "grid" // "grid" | "list"
|
|
property bool showSource: true
|
|
property bool selectMode: false
|
|
/// { albumId: true } — the host owns the selection set.
|
|
property var selected: ({})
|
|
/// Only the Albums tab opts into the phase-F1 native stream. The other
|
|
/// two collection users keep their existing bounded arrays.
|
|
property bool nativeSurface: false
|
|
property bool nativeActive: false
|
|
property var nativeModel: null
|
|
property string nativeJumpsJson: "[]"
|
|
|
|
/// The scrollbar belongs on the WINDOW's right edge, but hosts inset
|
|
/// their content (LocalAlbumsTab pads 32, plus 20 more to clear the A-Z
|
|
/// strip), which dragged the bar visibly inward compared with the rest of
|
|
/// the app. Both are measured from the window's right edge:
|
|
/// `scrollBarInset` is where the bar should land (LibraryView uses 4, or
|
|
/// 34 with the strip up) and `hostRightInset` is how far this component's
|
|
/// own right edge already is from it. Defaults of 0 leave callers that
|
|
/// have not opted in exactly where they were.
|
|
property int scrollBarInset: 0
|
|
property int hostRightInset: 0
|
|
|
|
signal openRequested(string id)
|
|
signal playRequested(string id)
|
|
signal enqueueRequested(string id, string mode)
|
|
/// `modifiers` rides through from the card's mouse event — Shift is
|
|
/// what turns a click into a range (controls/SelectionModel.qml).
|
|
signal toggleSelect(string id, int modifiers)
|
|
signal nativeToggleSelect(int index, int modifiers)
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property int cellW: 220
|
|
readonly property int cellH: 266
|
|
readonly property int headerH: 34
|
|
readonly property int listRowH: 56
|
|
/// Columns, or 0 while the width is still UNKNOWN.
|
|
///
|
|
/// Zero is not a degenerate case to be clamped away — it is the honest
|
|
/// answer, and clamping it to 1 is what put a full-length ONE-COLUMN grid
|
|
/// on screen at every mount. `Component.onCompleted` builds synchronously
|
|
/// (deliberately, so the grid is not empty for a frame), and at that moment
|
|
/// `width` is still 0, so `Math.max(1, …)` said "one column" and the whole
|
|
/// album list was chunked one-per-row. The real width arrives a moment
|
|
/// later, `onColsChanged` fires, and the entire model is thrown away and
|
|
/// rebuilt — the reflow the owner sees, and it is visible for exactly as
|
|
/// long as chunking every album twice takes.
|
|
///
|
|
/// A list view really is one column, so that arm keeps its 1.
|
|
readonly property int cols: viewMode === "grid"
|
|
? (width > 0 ? Math.max(1, Math.floor(width / cellW)) : 0) : 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": "",
|
|
"sources": [], "isFavorite": false, "favoriteable": false
|
|
})
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Chunk model
|
|
// ---------------------------------------------------------------------
|
|
// `flat` is the album array in display order (the window report indexes
|
|
// into it); `entries` is what the ListView actually mounts.
|
|
property var flat: []
|
|
property var entries: []
|
|
/// [{ letter, index }] with `index` = the ENTRY index of that letter's
|
|
/// header — what AlphaStrip needs to scroll here.
|
|
property var alphaJumps: []
|
|
|
|
// Counts and times every rebuild so a smoke run can FALSIFY the coalescing:
|
|
// one line per keystroke means it holds, two means it does not. Measured
|
|
// around the work itself, never from a deferred callback.
|
|
LoggingCategory {
|
|
id: colTiming
|
|
name: "qbz.nav.timing"
|
|
defaultLogLevel: LoggingCategory.Warning
|
|
}
|
|
property int rebuildCount: 0
|
|
|
|
function parseNativeJumps() {
|
|
try { return JSON.parse(nativeJumpsJson || "[]") }
|
|
catch (e) { return [] }
|
|
}
|
|
|
|
function rebuild() {
|
|
// No width yet: build NOTHING rather than something guaranteed wrong.
|
|
// One empty frame beats a full-length one-column grid that has to be
|
|
// discarded — and in the common case the width IS known here, so this
|
|
// never fires and the synchronous first build is unchanged.
|
|
if (cols <= 0) return
|
|
if (nativeActive) {
|
|
flat = []
|
|
entries = []
|
|
alphaJumps = parseNativeJumps()
|
|
report()
|
|
return
|
|
}
|
|
var _t0 = Date.now()
|
|
var out = []
|
|
var flatOut = []
|
|
var jumps = []
|
|
var per = cols
|
|
var i, j
|
|
function pushChunk(items) {
|
|
for (j = 0; j < items.length; j += per) {
|
|
out.push({ "t": 1, "base": flatOut.length + j,
|
|
"items": items.slice(j, j + per) })
|
|
}
|
|
for (j = 0; j < items.length; j++) flatOut.push(items[j])
|
|
}
|
|
if (grouped) {
|
|
for (i = 0; i < groups.length; i++) {
|
|
var g = groups[i]
|
|
if (!g.items || g.items.length === 0) continue
|
|
jumps.push({ "letter": g.letter, "index": out.length })
|
|
// 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 {
|
|
pushChunk(rows)
|
|
}
|
|
flat = flatOut
|
|
entries = out
|
|
alphaJumps = jumps
|
|
root.rebuildCount += 1
|
|
console.info(colTiming, "[coltiming] rebuild #" + root.rebuildCount
|
|
+ " surface=" + root.surface + " grouped=" + root.grouped
|
|
+ " cols=" + per
|
|
+ " albums=" + flatOut.length + " entries=" + out.length
|
|
+ " in " + (Date.now() - _t0) + "ms")
|
|
report()
|
|
}
|
|
// `rows` and `groups` are TWO bindings over ONE query: the host derives
|
|
// `albumsVisible` from the search box, then `albumsGrouped` FROM
|
|
// `albumsVisible` (LocalLibraryView.qml:378-380). So a single keystroke
|
|
// changes both, and with one handler each this rebuilt twice — and the
|
|
// first of the two was WRONG as well as wasted, because it chunked the new
|
|
// rows against the previous groups. Grouping being off does not save it:
|
|
// `groupRows` returns a FRESH `[]` every time, which is still a change.
|
|
//
|
|
// Coalesced into one rebuild per event-loop turn. A zero-interval Timer
|
|
// rather than `Qt.callLater`, because dedup then depends on the callback
|
|
// being the same function object every time, and `restart()` needs no such
|
|
// assumption — same shape as `artFlush` in LocalLibraryView.
|
|
Timer {
|
|
id: rebuildCoalescer
|
|
interval: 0
|
|
repeat: false
|
|
onTriggered: root.rebuild()
|
|
}
|
|
function scheduleRebuild() { rebuildCoalescer.restart() }
|
|
onRowsChanged: scheduleRebuild()
|
|
onGroupsChanged: scheduleRebuild()
|
|
onGroupedChanged: scheduleRebuild()
|
|
onViewModeChanged: { scheduleRebuild(); scheduleNativeReset() }
|
|
onColsChanged: { scheduleRebuild(); scheduleNativeReset() }
|
|
onNativeActiveChanged: { scheduleRebuild(); reportSoon() }
|
|
onNativeJumpsJsonChanged: if (nativeActive) alphaJumps = parseNativeJumps()
|
|
|
|
Timer {
|
|
id: nativeQueryCoalescer
|
|
interval: 0
|
|
repeat: false
|
|
onTriggered: root.resetNativeQuery()
|
|
}
|
|
function scheduleNativeReset() {
|
|
if (nativeSurface && cols > 0 && view
|
|
&& view.albumsFilter.favorite !== true)
|
|
nativeQueryCoalescer.restart()
|
|
}
|
|
function resetNativeQuery() {
|
|
if (!nativeSurface || !view || cols <= 0
|
|
|| view.albumsFilter.favorite === true) return
|
|
QbzLocal.albumsNativeReset(
|
|
view.albumsSearch,
|
|
view.albumsSort,
|
|
view.albumsGroup,
|
|
JSON.stringify(view.filter || ({})),
|
|
cols)
|
|
}
|
|
Connections {
|
|
target: root.nativeSurface ? root.view : null
|
|
function onAlbumsSearchChanged() { root.scheduleNativeReset() }
|
|
function onAlbumsSortChanged() { root.scheduleNativeReset() }
|
|
function onAlbumsGroupChanged() { root.scheduleNativeReset() }
|
|
function onFilterChanged() { root.scheduleNativeReset() }
|
|
}
|
|
Connections {
|
|
target: root.nativeSurface ? QbzLocal : null
|
|
function onLocalAlbumModeChanged() { root.scheduleNativeReset() }
|
|
}
|
|
// The first build stays synchronous — deferring it would show one frame of
|
|
// empty grid on every mount.
|
|
Component.onCompleted: { rebuild(); reportSoon(); scheduleNativeReset() }
|
|
Component.onDestruction: if (view) view.releaseWindow(root.surface)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Window report — the MOUNTED entry band, mapped back to flat indices.
|
|
// ---------------------------------------------------------------------
|
|
// TRIGGERS. A report used to leave here on `contentY` and on the model
|
|
// swap alone, and BOTH of those fire while this surface is still hidden:
|
|
// the tab body is mounted behind `localAlbumsLoading` and the rows land
|
|
// in the same pass, so the one report that mattered was thrown away by
|
|
// the `!visible` guard below and nothing was requested until the user
|
|
// 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.
|
|
// 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
|
|
}
|
|
var first = list.indexAt(4, list.contentY + 1)
|
|
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 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++)
|
|
resident.push(entry.items[itemIndex])
|
|
}
|
|
if (resident.length > 0)
|
|
view.queueWindowReport(resident, 0, resident.length - 1, root.surface)
|
|
else
|
|
view.releaseWindow(root.surface)
|
|
return
|
|
}
|
|
var lo = entries[first]
|
|
var hi = entries[last]
|
|
// 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)
|
|
}
|
|
|
|
/// Report NOW, then again once layout has settled. `indexAt` answers -1
|
|
/// until the ListView has laid its delegates out, which is exactly the
|
|
/// state a just-mounted / just-shown list is in — the immediate call gets
|
|
/// the covers moving off the conservative fallback band, the second one
|
|
/// corrects it to the real viewport.
|
|
Timer {
|
|
id: reportSettle
|
|
interval: 50
|
|
repeat: false
|
|
onTriggered: root.report()
|
|
}
|
|
function reportSoon() {
|
|
report()
|
|
reportSettle.restart()
|
|
}
|
|
|
|
onVisibleChanged: {
|
|
if (visible) reportSoon()
|
|
else if (view) view.releaseWindow(root.surface)
|
|
}
|
|
onHeightChanged: report()
|
|
Connections {
|
|
target: root.view
|
|
function onArtworkRefresh() { root.reportSoon() }
|
|
}
|
|
|
|
function jumpToEntry(entryIndex) {
|
|
list.positionViewAtIndex(entryIndex, ListView.Beginning)
|
|
}
|
|
|
|
ListView {
|
|
id: list
|
|
anchors.fill: parent
|
|
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.nativeActive && root.nativeModel ? root.nativeModel : root.entries
|
|
onContentYChanged: root.report(true)
|
|
onModelChanged: root.report()
|
|
onHeightChanged: root.report()
|
|
onVisibleChanged: if (visible) root.reportSoon()
|
|
|
|
delegate: Loader {
|
|
required property var modelData
|
|
width: list.width
|
|
height: modelData.t === 0 ? root.headerH
|
|
: root.viewMode === "grid" ? root.cellH : root.listRowH
|
|
sourceComponent: modelData.loading ? loadingComp
|
|
: modelData.t === 0 ? headerComp
|
|
: root.viewMode === "grid" ? cardRowComp : listRowComp
|
|
onModelDataChanged: root.reportSoon()
|
|
|
|
Component {
|
|
id: loadingComp
|
|
QbzSkeleton {
|
|
variant: root.viewMode === "grid" ? "cardGrid" : "rowList"
|
|
cellW: root.cellW
|
|
cellH: root.cellH
|
|
rowH: root.listRowH
|
|
rowGap: 0
|
|
rowArtSize: 40
|
|
phase: root.view ? root.view.skelPhase : false
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: headerComp
|
|
Item {
|
|
Text {
|
|
x: 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: modelData.label
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
}
|
|
}
|
|
}
|
|
|
|
// One visual row of album cards.
|
|
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: root.cols
|
|
delegate: Item {
|
|
id: cardCell
|
|
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
|
|
/// `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
|
|
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
|
|
|
|
// AlbumCard updates these two properties
|
|
// optimistically, which intentionally replaces the
|
|
// bindings below. The card instances in this fixed
|
|
// Repeater survive ListView row reuse, so restore
|
|
// the bindings whenever a different slot object is
|
|
// installed; otherwise the next album could inherit
|
|
// the previous card's heart/pin state.
|
|
function restoreMutableBindings() {
|
|
albumCard.isFavorite = Qt.binding(function () {
|
|
return root.view
|
|
? root.view.albumFavorite(cardCell.slot)
|
|
: cardCell.slot.isFavorite === true
|
|
})
|
|
albumCard.isPinned = Qt.binding(function () {
|
|
return QbzLibrary.pinState(
|
|
"album", cardCell.slot.id)
|
|
})
|
|
}
|
|
function scheduleMutableRestore() {
|
|
mutableRestore.expected = cardCell.slot
|
|
mutableRestore.restart()
|
|
}
|
|
onSlotChanged: cardCell.scheduleMutableRestore()
|
|
Component.onCompleted:
|
|
cardCell.scheduleMutableRestore()
|
|
|
|
// A queued Qt.callLater closure can outlive this
|
|
// recycled delegate and then call into an invalid
|
|
// QML context. A child timer is cancelled with its
|
|
// owner, so delayed binding repair cannot escape
|
|
// the card cell's lifetime.
|
|
Timer {
|
|
id: mutableRestore
|
|
interval: 0
|
|
repeat: false
|
|
property var expected: null
|
|
onTriggered: {
|
|
if (cardCell.slot === expected)
|
|
cardCell.restoreMutableBindings()
|
|
}
|
|
}
|
|
|
|
AlbumCard {
|
|
id: albumCard
|
|
localMode: true
|
|
localPlaylistAffordance: true
|
|
quickViewAffordance: true
|
|
pinAffordance: true
|
|
albumId: cardCell.slot.id
|
|
title: cardCell.slot.title
|
|
artist: cardCell.slot.artist
|
|
artistId: ""
|
|
genre: ""
|
|
year: cardCell.slot.year
|
|
qualityTier: cardCell.slot.qualityTier
|
|
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`
|
|
// — the card takes the raw source word and the
|
|
// BADGE is what the flag switches (blanking
|
|
// `source` would also silence the card's
|
|
// source-aware menu gates).
|
|
//
|
|
// `sourceRaw` FIRST (contract §D.1). It carries
|
|
// `qobuz_purchase` and nothing else; `source`
|
|
// folds that into "offline" so the source chips
|
|
// keep filtering it, which means the card's gold
|
|
// purchase chip (AlbumCard.qml:588) only ever
|
|
// fires off the raw word. All FOUR badge sites
|
|
// do this — grid and list must not disagree
|
|
// about the same album.
|
|
source: cardCell.slot.sourceRaw
|
|
|| cardCell.slot.source
|
|
sources: cardCell.slot.sources || []
|
|
showSourceBadge: root.showSource
|
|
hostFavorite: cardCell.slot.favoriteable === true
|
|
isFavorite: root.view
|
|
? root.view.albumFavorite(cardCell.slot)
|
|
: cardCell.slot.isFavorite === true
|
|
extraMenuEntries: [
|
|
{ "label": QbzSession.tr("Album info", QbzSession.trRev),
|
|
"icon": "info", "action": "album-info" }
|
|
]
|
|
onExtraMenuAction: function (action) {
|
|
if (action === "album-info")
|
|
QbzLocal.bulkAction("album",
|
|
JSON.stringify([String(cardCell.slot.id)]), action)
|
|
}
|
|
onFavoriteRequested: if (root.view) {
|
|
root.view.toggleAlbumFavorite(
|
|
cardCell.slot, albumCard.artSource)
|
|
}
|
|
// SELECT MODE IS THE CARD'S (AlbumCard's
|
|
// `selectMode`/`selected`/`selectToggled`, the
|
|
// port of discover/AlbumCard.slint:83, :179-197,
|
|
// :207, :239, :465). This host used to paint its
|
|
// OWN 22px tick on top of the card at top-left —
|
|
// a THIRD geometry, and one that could only add
|
|
// the indicator: the card's hover PLAY button
|
|
// stayed live underneath it, so hovering a card
|
|
// you meant to tick and hitting play started the
|
|
// album. The card hides the whole hover action
|
|
// row and the pin badge in select mode and draws
|
|
// the reference's 24px tick in the reference's
|
|
// corner (top-right); all three move together,
|
|
// which is exactly what a host overlay cannot
|
|
// do. Do not re-add one.
|
|
selectMode: root.selectMode
|
|
selected: root.nativeActive
|
|
? cardCell.slot.selected === true
|
|
: root.selected[cardCell.slot.id] === true
|
|
onSelectToggled: {
|
|
if (root.nativeActive)
|
|
root.nativeToggleSelect(cardCell.slot.nativeIndex, Qt.NoModifier)
|
|
else
|
|
root.toggleSelect(cardCell.slot.id, Qt.NoModifier)
|
|
}
|
|
// Non-select mode only — the card routes a
|
|
// select-mode click to `selectToggled` and never
|
|
// emits this while ticking.
|
|
onOpenRequested: root.openRequested(cardCell.slot.id)
|
|
onPlayRequested: root.playRequested(cardCell.slot.id)
|
|
onEnqueueRequested: function (m) {
|
|
root.enqueueRequested(cardCell.slot.id, m)
|
|
}
|
|
}
|
|
// 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
|
|
// (see LocalLibraryView.artSettleMs).
|
|
QbzSkeleton {
|
|
variant: "art"
|
|
width: 200
|
|
height: 200
|
|
pending: root.view
|
|
? root.view.artWanted(cardCell.slot.artKey) : false
|
|
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
|
|
settleHold: root.view ? root.view.artPulse : false
|
|
}
|
|
// RIGHT-only, declared after the card so every
|
|
// left click still reaches the card's own areas.
|
|
MouseArea {
|
|
id: cardRc
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.RightButton
|
|
onClicked: function (mouse) {
|
|
albumCard.openAlbumMenu(cardRc, mouse.x, mouse.y)
|
|
}
|
|
}
|
|
// (The multi-select tick that used to sit here is
|
|
// gone — it is AlbumCard's `selectMode` now; see
|
|
// the note on the card above.)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// One list row.
|
|
Component {
|
|
id: listRowComp
|
|
LocalAlbumRow {
|
|
width: list.width
|
|
view: root.view
|
|
item: modelData.items[0]
|
|
artSource: root.view
|
|
? (modelData.items[0].artPath
|
|
|| root.view.artMap[modelData.items[0].artKey] || "") : ""
|
|
isFavorite: root.view
|
|
? root.view.albumFavorite(modelData.items[0])
|
|
: modelData.items[0].isFavorite === true
|
|
showSource: root.showSource
|
|
selectMode: root.selectMode
|
|
checked: root.nativeActive
|
|
? modelData.items[0].selected === true
|
|
: root.selected[modelData.items[0].id] === true
|
|
onOpened: root.openRequested(modelData.items[0].id)
|
|
onPlayRequested: root.playRequested(modelData.items[0].id)
|
|
onEnqueueRequested: function (m) {
|
|
root.enqueueRequested(modelData.items[0].id, m)
|
|
}
|
|
onFavoriteRequested: if (root.view) {
|
|
root.view.toggleAlbumFavorite(modelData.items[0], artSource)
|
|
}
|
|
onToggleSelect: function (mods) {
|
|
if (root.nativeActive)
|
|
root.nativeToggleSelect(modelData.items[0].nativeIndex, mods)
|
|
else
|
|
root.toggleSelect(modelData.items[0].id, mods)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Back/forward scroll memory — OPT-IN, because this component is mounted
|
|
// by three different hosts (the Albums tab, the Folders tab and the artist
|
|
// detail pane) and only the two that ARE the page should own a scroll
|
|
// scope. The default "" leaves the third inert.
|
|
ScrollMemory { target: list; scope: root.scrollScope }
|
|
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
// Negative when the host insets its content — the bar overhangs to
|
|
// reach the real edge. Safe: `root` is a plain Item and does not clip
|
|
// (the `clip: true` in this file is on the ListView, a sibling).
|
|
anchors.rightMargin: root.scrollBarInset - root.hostRightInset
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: list
|
|
visible: list.contentHeight > list.height
|
|
}
|
|
}
|