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