Merge feat/multi-disc-artist-sections into main

This commit is contained in:
joren
2026-03-24 23:09:11 +01:00
16 changed files with 272 additions and 58 deletions

View File

@@ -34,6 +34,7 @@ enum QobuzEvent {
EV_PLAYLIST_CREATED = 20, EV_PLAYLIST_CREATED = 20,
EV_PLAYLIST_DELETED = 21, EV_PLAYLIST_DELETED = 21,
EV_PLAYLIST_TRACK_ADDED = 22, EV_PLAYLIST_TRACK_ADDED = 22,
EV_USER_OK = 23,
}; };
// Callback signature // Callback signature
@@ -46,6 +47,7 @@ void qobuz_backend_free(QobuzBackendOpaque *backend);
// Auth // Auth
void qobuz_backend_login(QobuzBackendOpaque *backend, const char *email, const char *password); void qobuz_backend_login(QobuzBackendOpaque *backend, const char *email, const char *password);
void qobuz_backend_set_token(QobuzBackendOpaque *backend, const char *token); void qobuz_backend_set_token(QobuzBackendOpaque *backend, const char *token);
void qobuz_backend_get_user(QobuzBackendOpaque *backend);
// Catalog // Catalog
void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit); void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit);

View File

@@ -266,6 +266,15 @@ impl QobuzClient {
Ok(serde_json::from_value(body)?) Ok(serde_json::from_value(body)?)
} }
pub async fn get_artist_page(&self, artist_id: i64) -> Result<Value> {
let resp = self
.get_request("artist/page")
.query(&[("artist_id", artist_id.to_string())])
.send()
.await?;
Self::check_response(resp).await
}
// --- Search --- // --- Search ---
pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result<SearchCatalogDto> { pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result<SearchCatalogDto> {

View File

@@ -46,6 +46,7 @@ pub struct SubscriptionDto {
pub struct TrackDto { pub struct TrackDto {
pub id: i64, pub id: i64,
pub title: Option<String>, pub title: Option<String>,
pub version: Option<String>,
pub duration: Option<i64>, pub duration: Option<i64>,
pub track_number: Option<i32>, pub track_number: Option<i32>,
pub playlist_track_id: Option<i64>, pub playlist_track_id: Option<i64>,
@@ -120,6 +121,10 @@ pub struct ArtistDto {
pub image: Option<ImageDto>, pub image: Option<ImageDto>,
pub biography: Option<BiographyDto>, pub biography: Option<BiographyDto>,
pub albums: Option<SearchResultItems<AlbumDto>>, pub albums: Option<SearchResultItems<AlbumDto>>,
#[serde(rename = "epSingles")]
pub ep_singles: Option<SearchResultItems<AlbumDto>>,
#[serde(rename = "liveAlbums")]
pub live_albums: Option<SearchResultItems<AlbumDto>>,
} }
#[derive(Debug, Deserialize, Clone, Serialize)] #[derive(Debug, Deserialize, Clone, Serialize)]

View File

@@ -239,7 +239,7 @@ pub unsafe extern "C" fn qobuz_backend_get_artist(ptr: *mut Backend, artist_id:
let cb = inner.cb; let ud = inner.ud; let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move { spawn(inner, async move {
let result = client.lock().await.get_artist(artist_id).await; let result = client.lock().await.get_artist_page(artist_id).await;
let (ev, json) = match result { let (ev, json) = match result {
Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()), Ok(r) => (EV_ARTIST_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_ARTIST_ERR, err_json(&e.to_string())), Err(e) => (EV_ARTIST_ERR, err_json(&e.to_string())),
@@ -563,6 +563,25 @@ pub unsafe extern "C" fn qobuz_backend_remove_fav_album(ptr: *mut Backend, album
}); });
} }
// ---------- User ----------
pub const EV_USER_OK: c_int = 23;
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_user(ptr: *mut Backend) {
let inner = &(*ptr).0;
let client = inner.client.clone();
let cb = inner.cb; let ud = inner.ud;
spawn(inner, async move {
let result = client.lock().await.get_user().await;
let (ev, json) = match result {
Ok(r) => (EV_USER_OK, serde_json::to_string(&r).unwrap_or_default()),
Err(e) => (EV_GENERIC_ERR, err_json(&e.to_string())),
};
call_cb(cb, ud, ev, &json);
});
}
// ---------- Playlist management ---------- // ---------- Playlist management ----------
pub const EV_PLAYLIST_CREATED: c_int = 20; pub const EV_PLAYLIST_CREATED: c_int = 20;

View File

@@ -35,6 +35,11 @@ void QobuzBackend::setToken(const QString &token)
qobuz_backend_set_token(m_backend, token.toUtf8().constData()); qobuz_backend_set_token(m_backend, token.toUtf8().constData());
} }
void QobuzBackend::getUser()
{
qobuz_backend_get_user(m_backend);
}
// ---- catalog ---- // ---- catalog ----
void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit) void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit)
@@ -249,6 +254,9 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case 22: // EV_PLAYLIST_TRACK_ADDED case 22: // EV_PLAYLIST_TRACK_ADDED
emit playlistTrackAdded(static_cast<qint64>(obj["playlist_id"].toDouble())); emit playlistTrackAdded(static_cast<qint64>(obj["playlist_id"].toDouble()));
break; break;
case EV_USER_OK:
emit userLoaded(obj);
break;
case EV_GENERIC_ERR: case EV_GENERIC_ERR:
case EV_TRACK_URL_ERR: case EV_TRACK_URL_ERR:
emit error(obj["error"].toString()); emit error(obj["error"].toString());

