feat(library): page Albums from local catalog

Materialize album browse fields and FTS in the derived catalog, then expose exact counts and stable keyset queries for the current filters, sort, grouping, and enabled sources.

Bind the existing Albums layout to a bounded native Qt model with asynchronous page misses, anchors, selection ranges, generation cancellation, artwork updates, and per-surface fallback. Resolve Plex native edition keys directly and retain the metadata-mode legacy reader.

cx
This commit is contained in:
vicrodh committed 2026-08-23 10:54:31 -06:00
1 parent 1bb3c41dcf
commit 299dcde5b4
21 files changed
+3092 -47

No files matched your search

+42 -6
View File
@@ -62,6 +62,7 @@ Rectangle {
// hand-written singleton owns the resident LRU; keeping this reference at
// the composition root also gives every Tracks delegate one stable model.
readonly property var nativeTracksModel: QbzLocalTracks
readonly property var nativeAlbumsModel: QbzLocalAlbums
// ============================ state ==================================
// Slint defaults, verbatim (state.slint LocalLibraryState).
@@ -74,7 +75,9 @@ Rectangle {
property string albumsView: "grid" // grid | list
property bool albumsMultiSelect: false
property var albumsSelected: ({})
readonly property int albumsSelectedCount: Object.keys(albumsSelected).length
readonly property int albumsSelectedCount: QbzLocal.localAlbumsNativeActive
? QbzLocal.localAlbumsNativeSelectedCount
: Object.keys(albumsSelected).length
// Folders tab.
property string foldersMode: "tree" // flat | tree
@@ -331,6 +334,7 @@ Rectangle {
target: QbzLocal
function onLocalArtworkReady(key, path) {
root.nativeTracksModel.setArtwork(key, path)
root.nativeAlbumsModel.setArtwork(key, path)
root._artInbox[key] = path
if (!artFlush.running) artFlush.start()
// An arrival is live evidence that the pass is still running, so
@@ -347,6 +351,12 @@ Rectangle {
QbzLocal.tracksNativePageMiss(page, generation)
}
}
Connections {
target: root.nativeAlbumsModel
function onPageMiss(page, generation) {
QbzLocal.albumsNativePageMiss(page, generation)
}
}
// A name route from LocalAlbumView ("go to artist" on a local album:
// local/Plex artists have no catalog id) lands the view on the Artists
@@ -393,11 +403,16 @@ Rectangle {
consumePendingArtist()
consumePendingRoute()
}
Component.onDestruction: {
if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection()
if (QbzLocal.localTracksNativeActive) QbzLocal.tracksNativeClearSelection()
}
onActiveTabChanged: {
QbzLocal.loadTab(activeTab)
// The Artists detail derives from the album set (the DB aggregates
// the contributor list per album), so make sure it is loaded.
if (activeTab === "artists" && albums.length === 0) QbzLocal.loadTab("albums")
if (activeTab === "artists" && albums.length === 0)
QbzLocal.loadTab("albums-legacy")
// The tab that just appeared has covers to ask for and the one that
// left has covers to let go of. Each surface answers for itself.
artworkRefresh()
@@ -551,8 +566,12 @@ Rectangle {
return out
}
readonly property var albumsVisible:
applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort))
// Once F1 owns the Albums tab, never run the legacy O(n) JS pipeline in
// parallel. The old document may still be resident because Artists uses
// it until F2; it is evaluated only while that legacy surface is active.
readonly property var albumsVisible: QbzLocal.localAlbumsNativeActive
&& activeTab === "albums" ? []
: applyFilter(sortRows(filterRows(albums, albumsSearch), albumsSort))
readonly property var albumsGrouped: groupRows(albumsVisible, albumsGroup)
readonly property var foldersVisible:
@@ -905,7 +924,10 @@ Rectangle {
function toggleAlbumsMultiSelect() {
albumsMultiSelect = !albumsMultiSelect
if (!albumsMultiSelect) { albumsSelected = ({}); albumSel.anchorId = "" }
if (!albumsMultiSelect) {
albumsSelected = ({}); albumSel.anchorId = ""
if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection()
}
}
/// Excel-style selection — controls/SelectionModel.qml holds the anchor
/// and the Shift-range rule; this view keeps owning its maps. Ranges run
@@ -916,7 +938,17 @@ Rectangle {
albumsSelected = albumSel.next(albumsSelected, id, albumsVisible,
mods === undefined ? Qt.NoModifier : mods)
}
function toggleNativeAlbumSelected(index, mods) {
QbzLocal.albumsNativeToggleSelect(
index, (mods & Qt.ShiftModifier) !== 0)
}
function albumsBulkAction(action) {
if (QbzLocal.localAlbumsNativeActive) {
if (action === "clear") QbzLocal.albumsNativeClearSelection()
else if (action === "select-all") QbzLocal.albumsNativeSelectAll()
else QbzLocal.albumsNativeBulkAction(action)
return
}
if (action === "clear") { albumsSelected = ({}); albumSel.anchorId = ""; return }
if (action === "select-all") {
var s = {}
@@ -974,7 +1006,11 @@ Rectangle {
function exitMultiSelectMode() {
// Same "leaving drops the selection" contract the toggle buttons
// carry (toggleAlbumsMultiSelect / toggleTracksMultiSelect).
if (root.albumsMultiSelect) { root.albumsMultiSelect = false; root.albumsSelected = ({}) }
if (root.albumsMultiSelect) {
root.albumsMultiSelect = false
root.albumsSelected = ({})
if (QbzLocal.localAlbumsNativeActive) QbzLocal.albumsNativeClearSelection()
}
if (root.tracksMultiSelect) {
root.tracksMultiSelect = false
root.tracksSelected = ({})
@@ -44,6 +44,12 @@ Item {
property bool selectMode: false
/// { albumId: true } — the host owns the selection set.
property var selected: ({})
/// Only the Albums tab opts into the phase-F1 native stream. The other
/// two collection users keep their existing bounded arrays.
property bool nativeSurface: false
property bool nativeActive: false
property var nativeModel: null
property string nativeJumpsJson: "[]"
/// The scrollbar belongs on the WINDOW's right edge, but hosts inset
/// their content (LocalAlbumsTab pads 32, plus 20 more to clear the A-Z
@@ -62,6 +68,7 @@ Item {
/// `modifiers` rides through from the card's mouse event — Shift is
/// what turns a click into a range (controls/SelectionModel.qml).
signal toggleSelect(string id, int modifiers)
signal nativeToggleSelect(int index, int modifiers)
QbzTheme { id: theme }
@@ -120,12 +127,24 @@ Item {
}
property int rebuildCount: 0
function parseNativeJumps() {
try { return JSON.parse(nativeJumpsJson || "[]") }
catch (e) { return [] }
}
function rebuild() {
// No width yet: build NOTHING rather than something guaranteed wrong.
// One empty frame beats a full-length one-column grid that has to be
// discarded — and in the common case the width IS known here, so this
// never fires and the synchronous first build is unchanged.
if (cols <= 0) return
if (nativeActive) {
flat = []
entries = []
alphaJumps = parseNativeJumps()
report()
return
}
var _t0 = Date.now()
var out = []
var flatOut = []
@@ -191,11 +210,43 @@ Item {
onRowsChanged: scheduleRebuild()
onGroupsChanged: scheduleRebuild()
onGroupedChanged: scheduleRebuild()
onViewModeChanged: scheduleRebuild()
onColsChanged: scheduleRebuild()
onViewModeChanged: { scheduleRebuild(); scheduleNativeReset() }
onColsChanged: { scheduleRebuild(); scheduleNativeReset() }
onNativeActiveChanged: { scheduleRebuild(); reportSoon() }
onNativeJumpsJsonChanged: if (nativeActive) alphaJumps = parseNativeJumps()
Timer {
id: nativeQueryCoalescer
interval: 0
repeat: false
onTriggered: root.resetNativeQuery()
}
function scheduleNativeReset() {
if (nativeSurface && cols > 0) nativeQueryCoalescer.restart()
}
function resetNativeQuery() {
if (!nativeSurface || !view || cols <= 0) return
QbzLocal.albumsNativeReset(
view.albumsSearch,
view.albumsSort,
view.albumsGroup,
JSON.stringify(view.filter || ({})),
cols)
}
Connections {
target: root.nativeSurface ? root.view : null
function onAlbumsSearchChanged() { root.scheduleNativeReset() }
function onAlbumsSortChanged() { root.scheduleNativeReset() }
function onAlbumsGroupChanged() { root.scheduleNativeReset() }
function onFilterChanged() { root.scheduleNativeReset() }
}
Connections {
target: root.nativeSurface ? QbzLocal : null
function onLocalAlbumModeChanged() { root.scheduleNativeReset() }
}
// The first build stays synchronous — deferring it would show one frame of
// empty grid on every mount.
Component.onCompleted: { rebuild(); reportSoon() }
Component.onCompleted: { rebuild(); reportSoon(); scheduleNativeReset() }
Component.onDestruction: if (view) view.releaseWindow(root.surface)
// ---------------------------------------------------------------------
@@ -211,14 +262,29 @@ Item {
// a viewport resize, and the scroll.
function report() {
if (!view || !list) return
if (!list.visible || entries.length === 0 || width <= 0) {
var count = nativeActive && nativeModel ? nativeModel.totalCount : entries.length
if (!list.visible || count === 0 || width <= 0) {
view.releaseWindow(root.surface)
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(entries.length - 1, first + 8)
if (last < 0) last = Math.min(count - 1, first + 8)
if (nativeActive && nativeModel) {
var resident = []
for (var entryIndex = first; entryIndex <= last; entryIndex++) {
var entry = nativeModel.rowAt(entryIndex)
if (!entry || entry.loading || !entry.items) continue
for (var itemIndex = 0; itemIndex < entry.items.length; itemIndex++)
resident.push(entry.items[itemIndex])
}
if (resident.length > 0)
view.queueWindowReport(resident, 0, resident.length - 1, root.surface)
else
view.releaseWindow(root.surface)
return
}
var lo = entries[first]
var hi = entries[last]
// Both entry kinds now carry `base`, so a header at either edge of
@@ -273,7 +339,7 @@ Item {
// pooled delegate its own Popup — so the menus above had to become
// lazy FIRST. In the other order this would install that bug.
reuseItems: true
model: root.entries
model: root.nativeActive && root.nativeModel ? root.nativeModel : root.entries
onContentYChanged: root.report()
onModelChanged: root.report()
onHeightChanged: root.report()
@@ -284,8 +350,23 @@ Item {
width: list.width
height: modelData.t === 0 ? root.headerH
: root.viewMode === "grid" ? root.cellH : root.listRowH
sourceComponent: modelData.t === 0 ? headerComp
sourceComponent: modelData.loading ? loadingComp
: modelData.t === 0 ? headerComp
: root.viewMode === "grid" ? cardRowComp : listRowComp
onModelDataChanged: root.reportSoon()
Component {
id: loadingComp
QbzSkeleton {
variant: root.viewMode === "grid" ? "cardGrid" : "rowList"
cellW: root.cellW
cellH: root.cellH
rowH: root.listRowH
rowGap: 0
rowArtSize: 40
phase: root.view ? root.view.skelPhase : false
}
}
Component {
id: headerComp
@@ -397,8 +478,8 @@ Item {
genre: ""
year: cardCell.slot.year
qualityTier: cardCell.slot.qualityTier
artSource: root.view
? (root.view.artMap[cardCell.slot.artKey] || "") : ""
artSource: cardCell.slot.artPath || (root.view
? (root.view.artMap[cardCell.slot.artKey] || "") : "")
// LocalLibraryView.slint:1267 `show-source-badge`
// — the card takes the raw source word and the
// BADGE is what the flag switches (blanking
@@ -432,8 +513,15 @@ Item {
// which is exactly what a host overlay cannot
// do. Do not re-add one.
selectMode: root.selectMode
selected: root.selected[cardCell.slot.id] === true
onSelectToggled: root.toggleSelect(cardCell.slot.id)
selected: root.nativeActive
? cardCell.slot.selected === true
: root.selected[cardCell.slot.id] === true
onSelectToggled: {
if (root.nativeActive)
root.nativeToggleSelect(cardCell.slot.nativeIndex, Qt.NoModifier)
else
root.toggleSelect(cardCell.slot.id, Qt.NoModifier)
}
// Non-select mode only — the card routes a
// select-mode click to `selectToggled` and never
// emits this while ticking.
@@ -494,17 +582,23 @@ Item {
view: root.view
item: modelData.items[0]
artSource: root.view
? (root.view.artMap[modelData.items[0].artKey] || "") : ""
? (modelData.items[0].artPath
|| root.view.artMap[modelData.items[0].artKey] || "") : ""
showSource: root.showSource
selectMode: root.selectMode
checked: root.selected[modelData.items[0].id] === true
checked: root.nativeActive
? modelData.items[0].selected === true
: root.selected[modelData.items[0].id] === true
onOpened: root.openRequested(modelData.items[0].id)
onPlayRequested: root.playRequested(modelData.items[0].id)
onEnqueueRequested: function (m) {
root.enqueueRequested(modelData.items[0].id, m)
}
onToggleSelect: function (mods) {
root.toggleSelect(modelData.items[0].id, mods)
if (root.nativeActive)
root.nativeToggleSelect(modelData.items[0].nativeIndex, mods)
else
root.toggleSelect(modelData.items[0].id, mods)
}
}
}
@@ -19,6 +19,9 @@ Item {
id: root
property var view: null
readonly property bool nativeActive: QbzLocal.localAlbumsNativeActive
readonly property int albumTotal: nativeActive
? QbzLocal.localAlbumsNativeTotal : root.view.albums.length
QbzTheme { id: theme }
@@ -71,12 +74,15 @@ Item {
LocalNote {
visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === ""
&& root.view.albums.length === 0 && root.view.albumsSearch === ""
&& root.albumTotal === 0 && root.view.albumsSearch === ""
text: QbzSession.tr("No albums in your local library yet.", QbzSession.trRev)
}
LocalNote {
visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === ""
&& root.view.albums.length > 0 && root.view.albumsVisible.length === 0
&& ((root.nativeActive && root.albumTotal === 0
&& root.view.albumsSearch !== "")
|| (!root.nativeActive && root.view.albums.length > 0
&& root.view.albumsVisible.length === 0))
text: QbzSession.tr("No albums match your search.", QbzSession.trRev)
}
@@ -87,7 +93,8 @@ Item {
anchors.topMargin: 16
spacing: 8
visible: !QbzLocal.localAlbumsLoading && QbzLocal.localAlbumsError === ""
&& root.view.albumsVisible.length > 0
&& (root.nativeActive ? root.albumTotal > 0
: root.view.albumsVisible.length > 0)
QbzMultiSelectBar {
visible: root.view.albumsMultiSelect
@@ -118,6 +125,10 @@ Item {
scrollBarInset: root.view.albumsGroup === "alpha" ? 34 : 4
view: root.view
surface: "albums"
nativeSurface: true
nativeActive: root.nativeActive
nativeModel: root.view.nativeAlbumsModel
nativeJumpsJson: QbzLocal.localAlbumsNativeJumpsJson
scrollScope: "local:albums"
rows: root.view.albumsVisible
groups: root.view.albumsGrouped
@@ -130,6 +141,9 @@ Item {
onPlayRequested: function (id) { QbzLocal.playAlbum(id, false) }
onEnqueueRequested: function (id, m) { QbzLocal.enqueue("album", id, m) }
onToggleSelect: function (id, mods) { root.view.toggleAlbumSelected(id, mods) }
onNativeToggleSelect: function (index, mods) {
root.view.toggleNativeAlbumSelected(index, mods)
}
}
QbzAlphaStrip {
visible: root.view.albumsGroup === "alpha"
@@ -76,7 +76,9 @@ Row {
// ============================ ALBUMS =================================
Row {
visible: root.view && root.view.activeTab === "albums"
&& (root.view.albums.length > 0 || root.view.albumsSearch !== "")
&& (root.view.albums.length > 0 || root.view.albumsSearch !== ""
|| QbzLocal.localAlbumsLoading
|| QbzLocal.localAlbumsNativeActive)
spacing: 8
height: parent.height