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 TypeRole = Qt::UserRole + 1;
static constexpr int IdRole = Qt::UserRole + 2; static constexpr int IdRole = Qt::UserRole + 2;
static constexpr int NameRole = Qt::UserRole + 3; static constexpr int NameRole = Qt::UserRole + 3;
static constexpr int IsOwnerRole = Qt::UserRole + 4;
enum NodeType { enum NodeType {
NodeFavTracks, NodeFavTracks,
@@ -86,22 +87,28 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
while (m_playlistsNode->childCount() > 0) while (m_playlistsNode->childCount() > 0)
delete m_playlistsNode->takeChild(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(); const QJsonArray items = result["items"].toArray();
for (const auto &v : items) { for (const auto &v : items) {
const QJsonObject pl = v.toObject(); const QJsonObject pl = v.toObject();
const QString name = pl["name"].toString(); const QString name = pl["name"].toString();
const qint64 id = static_cast<qint64>(pl["id"].toDouble()); 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}); auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name});
item->setData(0, TypeRole, NodePlaylist); item->setData(0, TypeRole, NodePlaylist);
item->setData(0, IdRole, id); item->setData(0, IdRole, id);
item->setData(0, NameRole, name); 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) void Library::onContextMenuRequested(const QPoint &pos)
@@ -128,21 +135,24 @@ void Library::onContextMenuRequested(const QPoint &pos)
}); });
if (isPlaylistItem) { if (isPlaylistItem) {
const qint64 plId = item->data(0, IdRole).toLongLong(); const qint64 plId = item->data(0, IdRole).toLongLong();
const QString plName = item->data(0, NameRole).toString(); const QString plName = item->data(0, NameRole).toString();
const bool isOwner = item->data(0, IsOwnerRole).toBool();
menu.addSeparator(); if (isOwner) {
auto *delPl = menu.addAction(tr("Delete \"%1\"").arg(plName)); menu.addSeparator();
connect(delPl, &QAction::triggered, this, [this, plId, plName] { auto *delPl = menu.addAction(tr("Delete \"%1\"").arg(plName));
const auto answer = QMessageBox::question( connect(delPl, &QAction::triggered, this, [this, plId, plName] {
this, const auto answer = QMessageBox::question(
tr("Delete Playlist"), this,
tr("Permanently delete \"%1\"? This cannot be undone.").arg(plName), tr("Delete Playlist"),
QMessageBox::Yes | QMessageBox::Cancel, tr("Permanently delete \"%1\"? This cannot be undone.").arg(plName),
QMessageBox::Cancel); QMessageBox::Yes | QMessageBox::Cancel,
if (answer == QMessageBox::Yes) QMessageBox::Cancel);
m_backend->deletePlaylist(plId); if (answer == QMessageBox::Yes)
}); m_backend->deletePlaylist(plId);
});
}
} }
menu.exec(viewport()->mapToGlobal(pos)); menu.exec(viewport()->mapToGlobal(pos));

View File

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

View File

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

View File

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

View File

@@ -242,6 +242,9 @@ void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user)
const QString email = user["email"].toString(); const QString email = user["email"].toString();
AppSettings::instance().setDisplayName(displayName); AppSettings::instance().setDisplayName(displayName);
AppSettings::instance().setUserEmail(email); AppSettings::instance().setUserEmail(email);
const qint64 userId = static_cast<qint64>(user["id"].toDouble());
if (userId > 0)
AppSettings::instance().setUserId(userId);
statusBar()->showMessage(tr("Signed in as %1").arg( statusBar()->showMessage(tr("Signed in as %1").arg(
displayName.isEmpty() ? email : displayName)); displayName.isEmpty() ? email : displayName));
m_library->refresh(); m_library->refresh();

View File

@@ -21,6 +21,9 @@ public:
QString displayName() const { return m_settings.value("user/display_name").toString(); } QString displayName() const { return m_settings.value("user/display_name").toString(); }
void setDisplayName(const QString &n) { m_settings.setValue("user/display_name", n); } void setDisplayName(const QString &n) { m_settings.setValue("user/display_name", n); }
qint64 userId() const { return m_settings.value("user/id", 0LL).toLongLong(); }
void setUserId(qint64 id) { m_settings.setValue("user/id", id); }
// 5 = MP3, 6 = CD, 7 = HiRes96, 27 = HiRes192 // 5 = MP3, 6 = CD, 7 = HiRes96, 27 = HiRes192
int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); } int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); }
void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); } void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); }