View File

@@ -23,6 +23,7 @@ public:
// --- auth --- // --- auth ---
void login(const QString &email, const QString &password); void login(const QString &email, const QString &password);
void setToken(const QString &token); void setToken(const QString &token);
void getUser();
// --- catalog --- // --- catalog ---
void search(const QString &query, quint32 offset = 0, quint32 limit = 20); void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
@@ -71,6 +72,7 @@ signals:
// auth // auth
void loginSuccess(const QString &token, const QJsonObject &user); void loginSuccess(const QString &token, const QJsonObject &user);
void loginError(const QString &error); void loginError(const QString &error);
void userLoaded(const QJsonObject &user);
// catalog // catalog
void searchResult(const QJsonObject &result); void searchResult(const QJsonObject &result);

View File

@@ -35,24 +35,32 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
this, &Tracks::onDoubleClicked); this, &Tracks::onDoubleClicked);
connect(this, &QTreeView::customContextMenuRequested, connect(this, &QTreeView::customContextMenuRequested,
this, &Tracks::onContextMenu); this, &Tracks::onContextMenu);
connect(m_model, &QAbstractItemModel::modelReset, this, [this] {
for (int row : m_model->discHeaderRows())
setFirstColumnSpanned(row, {}, true);
setSortingEnabled(!m_model->hasMultipleDiscs());
});
} }
void Tracks::loadTracks(const QJsonArray &tracks) void Tracks::loadTracks(const QJsonArray &tracks)
{ {
setPlaylistContext(0); setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, false);
m_model->setTracks(tracks, false, /*useSequential=*/true); m_model->setTracks(tracks, false, /*useSequential=*/true);
} }
void Tracks::loadAlbum(const QJsonObject &album) void Tracks::loadAlbum(const QJsonObject &album)
{ {
setPlaylistContext(0); setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, true);
const QJsonArray items = album["tracks"].toObject()["items"].toArray(); const QJsonArray items = album["tracks"].toObject()["items"].toArray();
m_model->setTracks(items); // album: use track_number m_model->setTracks(items); // album: use track_number
} }
void Tracks::loadPlaylist(const QJsonObject &playlist) void Tracks::loadPlaylist(const QJsonObject &playlist)
{ {
setColumnHidden(TrackListModel::ColAlbum, false);
const qint64 id = static_cast<qint64>(playlist["id"].toDouble()); const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
const qint64 ownId = static_cast<qint64>(playlist["owner"].toObject()["id"].toDouble()); const qint64 ownId = static_cast<qint64>(playlist["owner"].toObject()["id"].toDouble());
const qint64 myId = AppSettings::instance().userId(); const qint64 myId = AppSettings::instance().userId();
@@ -65,6 +73,7 @@ void Tracks::loadPlaylist(const QJsonObject &playlist)
void Tracks::loadSearchTracks(const QJsonArray &tracks) void Tracks::loadSearchTracks(const QJsonArray &tracks)
{ {
setPlaylistContext(0); setPlaylistContext(0);
setColumnHidden(TrackListModel::ColAlbum, false);
m_model->setTracks(tracks, false, /*useSequential=*/true); m_model->setTracks(tracks, false, /*useSequential=*/true);
} }
@@ -118,7 +127,11 @@ void Tracks::onDoubleClicked(const QModelIndex &index)
{ {
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong(); const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (id > 0) { if (id > 0) {
m_queue->setContext(m_model->currentTracksJson(), index.row()); // Compute filtered row (disc headers excluded from currentTracksJson)
int filteredRow = 0;
for (int r = 0; r < index.row(); ++r)
if (!m_model->trackAt(r).isDiscHeader) ++filteredRow;
m_queue->setContext(m_model->currentTracksJson(), filteredRow);
emit playTrackRequested(id); emit playTrackRequested(id);
} }
} }
@@ -129,6 +142,7 @@ void Tracks::onContextMenu(const QPoint &pos)
if (!index.isValid()) return; if (!index.isValid()) return;
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong(); const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (id <= 0) return; // disc header row
const QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject(); const QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject();
QMenu menu(this); QMenu menu(this);
@@ -153,9 +167,12 @@ void Tracks::onContextMenu(const QPoint &pos)
}); });
} }
const int row = index.row(); // Compute filtered row for multi-disc albums (disc headers excluded from currentTracksJson)
connect(playNow, &QAction::triggered, this, [this, id, row] { int filteredRow = 0;
m_queue->setContext(m_model->currentTracksJson(), row); for (int r = 0; r < index.row(); ++r)
if (!m_model->trackAt(r).isDiscHeader) ++filteredRow;
connect(playNow, &QAction::triggered, this, [this, id, filteredRow] {
m_queue->setContext(m_model->currentTracksJson(), filteredRow);
emit playTrackRequested(id); emit playTrackRequested(id);
}); });
connect(playNext, &QAction::triggered, this, [this, trackJson] { connect(playNext, &QAction::triggered, this, [this, trackJson] {

View File

@@ -72,6 +72,13 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
// ---- Backend signals ---- // ---- Backend signals ----
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess); connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError); connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
connect(m_backend, &QobuzBackend::userLoaded, this, [this](const QJsonObject &user) {
const qint64 id = static_cast<qint64>(user["id"].toDouble());
if (id > 0) {
AppSettings::instance().setUserId(id);
m_library->refresh(); // re-load playlists with correct ownership now
}
});
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded); connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded); connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
connect(m_backend, &QobuzBackend::favArtistsLoaded, this, &MainWindow::onFavArtistsLoaded); connect(m_backend, &QobuzBackend::favArtistsLoaded, this, &MainWindow::onFavArtistsLoaded);
@@ -199,6 +206,9 @@ void MainWindow::tryRestoreSession()
const QString token = AppSettings::instance().authToken(); const QString token = AppSettings::instance().authToken();
if (!token.isEmpty()) { if (!token.isEmpty()) {
m_backend->setToken(token); m_backend->setToken(token);
if (AppSettings::instance().userId() == 0)
m_backend->getUser(); // userLoaded will call m_library->refresh()
else
m_library->refresh(); m_library->refresh();
const QString name = AppSettings::instance().displayName(); const QString name = AppSettings::instance().displayName();
statusBar()->showMessage(tr("Signed in as %1").arg( statusBar()->showMessage(tr("Signed in as %1").arg(

View File

@@ -17,18 +17,28 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
m_tracks.clear(); m_tracks.clear();
m_tracks.reserve(tracks.size()); m_tracks.reserve(tracks.size());
// Parse into a temporary list first so we can detect multi-disc
QVector<TrackItem> parsed;
parsed.reserve(tracks.size());
int seq = 1; int seq = 1;
for (const QJsonValue &v : tracks) { for (const QJsonValue &v : tracks) {
const QJsonObject t = v.toObject(); const QJsonObject t = v.toObject();
TrackItem item; TrackItem item;
item.id = static_cast<qint64>(t["id"].toDouble()); item.id = static_cast<qint64>(t["id"].toDouble());
item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble()); item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble());
item.title = t["title"].toString(); item.discNumber = t["media_number"].toInt(1);
item.duration = static_cast<qint64>(t["duration"].toDouble()); item.duration = static_cast<qint64>(t["duration"].toDouble());
item.hiRes = t["hires_streamable"].toBool();
item.streamable = t["streamable"].toBool(true); item.streamable = t["streamable"].toBool(true);
item.hiRes = t["hires_streamable"].toBool();
item.raw = t; item.raw = t;
// Combine title + version ("Melody" + "Vocal Remix" → "Melody (Vocal Remix)")
const QString base = t["title"].toString();
const QString version = t["version"].toString().trimmed();
item.title = version.isEmpty() ? base
: base + QStringLiteral(" (") + version + QLatin1Char(')');
if (useSequential) { if (useSequential) {
item.number = seq++; item.number = seq++;
} else if (usePosition) { } else if (usePosition) {
@@ -48,17 +58,46 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
item.album = album["title"].toString(); item.album = album["title"].toString();
item.albumId = album["id"].toString(); item.albumId = album["id"].toString();
m_tracks.append(item); parsed.append(item);
} }
// Re-apply sort silently inside the reset (no layout signals needed here) // Multi-disc only makes sense for album context (not playlists / fav / search)
int maxDisc = 1;
if (!usePosition && !useSequential) {
for (const TrackItem &t : parsed)
maxDisc = qMax(maxDisc, t.discNumber);
}
m_hasMultipleDiscs = (maxDisc > 1);
if (m_hasMultipleDiscs) {
// Sort by disc then track number
std::stable_sort(parsed.begin(), parsed.end(), [](const TrackItem &a, const TrackItem &b) {
return a.discNumber != b.discNumber ? a.discNumber < b.discNumber
: a.number < b.number;
});
// Interleave disc header items
int currentDisc = -1;
for (const TrackItem &t : parsed) {
if (t.discNumber != currentDisc) {
TrackItem header;
header.isDiscHeader = true;
header.discNumber = t.discNumber;
header.title = tr("Disc %1").arg(t.discNumber);
m_tracks.append(header);
currentDisc = t.discNumber;
}
m_tracks.append(t);
}
} else {
m_tracks = parsed;
// Re-apply sort silently inside the reset
if (m_sortColumn >= 0) if (m_sortColumn >= 0)
sortData(m_sortColumn, m_sortOrder); sortData(m_sortColumn, m_sortOrder);
}
endResetModel(); endResetModel();
// Tell external listeners the sorted order is ready (e.g. PlayQueue sync) if (!m_hasMultipleDiscs && m_sortColumn >= 0)
if (m_sortColumn >= 0)
emit sortApplied(); emit sortApplied();
} }
@@ -115,6 +154,23 @@ void TrackListModel::setPlayingId(qint64 id)
{Qt::FontRole, Qt::DecorationRole}); {Qt::FontRole, Qt::DecorationRole});
} }
Qt::ItemFlags TrackListModel::flags(const QModelIndex &index) const
{
if (!index.isValid() || index.row() >= m_tracks.size())
return Qt::NoItemFlags;
if (m_tracks.at(index.row()).isDiscHeader)
return Qt::ItemIsEnabled;
return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}
QVector<int> TrackListModel::discHeaderRows() const
{
QVector<int> rows;
for (int i = 0; i < m_tracks.size(); ++i)
if (m_tracks[i].isDiscHeader) rows.append(i);
return rows;
}
int TrackListModel::rowCount(const QModelIndex &parent) const int TrackListModel::rowCount(const QModelIndex &parent) const
{ {
return parent.isValid() ? 0 : m_tracks.size(); return parent.isValid() ? 0 : m_tracks.size();
@@ -131,6 +187,19 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
return {}; return {};
const TrackItem &t = m_tracks.at(index.row()); const TrackItem &t = m_tracks.at(index.row());
// Disc header rows: styled separator spanning all columns via setFirstColumnSpanned
if (t.isDiscHeader) {
if (role == Qt::DisplayRole && index.column() == ColNumber)
return t.title;
if (role == Qt::FontRole) {
QFont f; f.setBold(true); return f;
}
if (role == Qt::ForegroundRole)
return QColor(0xFF, 0xB2, 0x32);
return {};
}
const bool isPlaying = (t.id == m_playingId && m_playingId != 0); const bool isPlaying = (t.id == m_playingId && m_playingId != 0);
if (role == Qt::DisplayRole) { if (role == Qt::DisplayRole) {
@@ -213,7 +282,8 @@ void TrackListModel::sort(int column, Qt::SortOrder order)
m_sortColumn = column; m_sortColumn = column;
m_sortOrder = order; m_sortOrder = order;
if (m_tracks.isEmpty()) return; // Multi-disc albums keep their disc-ordered layout; don't re-sort
if (m_hasMultipleDiscs || m_tracks.isEmpty()) return;
emit layoutAboutToBeChanged(); emit layoutAboutToBeChanged();
sortData(column, order); sortData(column, order);

View File

@@ -11,6 +11,8 @@ struct TrackItem {
qint64 id = 0; qint64 id = 0;
qint64 playlistTrackId = 0; qint64 playlistTrackId = 0;
int number = 0; int number = 0;
int discNumber = 1;
bool isDiscHeader = false;
QString title; QString title;
QString artist; QString artist;
QString album; QString album;
@@ -58,17 +60,22 @@ public:
void removeFavId(qint64 id); void removeFavId(qint64 id);
bool isFav(qint64 id) const { return m_favIds.contains(id); } bool isFav(qint64 id) const { return m_favIds.contains(id); }
bool hasMultipleDiscs() const { return m_hasMultipleDiscs; }
QVector<int> discHeaderRows() const;
Qt::ItemFlags flags(const QModelIndex &index) const override;
/// Optimistically remove a row (e.g. after deleting from playlist). /// Optimistically remove a row (e.g. after deleting from playlist).
void removeTrack(int row); void removeTrack(int row);
const TrackItem &trackAt(int row) const { return m_tracks.at(row); } const TrackItem &trackAt(int row) const { return m_tracks.at(row); }
// Returns the current (possibly sorted) raw JSON objects in display order. // Returns the current (possibly sorted) raw JSON objects in display order, skipping disc headers.
QJsonArray currentTracksJson() const QJsonArray currentTracksJson() const
{ {
QJsonArray out; QJsonArray out;
for (const auto &t : m_tracks) for (const auto &t : m_tracks)
out.append(t.raw); if (!t.isDiscHeader) out.append(t.raw);
return out; return out;
} }
@@ -89,6 +96,7 @@ private:
QVector<TrackItem> m_tracks; QVector<TrackItem> m_tracks;
QSet<qint64> m_favIds; QSet<qint64> m_favIds;
qint64 m_playingId = 0; qint64 m_playingId = 0;
bool m_hasMultipleDiscs = false;
int m_sortColumn = -1; int m_sortColumn = -1;
Qt::SortOrder m_sortOrder = Qt::AscendingOrder; Qt::SortOrder m_sortOrder = Qt::AscendingOrder;

View File

@@ -48,12 +48,26 @@ public:
for (const auto &v : albums) { for (const auto &v : albums) {
const QJsonObject a = v.toObject(); const QJsonObject a = v.toObject();
const QString id = a["id"].toString(); const QString id = a["id"].toString();
const QString title = a["title"].toString(); const QString base = a["title"].toString();
const QString artist = a["artist"].toObject()["name"].toString(); const QString ver = a["version"].toString().trimmed();
const QString title = ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')');
// artist.name is either a plain string (old AlbumDto) or {display: ...} (artist/page)
const QJsonValue artistNameVal = a["artist"].toObject()["name"];
const QString artist = artistNameVal.isObject()
? artistNameVal.toObject()["display"].toString()
: artistNameVal.toString();
// year: release_date_original (old) or dates.original (artist/page)
const QString date = a["release_date_original"].toString(); const QString date = a["release_date_original"].toString();
const QString year = date.left(4); const QString year = date.isEmpty()
? a["dates"].toObject()["original"].toString().left(4)
: date.left(4);
const int tracks = a["tracks_count"].toInt(); const int tracks = a["tracks_count"].toInt();
const bool hiRes = a["hires_streamable"].toBool(); // hires: flat field (old) or rights.hires_streamable (artist/page)
const bool hiRes = a["hires_streamable"].toBool()
|| a["rights"].toObject()["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(this); auto *item = new QTreeWidgetItem(this);
if (hiRes) { if (hiRes) {

View File

@@ -6,6 +6,7 @@
#include <QScrollArea> #include <QScrollArea>
#include <QFont> #include <QFont>
#include <QJsonValue> #include <QJsonValue>
#include <QRegularExpression>
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ArtistSection // ArtistSection
@@ -105,11 +106,15 @@ ArtistView::ArtistView(QWidget *parent)
sectLayout->setSpacing(8); sectLayout->setSpacing(8);
m_secAlbums = new ArtistSection(tr("Albums"), content); m_secAlbums = new ArtistSection(tr("Albums"), content);
m_secEps = new ArtistSection(tr("EPs & Singles"), content); m_secEps = new ArtistSection(tr("Singles & EPs"), content);
m_secLive = new ArtistSection(tr("Live"), content);
m_secCompilations = new ArtistSection(tr("Compilations"), content);
m_secOther = new ArtistSection(tr("Other"), content); m_secOther = new ArtistSection(tr("Other"), content);
sectLayout->addWidget(m_secAlbums); sectLayout->addWidget(m_secAlbums);
sectLayout->addWidget(m_secEps); sectLayout->addWidget(m_secEps);
sectLayout->addWidget(m_secLive);
sectLayout->addWidget(m_secCompilations);
sectLayout->addWidget(m_secOther); sectLayout->addWidget(m_secOther);
sectLayout->addStretch(); sectLayout->addStretch();
@@ -118,36 +123,56 @@ ArtistView::ArtistView(QWidget *parent)
connect(m_secAlbums, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); connect(m_secAlbums, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secEps, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); connect(m_secEps, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secLive, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secCompilations, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
connect(m_secOther, &ArtistSection::albumSelected, this, &ArtistView::albumSelected); connect(m_secOther, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
} }
void ArtistView::setArtist(const QJsonObject &artist) void ArtistView::setArtist(const QJsonObject &artist)
{ {
m_nameLabel->setText(artist["name"].toString()); // artist/page: name is {"display": "..."}
m_nameLabel->setText(artist["name"].toObject()["display"].toString());
const QString summary = artist["biography"].toObject()["summary"].toString(); // biography.content is HTML — strip tags for the summary label
m_bioLabel->setText(summary); const QString bioHtml = artist["biography"].toObject()["content"].toString();
m_bioLabel->setVisible(!summary.isEmpty()); if (!bioHtml.isEmpty()) {
// Remove HTML tags for plain-text display
QString plain = bioHtml;
plain.remove(QRegularExpression(QStringLiteral("<[^>]*>")));
plain = plain.trimmed();
m_bioLabel->setText(plain);
m_bioLabel->setVisible(true);
} else {
m_bioLabel->setVisible(false);
}
const QJsonArray allAlbums = artist["albums"].toObject()["items"].toArray(); // releases is an array of {type, has_more, items[]}
// types we care about: "album", "epSingle", "live"
QJsonArray albums, eps, other; const QJsonArray releases = artist["releases"].toArray();
for (const QJsonValue &v : allAlbums) { QJsonArray albums, eps, live, compilations;
const QJsonObject a = v.toObject(); for (const QJsonValue &rv : releases) {
const QString rt = a["release_type"].toString(); const QJsonObject rg = rv.toObject();
if (rt == QStringLiteral("album")) const QString type = rg["type"].toString();
albums.append(a); const QJsonArray items = rg["items"].toArray();
else if (rt == QStringLiteral("epSingle")) if (type == QStringLiteral("album"))
eps.append(a); albums = items;
else else if (type == QStringLiteral("epSingle"))
other.append(a); eps = items;
else if (type == QStringLiteral("live"))
live = items;
else if (type == QStringLiteral("compilation"))
compilations = items;
} }
m_secAlbums->setAlbums(albums); m_secAlbums->setAlbums(albums);
m_secEps->setAlbums(eps); m_secEps->setAlbums(eps);
m_secOther->setAlbums(other); m_secLive->setAlbums(live);
m_secCompilations->setAlbums(compilations);
m_secOther->setAlbums({});
m_secAlbums->setVisible(!m_secAlbums->isEmpty()); m_secAlbums->setVisible(!m_secAlbums->isEmpty());
m_secEps->setVisible(!m_secEps->isEmpty()); m_secEps->setVisible(!m_secEps->isEmpty());
m_secOther->setVisible(!m_secOther->isEmpty()); m_secLive->setVisible(!m_secLive->isEmpty());
m_secCompilations->setVisible(!m_secCompilations->isEmpty());
m_secOther->setVisible(false);
} }

View File

@@ -50,5 +50,7 @@ private:
QLabel *m_bioLabel = nullptr; QLabel *m_bioLabel = nullptr;
ArtistSection *m_secAlbums = nullptr; ArtistSection *m_secAlbums = nullptr;
ArtistSection *m_secEps = nullptr; ArtistSection *m_secEps = nullptr;
ArtistSection *m_secLive = nullptr;
ArtistSection *m_secCompilations = nullptr;
ArtistSection *m_secOther = nullptr; ArtistSection *m_secOther = nullptr;
}; };

View File

@@ -36,6 +36,11 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
[this] { m_tracks->playAll(false); }); [this] { m_tracks->playAll(false); });
QObject::connect(m_header->shuffleButton(), &QPushButton::clicked, QObject::connect(m_header->shuffleButton(), &QPushButton::clicked,
[this] { m_tracks->playAll(true); }); [this] { m_tracks->playAll(true); });
QObject::connect(m_header->subtitleButton(), &QPushButton::clicked,
[this] {
const qint64 id = m_header->artistId();
if (id > 0) emit artistRequested(id);
});
m_albumList = new AlbumListView(this); m_albumList = new AlbumListView(this);
m_artistList = new ArtistListView(this); m_artistList = new ArtistListView(this);

View File

@@ -173,7 +173,9 @@ void QueuePanel::refresh()
for (int i = 0; i < upcoming.size(); ++i) { for (int i = 0; i < upcoming.size(); ++i) {
const QJsonObject &t = upcoming.at(i); const QJsonObject &t = upcoming.at(i);
const QString title = t["title"].toString(); const QString base = t["title"].toString();
const QString ver = t["version"].toString().trimmed();
const QString title = ver.isEmpty() ? base : base + QStringLiteral(" (") + ver + QLatin1Char(')');
const QString artist = t["performer"].toObject()["name"].toString().isEmpty() const QString artist = t["performer"].toObject()["name"].toString().isEmpty()
? t["album"].toObject()["artist"].toObject()["name"].toString() ? t["album"].toObject()["artist"].toObject()["name"].toString()
: t["performer"].toObject()["name"].toString(); : t["performer"].toObject()["name"].toString();

View File

@@ -49,11 +49,16 @@ public:
m_title->setWordWrap(true); m_title->setWordWrap(true);
vlay->addWidget(m_title); vlay->addWidget(m_title);
m_subtitle = new QLabel(info); m_subtitle = new QPushButton(info);
m_subtitle->setFlat(true);
m_subtitle->setStyleSheet(QStringLiteral(
"QPushButton { border: none; background: none; text-align: left; padding: 0; margin: 0; }"
"QPushButton:enabled:hover { color: #FFB232; }"
"QPushButton:!enabled { color: palette(text); }"
));
QFont sf = m_subtitle->font(); QFont sf = m_subtitle->font();
sf.setPointSize(sf.pointSize() + 1); sf.setPointSize(sf.pointSize() + 1);
m_subtitle->setFont(sf); m_subtitle->setFont(sf);
m_subtitle->setWordWrap(true);
vlay->addWidget(m_subtitle); vlay->addWidget(m_subtitle);
m_meta = new QLabel(info); m_meta = new QLabel(info);
@@ -90,6 +95,7 @@ public:
btnRow->addStretch(); btnRow->addStretch();
vlay->addLayout(btnRow); vlay->addLayout(btnRow);
vlay->addStretch(1);
hlay->addWidget(info, 1); hlay->addWidget(info, 1);
@@ -108,10 +114,16 @@ public:
QPushButton *playButton() { return m_playBtn; } QPushButton *playButton() { return m_playBtn; }
QPushButton *shuffleButton() { return m_shuffleBtn; } QPushButton *shuffleButton() { return m_shuffleBtn; }
QPushButton *subtitleButton() { return m_subtitle; }
qint64 artistId() const { return m_artistId; }
void setAlbum(const QJsonObject &album) void setAlbum(const QJsonObject &album)
{ {
m_title->setText(album["title"].toString()); m_title->setText(album["title"].toString());
m_artistId = static_cast<qint64>(album["artist"].toObject()["id"].toDouble());
m_subtitle->setText(album["artist"].toObject()["name"].toString()); m_subtitle->setText(album["artist"].toObject()["name"].toString());
m_subtitle->setEnabled(m_artistId > 0);
m_subtitle->setCursor(m_artistId > 0 ? Qt::PointingHandCursor : Qt::ArrowCursor);
m_meta->setText(buildAlbumMeta(album)); m_meta->setText(buildAlbumMeta(album));
fetchArt(album["image"].toObject()); fetchArt(album["image"].toObject());
show(); show();
@@ -120,9 +132,12 @@ public:
void setPlaylist(const QJsonObject &playlist) void setPlaylist(const QJsonObject &playlist)
{ {
m_title->setText(playlist["name"].toString()); m_title->setText(playlist["name"].toString());
m_artistId = 0;
const QString desc = playlist["description"].toString(); const QString desc = playlist["description"].toString();
const QString owner = playlist["owner"].toObject()["name"].toString(); const QString owner = playlist["owner"].toObject()["name"].toString();
m_subtitle->setText(desc.isEmpty() ? owner : desc); m_subtitle->setText(desc.isEmpty() ? owner : desc);
m_subtitle->setEnabled(false);
m_subtitle->setCursor(Qt::ArrowCursor);
m_meta->setText(buildPlaylistMeta(playlist)); m_meta->setText(buildPlaylistMeta(playlist));
// Try images300 → images150 → images (API returns mosaic arrays, not image_rectangle) // Try images300 → images150 → images (API returns mosaic arrays, not image_rectangle)
@@ -200,10 +215,11 @@ private:
QLabel *m_art = nullptr; QLabel *m_art = nullptr;
QLabel *m_title = nullptr; QLabel *m_title = nullptr;
QLabel *m_subtitle = nullptr; QPushButton *m_subtitle = nullptr;
QLabel *m_meta = nullptr; QLabel *m_meta = nullptr;
QPushButton *m_playBtn = nullptr; QPushButton *m_playBtn = nullptr;
QPushButton *m_shuffleBtn = nullptr; QPushButton *m_shuffleBtn = nullptr;
QNetworkAccessManager *m_nam = nullptr; QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl; QString m_currentArtUrl;
qint64 m_artistId = 0;
}; };