feat(library): page Tracks from local catalog

Add an opt-in QAbstractListModel backed by keyset pages from the derived catalog, with bounded residency, global query semantics, source-aware actions, and per-session fallback to the existing reader.

Wire profile-scoped bootstrap locations and refresh the native surface only after a complete catalog is active.

cx
This commit is contained in:
vicrodh committed 2026-08-23 09:37:15 -06:00
1 parent 7689fb0df6
commit 1bb3c41dcf
23 files changed
+2634 -130

No files matched your search

@@ -235,7 +235,10 @@ Row {
// ============================ TRACKS =================================
Row {
visible: root.view && root.view.activeTab === "tracks"
&& (root.view.tracks.length > 0 || root.view.tracksSearch !== "")
&& ((QbzLocal.localTracksNativeActive
? QbzLocal.localTracksNativeTotal > 0
: root.view.tracks.length > 0)
|| root.view.tracksSearch !== "")
spacing: 8
height: parent.height
@@ -272,9 +275,9 @@ Row {
currentIndex: Math.max(0, root.tracksGroupIds.indexOf(root.view.tracksGroup))
// Through the bridge, like the sort above and the album identity
// below it: the choice is PERSISTED (locallibrary_ui.json, shared
// with the Slint build). Writing the QML property directly is what
// made it reset on every restart (PARITY-DEBT #13). No re-query —
// grouping is a client-side reorder, not a new ORDER BY.
// with the Slint build). The legacy reader reorders its loaded
// rows; Phase E resets an immutable SQL descriptor so grouping is
// global across every keyset page.
onSelected: function (i) { QbzLocal.tracksSetGroup(root.tracksGroupIds[i]) }
}
// Multi-select LAST — never to the LEFT of a search box. The expandable
@@ -64,6 +64,8 @@ Item {
property var view: null
property bool selectMode: false
property bool checked: false
property bool nativeActions: false
property int nativeIndex: -1
/// Alternating row tint, forwarded to the shared row. Off by default there
/// (rows/TrackRow.qml), which is why the local album page had none while
/// the Qobuz one (AlbumView.qml) did — the flag was simply never handed
@@ -246,7 +248,10 @@ Item {
// point, which owns the only source-aware row -> ref resolver
// (see the file header).
else if (a === "add-to-playlist" || a === "add-to-mixtape") {
QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a)
if (root.nativeActions)
QbzLocal.tracksNativeRowAction(root.nativeIndex, a)
else
QbzLocal.bulkAction("track", JSON.stringify([String(root.item.id)]), a)
}
else if (a === "go-album") {
QbzLocal.openAlbum(root.item.albumId)
+127 -20
View File
@@ -1,8 +1,7 @@
// 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.
// Tracks tab body (LocalLibraryView.slint:1317). The compatibility reader is
// server-paginated JSON; Phase E binds the same delegates to a native
// QAbstractListModel with exact rowCount, 250-row keyset pages and an eight-page
// LRU. Missing data is a placeholder and only emits an async page request.
//
// 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
@@ -28,6 +27,7 @@ Item {
readonly property int rowH: 50
readonly property int headerH: 32
readonly property bool nativeModelActive: QbzLocal.localTracksNativeActive
// --------------------------- entry model ------------------------------
property var entries: []
@@ -38,6 +38,17 @@ Item {
/// screen, by the number of headers scrolled past.
property var rowIndex: []
function rebuild() {
if (root.nativeModelActive) {
entries = []
rowIndex = []
try {
alphaJumps = JSON.parse(QbzLocal.localTracksNativeJumpsJson) || []
} catch (e) {
alphaJumps = []
}
reportSoon()
return
}
var rows = view ? view.tracksVisible : []
var mode = view ? view.tracksGroup : "off"
var out = []
@@ -68,13 +79,23 @@ Item {
Component.onDestruction: if (view) view.releaseWindow("tracks")
Connections {
target: root.view
function onTracksVisibleChanged() { root.rebuild() }
function onTracksVisibleChanged() { if (!root.nativeModelActive) 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() }
}
Connections {
target: QbzLocal
function onLocalTracksNativeActiveChanged() { root.rebuild() }
function onLocalTracksNativeJumpsJsonChanged() { root.rebuild() }
}
Connections {
target: root.view ? root.view.nativeTracksModel : null
function onDataChanged() { root.reportSoon() }
function onModelReset() { root.reportSoon() }
}
// --------------------------- row play ---------------------------------
// PARITY-DEBT #14. Rust used to queue `state.tracks_raw` — the raw SQL
@@ -94,8 +115,9 @@ Item {
for (var i = 0; i < rows.length; i++) out.push(rows[i].id)
return out
}
function playRow(id) {
QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id)
function playRow(index, id) {
if (root.nativeModelActive) QbzLocal.tracksNativePlay(index)
else QbzLocal.playTracksVisible(JSON.stringify(visibleTrackIds()), id)
}
// First page = the shape of the 50px track rows (the Slint mounts a bare
@@ -114,13 +136,17 @@ Item {
phase: root.view.skelPhase
}
LocalNote {
visible: !QbzLocal.localTracksLoading && root.view.tracks.length === 0
visible: !QbzLocal.localTracksLoading
&& (root.nativeModelActive ? QbzLocal.localTracksNativeTotal === 0
: 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.nativeModelActive ? QbzLocal.localTracksNativeTotal === 0
: (root.view.tracks.length === 0
|| root.view.tracksVisible.length === 0))
&& root.view.tracksSearch !== ""
text: QbzSession.tr("No tracks match your search.", QbzSession.trRev)
}
@@ -131,7 +157,9 @@ Item {
anchors.rightMargin: root.sideInset
anchors.topMargin: 12
spacing: 8
visible: !QbzLocal.localTracksLoading && root.view.tracksVisible.length > 0
visible: !QbzLocal.localTracksLoading
&& (root.nativeModelActive ? QbzLocal.localTracksNativeTotal > 0
: root.view.tracksVisible.length > 0)
QbzMultiSelectBar {
visible: root.view.tracksMultiSelect
@@ -165,7 +193,7 @@ Item {
clip: true
cacheBuffer: 50 * 12
boundsBehavior: Flickable.StopAtBounds
model: root.entries
model: root.nativeModelActive ? root.view.nativeTracksModel : root.entries
onContentYChanged: {
root.report()
@@ -195,7 +223,8 @@ Item {
// 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
if (!root.nativeModelActive
&& contentHeight > height
&& contentY + height >= contentHeight - 600
&& QbzLocal.localTracksHasMore
&& !QbzLocal.localTracksLoadingMore
@@ -213,8 +242,11 @@ Item {
delegate: Loader {
required property var modelData
width: list.width
height: modelData.t === 0 ? root.headerH : root.rowH
sourceComponent: modelData.t === 0 ? headerComp : rowComp
height: root.nativeModelActive
? root.rowH + (modelData.groupStart ? root.headerH : 0)
: (modelData.t === 0 ? root.headerH : root.rowH)
sourceComponent: root.nativeModelActive ? nativeRowComp
: (modelData.t === 0 ? headerComp : rowComp)
Component {
id: headerComp
@@ -251,13 +283,70 @@ Item {
showArtwork: root.view.trackArtwork
selectMode: root.view.tracksMultiSelect
checked: root.view.tracksSelected[modelData.row.id] === true
onPlayRequested: root.playRow(modelData.row.id)
onPlayRequested: root.playRow(modelData.n - 1, modelData.row.id)
onEnqueueRequested: function (m) {
QbzLocal.enqueue("track", modelData.row.id, m)
}
onToggleSelect: function (mods) { root.view.toggleTrackSelected(modelData.row.id, mods) }
}
}
Component {
id: nativeRowComp
Item {
Text {
x: 2
y: 0
width: parent.width - 4
height: root.headerH
visible: modelData.groupStart && !modelData.loading
verticalAlignment: Text.AlignVCenter
text: modelData.groupLabel
color: theme.textSecondary
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
elide: Text.ElideRight
}
LocalTrackRow {
y: modelData.groupStart ? root.headerH : 0
width: parent.width
height: root.rowH
visible: !modelData.loading
enabled: !modelData.loading
view: root.view
item: modelData.row
artSource: modelData.row.artPath
|| root.view.artMap[modelData.row.artKey] || ""
number: modelData.n
showAlbum: root.view.tracksGroup !== "album"
showArtwork: root.view.trackArtwork
selectMode: root.view.tracksMultiSelect
checked: modelData.selected
nativeActions: true
nativeIndex: modelData.n - 1
onPlayRequested: QbzLocal.tracksNativePlay(modelData.n - 1)
onEnqueueRequested: function (m) {
QbzLocal.tracksNativeEnqueue(modelData.n - 1, m)
}
onToggleSelect: function (mods) {
QbzLocal.tracksNativeToggleSelect(
modelData.n - 1,
(mods & Qt.ShiftModifier) !== 0)
}
}
QbzSkeleton {
y: modelData.groupStart ? root.headerH : 0
width: parent.width
height: root.rowH
visible: modelData.loading
variant: "rowList"
rowH: root.rowH
rowGap: 0
rowArt: root.view.trackArtwork
rowArtSize: 36
phase: root.view.skelPhase
}
}
}
}
// Infinite scroll: the NEXT page in the shape it will arrive
@@ -265,11 +354,12 @@ Item {
// placeholder is replaced by a real row as the page lands.
footer: Item {
width: list.width
height: QbzLocal.localTracksLoadingMore ? 3 * root.rowH : 0
height: !root.nativeModelActive && QbzLocal.localTracksLoadingMore
? 3 * root.rowH : 0
QbzSkeleton {
variant: "rowList"
anchors.fill: parent
visible: QbzLocal.localTracksLoadingMore
visible: !root.nativeModelActive && QbzLocal.localTracksLoadingMore
rowH: root.rowH
rowGap: 0
rowArt: root.view.trackArtwork
@@ -322,14 +412,31 @@ Item {
// 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) {
var modelCount = root.nativeModelActive
? QbzLocal.localTracksNativeTotal : root.entries.length
if (!list.visible || !view.trackArtwork || modelCount === 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)
if (last < 0) last = Math.min(modelCount - 1, first + 12)
if (root.nativeModelActive) {
var resident = []
var nativeFirst = Math.max(0, first - 4)
var nativeLast = Math.min(modelCount - 1, last + 4)
for (var j = nativeFirst; j <= nativeLast; j++) {
var data = root.view.nativeTracksModel.rowAt(j)
if (!data.loading && data.row && data.row.artKey) resident.push(data.row)
}
if (resident.length === 0) {
view.releaseWindow("tracks")
return
}
view.queueWindowReport(resident, 0, resident.length - 1, "tracks")
return
}
// Entry band -> row band (group headers carry no cover).
var lo = -1
var hi = -1