View File

@@ -19,13 +19,24 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
this); this);
m_welcome->setAlignment(Qt::AlignCenter); m_welcome->setAlignment(Qt::AlignCenter);
m_tracks = new List::Tracks(m_backend, queue, this); // Tracks page: context header + track list
auto *tracksPage = new QWidget(this);
auto *tracksLayout = new QVBoxLayout(tracksPage);
tracksLayout->setContentsMargins(0, 0, 0, 0);
tracksLayout->setSpacing(0);
m_header = new TrackContextHeader(tracksPage);
m_header->hide();
m_tracks = new List::Tracks(m_backend, queue, tracksPage);
tracksLayout->addWidget(m_header);
tracksLayout->addWidget(m_tracks, 1);
m_albumList = new AlbumListView(this); m_albumList = new AlbumListView(this);
m_artistList = new ArtistListView(this); m_artistList = new ArtistListView(this);
m_artistView = new ArtistView(this); m_artistView = new ArtistView(this);
m_stack->addWidget(m_welcome); // 0 m_stack->addWidget(m_welcome); // 0
m_stack->addWidget(m_tracks); // 1 m_stack->addWidget(tracksPage); // 1
m_stack->addWidget(m_albumList); // 2 m_stack->addWidget(m_albumList); // 2
m_stack->addWidget(m_artistList); // 3 m_stack->addWidget(m_artistList); // 3
m_stack->addWidget(m_artistView); // 4 m_stack->addWidget(m_artistView); // 4
@@ -41,24 +52,28 @@ void MainContent::showWelcome() { m_stack->setCurrentIndex(0); }
void MainContent::showAlbum(const QJsonObject &album) void MainContent::showAlbum(const QJsonObject &album)
{ {
m_header->setAlbum(album);
m_tracks->loadAlbum(album); m_tracks->loadAlbum(album);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(1);
} }
void MainContent::showPlaylist(const QJsonObject &playlist) void MainContent::showPlaylist(const QJsonObject &playlist)
{ {
m_header->setPlaylist(playlist);
m_tracks->loadPlaylist(playlist); m_tracks->loadPlaylist(playlist);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(1);
} }
void MainContent::showFavTracks(const QJsonObject &result) void MainContent::showFavTracks(const QJsonObject &result)
{ {
m_header->hide();
m_tracks->loadTracks(result["items"].toArray()); m_tracks->loadTracks(result["items"].toArray());
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(1);
} }
void MainContent::showSearchTracks(const QJsonArray &tracks) void MainContent::showSearchTracks(const QJsonArray &tracks)
{ {
m_header->hide();
m_tracks->loadSearchTracks(tracks); m_tracks->loadSearchTracks(tracks);
m_stack->setCurrentIndex(1); m_stack->setCurrentIndex(1);
} }

View File

@@ -6,6 +6,7 @@
#include "albumlistview.hpp" #include "albumlistview.hpp"
#include "artistlistview.hpp" #include "artistlistview.hpp"
#include "artistview.hpp" #include "artistview.hpp"
#include "trackcontextheader.hpp"
#include <QWidget> #include <QWidget>
#include <QLabel> #include <QLabel>
@@ -36,11 +37,12 @@ signals:
void artistRequested(qint64 artistId); void artistRequested(qint64 artistId);
private: private:
QobuzBackend *m_backend = nullptr; QobuzBackend *m_backend = nullptr;
QStackedWidget *m_stack = nullptr; QStackedWidget *m_stack = nullptr;
QLabel *m_welcome = nullptr; QLabel *m_welcome = nullptr;
List::Tracks *m_tracks = nullptr; List::Tracks *m_tracks = nullptr;
AlbumListView *m_albumList = nullptr; TrackContextHeader *m_header = nullptr;
ArtistListView *m_artistList = nullptr; AlbumListView *m_albumList = nullptr;
ArtistView *m_artistView = nullptr; ArtistListView *m_artistList = nullptr;
ArtistView *m_artistView = nullptr;
}; };

View File

