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

+1
View File
@@ -303,6 +303,7 @@ fn build_rhi_items() {
"ribbon_item",
"tunnelflow_item",
"local_tracks_model",
"local_albums_model",
] {
let header = format!("cxx/{item}.h");
let source = format!("cxx/{item}.cpp");
+305
View File
@@ -0,0 +1,305 @@
#include "local_albums_model.h"
#include <QtCore/QCoreApplication>
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QMetaMethod>
#include <QtCore/QMetaObject>
#include <QtCore/QPointer>
#include <QtQml/qqml.h>
#include <limits>
namespace {
QPointer<LocalAlbumsModel> g_model;
void registerLocalAlbumsModel()
{
auto *application = QCoreApplication::instance();
auto *model = new LocalAlbumsModel(application);
g_model = model;
qmlRegisterSingletonInstance<LocalAlbumsModel>(
"com.blitzfc.qbz", 1, 0, "QbzLocalAlbums", model);
}
Q_COREAPP_STARTUP_FUNCTION(registerLocalAlbumsModel)
}
LocalAlbumsModel::LocalAlbumsModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int LocalAlbumsModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid())
return 0;
return int(qMin<qint64>(m_totalCount, std::numeric_limits<int>::max()));
}
QVariant LocalAlbumsModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || role != ModelDataRole)
return {};
return modelData(index.row());
}
QHash<int, QByteArray> LocalAlbumsModel::roleNames() const
{
return {{ ModelDataRole, QByteArrayLiteral("modelData") }};
}
QVariantMap LocalAlbumsModel::rowAt(qint64 row) const
{
if (row < 0 || row >= m_totalCount)
return {};
return modelData(row);
}
QVariantMap LocalAlbumsModel::modelData(qint64 row) const
{
const int page = int(row / PageEntries);
const int offset = int(row % PageEntries);
const auto found = m_pages.constFind(page);
if (found == m_pages.cend() || offset >= found->size()) {
requestPage(page, false);
return {
{ QStringLiteral("t"), 1 },
{ QStringLiteral("loading"), true },
{ QStringLiteral("base"), 0 },
{ QStringLiteral("items"), QVariantList() },
};
}
touch(page);
QVariantMap values = found->at(offset).values;
values.insert(QStringLiteral("loading"), false);
QVariantList items = values.value(QStringLiteral("items")).toList();
for (QVariant &value : items) {
QVariantMap album = value.toMap();
album.insert(
QStringLiteral("selected"),
selected(album.value(QStringLiteral("nativeIndex")).toLongLong()));
value = album;
}
values.insert(QStringLiteral("items"), items);
return values;
}
void LocalAlbumsModel::requestPage(int page, bool prefetch) const
{
if (page < 0)
return;
const QMetaMethod missSignal = QMetaMethod::fromSignal(&LocalAlbumsModel::pageMiss);
if (!isSignalConnected(missSignal))
return;
if (m_pending.contains(page)) {
if (!prefetch)
m_pending.insert(page, false);
return;
}
m_pending.insert(page, prefetch);
const int generation = m_generation;
auto *self = const_cast<LocalAlbumsModel *>(this);
QMetaObject::invokeMethod(
self,
[self, page, generation]() {
if (generation != self->m_generation)
return;
const QMetaMethod missSignal =
QMetaMethod::fromSignal(&LocalAlbumsModel::pageMiss);
if (self->isSignalConnected(missSignal)) {
emit self->pageMiss(page, generation);
} else {
self->m_pending.remove(page);
}
},
Qt::QueuedConnection);
}
void LocalAlbumsModel::touch(int page) const
{
m_lru.removeAll(page);
m_lru.append(page);
}
bool LocalAlbumsModel::selected(qint64 row) const
{
bool inRange = false;
for (const auto &range : m_selectionRanges) {
if (row < range.first)
break;
if (row <= range.second) {
inRange = true;
break;
}
}
return m_selectAll ? !inRange : inRange;
}
void LocalAlbumsModel::resetQuery(int generation, qint64 totalCount, qint64 albumTotal)
{
beginResetModel();
m_generation = generation;
m_totalCount = qMax<qint64>(0, totalCount);
m_albumTotal = qMax<qint64>(0, albumTotal);
m_pages.clear();
m_lru.clear();
m_pending.clear();
m_residentAlbums = 0;
m_evictions = 0;
m_selectAll = false;
m_selectionRanges.clear();
m_selectedCount = 0;
endResetModel();
emit generationChanged();
emit totalCountChanged();
emit albumTotalChanged();
emit residentAlbumsChanged();
emit evictionsChanged();
emit selectedCountChanged();
}
bool LocalAlbumsModel::applyPage(int generation, int page, const QByteArray &json)
{
if (generation != m_generation || page < 0)
return false;
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(json, &error);
if (error.error != QJsonParseError::NoError || !document.isArray())
return false;
Page entries;
const QJsonArray array = document.array();
entries.reserve(array.size());
for (const QJsonValue &value : array) {
if (!value.isObject())
return false;
Entry entry;
entry.values = value.toObject().toVariantMap();
const QVariantList items = entry.values.value(QStringLiteral("items")).toList();
entry.albumCount = items.size();
for (const QVariant &item : items) {
const QString key = item.toMap().value(QStringLiteral("artKey")).toString();
if (!key.isEmpty())
entry.artKeys.append(key);
}
entries.append(std::move(entry));
}
const auto previous = m_pages.constFind(page);
if (previous != m_pages.cend()) {
for (const Entry &entry : *previous)
m_residentAlbums -= entry.albumCount;
}
m_pages.insert(page, std::move(entries));
for (const Entry &entry : m_pages.value(page))
m_residentAlbums += entry.albumCount;
const bool wasPrefetch = m_pending.take(page);
touch(page);
evictIfNeeded();
const int first = page * PageEntries;
const int last = qMin(rowCount() - 1, first + PageEntries - 1);
if (first <= last)
emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole });
emit residentAlbumsChanged();
const int next = page + 1;
if (!wasPrefetch && qint64(next) * PageEntries < m_totalCount && !m_pages.contains(next))
requestPage(next, true);
return true;
}
void LocalAlbumsModel::evictIfNeeded()
{
while (m_residentAlbums > MaxResidentAlbums && m_pages.size() > 1 && !m_lru.isEmpty()) {
const int page = m_lru.takeFirst();
const auto found = m_pages.find(page);
if (found == m_pages.end())
continue;
for (const Entry &entry : *found)
m_residentAlbums -= entry.albumCount;
m_pages.erase(found);
++m_evictions;
const int first = page * PageEntries;
const int last = qMin(rowCount() - 1, first + PageEntries - 1);
if (first <= last)
emit dataChanged(index(first, 0), index(last, 0), { ModelDataRole });
}
emit evictionsChanged();
}
void LocalAlbumsModel::setArtwork(const QString &artKey, const QString &path)
{
if (artKey.isEmpty() || path.isEmpty())
return;
for (auto page = m_pages.begin(); page != m_pages.end(); ++page) {
for (int offset = 0; offset < page->size(); ++offset) {
Entry &entry = (*page)[offset];
if (!entry.artKeys.contains(artKey))
continue;
QVariantList items = entry.values.value(QStringLiteral("items")).toList();
for (QVariant &value : items) {
QVariantMap album = value.toMap();
if (album.value(QStringLiteral("artKey")).toString() == artKey)
album.insert(QStringLiteral("artPath"), path);
value = album;
}
entry.values.insert(QStringLiteral("items"), items);
const int row = page.key() * PageEntries + offset;
emit dataChanged(index(row, 0), index(row, 0), { ModelDataRole });
}
}
}
void LocalAlbumsModel::setSelection(
int generation, const QByteArray &json, qint64 selectedCount)
{
if (generation != m_generation)
return;
const QJsonDocument document = QJsonDocument::fromJson(json);
if (!document.isObject())
return;
const QJsonObject object = document.object();
QList<QPair<qint64, qint64>> ranges;
for (const QJsonValue &value : object.value(QStringLiteral("ranges")).toArray()) {
const QJsonArray pair = value.toArray();
if (pair.size() != 2)
continue;
ranges.append({ qMax<qint64>(0, pair.at(0).toInteger()),
qMax<qint64>(0, pair.at(1).toInteger()) });
}
m_selectAll = object.value(QStringLiteral("all")).toBool();
m_selectionRanges = std::move(ranges);
m_selectedCount = qMax<qint64>(0, selectedCount);
emit selectedCountChanged();
if (rowCount() > 0)
emit dataChanged(index(0, 0), index(rowCount() - 1, 0), { ModelDataRole });
}
extern "C" void qbz_local_albums_register_qml_type()
{
// Link anchor. Registration is performed by Q_COREAPP_STARTUP_FUNCTION.
}
extern "C" void qbz_local_albums_reset(
int generation, qint64 totalCount, qint64 albumTotal)
{
if (g_model)
g_model->resetQuery(generation, totalCount, albumTotal);
}
extern "C" bool qbz_local_albums_apply_page(int generation, int page, const char *json)
{
return g_model && g_model->applyPage(
generation, page, QByteArray(json ? json : "[]"));
}
extern "C" void qbz_local_albums_set_selection(
int generation, const char *json, qint64 selectedCount)
{
if (g_model)
g_model->setSelection(
generation, QByteArray(json ? json : "{}"), selectedCount);
}
+88
View File
@@ -0,0 +1,88 @@
#ifndef QBZ_LOCAL_ALBUMS_MODEL_H
#define QBZ_LOCAL_ALBUMS_MODEL_H
#include <QtCore/QAbstractListModel>
#include <QtCore/QHash>
#include <QtCore/QList>
#include <QtCore/QVariantMap>
class LocalAlbumsModel final : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(int generation READ generation NOTIFY generationChanged)
Q_PROPERTY(qint64 totalCount READ totalCount NOTIFY totalCountChanged)
Q_PROPERTY(qint64 albumTotal READ albumTotal NOTIFY albumTotalChanged)
Q_PROPERTY(int residentAlbums READ residentAlbums NOTIFY residentAlbumsChanged)
Q_PROPERTY(qint64 evictions READ evictions NOTIFY evictionsChanged)
Q_PROPERTY(qint64 selectedCount READ selectedCount NOTIFY selectedCountChanged)
public:
enum Role { ModelDataRole = Qt::UserRole + 1 };
explicit LocalAlbumsModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
int generation() const { return m_generation; }
qint64 totalCount() const { return m_totalCount; }
qint64 albumTotal() const { return m_albumTotal; }
int residentAlbums() const { return m_residentAlbums; }
qint64 evictions() const { return m_evictions; }
qint64 selectedCount() const { return m_selectedCount; }
Q_INVOKABLE QVariantMap rowAt(qint64 row) const;
Q_INVOKABLE void setArtwork(const QString &artKey, const QString &path);
void resetQuery(int generation, qint64 totalCount, qint64 albumTotal);
bool applyPage(int generation, int page, const QByteArray &json);
void setSelection(int generation, const QByteArray &json, qint64 selectedCount);
signals:
void generationChanged();
void totalCountChanged();
void albumTotalChanged();
void residentAlbumsChanged();
void evictionsChanged();
void selectedCountChanged();
void pageMiss(int page, int generation);
private:
struct Entry {
QVariantMap values;
QStringList artKeys;
int albumCount = 0;
};
using Page = QList<Entry>;
static constexpr int PageEntries = 100;
static constexpr int MaxResidentAlbums = 2000;
QVariantMap modelData(qint64 row) const;
void requestPage(int page, bool prefetch = false) const;
void touch(int page) const;
bool selected(qint64 row) const;
void evictIfNeeded();
int m_generation = 0;
qint64 m_totalCount = 0;
qint64 m_albumTotal = 0;
QHash<int, Page> m_pages;
mutable QList<int> m_lru;
mutable QHash<int, bool> m_pending;
int m_residentAlbums = 0;
qint64 m_evictions = 0;
bool m_selectAll = false;
QList<QPair<qint64, qint64>> m_selectionRanges;
qint64 m_selectedCount = 0;
};
extern "C" {
void qbz_local_albums_register_qml_type();
void qbz_local_albums_reset(int generation, qint64 totalCount, qint64 albumTotal);
bool qbz_local_albums_apply_page(int generation, int page, const char *json);
void qbz_local_albums_set_selection(int generation, const char *json, qint64 selectedCount);
}
#endif // QBZ_LOCAL_ALBUMS_MODEL_H
+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
+4 -3
View File
@@ -520,8 +520,9 @@ pub fn load_counts_blocking() -> LocalCounts {
})
}
/// An album's tracks by group key: the Plex cache for a `plex:` key, else
/// the ACTIVE identity mode's query against `library.db`.
/// An album's tracks by group key: the Plex cache for a legacy content hash
/// or native edition key, else the ACTIVE identity mode's query against
/// `library.db`.
pub fn fetch_album_tracks_blocking(id: &str) -> Vec<LocalTrack> {
if id.starts_with("plex:") {
return crate::local_plex::album_tracks(id);
@@ -550,7 +551,7 @@ pub fn fetch_album_tracks_blocking(id: &str) -> Vec<LocalTrack> {
}
/// The local album-detail document (header + versions + track list). `id` is
/// the group key of the ACTIVE identity mode, or a Plex `plex:<hash>` key.
/// the group key of the ACTIVE identity mode, or a Plex legacy/native key.
///
/// The tracks are split into VERSIONS by source directory before the header is
/// built — two physical copies of the same album must not merge into one
File diff suppressed because it is too large. Load diff
+93
View File
@@ -56,6 +56,14 @@ pub mod qbz_local {
#[qproperty(bool, local_albums_loading)]
#[qproperty(QString, local_albums_error)]
#[qproperty(QString, local_albums_json)]
/// Phase-F1 surface switch. False keeps the legacy JSON collection;
/// true binds LocalAlbumsTab to the paged QAbstractListModel.
#[qproperty(bool, local_albums_native_active)]
#[qproperty(i64, local_albums_native_total)]
#[qproperty(i64, local_albums_native_selected_count)]
/// Bounded A-Z jump metadata (`[{letter,index}]`), never album rows.
#[qproperty(QString, local_albums_native_jumps_json)]
#[qproperty(QString, local_albums_native_error)]
// --- Artists tab --------------------------------------------------
#[qproperty(bool, local_artists_loading)]
@@ -245,6 +253,36 @@ pub mod qbz_local {
#[qinvokable]
fn set_album_mode(self: Pin<&mut QbzLocal>, mode: QString);
// --- Albums tab (native paged model) ------------------------------
/// Reset the immutable SQL descriptor and publish its first page.
#[qinvokable]
fn albums_native_reset(
self: Pin<&mut QbzLocal>,
search: QString,
sort: QString,
group: QString,
filter_json: QString,
columns: i32,
);
#[qinvokable]
fn albums_native_page_miss(
self: Pin<&mut QbzLocal>,
page: i32,
generation: i32,
);
#[qinvokable]
fn albums_native_toggle_select(
self: Pin<&mut QbzLocal>,
index: i64,
shift: bool,
);
#[qinvokable]
fn albums_native_select_all(self: Pin<&mut QbzLocal>);
#[qinvokable]
fn albums_native_clear_selection(self: Pin<&mut QbzLocal>);
#[qinvokable]
fn albums_native_bulk_action(self: Pin<&mut QbzLocal>, action: QString);
// --- Tracks tab ----------------------------------------------------
/// Toolbar search (server-side; resets to page 1).
#[qinvokable]
@@ -564,6 +602,11 @@ pub struct QbzLocalRust {
local_albums_loading: bool,
local_albums_error: QString,
local_albums_json: QString,
local_albums_native_active: bool,
local_albums_native_total: i64,
local_albums_native_selected_count: i64,
local_albums_native_jumps_json: QString,
local_albums_native_error: QString,
local_artists_loading: bool,
local_artists_json: QString,
local_folders_loading: bool,
@@ -626,6 +669,11 @@ impl Default for QbzLocalRust {
local_albums_loading: false,
local_albums_error: QString::default(),
local_albums_json: QString::from("[]"),
local_albums_native_active: false,
local_albums_native_total: 0,
local_albums_native_selected_count: 0,
local_albums_native_jumps_json: QString::from("[]"),
local_albums_native_error: QString::default(),
local_artists_loading: false,
local_artists_json: QString::from("[]"),
local_folders_loading: false,
@@ -757,6 +805,51 @@ impl qbz_local::QbzLocal {
invalidate_artists();
}
pub fn albums_native_reset(
self: Pin<&mut Self>,
search: QString,
sort: QString,
group: QString,
filter_json: QString,
columns: i32,
) {
crate::local_albums_model_qt::reset(
search.to_string(),
sort.to_string(),
group.to_string(),
filter_json.to_string(),
columns,
);
}
pub fn albums_native_page_miss(
self: Pin<&mut Self>,
page: i32,
generation: i32,
) {
crate::local_albums_model_qt::request_page(page, generation);
}
pub fn albums_native_toggle_select(
self: Pin<&mut Self>,
index: i64,
shift: bool,
) {
crate::local_albums_model_qt::toggle_selection(index, shift);
}
pub fn albums_native_select_all(self: Pin<&mut Self>) {
crate::local_albums_model_qt::select_all();
}
pub fn albums_native_clear_selection(self: Pin<&mut Self>) {
crate::local_albums_model_qt::clear_selection();
}
pub fn albums_native_bulk_action(self: Pin<&mut Self>, action: QString) {
crate::local_albums_model_qt::bulk_action(action.to_string());
}
pub fn tracks_search(self: Pin<&mut Self>, query: QString) {
lib::set_tracks_query(&query.to_string());
load_tracks(true);
+20
View File
@@ -207,6 +207,9 @@ pub(crate) fn emit_artwork(pairs: Vec<(String, String)>) {
pub(crate) fn load_tab_impl(tab: String) {
match tab.as_str() {
"albums" => load_albums(),
// Artists remains on the bounded legacy reader until its own F2
// commit, and it still derives album credits from that document.
"albums-legacy" => load_albums_legacy(),
"artists" => load_artists(),
"folders" => load_folders(),
"tracks" => load_tracks(true),
@@ -221,6 +224,23 @@ pub(crate) fn load_tab_impl(tab: String) {
}
pub(crate) fn load_albums() {
ui(|mut b| {
b.as_mut().set_local_albums_loading(true);
b.as_mut().set_local_albums_error(QString::from(""));
});
if crate::local_albums_model_qt::requested()
&& crate::local_library_qt::album_mode() == "folder"
{
// Once QML has supplied its width-dependent descriptor this is also
// the retry path used by tab remounts and album-mode changes. On the
// first mount LocalAlbumCollection supplies it immediately.
crate::local_albums_model_qt::retry_last();
return;
}
load_albums_legacy();
}
pub(crate) fn load_albums_legacy() {
ui(|mut b| {
b.as_mut().set_local_albums_loading(true);
b.as_mut().set_local_albums_error(QString::from(""));
+6
View File
@@ -175,6 +175,12 @@ fn resolve_blocking(scope: &str, ids: &[String]) -> Vec<LocalTrack> {
.collect()
}
pub(crate) fn resolve_album_ids_blocking(ids: &[String]) -> Vec<LocalTrack> {
ids.iter()
.flat_map(|key| fetch_album_tracks_blocking(key))
.collect()
}
/// Run one action over already-resolved rows. Returns whether the caller
/// should DROP its selection afterwards — the Slint clears after an enqueue
/// and keeps it while a picker is still open.
+6
View File
@@ -144,6 +144,9 @@ pub(crate) fn start() {
if ready && crate::local_tracks_model_qt::requested() {
crate::local_bridge_ops::load_tracks(true);
}
if ready && crate::local_albums_model_qt::requested() {
crate::local_albums_model_qt::retry_last();
}
}
Ok(Err(CatalogError::InsufficientSpace {
required_bytes,
@@ -252,6 +255,9 @@ pub(crate) fn request_catch_up() {
if ready && crate::local_tracks_model_qt::requested() {
crate::local_bridge_ops::load_tracks(true);
}
if ready && crate::local_albums_model_qt::requested() {
crate::local_albums_model_qt::retry_last();
}
}
Ok(Err(CatalogError::InsufficientSpace {
required_bytes,
+2 -1
View File
@@ -379,7 +379,8 @@ pub fn search_tracks_page(
.collect()
}
/// One Plex album's tracks, by the content-hash key (`plex:<hash>`).
/// One Plex album's tracks, by the legacy content hash (`plex:<hash>`) or the
/// source-native edition key (`plex:album:<parentRatingKey>`).
pub fn album_tracks(album_key: &str) -> Vec<LocalTrack> {
qbz_plex::plex_cache_get_album_tracks(album_key.to_string())
.unwrap_or_default()
+3
View File
@@ -184,6 +184,8 @@ mod local_catalog_qt;
// Native bounded Tracks QAbstractListModel + catalog query controller (phase E).
// Plain Rust module; the QML type itself is hand-written C++ under cxx/.
mod local_tracks_model_qt;
// Native bounded Albums entry model (phase F1); grid/list geometry stays QML.
mod local_albums_model_qt;
mod local_plex;
// Plex PIN sign-in (the "Authorize" half of the Plex settings) + the
// Check-connection ping. Over `qbz_plex`'s existing pin/start, pin/check and
@@ -3037,6 +3039,7 @@ fn main() {
// Link anchor for the hand-written QAbstractListModel. Its QML singleton
// registration itself runs at QCoreApplication startup.
local_tracks_model_qt::register_qml_model();
local_albums_model_qt::register_qml_model();
// BEFORE any restore reads a pref: read + increment the crash chain, so a
// boot that dies from a restored view or queue degrades the NEXT boot
// instead of trapping the user in a loop it cannot escape from inside the