feat: add playlist browse/search discovery and follow controls
Some checks failed
Build for Windows / build-windows (push) Has been cancelled

This commit is contained in:
joren
2026-03-31 00:23:56 +02:00
parent 07d6c8a88d
commit 96bb21adff
13 changed files with 833 additions and 26 deletions

View File

@@ -41,6 +41,11 @@ enum QobuzEvent {
EV_GENRES_OK = 27,
EV_FEATURED_ALBUMS_OK = 28,
EV_DYNAMIC_SUGGEST_OK = 29,
EV_FEATURED_PLAYLISTS_OK = 30,
EV_DISCOVER_PLAYLISTS_OK = 31,
EV_PLAYLIST_SEARCH_OK = 32,
EV_PLAYLIST_SUBSCRIBED = 33,
EV_PLAYLIST_UNSUBSCRIBED = 34,
};
// Callback signature
@@ -96,13 +101,18 @@ void qobuz_backend_get_albums_tracks(QobuzBackendOpaque *backend, const char *al
// Browse
void qobuz_backend_get_genres(QobuzBackendOpaque *backend);
void qobuz_backend_get_featured_albums(QobuzBackendOpaque *backend, int64_t genre_id, const char *kind, uint32_t limit, uint32_t offset);
void qobuz_backend_get_featured_albums(QobuzBackendOpaque *backend, const char *genre_ids, const char *kind, uint32_t limit, uint32_t offset);
void qobuz_backend_get_featured_playlists(QobuzBackendOpaque *backend, const char *genre_ids, const char *kind, uint32_t limit, uint32_t offset);
void qobuz_backend_discover_playlists(QobuzBackendOpaque *backend, const char *genre_ids, const char *tags, uint32_t limit, uint32_t offset);
void qobuz_backend_search_playlists(QobuzBackendOpaque *backend, const char *query, uint32_t limit, uint32_t offset);
// Playlist management
void qobuz_backend_create_playlist(QobuzBackendOpaque *backend, const char *name);
void qobuz_backend_delete_playlist(QobuzBackendOpaque *backend, int64_t playlist_id);
void qobuz_backend_add_track_to_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, int64_t track_id);
void qobuz_backend_delete_track_from_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, int64_t playlist_track_id);
void qobuz_backend_subscribe_playlist(QobuzBackendOpaque *backend, int64_t playlist_id);
void qobuz_backend_unsubscribe_playlist(QobuzBackendOpaque *backend, int64_t playlist_id);
// Favorites modification
void qobuz_backend_add_fav_track(QobuzBackendOpaque *backend, int64_t track_id);

View File

