Excel-style selection (750c59034) read the modifiers only where a row BODY took the click. Every other target a select-mode surface draws dropped them, so a Shift-click there was a plain toggle: - the track row's checkbox disc passed Qt.NoModifier; - QbzCheckbox and SelectCheck emitted toggled() with no argument, so the Library album rows, the local album rows and the folder rows, which already expected `function (mods)`, received undefined; - AlbumCard emitted selectToggled() with no argument, and the paged local albums grid (299dcde5b) spelled the drop out as Qt.NoModifier. Some targets were never selection targets at all: in select mode the artist and album names on a track row, the artist and label on a Library album row and the artist line of an album card still navigated, leaving the page and the selection mid-gesture. The row links stand down in select mode so the click reaches the row body with its modifiers, and the card's artist line toggles like its artwork and title. A hover-only tooltip on the pulled but cached glyph no longer eats the row's click. Reproduced on the release binary in a nested KWin with injected input: in Local Library Tracks, the checkbox discs of rows 1, Shift 4 and Ctrl 6 selected 3 rows; the same clicks on the row bodies selected 5. No list view, Flickable or wheel code changes; tst_wheel passes unchanged. tst_multi_select clicks each of these targets through the real TrackRow, AlbumListRow, AlbumCard, QbzCheckbox and SelectCheck. cc
710 lines
36 KiB
QML
710 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
|
|
// The card's modifiers, straight through: the
|
|
// paged grid (299dcde5b) passed NoModifier here,
|
|
// so a Shift-click on a card was a toggle.
|
|
onSelectToggled: function (mods) {
|
|
if (root.nativeActive)
|
|
root.nativeToggleSelect(cardCell.slot.nativeIndex, mods)
|
|
else
|
|
root.toggleSelect(cardCell.slot.id, mods)
|
|
}
|
|
// 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
|
|
}
|
|
}
|