Selection existed but had no keyboard: every click was a single toggle,
so picking 40 tracks meant 40 clicks. Slint and Tauri have had ranges
since 2026-06; this is that port.
NINE surfaces, not the five the track views suggested: Album, Artist,
Playlist, Label, Local Album, plus Library's tracks and albums and Local
Library's tracks and albums. Each carried its own hand-copied `selected`
map and its own `toggleSelected`, so the rule went into
controls/SelectionModel.qml and the maps stayed where they were — the
smallest change that stops it being copied a tenth time.
The rules are Tauri's `applyShiftRange`, unchanged: a plain click
toggles one row without clearing the rest, Shift fills the range from
the anchor ADDITIVELY (so re-dragging a range cannot eat the set) and
leaves the anchor where it is, and Ctrl/Cmd is the same as a plain click
— not a shortcut, but the behaviour both references already have,
because an accumulating click is what "add without disturbing" means.
Qt makes this CHEAPER than Slint, and the difference is worth recording:
Slint had no modifiers at click time and had to read a thread-local set
from a ModifiersChanged handler, with a documented stale-read edge case
when the window lost focus mid-chord. A QML MouseArea delivers
`mouse.modifiers` on the event itself, so the modifier and the click can
never disagree and no such seam exists here.
Three judgement calls, stated because they are not forced:
- Library gets TWO models, one per kind. Its rows are mixed, and one
anchor would let a Shift-click on a track sweep up the albums drawn
between it and the anchor.
- Ranges run over the VISIBLE rows. Local Library's search box is a
view filter, and a range over rows the user cannot see is not the
range they asked for — the same call `select-all` already made.
- The Local Library tree rail keeps plain toggles. Its selection lives
in Rust (QbzLocal.treeToggle*Select), so there is no QML map to hang
an anchor off; teaching the Rust side about an anchor is its own
change, and the file says so where a reader would expect otherwise.
The anchor is an ID, not an index, because every one of these views can
re-sort or filter under a live selection — an index anchor silently
points at a different track the moment the order changes. Slint's
selection.rs made the same call for the same reason.
Verified by driving the app: in Abbey Road, click row 1 then Shift-click
row 5 selects exactly rows 1-5 and leaves 6-8 untouched, with the bar
reading "5 selected"; the same in a playlist, additively over a row that
was already selected.
cc
532 lines
27 KiB
QML
532 lines
27 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 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: ({})
|
|
|
|
/// 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)
|
|
|
|
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": ""
|
|
})
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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 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
|
|
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()
|
|
onColsChanged: scheduleRebuild()
|
|
// The first build stays synchronous — deferring it would show one frame of
|
|
// empty grid on every mount.
|
|
Component.onCompleted: { rebuild(); reportSoon() }
|
|
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.
|
|
function report() {
|
|
if (!view || !list) return
|
|
if (!list.visible || entries.length === 0 || width <= 0) {
|
|
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(entries.length - 1, first + 8)
|
|
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.entries
|
|
onContentYChanged: root.report()
|
|
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.t === 0 ? headerComp
|
|
: root.viewMode === "grid" ? cardRowComp : listRowComp
|
|
|
|
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
|
|
visible: cardCell.modelData !== null
|
|
width: 200
|
|
height: 246
|
|
|
|
// Right-click menu for the card (AlbumCard.slint's
|
|
// album-menu, local arm: no favourite, no Block —
|
|
// the Slint hides Block for source local/plex).
|
|
// The card's own ⋯ overlay button opens AlbumCard's
|
|
// built-in menu; see the GLUE note about aligning
|
|
// that one's entries with these.
|
|
// LAZY, like AlbumCard's own. This cell carried a
|
|
// SECOND popup on top of the card's, so a grid of
|
|
// 1267 local albums paid two Popup constructions
|
|
// per cell — and the ListView recycles a screenful
|
|
// of cells on every scroll step, which is what the
|
|
// owner reports as heavy scrolling here.
|
|
Loader {
|
|
id: cardMenuLoader
|
|
active: false
|
|
sourceComponent: CardMenu {
|
|
menuWidth: 196
|
|
entries: [
|
|
{ "label": QbzSession.tr("Open album", QbzSession.trRev), "icon": "library-big", "action": "open" },
|
|
{ "label": QbzSession.tr("Play", QbzSession.trRev), "icon": "play-fill", "action": "play" },
|
|
{ "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "action": "next" },
|
|
{ "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "action": "later" },
|
|
{ "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "action": "queue" },
|
|
]
|
|
onPicked: function (a) {
|
|
if (a === "open") root.openRequested(cardCell.slot.id)
|
|
else if (a === "play") root.playRequested(cardCell.slot.id)
|
|
else root.enqueueRequested(cardCell.slot.id, a)
|
|
}
|
|
}
|
|
}
|
|
/// Build the popup on first use, then open it.
|
|
function openCardMenu(anchor, x, y) {
|
|
cardMenuLoader.active = true
|
|
cardMenuLoader.item.openAtCursor(anchor, x, y)
|
|
}
|
|
|
|
AlbumCard {
|
|
localMode: true
|
|
albumId: cardCell.slot.id
|
|
title: cardCell.slot.title
|
|
artist: cardCell.slot.artist
|
|
artistId: ""
|
|
genre: ""
|
|
year: cardCell.slot.year
|
|
qualityTier: cardCell.slot.qualityTier
|
|
artSource: root.view
|
|
? (root.view.artMap[cardCell.slot.artKey] || "") : ""
|
|
// 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
|
|
showSourceBadge: root.showSource
|
|
// 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.selected[cardCell.slot.id] === true
|
|
onSelectToggled: root.toggleSelect(cardCell.slot.id)
|
|
// 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)
|
|
}
|
|
}
|
|
// 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.
|
|
// 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
|
|
coverSource: root.view
|
|
? 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) {
|
|
cardCell.openCardMenu(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
|
|
? (root.view.artMap[modelData.items[0].artKey] || "") : ""
|
|
showSource: root.showSource
|
|
selectMode: root.selectMode
|
|
checked: 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)
|
|
}
|
|
onToggleSelect: function (mods) {
|
|
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
|
|
}
|
|
}
|