Files
qbz/crates/qbz-qt/qml/views/local/LocalTracksTab.qml
T
vicrodh 16a25bbbe6 fix(qt): pin the Local Library tracks scrollbar to the view edge
The bar sat inside the tab's 32px content inset AND carried its own 4px
margin, leaving it 36px from the window with an empty channel beside it.
It now cancels the inset; the rows keep theirs.

The "By name" arm is untouched: there the alpha strip owns the right edge
and 22px is what clears it. The inset is a named property now because the
scrollbar has to cancel it — written twice, moving one silently misaligns
the other.

cc
2026-08-16 18:53:04 -06:00

357 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: root.view.toggleTrackSelected(modelData.row.id)
}
}
}
// 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)
}
}
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()
}
}