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:
21 files changed
+3092
-47
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user