Files
qbz/crates/qbz-qt/qml/views/local/LocalTracksTab.qml
T
vicrodh e816436fd0 feat(qt): remember where each page was scrolled, across back and forward
The port of the block Slint repeats in 27 views (NavState.restore-scope /
scroll-restore / report-scroll). A .slint file cannot hand a component a
reference to a Flickable, which is why the reference inlines it; QML can, so
controls/ScrollMemory.qml exists once and 31 containers mount it.

Two divergences from the reference, both forced by the same fact — tabs are not
history entries in this port:

1. THE SCOPE IS NAMED BY THE VIEW, not derived from the entry by a scope_for
   table. Slint can derive it because its entries carry the tab
   (Favorites { tab }); here an entry is a bare route id, so a derived scope
   would read "library" for all five Library tabs and pour the Albums grid's
   offset into whatever tab mounted. Letting the reporter name its own scope
   buys tab granularity for free, and the mismatch case degrades the right way:
   an unmatched scope restores NOTHING instead of restoring the wrong thing.

2. THE RESTORE RE-ASSERTS instead of firing once. A Qt item view resets
   contentY to 0 whenever its model is replaced, and several pages assign their
   model from a binding whose inputs arrive over the network after the mount —
   the album page rebuilds listCells from three deferred rails, so it hands its
   ListView a new array up to four times in its first seconds and snaps back to
   the top over a restore that already ran correctly. The offset is held and
   re-applied until a 2.5 s deadline, or until the user takes over (a wheel, a
   drag or a flick raises `moving`, and from then the position is theirs).

Diagnostics are off by default: QT_LOGGING_RULES="qbz.nav.scroll.info=true" for
the QML half, RUST_LOG=qbz=debug for the stamp/arm half.

cc
2026-08-19 23:54:46 -06:00

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: 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)
}
}
// 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()
}
}