@@ -416,7 +416,7 @@ impl QobuzClient {
pub async fn get_featured_albums(
&self,
genre_id: i64,
genre_ids: &str,
kind: &str,
limit: u32,
offset: u32,
@@ -425,7 +425,64 @@ impl QobuzClient {
.get_request("album/getFeatured")
.query(&[
("type", kind.to_string()),
("genre_id", genre_id.to_string()),
("genre_id", genre_ids.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send()
.await?;
Self::check_response(resp).await
}
pub async fn get_featured_playlists(
&self,
genre_ids: &str,
kind: &str,
limit: u32,
offset: u32,
) -> Result<Value> {
let resp = self
.get_request("playlist/getFeatured")
.query(&[
("type", kind.to_string()),
("genre_ids", genre_ids.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send()
.await?;
Self::check_response(resp).await
}
pub async fn discover_playlists(
&self,
genre_ids: &str,
tags: &str,
limit: u32,
offset: u32,
) -> Result<Value> {
let mut query = vec![
("genre_ids", genre_ids.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
];
if !tags.is_empty() {
query.push(("tags", tags.to_string()));
}
let resp = self
.get_request("discover/playlists")
.query(&query)
.send()
.await?;
Self::check_response(resp).await
}
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<Value> {
let resp = self
.get_request("playlist/search")
.query(&[
("query", query.to_string()),
("limit", limit.to_string()),
("offset", offset.to_string()),
])
@@ -779,6 +836,26 @@ impl QobuzClient {
Ok(())
}
pub async fn subscribe_playlist(&self, playlist_id: i64) -> Result<()> {
let resp = self
.get_request("playlist/subscribe")
.query(&[("playlist_id", playlist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn unsubscribe_playlist(&self, playlist_id: i64) -> Result<()> {
let resp = self
.get_request("playlist/unsubscribe")
.query(&[("playlist_id", playlist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await?;
Ok(())
}
pub async fn add_fav_track(&self, track_id: i64) -> Result<()> {
let resp = self
.get_request("favorite/create")

View File

@@ -49,6 +49,11 @@ pub const EV_MOST_POPULAR_OK: c_int = 26;
pub const EV_GENRES_OK: c_int = 27;
pub const EV_FEATURED_ALBUMS_OK: c_int = 28;
pub const EV_DYNAMIC_SUGGEST_OK: c_int = 29;
pub const EV_FEATURED_PLAYLISTS_OK: c_int = 30;
pub const EV_DISCOVER_PLAYLISTS_OK: c_int = 31;
pub const EV_PLAYLIST_SEARCH_OK: c_int = 32;
pub const EV_PLAYLIST_SUBSCRIBED: c_int = 33;
pub const EV_PLAYLIST_UNSUBSCRIBED: c_int = 34;
// ---------- Callback ----------
@@ -481,7 +486,7 @@ pub unsafe extern "C" fn qobuz_backend_get_genres(ptr: *mut Backend) {
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_featured_albums(
ptr: *mut Backend,
genre_id: i64,
genre_ids: *const c_char,
kind: *const c_char,
limit: u32,
offset: u32,
@@ -490,13 +495,14 @@ pub unsafe extern "C" fn qobuz_backend_get_featured_albums(
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
let genre_ids_str = CStr::from_ptr(genre_ids).to_string_lossy().into_owned();
let kind_str = CStr::from_ptr(kind).to_string_lossy().into_owned();
spawn(inner, async move {
let result = client
.lock()
.await
.get_featured_albums(genre_id, &kind_str, limit, offset)
.get_featured_albums(&genre_ids_str, &kind_str, limit, offset)
.await;
match result {
Ok(r) => {
@@ -506,7 +512,7 @@ pub unsafe extern "C" fn qobuz_backend_get_featured_albums(
"items": items,
"total": total,
"type": kind_str,
"genre_id": genre_id,
"genre_ids": genre_ids_str,
});
call_cb(
cb,
@@ -520,6 +526,132 @@ pub unsafe extern "C" fn qobuz_backend_get_featured_albums(
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_featured_playlists(
ptr: *mut Backend,
genre_ids: *const c_char,
kind: *const c_char,
limit: u32,
offset: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
let kind_str = CStr::from_ptr(kind).to_string_lossy().into_owned();
let genre_ids_str = CStr::from_ptr(genre_ids).to_string_lossy().into_owned();
spawn(inner, async move {
let result = client
.lock()
.await
.get_featured_playlists(&genre_ids_str, &kind_str, limit, offset)
.await;
match result {
Ok(r) => {
let items = r["playlists"]["items"].clone();
let total = r["playlists"]["total"].as_i64().unwrap_or(0);
let out = serde_json::json!({
"items": items,
"total": total,
"type": kind_str,
"genre_ids": genre_ids_str,
});
call_cb(
cb,
ud,
EV_FEATURED_PLAYLISTS_OK,
&serde_json::to_string(&out).unwrap_or_default(),
);
}
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_discover_playlists(
ptr: *mut Backend,
genre_ids: *const c_char,
tags: *const c_char,
limit: u32,
offset: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
let genre_ids_str = CStr::from_ptr(genre_ids).to_string_lossy().into_owned();
let tags_str = CStr::from_ptr(tags).to_string_lossy().into_owned();
spawn(inner, async move {
let result = client
.lock()
.await
.discover_playlists(&genre_ids_str, &tags_str, limit, offset)
.await;
match result {
Ok(r) => {
let items = r["items"].clone();
let total = r["total"].as_i64().unwrap_or(0);
let out = serde_json::json!({
"items": items,
"total": total,
"genre_ids": genre_ids_str,
"tags": tags_str,
});
call_cb(
cb,
ud,
EV_DISCOVER_PLAYLISTS_OK,
&serde_json::to_string(&out).unwrap_or_default(),
);
}
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_search_playlists(
ptr: *mut Backend,
query: *const c_char,
limit: u32,
offset: u32,
) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
let query_str = CStr::from_ptr(query).to_string_lossy().into_owned();
spawn(inner, async move {
let result = client
.lock()
.await
.search_playlists(&query_str, limit, offset)
.await;
match result {
Ok(r) => {
let items = r["playlists"]["items"].clone();
let total = r["playlists"]["total"].as_i64().unwrap_or(0);
let out = serde_json::json!({
"items": items,
"total": total,
"query": query_str,
});
call_cb(
cb,
ud,
EV_PLAYLIST_SEARCH_OK,
&serde_json::to_string(&out).unwrap_or_default(),
);
}
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
// ---------- Playlist ----------
#[no_mangle]
@@ -1129,3 +1261,41 @@ pub unsafe extern "C" fn qobuz_backend_delete_track_from_playlist(
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_subscribe_playlist(ptr: *mut Backend, playlist_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
spawn(inner, async move {
match client.lock().await.subscribe_playlist(playlist_id).await {
Ok(()) => call_cb(
cb,
ud,
EV_PLAYLIST_SUBSCRIBED,
&serde_json::json!({"playlist_id": playlist_id}).to_string(),
),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_unsubscribe_playlist(ptr: *mut Backend, playlist_id: i64) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb;
let ud = inner.ud;
spawn(inner, async move {
match client.lock().await.unsubscribe_playlist(playlist_id).await {
Ok(()) => call_cb(
cb,
ud,
EV_PLAYLIST_UNSUBSCRIBED,
&serde_json::json!({"playlist_id": playlist_id}).to_string(),
),
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
}
});
}

View File

@@ -100,9 +100,29 @@ void QobuzBackend::getGenres()
qobuz_backend_get_genres(m_backend);
}
void QobuzBackend::getFeaturedAlbums(qint64 genreId, const QString &kind, quint32 limit, quint32 offset)
void QobuzBackend::getFeaturedAlbums(const QString &genreIds, const QString &kind, quint32 limit, quint32 offset)
{
qobuz_backend_get_featured_albums(m_backend, genreId, kind.toUtf8().constData(), limit, offset);
qobuz_backend_get_featured_albums(m_backend, genreIds.toUtf8().constData(), kind.toUtf8().constData(), limit, offset);
}
void QobuzBackend::getFeaturedPlaylists(const QString &genreIds, const QString &kind, quint32 limit, quint32 offset)
{
qobuz_backend_get_featured_playlists(m_backend, genreIds.toUtf8().constData(), kind.toUtf8().constData(), limit, offset);
}
void QobuzBackend::discoverPlaylists(const QString &genreIds, const QString &tags, quint32 limit, quint32 offset)
{
qobuz_backend_discover_playlists(
m_backend,
genreIds.toUtf8().constData(),
tags.toUtf8().constData(),
limit,
offset);
}
void QobuzBackend::searchPlaylists(const QString &query, quint32 limit, quint32 offset)
{
qobuz_backend_search_playlists(m_backend, query.toUtf8().constData(), limit, offset);
}
// ---- favorites ----
@@ -166,6 +186,16 @@ void QobuzBackend::deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTra
qobuz_backend_delete_track_from_playlist(m_backend, playlistId, playlistTrackId);
}
void QobuzBackend::subscribePlaylist(qint64 playlistId)
{
qobuz_backend_subscribe_playlist(m_backend, playlistId);
}
void QobuzBackend::unsubscribePlaylist(qint64 playlistId)
{
qobuz_backend_unsubscribe_playlist(m_backend, playlistId);
}
// ---- fav modification ----
void QobuzBackend::addFavTrack(qint64 trackId)
@@ -302,6 +332,15 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case 28: // EV_FEATURED_ALBUMS_OK
emit featuredAlbumsLoaded(obj);
break;
case 30: // EV_FEATURED_PLAYLISTS_OK
emit featuredPlaylistsLoaded(obj);
break;
case 31: // EV_DISCOVER_PLAYLISTS_OK
emit discoverPlaylistsLoaded(obj);
break;
case 32: // EV_PLAYLIST_SEARCH_OK
emit playlistSearchLoaded(obj);
break;
case EV_ARTIST_ERR:
emit error(obj["error"].toString());
break;
@@ -338,6 +377,12 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case 22: // EV_PLAYLIST_TRACK_ADDED
emit playlistTrackAdded(static_cast<qint64>(obj["playlist_id"].toDouble()));
break;
case 33: // EV_PLAYLIST_SUBSCRIBED
emit playlistSubscribed(static_cast<qint64>(obj["playlist_id"].toDouble()));
break;
case 34: // EV_PLAYLIST_UNSUBSCRIBED
emit playlistUnsubscribed(static_cast<qint64>(obj["playlist_id"].toDouble()));
break;
case EV_USER_OK:
emit userLoaded(obj);
break;

View File

@@ -36,7 +36,10 @@ public:
void getAlbumsTracks(const QStringList &albumIds);
void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500);
void getGenres();
void getFeaturedAlbums(qint64 genreId, const QString &kind, quint32 limit = 50, quint32 offset = 0);
void getFeaturedAlbums(const QString &genreIds, const QString &kind, quint32 limit = 50, quint32 offset = 0);
void getFeaturedPlaylists(const QString &genreIds, const QString &kind, quint32 limit = 25, quint32 offset = 0);
void discoverPlaylists(const QString &genreIds, const QString &tags = QString(), quint32 limit = 25, quint32 offset = 0);
void searchPlaylists(const QString &query, quint32 limit = 8, quint32 offset = 0);
// --- favorites ---
void getFavTracks(quint32 offset = 0, quint32 limit = 500);
@@ -54,6 +57,8 @@ public:
void deletePlaylist(qint64 playlistId);
void addTrackToPlaylist(qint64 playlistId, qint64 trackId);
void deleteTrackFromPlaylist(qint64 playlistId, qint64 playlistTrackId);
void subscribePlaylist(qint64 playlistId);
void unsubscribePlaylist(qint64 playlistId);
// --- fav modification ---
void addFavTrack(qint64 trackId);
@@ -93,10 +98,15 @@ signals:
void dynamicSuggestionsLoaded(const QJsonObject &result);
void genresLoaded(const QJsonObject &result);
void featuredAlbumsLoaded(const QJsonObject &result);
void featuredPlaylistsLoaded(const QJsonObject &result);
void discoverPlaylistsLoaded(const QJsonObject &result);
void playlistSearchLoaded(const QJsonObject &result);
void playlistLoaded(const QJsonObject &playlist);
void playlistCreated(const QJsonObject &playlist);
void playlistDeleted(const QJsonObject &result);
void playlistTrackAdded(qint64 playlistId);
void playlistSubscribed(qint64 playlistId);
void playlistUnsubscribed(qint64 playlistId);
// favorites
void favTracksLoaded(const QJsonObject &result);

View File

@@ -21,6 +21,7 @@ enum NodeType {
NodeFavArtists,
NodePlaylist,
NodeBrowseGenres,
NodeBrowsePlaylists,
};
Library::Library(QobuzBackend *backend, QWidget *parent)
@@ -79,6 +80,9 @@ void Library::buildStaticNodes()
auto *genresItem = new QTreeWidgetItem(m_browseNode, QStringList{tr("Genres")});
genresItem->setData(0, TypeRole, NodeBrowseGenres);
auto *playlistsItem = new QTreeWidgetItem(m_browseNode, QStringList{tr("Playlists")});
playlistsItem->setData(0, TypeRole, NodeBrowsePlaylists);
}
void Library::refresh()
@@ -176,6 +180,7 @@ void Library::onItemClicked(QTreeWidgetItem *item, int)
case NodeFavAlbums: emit favAlbumsRequested(); break;
case NodeFavArtists: emit favArtistsRequested(); break;
case NodeBrowseGenres: emit browseGenresRequested(); break;
case NodeBrowsePlaylists: emit browsePlaylistsRequested(); break;
case NodePlaylist: {
const qint64 id = item->data(0, IdRole).toLongLong();
const QString name = item->data(0, NameRole).toString();

View File

@@ -27,6 +27,7 @@ namespace List
void favAlbumsRequested();
void favArtistsRequested();
void browseGenresRequested();
void browsePlaylistsRequested();
void playlistRequested(qint64 playlistId, const QString &name);
/// Emitted after playlists are loaded so others can cache the list.
void userPlaylistsChanged(const QVector<QPair<qint64, QString>> &playlists);

View File

@@ -127,6 +127,18 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_backend->getPlaylist(playlistId);
statusBar()->showMessage(tr("Track added to playlist"), 3000);
});
connect(m_backend, &QobuzBackend::playlistSubscribed, this, [this](qint64 playlistId) {
m_library->refresh();
if (m_content->tracksList()->playlistId() == playlistId)
m_content->setCurrentPlaylistFollowed(true);
statusBar()->showMessage(tr("Playlist followed"), 3000);
});
connect(m_backend, &QobuzBackend::playlistUnsubscribed, this, [this](qint64 playlistId) {
m_library->refresh();
if (m_content->tracksList()->playlistId() == playlistId)
m_content->setCurrentPlaylistFollowed(false);
statusBar()->showMessage(tr("Playlist unfollowed"), 3000);
});
connect(m_backend, &QobuzBackend::trackChanged, this, &MainWindow::onTrackChanged);
connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) {
statusBar()->showMessage(tr("Error: %1").arg(msg), 6000);
@@ -210,6 +222,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_content->showGenreBrowser();
statusBar()->showMessage(tr("Browse Genres"));
});
connect(m_library, &List::Library::browsePlaylistsRequested, this, [this] {
m_content->showPlaylistBrowser();
statusBar()->showMessage(tr("Browse Playlists"));
});
// ---- Track list → playback / playlist management ----
connect(m_content->tracksList(), &List::Tracks::playTrackRequested,
@@ -243,6 +259,18 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
this, &MainWindow::onSearchAlbumSelected);
connect(m_content, &MainContent::artistRequested,
this, &MainWindow::onSearchArtistSelected);
connect(m_content, &MainContent::playlistRequested,
this, [this](qint64 playlistId) {
m_backend->getPlaylist(playlistId);
statusBar()->showMessage(tr("Loading playlist…"));
});
connect(m_content, &MainContent::playlistFollowToggled,
this, [this](qint64 playlistId, bool follow) {
if (follow)
m_backend->subscribePlaylist(playlistId);
else
m_backend->unsubscribePlaylist(playlistId);
});
connect(m_content, &MainContent::playTrackRequested,
this, &MainWindow::onPlayTrackRequested);
@@ -462,7 +490,20 @@ void MainWindow::onArtistLoaded(const QJsonObject &artist)
void MainWindow::onPlaylistLoaded(const QJsonObject &playlist)
{
m_content->showPlaylist(playlist);
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
const qint64 ownerId = static_cast<qint64>(playlist["owner"].toObject()["id"].toDouble());
const qint64 myId = AppSettings::instance().userId();
const bool isOwned = (myId > 0 && ownerId == myId);
bool isFollowed = false;
for (const auto &pl : m_userPlaylists) {
if (pl.first == id) {
isFollowed = true;
break;
}
}
m_content->showPlaylist(playlist, isFollowed, isOwned);
statusBar()->showMessage(
tr("Playlist: %1").arg(playlist["name"].toString()), 4000);
}

View File

@@ -1,12 +1,20 @@
#include "genrebrowser.hpp"
#include <QAction>
#include <QDialog>
#include <QDialogButtonBox>
#include <QHeaderView>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QMenu>
#include <QPushButton>
#include <QSignalBlocker>
#include <QTreeWidgetItem>
#include <QVBoxLayout>
#include <algorithm>
GenreBrowserView::GenreBrowserView(QobuzBackend *backend, QWidget *parent)
: QWidget(parent)
, m_backend(backend)
@@ -19,41 +27,127 @@ GenreBrowserView::GenreBrowserView(QobuzBackend *backend, QWidget *parent)
auto *topLayout = new QHBoxLayout(topBar);
topLayout->setContentsMargins(8, 6, 8, 6);
topLayout->addWidget(new QLabel(tr("Genre:"), this));
m_browseLabel = new QLabel(tr("Show:"), this);
topLayout->addWidget(m_browseLabel);
m_kindCombo = new QComboBox(this);
m_kindCombo->addItem(tr("Albums"), QStringLiteral("albums"));
m_kindCombo->addItem(tr("Playlists"), QStringLiteral("playlists"));
topLayout->addWidget(m_kindCombo);
m_gapAfterKind = new QWidget(this);
m_gapAfterKind->setFixedWidth(12);
topLayout->addWidget(m_gapAfterKind);
m_genreLabel = new QLabel(tr("Genre:"), this);
topLayout->addWidget(m_genreLabel);
m_genreCombo = new QComboBox(this);
m_genreCombo->setMinimumWidth(160);
topLayout->addWidget(m_genreCombo);
topLayout->addSpacing(16);
m_gapAfterGenre = new QWidget(this);
m_gapAfterGenre->setFixedWidth(16);
topLayout->addWidget(m_gapAfterGenre);
topLayout->addWidget(new QLabel(tr("Type:"), this));
m_typeLabel = new QLabel(tr("Type:"), this);
topLayout->addWidget(m_typeLabel);
m_typeCombo = new QComboBox(this);
m_typeCombo->addItem(tr("New Releases"), QStringLiteral("new-releases"));
m_typeCombo->addItem(tr("Best Sellers"), QStringLiteral("best-sellers"));
m_typeCombo->addItem(tr("Most Streamed"), QStringLiteral("most-streamed"));
m_typeCombo->addItem(tr("Editor Picks"), QStringLiteral("editor-picks"));
m_typeCombo->addItem(tr("Press Awards"), QStringLiteral("press-awards"));
topLayout->addWidget(m_typeCombo);
m_playlistSearchLabel = new QLabel(tr("Search:"), this);
m_playlistSearchLabel->setVisible(false);
topLayout->addWidget(m_playlistSearchLabel);
m_playlistSearchBox = new QLineEdit(this);
m_playlistSearchBox->setPlaceholderText(tr("Search playlists..."));
m_playlistSearchBox->setClearButtonEnabled(true);
m_playlistSearchBox->setVisible(false);
m_playlistSearchBox->setMinimumWidth(180);
m_playlistSearchBox->setMaximumWidth(320);
topLayout->addWidget(m_playlistSearchBox);
m_playlistSearchBtn = new QPushButton(tr("Search"), this);
m_playlistSearchBtn->setVisible(false);
topLayout->addWidget(m_playlistSearchBtn);
topLayout->addStretch();
layout->addWidget(topBar);
m_resultsStack = new QStackedWidget(this);
m_albumList = new AlbumListView(this);
m_albumList->setContextMenuPolicy(Qt::CustomContextMenu);
layout->addWidget(m_albumList, 1);
m_playlistList = new QTreeWidget(this);
m_playlistList->setColumnCount(4);
m_playlistList->setHeaderLabels({tr(""), tr("Playlist"), tr("Owner"), tr("Tracks")});
m_playlistList->setRootIsDecorated(false);
m_playlistList->setAlternatingRowColors(true);
m_playlistList->setSelectionBehavior(QAbstractItemView::SelectRows);
m_playlistList->setSortingEnabled(true);
m_playlistList->setContextMenuPolicy(Qt::CustomContextMenu);
m_playlistList->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_playlistList->header()->setSectionResizeMode(1, QHeaderView::Stretch);
m_playlistList->header()->setSectionResizeMode(2, QHeaderView::Stretch);
m_playlistList->header()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
m_playlistList->header()->setStretchLastSection(false);
m_resultsStack->addWidget(m_albumList);
m_resultsStack->addWidget(m_playlistList);
layout->addWidget(m_resultsStack, 1);
connect(m_backend, &QobuzBackend::genresLoaded,
this, &GenreBrowserView::onGenresLoaded);
connect(m_backend, &QobuzBackend::featuredAlbumsLoaded,
this, &GenreBrowserView::onFeaturedAlbumsLoaded);
connect(m_backend, &QobuzBackend::featuredPlaylistsLoaded,
this, &GenreBrowserView::onFeaturedPlaylistsLoaded);
connect(m_backend, &QobuzBackend::discoverPlaylistsLoaded,
this, &GenreBrowserView::onDiscoverPlaylistsLoaded);
connect(m_backend, &QobuzBackend::playlistSearchLoaded,
this, &GenreBrowserView::onPlaylistSearchLoaded);
connect(m_genreCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &GenreBrowserView::onSelectionChanged);
this, [this](int index) {
const QString data = m_genreCombo->itemData(index).toString();
if (data == QStringLiteral("__multi__")) {
if (!chooseMultiGenres()) {
const QSignalBlocker blocker(m_genreCombo);
m_genreCombo->setCurrentIndex(m_lastGenreComboIndex);
return;
}
m_lastGenreComboIndex = index;
updateMultiGenreLabel();
} else {
m_lastGenreComboIndex = index;
if (data == QStringLiteral("__all__")) {
m_multiGenreIds.clear();
updateMultiGenreLabel();
}
}
onSelectionChanged();
});
connect(m_kindCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, [this](int) {
refreshGenreTypeChoices();
onSelectionChanged();
});
connect(m_typeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &GenreBrowserView::onSelectionChanged);
connect(m_playlistSearchBox, &QLineEdit::returnPressed,
this, &GenreBrowserView::onSelectionChanged);
connect(m_playlistSearchBtn, &QPushButton::clicked,
this, &GenreBrowserView::onSelectionChanged);
connect(m_albumList, &AlbumListView::albumSelected,
this, &GenreBrowserView::albumSelected);
connect(m_albumList, &QTreeWidget::customContextMenuRequested,
this, &GenreBrowserView::onAlbumContextMenu);
connect(m_playlistList, &QTreeWidget::itemDoubleClicked,
this, &GenreBrowserView::onPlaylistActivated);
connect(m_playlistList, &QTreeWidget::customContextMenuRequested,
this, &GenreBrowserView::onPlaylistContextMenu);
m_kindCombo->setCurrentIndex(0);
refreshModeUi();
}
void GenreBrowserView::ensureGenresLoaded()
@@ -62,11 +156,152 @@ void GenreBrowserView::ensureGenresLoaded()
m_backend->getGenres();
}
void GenreBrowserView::setBrowseMode(BrowseMode mode)
{
if (m_mode == mode)
return;
m_mode = mode;
refreshModeUi();
onSelectionChanged();
}
void GenreBrowserView::refreshModeUi()
{
const bool genreMode = (m_mode == BrowseMode::Genres);
m_browseLabel->setVisible(genreMode);
m_kindCombo->setVisible(genreMode);
m_gapAfterKind->setVisible(genreMode);
m_genreLabel->setVisible(genreMode);
m_genreCombo->setVisible(genreMode);
m_gapAfterGenre->setVisible(genreMode);
m_typeLabel->setVisible(genreMode);
m_typeCombo->setVisible(genreMode);
if (genreMode) {
m_playlistSearchBox->setVisible(false);
m_playlistSearchLabel->setVisible(false);
m_playlistSearchBtn->setVisible(false);
refreshGenreTypeChoices();
return;
}
m_typeCombo->blockSignals(true);
m_typeCombo->clear();
m_typeCombo->addItem(tr("Search"), QStringLiteral("search"));
m_typeCombo->blockSignals(false);
m_playlistSearchLabel->setVisible(true);
m_playlistSearchBox->setVisible(true);
m_playlistSearchBtn->setVisible(true);
m_resultsStack->setCurrentIndex(1);
}
void GenreBrowserView::refreshGenreTypeChoices()
{
m_typeCombo->blockSignals(true);
m_typeCombo->clear();
const QString kind = m_kindCombo->currentData().toString();
if (kind == QStringLiteral("playlists")) {
m_typeCombo->addItem(tr("Featured: Last Created"), QStringLiteral("last-created"));
m_typeCombo->addItem(tr("Discover: New"), QStringLiteral("discover-new"));
m_typeCombo->addItem(tr("Discover: Hi-Res"), QStringLiteral("discover-hires"));
m_typeCombo->addItem(tr("Discover: Focus"), QStringLiteral("discover-focus"));
m_typeCombo->addItem(tr("Discover: Qobuz Digs"), QStringLiteral("discover-qobuzdigs"));
m_resultsStack->setCurrentIndex(1);
} else {
m_typeCombo->addItem(tr("New Releases"), QStringLiteral("new-releases"));
m_typeCombo->addItem(tr("Best Sellers"), QStringLiteral("best-sellers"));
m_typeCombo->addItem(tr("Most Streamed"), QStringLiteral("most-streamed"));
m_typeCombo->addItem(tr("Editor Picks"), QStringLiteral("editor-picks"));
m_typeCombo->addItem(tr("Press Awards"), QStringLiteral("press-awards"));
m_resultsStack->setCurrentIndex(0);
}
m_typeCombo->blockSignals(false);
}
QString GenreBrowserView::currentGenreIds() const
{
const QString data = m_genreCombo->currentData().toString();
if (data == QStringLiteral("__all__"))
return QString();
if (data == QStringLiteral("__multi__")) {
if (m_multiGenreIds.isEmpty())
return QString();
QList<qint64> ids = m_multiGenreIds.values();
std::sort(ids.begin(), ids.end());
QStringList out;
out.reserve(ids.size());
for (qint64 id : ids)
out.push_back(QString::number(id));
return out.join(QLatin1Char(','));
}
return data;
}
bool GenreBrowserView::chooseMultiGenres()
{
QDialog dlg(this);
dlg.setWindowTitle(tr("Select genres"));
dlg.resize(320, 420);
auto *layout = new QVBoxLayout(&dlg);
auto *list = new QListWidget(&dlg);
list->setAlternatingRowColors(true);
layout->addWidget(list, 1);
for (int i = 0; i < m_genreCombo->count(); ++i) {
const QString data = m_genreCombo->itemData(i).toString();
if (data == QStringLiteral("__all__") || data == QStringLiteral("__multi__"))
continue;
auto *item = new QListWidgetItem(m_genreCombo->itemText(i), list);
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
const qint64 id = data.toLongLong();
item->setData(Qt::UserRole, id);
item->setCheckState(m_multiGenreIds.contains(id) ? Qt::Checked : Qt::Unchecked);
}
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg);
layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
if (dlg.exec() != QDialog::Accepted)
return false;
m_multiGenreIds.clear();
for (int i = 0; i < list->count(); ++i) {
QListWidgetItem *item = list->item(i);
if (item->checkState() == Qt::Checked)
m_multiGenreIds.insert(item->data(Qt::UserRole).toLongLong());
}
return true;
}
void GenreBrowserView::updateMultiGenreLabel()
{
const int multiIndex = m_genreCombo->count() - 1;
if (multiIndex < 0)
return;
const QString text = m_multiGenreIds.isEmpty()
? tr("Multiple...")
: tr("Multiple (%1)").arg(m_multiGenreIds.size());
m_genreCombo->setItemText(multiIndex, text);
}
void GenreBrowserView::onGenresLoaded(const QJsonObject &result)
{
m_genresLoaded = true;
m_genreCombo->blockSignals(true);
m_genreCombo->clear();
m_genreCombo->addItem(tr("All genres"), QStringLiteral("__all__"));
const QJsonArray items = result["items"].toArray();
for (const auto &value : items) {
@@ -76,23 +311,81 @@ void GenreBrowserView::onGenresLoaded(const QJsonObject &result)
static_cast<qint64>(genre["id"].toDouble()));
}
m_genreCombo->addItem(tr("Multiple..."), QStringLiteral("__multi__"));
updateMultiGenreLabel();
m_lastGenreComboIndex = 0;
m_genreCombo->setCurrentIndex(0);
m_genreCombo->blockSignals(false);
onSelectionChanged();
}
void GenreBrowserView::onFeaturedAlbumsLoaded(const QJsonObject &result)
{
m_resultsStack->setCurrentIndex(0);
m_albumList->setAlbums(result["items"].toArray());
}
void GenreBrowserView::onFeaturedPlaylistsLoaded(const QJsonObject &result)
{
m_resultsStack->setCurrentIndex(1);
setPlaylistItems(result["items"].toArray());
}
void GenreBrowserView::onDiscoverPlaylistsLoaded(const QJsonObject &result)
{
m_resultsStack->setCurrentIndex(1);
setPlaylistItems(result["items"].toArray());
}
void GenreBrowserView::onPlaylistSearchLoaded(const QJsonObject &result)
{
m_resultsStack->setCurrentIndex(1);
setPlaylistItems(result["items"].toArray());
}
void GenreBrowserView::onSelectionChanged()
{
if (m_mode == BrowseMode::PlaylistSearch) {
m_resultsStack->setCurrentIndex(1);
m_playlistSearchLabel->setVisible(true);
m_playlistSearchBox->setVisible(true);
m_playlistSearchBtn->setVisible(true);
const QString query = m_playlistSearchBox->text().trimmed();
if (query.size() < 2) {
m_playlistList->clear();
} else {
m_backend->searchPlaylists(query, 8, 0);
}
return;
}
if (m_genreCombo->count() == 0)
return;
const qint64 genreId = m_genreCombo->currentData().toLongLong();
const QString genreIds = currentGenreIds();
const QString type = m_typeCombo->currentData().toString();
m_backend->getFeaturedAlbums(genreId, type, 50, 0);
const QString kind = m_kindCombo->currentData().toString();
m_playlistSearchLabel->setVisible(false);
m_playlistSearchBox->setVisible(false);
m_playlistSearchBtn->setVisible(false);
if (kind == QStringLiteral("playlists")) {
m_resultsStack->setCurrentIndex(1);
if (type == QStringLiteral("discover-new"))
m_backend->discoverPlaylists(genreIds, QStringLiteral("new"), 25, 0);
else if (type == QStringLiteral("discover-hires"))
m_backend->discoverPlaylists(genreIds, QStringLiteral("hi-res"), 25, 0);
else if (type == QStringLiteral("discover-focus"))
m_backend->discoverPlaylists(genreIds, QStringLiteral("focus"), 25, 0);
else if (type == QStringLiteral("discover-qobuzdigs"))
m_backend->discoverPlaylists(genreIds, QStringLiteral("qobuzdigs"), 25, 0);
else
m_backend->getFeaturedPlaylists(genreIds, type, 25, 0);
} else {
m_resultsStack->setCurrentIndex(0);
m_backend->getFeaturedAlbums(genreIds, type, 50, 0);
}
}
void GenreBrowserView::onAlbumContextMenu(const QPoint &pos)
@@ -120,3 +413,55 @@ void GenreBrowserView::onAlbumContextMenu(const QPoint &pos)
menu.exec(m_albumList->viewport()->mapToGlobal(pos));
}
void GenreBrowserView::onPlaylistActivated(QTreeWidgetItem *item, int)
{
if (!item)
return;
const qint64 playlistId = item->data(0, Qt::UserRole).toLongLong();
if (playlistId > 0)
emit playlistSelected(playlistId);
}
void GenreBrowserView::onPlaylistContextMenu(const QPoint &pos)
{
QTreeWidgetItem *item = m_playlistList->itemAt(pos);
if (!item)
return;
const qint64 playlistId = item->data(0, Qt::UserRole).toLongLong();
if (playlistId <= 0)
return;
QMenu menu(this);
auto *openPlaylist = menu.addAction(tr("Open Playlist"));
connect(openPlaylist, &QAction::triggered, this, [this, playlistId] {
emit playlistSelected(playlistId);
});
menu.exec(m_playlistList->viewport()->mapToGlobal(pos));
}
void GenreBrowserView::setPlaylistItems(const QJsonArray &items)
{
m_playlistList->clear();
QFont tagFont;
tagFont.setBold(true);
tagFont.setPointSizeF(tagFont.pointSizeF() * 0.85);
for (const auto &value : items) {
const QJsonObject playlist = value.toObject();
const qint64 playlistId = static_cast<qint64>(playlist["id"].toDouble());
const QString name = playlist["name"].toString();
const QString owner = playlist["owner"].toObject()["name"].toString();
const int tracksCount = playlist["tracks_count"].toInt();
auto *item = new QTreeWidgetItem(m_playlistList,
QStringList{QStringLiteral("P"), name, owner, tracksCount > 0 ? QString::number(tracksCount) : QString()});
item->setData(0, Qt::UserRole, playlistId);
item->setForeground(0, QColor(QStringLiteral("#2B7CD3")));
item->setFont(0, tagFont);
item->setTextAlignment(0, Qt::AlignCenter);
}
}

View File

@@ -4,8 +4,14 @@
#include "albumlistview.hpp"
#include <QComboBox>
#include <QLabel>
#include <QLineEdit>
#include <QJsonArray>
#include <QJsonObject>
#include <QSet>
#include <QPushButton>
#include <QStackedWidget>
#include <QTreeWidget>
#include <QWidget>
class GenreBrowserView : public QWidget
@@ -13,24 +19,57 @@ class GenreBrowserView : public QWidget
Q_OBJECT
public:
enum class BrowseMode {
Genres,
PlaylistSearch,
};
explicit GenreBrowserView(QobuzBackend *backend, QWidget *parent = nullptr);
void ensureGenresLoaded();
void setBrowseMode(BrowseMode mode);
signals:
void albumSelected(const QString &albumId);
void artistSelected(qint64 artistId);
void playlistSelected(qint64 playlistId);
private slots:
void onGenresLoaded(const QJsonObject &result);
void onFeaturedAlbumsLoaded(const QJsonObject &result);
void onFeaturedPlaylistsLoaded(const QJsonObject &result);
void onDiscoverPlaylistsLoaded(const QJsonObject &result);
void onPlaylistSearchLoaded(const QJsonObject &result);
void onSelectionChanged();
void onAlbumContextMenu(const QPoint &pos);
void onPlaylistActivated(QTreeWidgetItem *item, int column);
void onPlaylistContextMenu(const QPoint &pos);
private:
QobuzBackend *m_backend = nullptr;
QLabel *m_browseLabel = nullptr;
QLabel *m_genreLabel = nullptr;
QLabel *m_typeLabel = nullptr;
QLabel *m_playlistSearchLabel = nullptr;
QWidget *m_gapAfterKind = nullptr;
QWidget *m_gapAfterGenre = nullptr;
QComboBox *m_kindCombo = nullptr;
QComboBox *m_genreCombo = nullptr;
QComboBox *m_typeCombo = nullptr;
QLineEdit *m_playlistSearchBox = nullptr;
QPushButton *m_playlistSearchBtn = nullptr;
QStackedWidget *m_resultsStack = nullptr;
AlbumListView *m_albumList = nullptr;
QTreeWidget *m_playlistList = nullptr;
BrowseMode m_mode = BrowseMode::Genres;
bool m_genresLoaded = false;
int m_lastGenreComboIndex = 0;
QSet<qint64> m_multiGenreIds;
void refreshModeUi();
void refreshGenreTypeChoices();
QString currentGenreIds() const;
bool chooseMultiGenres();
void updateMultiGenreLabel();
void setPlaylistItems(const QJsonArray &items);
};

View File

@@ -41,6 +41,13 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
const qint64 id = m_header->artistId();
if (id > 0) emit artistRequested(id);
});
QObject::connect(m_header->followButton(), &QPushButton::clicked,
[this] {
const qint64 id = m_header->playlistId();
if (id <= 0 || m_header->playlistOwned())
return;
emit playlistFollowToggled(id, !m_header->playlistFollowed());
});
m_albumList = new AlbumListView(this);
m_artistList = new ArtistListView(this);
@@ -62,6 +69,7 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
connect(m_artistView, &ArtistView::playTrackRequested, this, &MainContent::playTrackRequested);
connect(m_genreBrowser, &GenreBrowserView::albumSelected, this, &MainContent::albumRequested);
connect(m_genreBrowser, &GenreBrowserView::artistSelected, this, &MainContent::artistRequested);
connect(m_genreBrowser, &GenreBrowserView::playlistSelected, this, &MainContent::playlistRequested);
}
void MainContent::showWelcome() { m_stack->setCurrentIndex(0); }
@@ -73,9 +81,9 @@ void MainContent::showAlbum(const QJsonObject &album)
m_stack->setCurrentIndex(1);
}
void MainContent::showPlaylist(const QJsonObject &playlist)
void MainContent::showPlaylist(const QJsonObject &playlist, bool isFollowed, bool isOwned)
{
m_header->setPlaylist(playlist);
m_header->setPlaylist(playlist, isFollowed, isOwned);
m_tracks->loadPlaylist(playlist);
m_stack->setCurrentIndex(1);
}
@@ -130,5 +138,18 @@ void MainContent::onDeepShuffleTracks(const QJsonArray &tracks)
void MainContent::showGenreBrowser()
{
m_genreBrowser->ensureGenresLoaded();
m_genreBrowser->setBrowseMode(GenreBrowserView::BrowseMode::Genres);
m_stack->setCurrentIndex(5);
}
void MainContent::showPlaylistBrowser()
{
m_genreBrowser->ensureGenresLoaded();
m_genreBrowser->setBrowseMode(GenreBrowserView::BrowseMode::PlaylistSearch);
m_stack->setCurrentIndex(5);
}
void MainContent::setCurrentPlaylistFollowed(bool followed)
{
m_header->setPlaylistFollowed(followed);
}

View File

@@ -26,7 +26,7 @@ public:
void showWelcome();
void showAlbum(const QJsonObject &album);
void showPlaylist(const QJsonObject &playlist);
void showPlaylist(const QJsonObject &playlist, bool isFollowed, bool isOwned);
void showFavTracks(const QJsonObject &result);
void showSearchTracks(const QJsonArray &tracks);
void showFavAlbums(const QJsonObject &result);
@@ -36,12 +36,16 @@ public:
void setFavArtistIds(const QSet<qint64> &ids);
void onDeepShuffleTracks(const QJsonArray &tracks);
void showGenreBrowser();
void showPlaylistBrowser();
void setCurrentPlaylistFollowed(bool followed);
ArtistView *artistView() const { return m_artistView; }
signals:
void albumRequested(const QString &albumId);
void artistRequested(qint64 artistId);
void playlistRequested(qint64 playlistId);
void playlistFollowToggled(qint64 playlistId, bool follow);
void playTrackRequested(qint64 trackId);
private:

View File

@@ -93,6 +93,13 @@ public:
"QPushButton:pressed { background: #333; }"));
btnRow->addWidget(m_shuffleBtn);
m_followBtn = new QPushButton(tr("Follow"), info);
m_followBtn->setStyleSheet(btnBase +
QStringLiteral("QPushButton { background: #2a2a2a; color: #ddd; border: 1px solid #666; }"
"QPushButton:pressed { background: #333; }"));
m_followBtn->hide();
btnRow->addWidget(m_followBtn);
btnRow->addStretch();
vlay->addLayout(btnRow);
vlay->addStretch(1);
@@ -113,9 +120,13 @@ public:
QPushButton *playButton() { return m_playBtn; }
QPushButton *shuffleButton() { return m_shuffleBtn; }
QPushButton *followButton() { return m_followBtn; }
QPushButton *subtitleButton() { return m_subtitle; }
qint64 artistId() const { return m_artistId; }
qint64 playlistId() const { return m_playlistId; }
bool playlistFollowed() const { return m_playlistFollowed; }
bool playlistOwned() const { return m_playlistOwned; }
void setAlbum(const QJsonObject &album)
{
@@ -127,14 +138,21 @@ public:
m_subtitle->setEnabled(m_artistId > 0);
m_subtitle->setCursor(m_artistId > 0 ? Qt::PointingHandCursor : Qt::ArrowCursor);
m_meta->setText(buildAlbumMeta(album));
m_followBtn->hide();
m_playlistId = 0;
m_playlistFollowed = false;
m_playlistOwned = false;
fetchArt(album["image"].toObject());
show();
}
void setPlaylist(const QJsonObject &playlist)
void setPlaylist(const QJsonObject &playlist, bool isFollowed, bool isOwned)
{
m_title->setText(playlist["name"].toString());
m_artistId = 0;
m_playlistId = static_cast<qint64>(playlist["id"].toDouble());
m_playlistFollowed = isFollowed;
m_playlistOwned = isOwned;
const QString desc = playlist["description"].toString();
const QString owner = playlist["owner"].toObject()["name"].toString();
m_subtitle->setText(desc.isEmpty() ? owner : desc);
@@ -142,6 +160,16 @@ public:
m_subtitle->setCursor(Qt::ArrowCursor);
m_meta->setText(buildPlaylistMeta(playlist));
if (m_playlistOwned) {
m_followBtn->setText(tr("Owned"));
m_followBtn->setEnabled(false);
m_followBtn->show();
} else {
m_followBtn->setText(m_playlistFollowed ? tr("Unfollow") : tr("Follow"));
m_followBtn->setEnabled(m_playlistId > 0);
m_followBtn->show();
}
// Try images300 → images150 → images (API returns mosaic arrays, not image_rectangle)
const QJsonArray imgs300 = playlist["images300"].toArray();
const QJsonArray imgs150 = playlist["images150"].toArray();
@@ -156,6 +184,13 @@ public:
show();
}
void setPlaylistFollowed(bool followed)
{
m_playlistFollowed = followed;
if (!m_playlistOwned)
m_followBtn->setText(m_playlistFollowed ? tr("Unfollow") : tr("Follow"));
}
private:
void fetchArt(const QJsonObject &img)
{
@@ -221,7 +256,11 @@ private:
QLabel *m_meta = nullptr;
QPushButton *m_playBtn = nullptr;
QPushButton *m_shuffleBtn = nullptr;
QPushButton *m_followBtn = nullptr;
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
qint64 m_artistId = 0;
qint64 m_playlistId = 0;
bool m_playlistFollowed = false;
bool m_playlistOwned = false;
};