@@ -0,0 +1,162 @@
#pragma once
#include <QWidget>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QLabel>
#include <QFont>
#include <QPixmap>
#include <QStringList>
#include <QJsonObject>
#include <QJsonArray>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
/// Header strip shown above the track list when an album or playlist is open.
/// Displays album art, title, subtitle, and metadata.
class TrackContextHeader : public QWidget
{
public:
explicit TrackContextHeader(QWidget *parent = nullptr)
: QWidget(parent)
{
setFixedHeight(140);
auto *hlay = new QHBoxLayout(this);
hlay->setContentsMargins(12, 8, 12, 8);
hlay->setSpacing(14);
m_art = new QLabel(this);
m_art->setFixedSize(120, 120);
m_art->setScaledContents(true);
m_art->setAlignment(Qt::AlignCenter);
m_art->setStyleSheet(QStringLiteral("background: #1a1a1a; border-radius: 4px;"));
hlay->addWidget(m_art, 0, Qt::AlignVCenter);
auto *info = new QWidget(this);
auto *vlay = new QVBoxLayout(info);
vlay->setContentsMargins(0, 0, 0, 0);
vlay->setSpacing(4);
m_title = new QLabel(info);
QFont tf = m_title->font();
tf.setPointSize(tf.pointSize() + 5);
tf.setBold(true);
m_title->setFont(tf);
m_title->setWordWrap(true);
vlay->addWidget(m_title);
m_subtitle = new QLabel(info);
QFont sf = m_subtitle->font();
sf.setPointSize(sf.pointSize() + 1);
m_subtitle->setFont(sf);
m_subtitle->setWordWrap(true);
vlay->addWidget(m_subtitle);
m_meta = new QLabel(info);
QPalette mp = m_meta->palette();
mp.setColor(QPalette::WindowText, QColor(0xaa, 0xaa, 0xaa));
m_meta->setPalette(mp);
vlay->addWidget(m_meta);
vlay->addStretch();
hlay->addWidget(info, 1);
m_nam = new QNetworkAccessManager(this);
QObject::connect(m_nam, &QNetworkAccessManager::finished,
[this](QNetworkReply *reply) {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError)
return;
QPixmap pix;
if (pix.loadFromData(reply->readAll()))
m_art->setPixmap(pix);
});
}
void setAlbum(const QJsonObject &album)
{
m_title->setText(album["title"].toString());
m_subtitle->setText(album["artist"].toObject()["name"].toString());
m_meta->setText(buildAlbumMeta(album));
fetchArt(album["image"].toObject());
show();
}
void setPlaylist(const QJsonObject &playlist)
{
m_title->setText(playlist["name"].toString());
const QString desc = playlist["description"].toString();
const QString owner = playlist["owner"].toObject()["name"].toString();
m_subtitle->setText(desc.isEmpty() ? owner : desc);
m_meta->setText(buildPlaylistMeta(playlist));
const QJsonArray imgs = playlist["image_rectangle"].toArray();
if (!imgs.isEmpty())
fetchUrl(imgs.first().toString());
else
m_art->setPixmap(QPixmap());
show();
}
private:
void fetchArt(const QJsonObject &img)
{
QString url = img["large"].toString();
if (url.isEmpty()) url = img["small"].toString();
fetchUrl(url);
}
void fetchUrl(const QString &url)
{
if (url.isEmpty()) {
m_art->setPixmap(QPixmap());
return;
}
if (url == m_currentArtUrl)
return;
m_currentArtUrl = url;
m_nam->get(QNetworkRequest(QUrl(url)));
}
static QString formatDuration(int totalSecs)
{
const int h = totalSecs / 3600;
const int m = (totalSecs % 3600) / 60;
if (h > 0)
return QStringLiteral("%1h %2m").arg(h).arg(m);
return QStringLiteral("%1 min").arg(m);
}
static QString buildAlbumMeta(const QJsonObject &album)
{
QStringList parts;
const QString year = album["release_date_original"].toString().left(4);
if (!year.isEmpty()) parts << year;
const int tracks = album["tracks_count"].toInt();
if (tracks > 0) parts << QStringLiteral("%1 tracks").arg(tracks);
const int dur = static_cast<int>(album["duration"].toDouble());
if (dur > 0) parts << formatDuration(dur);
return parts.join(QStringLiteral(" · "));
}
static QString buildPlaylistMeta(const QJsonObject &playlist)
{
QStringList parts;
const int tracks = playlist["tracks_count"].toInt();
if (tracks > 0) parts << QStringLiteral("%1 tracks").arg(tracks);
const int dur = static_cast<int>(playlist["duration"].toDouble());
if (dur > 0) parts << formatDuration(dur);
return parts.join(QStringLiteral(" · "));
}
QLabel *m_art = nullptr;
QLabel *m_title = nullptr;
QLabel *m_subtitle = nullptr;
QLabel *m_meta = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
};