From 8088412d4b9fdaf4f114a1ed9b7dc3f9bb4cc24e Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 11:34:04 +0100 Subject: [PATCH] feat: album/playlist header and playlist ownership filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/list/library.cpp | 56 ++++++----- src/list/library.hpp | 1 + src/list/tracks.cpp | 13 ++- src/list/tracks.hpp | 4 +- src/mainwindow.cpp | 3 + src/util/settings.hpp | 3 + src/view/maincontent.cpp | 19 +++- src/view/maincontent.hpp | 16 ++-- src/view/trackcontextheader.hpp | 162 ++++++++++++++++++++++++++++++++ 9 files changed, 240 insertions(+), 37 deletions(-) create mode 100644 src/view/trackcontextheader.hpp diff --git a/src/list/library.cpp b/src/list/library.cpp index 288779b..fdbe433 100644 --- a/src/list/library.cpp +++ b/src/list/library.cpp @@ -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> playlists; + QVector> 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(pl["id"].toDouble()); + const QJsonObject pl = v.toObject(); + const QString name = pl["name"].toString(); + const qint64 id = static_cast(pl["id"].toDouble()); + const qint64 ownId = static_cast(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)); diff --git a/src/list/library.hpp b/src/list/library.hpp index 4bcdf67..529aa2c 100644 --- a/src/list/library.hpp +++ b/src/list/library.hpp @@ -1,6 +1,7 @@ #pragma once #include "../backend/qobuzbackend.hpp" +#include "../util/settings.hpp" #include #include diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp index 75caa61..27e9926 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -1,4 +1,5 @@ #include "tracks.hpp" +#include "../util/settings.hpp" #include #include @@ -52,8 +53,11 @@ void Tracks::loadAlbum(const QJsonObject &album) void Tracks::loadPlaylist(const QJsonObject &playlist) { - const qint64 id = static_cast(playlist["id"].toDouble()); - setPlaylistContext(id); + const qint64 id = static_cast(playlist["id"].toDouble()); + const qint64 ownId = static_cast(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> &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) { diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 2b7b3b7..5df2f9a 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -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> &playlists); @@ -44,6 +45,7 @@ namespace List QobuzBackend *m_backend = nullptr; PlayQueue *m_queue = nullptr; qint64 m_playlistId = 0; + bool m_playlistIsOwned = false; QVector> m_userPlaylists; void onDoubleClicked(const QModelIndex &index); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 8ecab59..6683484 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -242,6 +242,9 @@ void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user) const QString email = user["email"].toString(); AppSettings::instance().setDisplayName(displayName); AppSettings::instance().setUserEmail(email); + const qint64 userId = static_cast(user["id"].toDouble()); + if (userId > 0) + AppSettings::instance().setUserId(userId); statusBar()->showMessage(tr("Signed in as %1").arg( displayName.isEmpty() ? email : displayName)); m_library->refresh(); diff --git a/src/util/settings.hpp b/src/util/settings.hpp index c00fb36..e637ac6 100644 --- a/src/util/settings.hpp +++ b/src/util/settings.hpp @@ -21,6 +21,9 @@ public: QString displayName() const { return m_settings.value("user/display_name").toString(); } 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 int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); } void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); } diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp index 765d45a..d26170f 100644 --- a/src/view/maincontent.cpp +++ b/src/view/maincontent.cpp @@ -19,13 +19,24 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren this); 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_artistList = new ArtistListView(this); m_artistView = new ArtistView(this); 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_artistList); // 3 m_stack->addWidget(m_artistView); // 4 @@ -41,24 +52,28 @@ void MainContent::showWelcome() { m_stack->setCurrentIndex(0); } void MainContent::showAlbum(const QJsonObject &album) { + m_header->setAlbum(album); m_tracks->loadAlbum(album); m_stack->setCurrentIndex(1); } void MainContent::showPlaylist(const QJsonObject &playlist) { + m_header->setPlaylist(playlist); m_tracks->loadPlaylist(playlist); m_stack->setCurrentIndex(1); } void MainContent::showFavTracks(const QJsonObject &result) { + m_header->hide(); m_tracks->loadTracks(result["items"].toArray()); m_stack->setCurrentIndex(1); } void MainContent::showSearchTracks(const QJsonArray &tracks) { + m_header->hide(); m_tracks->loadSearchTracks(tracks); m_stack->setCurrentIndex(1); } diff --git a/src/view/maincontent.hpp b/src/view/maincontent.hpp index 487c045..b3d81c2 100644 --- a/src/view/maincontent.hpp +++ b/src/view/maincontent.hpp @@ -6,6 +6,7 @@ #include "albumlistview.hpp" #include "artistlistview.hpp" #include "artistview.hpp" +#include "trackcontextheader.hpp" #include #include @@ -36,11 +37,12 @@ signals: void artistRequested(qint64 artistId); private: - QobuzBackend *m_backend = nullptr; - QStackedWidget *m_stack = nullptr; - QLabel *m_welcome = nullptr; - List::Tracks *m_tracks = nullptr; - AlbumListView *m_albumList = nullptr; - ArtistListView *m_artistList = nullptr; - ArtistView *m_artistView = nullptr; + QobuzBackend *m_backend = nullptr; + QStackedWidget *m_stack = nullptr; + QLabel *m_welcome = nullptr; + List::Tracks *m_tracks = nullptr; + TrackContextHeader *m_header = nullptr; + AlbumListView *m_albumList = nullptr; + ArtistListView *m_artistList = nullptr; + ArtistView *m_artistView = nullptr; }; diff --git a/src/view/trackcontextheader.hpp b/src/view/trackcontextheader.hpp new file mode 100644 index 0000000..4eb8dcd --- /dev/null +++ b/src/view/trackcontextheader.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// 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(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(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; +};