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
359 lines
17 KiB
QML
359 lines
17 KiB
QML
// Tracks tab body (LocalLibraryView.slint:1317) — the SERVER-PAGINATED flat
|
|
// list. This is the surface that froze the Slint build at 16k tracks: the
|
|
// model is never the whole table (500 rows per page, appended within 600px
|
|
// of the tail — the Slint's own trigger distance at :1442) and the ListView
|
|
// only ever instantiates its window.
|
|
//
|
|
// Grouping (off / album / artist / name) inserts 32px header rows, so the
|
|
// model is an entry array — { t: 0, label } | { t: 1, row, n } — exactly as
|
|
// the collection does it, keeping ONE windowing ListView with variable row
|
|
// heights instead of a Repeater per group.
|
|
|
|
import QtQuick
|
|
import com.blitzfc.qbz
|
|
import "../../controls"
|
|
import "../../theme"
|
|
|
|
Item {
|
|
id: root
|
|
|
|
/// The tab's own left/right breathing room. Named because the scrollbar
|
|
/// has to CANCEL it (see the QbzScrollBar at the bottom of the list) —
|
|
/// with the number written twice, moving one would silently misalign it.
|
|
readonly property int sideInset: 32
|
|
|
|
property var view: null
|
|
|
|
QbzTheme { id: theme }
|
|
|
|
readonly property int rowH: 50
|
|
readonly property int headerH: 32
|
|
|
|
// --------------------------- entry model ------------------------------
|
|
property var entries: []
|
|
property var alphaJumps: []
|
|
/// entry index -> index into `view.tracksVisible`, -1 for a group header.
|
|
/// Group headers inflate the entry indices, so reporting them straight
|
|
/// into the row array asked for the covers of tracks BELOW the ones on
|
|
/// screen, by the number of headers scrolled past.
|
|
property var rowIndex: []
|
|
function rebuild() {
|
|
var rows = view ? view.tracksVisible : []
|
|
var mode = view ? view.tracksGroup : "off"
|
|
var out = []
|
|
var jumps = []
|
|
var idx = []
|
|
var prev = null
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var t = rows[i]
|
|
var key = mode === "album" ? (t.album || "")
|
|
: mode === "artist" ? (t.artist || "")
|
|
: mode === "name" ? (t.title || "").slice(0, 1).toUpperCase()
|
|
: ""
|
|
if (mode !== "off" && key !== "" && key !== prev) {
|
|
if (mode === "name") jumps.push({ "letter": key, "index": out.length })
|
|
out.push({ "t": 0, "label": key })
|
|
idx.push(-1)
|
|
prev = key
|
|
}
|
|
out.push({ "t": 1, "row": t, "n": i + 1 })
|
|
idx.push(i)
|
|
}
|
|
entries = out
|
|
alphaJumps = jumps
|
|
rowIndex = idx
|
|
report()
|
|
}
|
|
Component.onCompleted: { rebuild(); reportSoon() }
|
|
Component.onDestruction: if (view) view.releaseWindow("tracks")
|
|
Connections {
|
|
target: root.view
|
|
function onTracksVisibleChanged() { root.rebuild() }
|
|
function onTracksGroupChanged() { root.rebuild() }
|
|
// The row artwork column is OFF by default; turning it on has to ask
|
|
// for the covers the rows are already bound to.
|
|
function onTrackArtworkChanged() { root.reportSoon() }
|
|
function onArtworkRefresh() { root.reportSoon() }
|
|
}
|
|
|
|
// --------------------------- row play ---------------------------------
|
|
// PARITY-DEBT #14. Rust used to queue `state.tracks_raw` — the raw SQL
|
|
// page order — and find the clicked row in it, so with a group mode on
|
|
// (a CLIENT-side reorder, see LocalLibraryView.tracksVisible) the user
|
|
// heard a different order than the one on screen. The visible order only
|
|
// exists QML-side, so it goes down as a JSON id array and the raw rows
|
|
// play the part of the authoritative cache
|
|
// (local_playback::order_by_visible, the local twin of
|
|
// library_qt::order_by_visible).
|
|
//
|
|
// `view.tracksVisible` is THE array `entries` is built from — never read
|
|
// `list.model` back off the view (see views/LibraryView.qml).
|
|
function visibleTrackIds() {
|
|
var rows = root.view ? root.view.tracksVisible : []
|
|
var out = []
|
|
for (var i = 0; i < rows.length; i++) out.push(rows[i].id)
|
|
return out
|
|
}
|
|
function playRow(id) {
|
|
QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id)
|
|
}
|
|
|
|
// First page = the shape of the 50px track rows (the Slint mounts a bare
|
|
// 36px LoadingSpinner). ONE instance for the whole viewport.
|
|
QbzSkeleton {
|
|
variant: "rowList"
|
|
anchors.fill: parent
|
|
anchors.leftMargin: root.sideInset
|
|
anchors.rightMargin: root.sideInset
|
|
anchors.topMargin: 12
|
|
visible: QbzLocal.localTracksLoading
|
|
rowH: root.rowH
|
|
rowGap: 0
|
|
rowArt: root.view.trackArtwork
|
|
rowArtSize: 36
|
|
phase: root.view.skelPhase
|
|
}
|
|
LocalNote {
|
|
visible: !QbzLocal.localTracksLoading && root.view.tracks.length === 0
|
|
&& root.view.tracksSearch === ""
|
|
text: QbzSession.tr("No tracks in your local library yet.", QbzSession.trRev)
|
|
}
|
|
LocalNote {
|
|
visible: !QbzLocal.localTracksLoading
|
|
&& (root.view.tracks.length === 0 || root.view.tracksVisible.length === 0)
|
|
&& root.view.tracksSearch !== ""
|
|
text: QbzSession.tr("No tracks match your search.", QbzSession.trRev)
|
|
}
|
|
|
|
Column {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: root.sideInset
|
|
anchors.rightMargin: root.sideInset
|
|
anchors.topMargin: 12
|
|
spacing: 8
|
|
visible: !QbzLocal.localTracksLoading && root.view.tracksVisible.length > 0
|
|
|
|
QbzMultiSelectBar {
|
|
visible: root.view.tracksMultiSelect
|
|
width: parent.width
|
|
selectedCount: root.view.tracksSelectedCount
|
|
actions: [
|
|
{ "id": "select-all", "label": QbzSession.tr("Select all", QbzSession.trRev), "icon": "square-check-big", "danger": false, "needsSelection": false },
|
|
{ "id": "queue", "label": QbzSession.tr("Add to queue", QbzSession.trRev), "icon": "list-end", "danger": false, "needsSelection": true },
|
|
{ "id": "play-later", "label": QbzSession.tr("Play later", QbzSession.trRev), "icon": "list-plus", "danger": false, "needsSelection": true },
|
|
{ "id": "play-next", "label": QbzSession.tr("Play next", QbzSession.trRev), "icon": "list-start", "danger": false, "needsSelection": true },
|
|
// NO "Remove from favorites": this tab is the whole local
|
|
// library, not a favourites surface — removing from a
|
|
// collection the user is not looking at is not an action this
|
|
// context can offer. (It was doubly wrong: local hearts are
|
|
// unwired, so `local_bulk.rs`'s arm is a log-only no-op.)
|
|
{ "id": "add-to-playlist", "label": QbzSession.tr("Add to playlist", QbzSession.trRev), "icon": "list-music", "danger": false, "needsSelection": true },
|
|
{ "id": "add-to-mixtape", "label": QbzSession.tr("Add to Mixtape/Collection", QbzSession.trRev), "icon": "cassette-tape", "danger": false, "needsSelection": true },
|
|
{ "id": "clear", "label": QbzSession.tr("Clear", QbzSession.trRev), "icon": "x", "danger": false, "needsSelection": true },
|
|
]
|
|
onAction: function (id) { root.view.tracksBulkAction(id) }
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: parent.height - (root.view.tracksMultiSelect ? 52 : 0)
|
|
|
|
ListView {
|
|
id: list
|
|
anchors.fill: parent
|
|
anchors.rightMargin: root.view.tracksGroup === "name" ? 20 : 0
|
|
clip: true
|
|
cacheBuffer: 50 * 12
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
model: root.entries
|
|
|
|
onContentYChanged: {
|
|
root.report()
|
|
// Infinite scroll: 600px of runway (Slint :1442).
|
|
//
|
|
// `contentHeight > height` states the precondition the
|
|
// predicate always had: a list shorter than its viewport
|
|
// has no bottom to run out of, so it cannot be "600px from
|
|
// the end". Nothing is lost by it — a list that short
|
|
// cannot scroll (StopAtBounds), so contentY never moves
|
|
// and this handler never runs for it.
|
|
//
|
|
// It is ALSO the guard for this handler's one re-entrant
|
|
// entry: it runs from INSIDE QQuickItemView::setModel()
|
|
// (the same window that makes a `.model` readback segfault
|
|
// — see views/LibraryView.qml), where contentY has been
|
|
// reset but the layout has not been redone.
|
|
//
|
|
// MEASURED, so nobody re-hunts it: that entry does NOT in
|
|
// fact misfire under qml6 6.11.1. contentHeight inside the
|
|
// swap is the PREVIOUS model's value, not 0 — checked over
|
|
// empty->long, short->long, long->short, long->empty,
|
|
// same-length republishes, both with forceLayout and on
|
|
// the real async polish path. Every one of them entered
|
|
// the handler with (contentY 0, contentHeight stale-LARGE)
|
|
// and evaluated FALSE. So this line changes no observed
|
|
// behaviour today; it is kept because it is true and free,
|
|
// and because the only shape that would misfire is the one
|
|
// it covers.
|
|
if (contentHeight > height
|
|
&& contentY + height >= contentHeight - 600
|
|
&& QbzLocal.localTracksHasMore
|
|
&& !QbzLocal.localTracksLoadingMore
|
|
&& !QbzLocal.localTracksLoading) {
|
|
QbzLocal.tracksLoadMore()
|
|
}
|
|
}
|
|
onModelChanged: root.report()
|
|
onHeightChanged: root.report()
|
|
onVisibleChanged: {
|
|
if (visible) root.reportSoon()
|
|
else if (root.view) root.view.releaseWindow("tracks")
|
|
}
|
|
|
|
delegate: Loader {
|
|
required property var modelData
|
|
width: list.width
|
|
height: modelData.t === 0 ? root.headerH : root.rowH
|
|
sourceComponent: modelData.t === 0 ? headerComp : rowComp
|
|
|
|
Component {
|
|
id: headerComp
|
|
Item {
|
|
Text {
|
|
x: 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - 4
|
|
text: modelData.label
|
|
color: theme.textSecondary
|
|
font.pixelSize: theme.fontLegal
|
|
font.weight: theme.weightSemibold
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
Component {
|
|
id: rowComp
|
|
LocalTrackRow {
|
|
width: list.width
|
|
view: root.view
|
|
item: modelData.row
|
|
// The row artwork column was rendering a
|
|
// permanently blank 36px cell: local track rows
|
|
// ship an EMPTY artPath (local_rows.rs:284) and
|
|
// nothing ever fed LocalTrackRow.artSource. It is
|
|
// the same id-keyed artMap every other local
|
|
// surface reads.
|
|
artSource: root.view.artMap[modelData.row.artKey] || ""
|
|
number: modelData.n
|
|
// Album column hides when the list is already
|
|
// grouped by album (Slint :1486).
|
|
showAlbum: root.view.tracksGroup !== "album"
|
|
showArtwork: root.view.trackArtwork
|
|
selectMode: root.view.tracksMultiSelect
|
|
checked: root.view.tracksSelected[modelData.row.id] === true
|
|
onPlayRequested: root.playRow(modelData.row.id)
|
|
onEnqueueRequested: function (m) {
|
|
QbzLocal.enqueue("track", modelData.row.id, m)
|
|
}
|
|
onToggleSelect: function (mods) { root.view.toggleTrackSelected(modelData.row.id, mods) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Infinite scroll: the NEXT page in the shape it will arrive
|
|
// in (3 rows), appended below the last real row. Each
|
|
// placeholder is replaced by a real row as the page lands.
|
|
footer: Item {
|
|
width: list.width
|
|
height: QbzLocal.localTracksLoadingMore ? 3 * root.rowH : 0
|
|
QbzSkeleton {
|
|
variant: "rowList"
|
|
anchors.fill: parent
|
|
visible: QbzLocal.localTracksLoadingMore
|
|
rowH: root.rowH
|
|
rowGap: 0
|
|
rowArt: root.view.trackArtwork
|
|
rowArtSize: 36
|
|
phase: root.view.skelPhase
|
|
}
|
|
}
|
|
}
|
|
QbzAlphaStrip {
|
|
visible: root.view.tracksGroup === "name" && root.alphaJumps.length > 0
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
jumps: root.alphaJumps
|
|
onJump: function (ordinal, index) {
|
|
list.positionViewAtIndex(index, ListView.Beginning)
|
|
}
|
|
}
|
|
// Back/forward scroll memory (controls/ScrollMemory.qml).
|
|
ScrollMemory { target: list; scope: "local:tracks" }
|
|
QbzScrollBar {
|
|
anchors.right: parent.right
|
|
// The bar belongs on the VIEW edge, not on the content edge.
|
|
// Its parent sits inside the tab's 32px inset, so a plain 4px
|
|
// margin put the bar 36px from the window — a visibly wide
|
|
// empty channel with nothing in it (owner, 2026-08-16). The
|
|
// negative margin cancels the inset and leaves the same 4px
|
|
// gap the other lists have; the ROWS keep their inset.
|
|
//
|
|
// The "name" arm is untouched on purpose: there the alpha
|
|
// strip owns the right edge at the inset, and 22 is what
|
|
// clears it. Pushing the bar out there would jump it past the
|
|
// strip.
|
|
anchors.rightMargin: root.view.tracksGroup === "name"
|
|
? 22 : 4 - root.sideInset
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
target: list
|
|
visible: list.contentHeight > list.height
|
|
}
|
|
}
|
|
}
|
|
|
|
// TRIGGERS: `contentY` + the model swap alone never fired for a list that
|
|
// was still hidden behind `localTracksLoading` when its first page landed
|
|
// — the covers only started resolving on the first scroll. Mount, the
|
|
// rebuild, becoming visible and a resize report too.
|
|
function report() {
|
|
if (!view || !list) return
|
|
// The row artwork column is gated on the appearance setting (default
|
|
// OFF, for the 16K-track freeze). With it off nothing on this list
|
|
// renders a cover, so nothing is worth decoding.
|
|
if (!list.visible || !view.trackArtwork || root.entries.length === 0) {
|
|
view.releaseWindow("tracks")
|
|
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(root.entries.length - 1, first + 12)
|
|
// Entry band -> row band (group headers carry no cover).
|
|
var lo = -1
|
|
var hi = -1
|
|
for (var i = first; i <= last; i++) {
|
|
var n = root.rowIndex[i]
|
|
if (n === undefined || n < 0) continue
|
|
if (lo < 0) lo = n
|
|
hi = n
|
|
}
|
|
if (lo < 0) { lo = 0; hi = Math.min(view.tracksVisible.length - 1, 12) }
|
|
view.queueWindowReport(view.tracksVisible, Math.max(0, lo - 4), hi + 4, "tracks")
|
|
}
|
|
|
|
/// Report now, then again once the ListView has laid out (`indexAt`
|
|
/// answers -1 until then — the state a just-shown list is in).
|
|
Timer {
|
|
id: reportSettle
|
|
interval: 50
|
|
repeat: false
|
|
onTriggered: root.report()
|
|
}
|
|
function reportSoon() {
|
|
report()
|
|
reportSettle.restart()
|
|
}
|
|
}
|