feat: album/playlist header and playlist ownership filtering

- Add TrackContextHeader widget: shows album art (fetched via NAM),
  title, subtitle (artist/description), and metadata (year · tracks · duration)
  above the track list when an album or playlist is opened
- Hide header for favorite tracks and search results
- Store user ID in AppSettings on login
- Only show "Delete playlist" for playlists the user owns
- "Add to playlist" submenu only lists owned playlists
- "Remove from this playlist" only appears when viewing an owned playlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-24 11:34:04 +01:00
parent 75e2b623b8
commit 8088412d4b
9 changed files with 240 additions and 37 deletions

View File

@@ -13,6 +13,7 @@ namespace List
static constexpr int TypeRole = Qt::UserRole + 1;
static constexpr int IdRole = Qt::UserRole + 2;
static constexpr int NameRole = Qt::UserRole + 3;
static constexpr int IsOwnerRole = Qt::UserRole + 4;
enum NodeType {
NodeFavTracks,
@@ -86,22 +87,28 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
while (m_playlistsNode->childCount() > 0)
delete m_playlistsNode->takeChild(0);
QVector<QPair<qint64, QString>> playlists;
QVector<QPair<qint64, QString>> editablePlaylists;
const qint64 myUserId = AppSettings::instance().userId();
const QJsonArray items = result["items"].toArray();
for (const auto &v : items) {
const QJsonObject pl = v.toObject();
const QString name = pl["name"].toString();
const qint64 id = static_cast<qint64>(pl["id"].toDouble());
const QJsonObject pl = v.toObject();
const QString name = pl["name"].toString();
const qint64 id = static_cast<qint64>(pl["id"].toDouble());
const qint64 ownId = static_cast<qint64>(pl["owner"].toObject()["id"].toDouble());
const bool isOwner = (myUserId > 0 && ownId == myUserId);
auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name});
item->setData(0, TypeRole, NodePlaylist);
item->setData(0, IdRole, id);
item->setData(0, NameRole, name);
item->setData(0, TypeRole, NodePlaylist);
item->setData(0, IdRole, id);
item->setData(0, NameRole, name);
item->setData(0, IsOwnerRole, isOwner);
playlists.append({id, name});
// Only include playlists we can edit in the "Add to playlist" submenu
if (isOwner)
editablePlaylists.append({id, name});
}
emit userPlaylistsChanged(playlists);
emit userPlaylistsChanged(editablePlaylists);
}
void Library::onContextMenuRequested(const QPoint &pos)
@@ -128,21 +135,24 @@ void Library::onContextMenuRequested(const QPoint &pos)
});
if (isPlaylistItem) {
const qint64 plId = item->data(0, IdRole).toLongLong();
const QString plName = item->data(0, NameRole).toString();
const qint64 plId = item->data(0, IdRole).toLongLong();
const QString plName = item->data(0, NameRole).toString();
const bool isOwner = item->data(0, IsOwnerRole).toBool();
menu.addSeparator();
auto *delPl = menu.addAction(tr("Delete \"%1\"").arg(plName));
connect(delPl, &QAction::triggered, this, [this, plId, plName] {
const auto answer = QMessageBox::question(
this,
tr("Delete Playlist"),
tr("Permanently delete \"%1\"? This cannot be undone.").arg(plName),
QMessageBox::Yes | QMessageBox::Cancel,
QMessageBox::Cancel);
if (answer == QMessageBox::Yes)
m_backend->deletePlaylist(plId);
});
if (isOwner) {
menu.addSeparator();
auto *delPl = menu.addAction(tr("Delete \"%1\"").arg(plName));
connect(delPl, &QAction::triggered, this, [this, plId, plName] {
const auto answer = QMessageBox::question(
this,
tr("Delete Playlist"),
tr("Permanently delete \"%1\"? This cannot be undone.").arg(plName),
QMessageBox::Yes | QMessageBox::Cancel,
QMessageBox::Cancel);
if (answer == QMessageBox::Yes)
m_backend->deletePlaylist(plId);
});
}
}
menu.exec(viewport()->mapToGlobal(pos));

View File

@@ -1,6 +1,7 @@
#pragma once
#include "../backend/qobuzbackend.hpp"
#include "../util/settings.hpp"
#include <QTreeWidget>
#include <QVector>

View File

@@ -1,4 +1,5 @@
#include "tracks.hpp"
#include "../util/settings.hpp"
#include <QHeaderView>
#include <QMenu>
@@ -52,8 +53,11 @@ void Tracks::loadAlbum(const QJsonObject &album)
void Tracks::loadPlaylist(const QJsonObject &playlist)
{
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
setPlaylistContext(id);
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
const qint64 ownId = static_cast<qint64>(playlist["owner"].toObject()["id"].toDouble());
const qint64 myId = AppSettings::instance().userId();
const bool isOwned = (myId > 0 && ownId == myId);
setPlaylistContext(id, isOwned);
const QJsonArray items = playlist["tracks"].toObject()["items"].toArray();
m_model->setTracks(items, /*usePosition=*/true);
}
@@ -64,9 +68,10 @@ void Tracks::loadSearchTracks(const QJsonArray &tracks)
m_model->setTracks(tracks, false, /*useSequential=*/true);
}
void Tracks::setPlaylistContext(qint64 playlistId)
void Tracks::setPlaylistContext(qint64 playlistId, bool isOwned)
{
m_playlistId = playlistId;
m_playlistIsOwned = isOwned;
}
void Tracks::setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists)
@@ -164,7 +169,7 @@ void Tracks::onContextMenu(const QPoint &pos)
}
}
if (m_playlistId > 0) {
if (m_playlistId > 0 && m_playlistIsOwned) {
const qint64 playlistTrackId =
m_model->data(index, TrackListModel::PlaylistTrackIdRole).toLongLong();
if (playlistTrackId > 0) {

View File

@@ -29,7 +29,8 @@ namespace List
void setPlayingTrackId(qint64 id);
/// Set which playlist is currently displayed (0 = none).
void setPlaylistContext(qint64 playlistId);
/// isOwned controls whether "Remove from this playlist" is shown.
void setPlaylistContext(qint64 playlistId, bool isOwned = false);
qint64 playlistId() const { return m_playlistId; }
/// Provide the user's playlist list for the "Add to playlist" submenu.
void setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists);
@@ -44,6 +45,7 @@ namespace List
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
qint64 m_playlistId = 0;
bool m_playlistIsOwned = false;
QVector<QPair<qint64, QString>> m_userPlaylists;
void onDoubleClicked(const QModelIndex &index);