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:
@@ -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));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "../backend/qobuzbackend.hpp"
|
||||
#include "../util/settings.hpp"
|
||||
|
||||
#include <QTreeWidget>
|
||||
#include <QVector>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<qint64>(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();
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "albumlistview.hpp"
|
||||
#include "artistlistview.hpp"
|
||||
#include "artistview.hpp"
|
||||
#include "trackcontextheader.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QLabel>
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
162
src/view/trackcontextheader.hpp
Normal file
162
src/view/trackcontextheader.hpp
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user