feat: artist sections, fav indicator, art scaling fix, volume popup fix
- Artist profile: collapsible Albums / EPs & Singles / Other sections keyed on release_type; fetches up to 200 albums per artist - Favorites: starred icon on favorited tracks, context menu shows Add or Remove (not both); IDs cached when fav tracks are loaded - Shuffle button: one-time shuffle via shuffleNow() without touching global shuffle flag, so double-click still plays in order - Now-playing art: replaced setFixedHeight hack with ArtWidget that overrides hasHeightForWidth() — scales smoothly up and down, no min-size - Volume popup: replaced QMenu (laggy, broken drag) with Qt::Popup QFrame; appears below button; fixed size locked at 100% label width Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -258,6 +258,7 @@ impl QobuzClient {
|
|||||||
.query(&[
|
.query(&[
|
||||||
("artist_id", artist_id.to_string()),
|
("artist_id", artist_id.to_string()),
|
||||||
("extra", "albums".to_string()),
|
("extra", "albums".to_string()),
|
||||||
|
("albums_limit", "200".to_string()),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ pub struct AlbumDto {
|
|||||||
pub maximum_sampling_rate: Option<f64>,
|
pub maximum_sampling_rate: Option<f64>,
|
||||||
pub hires_streamable: Option<bool>,
|
pub hires_streamable: Option<bool>,
|
||||||
pub streamable: Option<bool>,
|
pub streamable: Option<bool>,
|
||||||
|
pub release_type: Option<String>,
|
||||||
pub tracks: Option<TracksWrapper>,
|
pub tracks: Option<TracksWrapper>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ target_sources(qobuz-qt PRIVATE
|
|||||||
view/albumlistview.hpp
|
view/albumlistview.hpp
|
||||||
view/artistlistview.hpp
|
view/artistlistview.hpp
|
||||||
view/artistview.hpp
|
view/artistview.hpp
|
||||||
|
view/artistview.cpp
|
||||||
view/sidepanel/view.hpp
|
view/sidepanel/view.hpp
|
||||||
view/sidepanel/view.cpp
|
view/sidepanel/view.cpp
|
||||||
|
|
||||||
|
|||||||
@@ -84,12 +84,30 @@ void Tracks::setPlayingTrackId(qint64 id)
|
|||||||
m_model->setPlayingId(id);
|
m_model->setPlayingId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Tracks::setFavTrackIds(const QSet<qint64> &ids)
|
||||||
|
{
|
||||||
|
m_model->setFavIds(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Tracks::addFavTrackId(qint64 id)
|
||||||
|
{
|
||||||
|
m_model->addFavId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Tracks::removeFavTrackId(qint64 id)
|
||||||
|
{
|
||||||
|
m_model->removeFavId(id);
|
||||||
|
}
|
||||||
|
|
||||||
void Tracks::playAll(bool shuffle)
|
void Tracks::playAll(bool shuffle)
|
||||||
{
|
{
|
||||||
const QJsonArray tracks = m_model->currentTracksJson();
|
const QJsonArray tracks = m_model->currentTracksJson();
|
||||||
if (tracks.isEmpty()) return;
|
if (tracks.isEmpty()) return;
|
||||||
m_queue->setShuffle(shuffle);
|
|
||||||
m_queue->setContext(tracks, 0);
|
m_queue->setContext(tracks, 0);
|
||||||
|
// Shuffle once without touching the global shuffle flag — so a subsequent
|
||||||
|
// double-click on a track plays in normal order (unless global shuffle is on).
|
||||||
|
if (shuffle && !m_queue->shuffleEnabled())
|
||||||
|
m_queue->shuffleNow();
|
||||||
const qint64 firstId = static_cast<qint64>(m_queue->current()["id"].toDouble());
|
const qint64 firstId = static_cast<qint64>(m_queue->current()["id"].toDouble());
|
||||||
if (firstId > 0)
|
if (firstId > 0)
|
||||||
emit playTrackRequested(firstId);
|
emit playTrackRequested(firstId);
|
||||||
@@ -119,8 +137,21 @@ void Tracks::onContextMenu(const QPoint &pos)
|
|||||||
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
|
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
|
||||||
auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
|
auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
|
|
||||||
|
const bool isFav = m_model->isFav(id);
|
||||||
|
if (isFav) {
|
||||||
auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites"));
|
auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from favorites"));
|
||||||
|
connect(remFav, &QAction::triggered, this, [this, id] {
|
||||||
|
m_backend->removeFavTrack(id);
|
||||||
|
m_model->removeFavId(id);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
|
||||||
|
connect(addFav, &QAction::triggered, this, [this, id] {
|
||||||
|
m_backend->addFavTrack(id);
|
||||||
|
m_model->addFavId(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const int row = index.row();
|
const int row = index.row();
|
||||||
connect(playNow, &QAction::triggered, this, [this, id, row] {
|
connect(playNow, &QAction::triggered, this, [this, id, row] {
|
||||||
@@ -133,12 +164,6 @@ void Tracks::onContextMenu(const QPoint &pos)
|
|||||||
connect(addQueue, &QAction::triggered, this, [this, trackJson] {
|
connect(addQueue, &QAction::triggered, this, [this, trackJson] {
|
||||||
m_queue->addToQueue(trackJson);
|
m_queue->addToQueue(trackJson);
|
||||||
});
|
});
|
||||||
connect(addFav, &QAction::triggered, this, [this, id] {
|
|
||||||
m_backend->addFavTrack(id);
|
|
||||||
});
|
|
||||||
connect(remFav, &QAction::triggered, this, [this, id] {
|
|
||||||
m_backend->removeFavTrack(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open album
|
// Open album
|
||||||
const QString albumId = m_model->trackAt(index.row()).albumId;
|
const QString albumId = m_model->trackAt(index.row()).albumId;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <QVector>
|
#include <QVector>
|
||||||
#include <QPair>
|
#include <QPair>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
namespace List
|
namespace List
|
||||||
{
|
{
|
||||||
@@ -28,6 +29,11 @@ namespace List
|
|||||||
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
|
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
|
||||||
void setPlayingTrackId(qint64 id);
|
void setPlayingTrackId(qint64 id);
|
||||||
|
|
||||||
|
/// Populate favorite track IDs so the star indicator and context menu reflect fav status.
|
||||||
|
void setFavTrackIds(const QSet<qint64> &ids);
|
||||||
|
void addFavTrackId(qint64 id);
|
||||||
|
void removeFavTrackId(qint64 id);
|
||||||
|
|
||||||
/// Start playing all tracks in the current view from the beginning.
|
/// Start playing all tracks in the current view from the beginning.
|
||||||
/// If shuffle is true, enables shuffle mode before starting.
|
/// If shuffle is true, enables shuffle mode before starting.
|
||||||
void playAll(bool shuffle = false);
|
void playAll(bool shuffle = false);
|
||||||
|
|||||||
@@ -283,6 +283,15 @@ void MainWindow::onTrackChanged(const QJsonObject &track)
|
|||||||
|
|
||||||
void MainWindow::onFavTracksLoaded(const QJsonObject &result)
|
void MainWindow::onFavTracksLoaded(const QJsonObject &result)
|
||||||
{
|
{
|
||||||
|
// Cache fav IDs so the star indicator and context menu stay in sync
|
||||||
|
QSet<qint64> ids;
|
||||||
|
const QJsonArray items = result["items"].toArray();
|
||||||
|
for (const QJsonValue &v : items) {
|
||||||
|
const qint64 id = static_cast<qint64>(v.toObject()["id"].toDouble());
|
||||||
|
if (id > 0) ids.insert(id);
|
||||||
|
}
|
||||||
|
m_content->tracksList()->setFavTrackIds(ids);
|
||||||
|
|
||||||
m_content->showFavTracks(result);
|
m_content->showFavTracks(result);
|
||||||
statusBar()->showMessage(
|
statusBar()->showMessage(
|
||||||
tr("%1 favorite tracks").arg(result["total"].toInt()), 4000);
|
tr("%1 favorite tracks").arg(result["total"].toInt()), 4000);
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
#include <QDockWidget>
|
#include <QDockWidget>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QJsonArray>
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
|
#include <QSet>
|
||||||
#include <QPair>
|
#include <QPair>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,36 @@ void TrackListModel::removeTrack(int row)
|
|||||||
endRemoveRows();
|
endRemoveRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TrackListModel::setFavIds(const QSet<qint64> &ids)
|
||||||
|
{
|
||||||
|
m_favIds = ids;
|
||||||
|
if (!m_tracks.isEmpty())
|
||||||
|
emit dataChanged(index(0, ColTitle), index(rowCount() - 1, ColTitle),
|
||||||
|
{Qt::DecorationRole});
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrackListModel::addFavId(qint64 id)
|
||||||
|
{
|
||||||
|
m_favIds.insert(id);
|
||||||
|
for (int r = 0; r < m_tracks.size(); ++r) {
|
||||||
|
if (m_tracks[r].id == id) {
|
||||||
|
const auto idx = index(r, ColTitle);
|
||||||
|
emit dataChanged(idx, idx, {Qt::DecorationRole});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TrackListModel::removeFavId(qint64 id)
|
||||||
|
{
|
||||||
|
m_favIds.remove(id);
|
||||||
|
for (int r = 0; r < m_tracks.size(); ++r) {
|
||||||
|
if (m_tracks[r].id == id) {
|
||||||
|
const auto idx = index(r, ColTitle);
|
||||||
|
emit dataChanged(idx, idx, {Qt::DecorationRole});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void TrackListModel::setPlayingId(qint64 id)
|
void TrackListModel::setPlayingId(qint64 id)
|
||||||
{
|
{
|
||||||
m_playingId = id;
|
m_playingId = id;
|
||||||
@@ -128,6 +158,10 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
|
|||||||
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
|
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role == Qt::DecorationRole && index.column() == ColTitle && m_favIds.contains(t.id)) {
|
||||||
|
return QIcon(QStringLiteral(":/res/icons/starred-symbolic.svg"));
|
||||||
|
}
|
||||||
|
|
||||||
if (role == TrackIdRole) return t.id;
|
if (role == TrackIdRole) return t.id;
|
||||||
if (role == TrackJsonRole) return t.raw;
|
if (role == TrackJsonRole) return t.raw;
|
||||||
if (role == HiResRole) return t.hiRes;
|
if (role == HiResRole) return t.hiRes;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QVector>
|
#include <QVector>
|
||||||
|
#include <QSet>
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
|
|
||||||
struct TrackItem {
|
struct TrackItem {
|
||||||
@@ -52,6 +53,11 @@ public:
|
|||||||
void setPlayingId(qint64 id);
|
void setPlayingId(qint64 id);
|
||||||
qint64 playingId() const { return m_playingId; }
|
qint64 playingId() const { return m_playingId; }
|
||||||
|
|
||||||
|
void setFavIds(const QSet<qint64> &ids);
|
||||||
|
void addFavId(qint64 id);
|
||||||
|
void removeFavId(qint64 id);
|
||||||
|
bool isFav(qint64 id) const { return m_favIds.contains(id); }
|
||||||
|
|
||||||
/// 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);
|
||||||
|
|
||||||
@@ -81,6 +87,7 @@ signals:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
QVector<TrackItem> m_tracks;
|
QVector<TrackItem> m_tracks;
|
||||||
|
QSet<qint64> m_favIds;
|
||||||
qint64 m_playingId = 0;
|
qint64 m_playingId = 0;
|
||||||
int m_sortColumn = -1;
|
int m_sortColumn = -1;
|
||||||
Qt::SortOrder m_sortOrder = Qt::AscendingOrder;
|
Qt::SortOrder m_sortOrder = Qt::AscendingOrder;
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ public:
|
|||||||
emit queueChanged();
|
emit queueChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shuffle the current queue once without changing the global shuffle flag.
|
||||||
|
void shuffleNow()
|
||||||
|
{
|
||||||
|
if (m_queue.isEmpty()) return;
|
||||||
|
shuffleQueue(m_index);
|
||||||
|
emit queueChanged();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Play-next prepend queue (like "Add to queue" ----
|
// ---- Play-next prepend queue (like "Add to queue" ----
|
||||||
|
|
||||||
void addToQueue(const QJsonObject &track)
|
void addToQueue(const QJsonObject &track)
|
||||||
|
|||||||
153
src/view/artistview.cpp
Normal file
153
src/view/artistview.cpp
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
#include "artistview.hpp"
|
||||||
|
#include "albumlistview.hpp"
|
||||||
|
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QJsonValue>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ArtistSection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ArtistSection::ArtistSection(const QString &title, QWidget *parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
, m_baseTitle(title)
|
||||||
|
{
|
||||||
|
auto *layout = new QVBoxLayout(this);
|
||||||
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
layout->setSpacing(0);
|
||||||
|
|
||||||
|
m_toggle = new QToolButton(this);
|
||||||
|
m_toggle->setCheckable(true);
|
||||||
|
m_toggle->setChecked(true);
|
||||||
|
m_toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||||
|
m_toggle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||||
|
m_toggle->setStyleSheet(QStringLiteral(
|
||||||
|
"QToolButton { text-align: left; font-weight: bold; padding: 4px 6px;"
|
||||||
|
" border: none; border-bottom: 1px solid #333; }"
|
||||||
|
"QToolButton:hover { background: #1e1e1e; }"
|
||||||
|
));
|
||||||
|
updateToggleText(0);
|
||||||
|
layout->addWidget(m_toggle);
|
||||||
|
|
||||||
|
m_list = new AlbumListView(this);
|
||||||
|
layout->addWidget(m_list);
|
||||||
|
|
||||||
|
connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible);
|
||||||
|
connect(m_list, &AlbumListView::albumSelected, this, &ArtistSection::albumSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ArtistSection::setAlbums(const QJsonArray &albums)
|
||||||
|
{
|
||||||
|
m_list->setAlbums(albums);
|
||||||
|
updateToggleText(albums.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ArtistSection::isEmpty() const
|
||||||
|
{
|
||||||
|
return m_list->topLevelItemCount() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ArtistSection::updateToggleText(int count)
|
||||||
|
{
|
||||||
|
const QString arrow = m_toggle->isChecked() ? QStringLiteral("▼ ") : QStringLiteral("▶ ");
|
||||||
|
const QString text = count > 0
|
||||||
|
? QStringLiteral("%1%2 (%3)").arg(arrow, m_baseTitle).arg(count)
|
||||||
|
: arrow + m_baseTitle;
|
||||||
|
m_toggle->setText(text);
|
||||||
|
|
||||||
|
// Keep arrow in sync when toggled
|
||||||
|
disconnect(m_toggle, &QToolButton::toggled, nullptr, nullptr);
|
||||||
|
connect(m_toggle, &QToolButton::toggled, m_list, &AlbumListView::setVisible);
|
||||||
|
connect(m_toggle, &QToolButton::toggled, this, [this, count](bool open) {
|
||||||
|
const QString a = open ? QStringLiteral("▼ ") : QStringLiteral("▶ ");
|
||||||
|
const QString t = count > 0
|
||||||
|
? QStringLiteral("%1%2 (%3)").arg(a, m_baseTitle).arg(count)
|
||||||
|
: a + m_baseTitle;
|
||||||
|
m_toggle->setText(t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ArtistView
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ArtistView::ArtistView(QWidget *parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
{
|
||||||
|
auto *outerLayout = new QVBoxLayout(this);
|
||||||
|
outerLayout->setContentsMargins(8, 8, 8, 8);
|
||||||
|
outerLayout->setSpacing(6);
|
||||||
|
|
||||||
|
m_nameLabel = new QLabel(this);
|
||||||
|
QFont f = m_nameLabel->font();
|
||||||
|
f.setPointSize(f.pointSize() + 4);
|
||||||
|
f.setBold(true);
|
||||||
|
m_nameLabel->setFont(f);
|
||||||
|
outerLayout->addWidget(m_nameLabel);
|
||||||
|
|
||||||
|
m_bioLabel = new QLabel(this);
|
||||||
|
m_bioLabel->setWordWrap(true);
|
||||||
|
m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
|
||||||
|
m_bioLabel->setMaximumHeight(80);
|
||||||
|
outerLayout->addWidget(m_bioLabel);
|
||||||
|
|
||||||
|
// Scrollable sections area
|
||||||
|
auto *scroll = new QScrollArea(this);
|
||||||
|
scroll->setWidgetResizable(true);
|
||||||
|
scroll->setFrameShape(QFrame::NoFrame);
|
||||||
|
|
||||||
|
auto *content = new QWidget(scroll);
|
||||||
|
auto *sectLayout = new QVBoxLayout(content);
|
||||||
|
sectLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
sectLayout->setSpacing(8);
|
||||||
|
|
||||||
|
m_secAlbums = new ArtistSection(tr("Albums"), content);
|
||||||
|
m_secEps = new ArtistSection(tr("EPs & Singles"), content);
|
||||||
|
m_secOther = new ArtistSection(tr("Other"), content);
|
||||||
|
|
||||||
|
sectLayout->addWidget(m_secAlbums);
|
||||||
|
sectLayout->addWidget(m_secEps);
|
||||||
|
sectLayout->addWidget(m_secOther);
|
||||||
|
sectLayout->addStretch();
|
||||||
|
|
||||||
|
scroll->setWidget(content);
|
||||||
|
outerLayout->addWidget(scroll, 1);
|
||||||
|
|
||||||
|
connect(m_secAlbums, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
|
||||||
|
connect(m_secEps, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
|
||||||
|
connect(m_secOther, &ArtistSection::albumSelected, this, &ArtistView::albumSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ArtistView::setArtist(const QJsonObject &artist)
|
||||||
|
{
|
||||||
|
m_nameLabel->setText(artist["name"].toString());
|
||||||
|
|
||||||
|
const QString summary = artist["biography"].toObject()["summary"].toString();
|
||||||
|
m_bioLabel->setText(summary);
|
||||||
|
m_bioLabel->setVisible(!summary.isEmpty());
|
||||||
|
|
||||||
|
const QJsonArray allAlbums = artist["albums"].toObject()["items"].toArray();
|
||||||
|
|
||||||
|
QJsonArray albums, eps, other;
|
||||||
|
for (const QJsonValue &v : allAlbums) {
|
||||||
|
const QJsonObject a = v.toObject();
|
||||||
|
const QString rt = a["release_type"].toString();
|
||||||
|
if (rt == QStringLiteral("album"))
|
||||||
|
albums.append(a);
|
||||||
|
else if (rt == QStringLiteral("epSingle"))
|
||||||
|
eps.append(a);
|
||||||
|
else
|
||||||
|
other.append(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_secAlbums->setAlbums(albums);
|
||||||
|
m_secEps->setAlbums(eps);
|
||||||
|
m_secOther->setAlbums(other);
|
||||||
|
|
||||||
|
m_secAlbums->setVisible(!m_secAlbums->isEmpty());
|
||||||
|
m_secEps->setVisible(!m_secEps->isEmpty());
|
||||||
|
m_secOther->setVisible(!m_secOther->isEmpty());
|
||||||
|
}
|
||||||
@@ -3,56 +3,44 @@
|
|||||||
#include "albumlistview.hpp"
|
#include "albumlistview.hpp"
|
||||||
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <QVBoxLayout>
|
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QFont>
|
#include <QToolButton>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
|
|
||||||
/// Artist detail page: name, biography summary, and their album list.
|
class AlbumListView;
|
||||||
|
|
||||||
|
/// One collapsible section (e.g. "Albums", "EPs & Singles") inside ArtistView.
|
||||||
|
class ArtistSection : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ArtistSection(const QString &title, QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
void setAlbums(const QJsonArray &albums);
|
||||||
|
bool isEmpty() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void albumSelected(const QString &albumId);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_baseTitle;
|
||||||
|
QToolButton *m_toggle = nullptr;
|
||||||
|
AlbumListView *m_list = nullptr;
|
||||||
|
|
||||||
|
void updateToggleText(int count);
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Artist detail page: name, biography, and albums split into collapsible sections
|
||||||
|
/// (Albums / EPs & Singles / Other) keyed on the release_type field.
|
||||||
class ArtistView : public QWidget
|
class ArtistView : public QWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ArtistView(QWidget *parent = nullptr) : QWidget(parent)
|
explicit ArtistView(QWidget *parent = nullptr);
|
||||||
{
|
|
||||||
auto *layout = new QVBoxLayout(this);
|
|
||||||
layout->setContentsMargins(8, 8, 8, 8);
|
|
||||||
layout->setSpacing(6);
|
|
||||||
|
|
||||||
m_nameLabel = new QLabel(this);
|
void setArtist(const QJsonObject &artist);
|
||||||
QFont f = m_nameLabel->font();
|
|
||||||
f.setPointSize(f.pointSize() + 4);
|
|
||||||
f.setBold(true);
|
|
||||||
m_nameLabel->setFont(f);
|
|
||||||
|
|
||||||
m_bioLabel = new QLabel(this);
|
|
||||||
m_bioLabel->setWordWrap(true);
|
|
||||||
m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
|
|
||||||
m_bioLabel->setMaximumHeight(80);
|
|
||||||
|
|
||||||
m_albums = new AlbumListView(this);
|
|
||||||
|
|
||||||
layout->addWidget(m_nameLabel);
|
|
||||||
layout->addWidget(m_bioLabel);
|
|
||||||
layout->addWidget(m_albums, 1);
|
|
||||||
|
|
||||||
connect(m_albums, &AlbumListView::albumSelected,
|
|
||||||
this, &ArtistView::albumSelected);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setArtist(const QJsonObject &artist)
|
|
||||||
{
|
|
||||||
m_nameLabel->setText(artist["name"].toString());
|
|
||||||
|
|
||||||
const QString summary = artist["biography"].toObject()["summary"].toString();
|
|
||||||
m_bioLabel->setText(summary);
|
|
||||||
m_bioLabel->setVisible(!summary.isEmpty());
|
|
||||||
|
|
||||||
const QJsonArray albums = artist["albums"].toObject()["items"].toArray();
|
|
||||||
m_albums->setAlbums(albums);
|
|
||||||
}
|
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void albumSelected(const QString &albumId);
|
void albumSelected(const QString &albumId);
|
||||||
@@ -60,5 +48,7 @@ signals:
|
|||||||
private:
|
private:
|
||||||
QLabel *m_nameLabel = nullptr;
|
QLabel *m_nameLabel = nullptr;
|
||||||
QLabel *m_bioLabel = nullptr;
|
QLabel *m_bioLabel = nullptr;
|
||||||
AlbumListView *m_albums = nullptr;
|
ArtistSection *m_secAlbums = nullptr;
|
||||||
|
ArtistSection *m_secEps = nullptr;
|
||||||
|
ArtistSection *m_secOther = nullptr;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ View::View(QobuzBackend *backend, QWidget *parent)
|
|||||||
layout->setContentsMargins(8, 8, 8, 8);
|
layout->setContentsMargins(8, 8, 8, 8);
|
||||||
layout->setSpacing(6);
|
layout->setSpacing(6);
|
||||||
|
|
||||||
m_albumArt = new QLabel(container);
|
m_albumArt = new ArtWidget(container);
|
||||||
m_albumArt->setAlignment(Qt::AlignCenter);
|
|
||||||
m_albumArt->setStyleSheet(QStringLiteral(
|
|
||||||
"background: #1a1a1a; border-radius: 4px;"));
|
|
||||||
m_albumArt->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
|
||||||
layout->addWidget(m_albumArt);
|
layout->addWidget(m_albumArt);
|
||||||
|
|
||||||
m_title = new QLabel(tr("Not playing"), container);
|
m_title = new QLabel(tr("Not playing"), container);
|
||||||
@@ -45,7 +41,6 @@ View::View(QobuzBackend *backend, QWidget *parent)
|
|||||||
|
|
||||||
layout->addStretch();
|
layout->addStretch();
|
||||||
setWidget(container);
|
setWidget(container);
|
||||||
setMinimumWidth(160);
|
|
||||||
|
|
||||||
connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged);
|
connect(m_backend, &QobuzBackend::trackChanged, this, &View::onTrackChanged);
|
||||||
}
|
}
|
||||||
@@ -60,7 +55,6 @@ void View::onTrackChanged(const QJsonObject &track)
|
|||||||
m_title->setText(title.isEmpty() ? tr("Not playing") : title);
|
m_title->setText(title.isEmpty() ? tr("Not playing") : title);
|
||||||
m_artist->setText(artist);
|
m_artist->setText(artist);
|
||||||
|
|
||||||
// Prefer "large" image, fall back to "small"
|
|
||||||
const QJsonObject img = track["album"].toObject()["image"].toObject();
|
const QJsonObject img = track["album"].toObject()["image"].toObject();
|
||||||
QString artUrl = img["large"].toString();
|
QString artUrl = img["large"].toString();
|
||||||
if (artUrl.isEmpty())
|
if (artUrl.isEmpty())
|
||||||
@@ -77,26 +71,9 @@ void View::onArtReady(QNetworkReply *reply)
|
|||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
if (reply->error() != QNetworkReply::NoError)
|
if (reply->error() != QNetworkReply::NoError)
|
||||||
return;
|
return;
|
||||||
if (m_artPixmap.loadFromData(reply->readAll()))
|
QPixmap pix;
|
||||||
scaleArtToWidth();
|
if (pix.loadFromData(reply->readAll()))
|
||||||
}
|
m_albumArt->setPixmap(pix);
|
||||||
|
|
||||||
void View::resizeEvent(QResizeEvent *event)
|
|
||||||
{
|
|
||||||
QDockWidget::resizeEvent(event);
|
|
||||||
if (m_artPixmap.isNull()) return;
|
|
||||||
// Use the new dock width from the event so we don't lag behind the layout
|
|
||||||
const int side = qMax(32, event->size().width() - 16);
|
|
||||||
m_albumArt->setFixedHeight(side);
|
|
||||||
m_albumArt->setPixmap(m_artPixmap.scaled(side, side, Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
|
||||||
}
|
|
||||||
|
|
||||||
void View::scaleArtToWidth()
|
|
||||||
{
|
|
||||||
if (m_artPixmap.isNull()) return;
|
|
||||||
const int side = qMax(32, width() - 16);
|
|
||||||
m_albumArt->setFixedHeight(side);
|
|
||||||
m_albumArt->setPixmap(m_artPixmap.scaled(side, side, Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace Context
|
} // namespace Context
|
||||||
|
|||||||
@@ -3,15 +3,49 @@
|
|||||||
#include "../../backend/qobuzbackend.hpp"
|
#include "../../backend/qobuzbackend.hpp"
|
||||||
|
|
||||||
#include <QDockWidget>
|
#include <QDockWidget>
|
||||||
|
#include <QWidget>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QResizeEvent>
|
#include <QPainter>
|
||||||
|
#include <QPaintEvent>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
|
||||||
namespace Context
|
namespace Context
|
||||||
{
|
{
|
||||||
|
/// Square art widget: always as wide as its parent allows, height follows width.
|
||||||
|
class ArtWidget : public QWidget
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit ArtWidget(QWidget *parent = nullptr) : QWidget(parent)
|
||||||
|
{
|
||||||
|
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPixmap(const QPixmap &px) { m_pix = px; update(); }
|
||||||
|
bool hasHeightForWidth() const override { return true; }
|
||||||
|
int heightForWidth(int w) const override { return w; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void paintEvent(QPaintEvent *) override
|
||||||
|
{
|
||||||
|
QPainter p(this);
|
||||||
|
if (m_pix.isNull()) {
|
||||||
|
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QPixmap scaled = m_pix.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||||
|
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a));
|
||||||
|
p.drawPixmap((width() - scaled.width()) / 2,
|
||||||
|
(height() - scaled.height()) / 2,
|
||||||
|
scaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
QPixmap m_pix;
|
||||||
|
};
|
||||||
|
|
||||||
class View : public QDockWidget
|
class View : public QDockWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@@ -23,18 +57,12 @@ namespace Context
|
|||||||
void onTrackChanged(const QJsonObject &track);
|
void onTrackChanged(const QJsonObject &track);
|
||||||
void onArtReady(QNetworkReply *reply);
|
void onArtReady(QNetworkReply *reply);
|
||||||
|
|
||||||
protected:
|
|
||||||
void resizeEvent(QResizeEvent *event) override;
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void scaleArtToWidth();
|
|
||||||
|
|
||||||
QobuzBackend *m_backend = nullptr;
|
QobuzBackend *m_backend = nullptr;
|
||||||
QLabel *m_albumArt = nullptr;
|
ArtWidget *m_albumArt = nullptr;
|
||||||
QLabel *m_title = nullptr;
|
QLabel *m_title = nullptr;
|
||||||
QLabel *m_artist = nullptr;
|
QLabel *m_artist = nullptr;
|
||||||
QNetworkAccessManager *m_nam = nullptr;
|
QNetworkAccessManager *m_nam = nullptr;
|
||||||
QString m_currentArtUrl;
|
QString m_currentArtUrl;
|
||||||
QPixmap m_artPixmap;
|
|
||||||
};
|
};
|
||||||
} // namespace Context
|
} // namespace Context
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
#include "../util/icon.hpp"
|
#include "../util/icon.hpp"
|
||||||
|
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QWidgetAction>
|
#include <QFrame>
|
||||||
#include <QMenu>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
|
||||||
/// A toolbar button that shows a volume slider popup when clicked.
|
/// A toolbar button that shows a volume slider popup when clicked.
|
||||||
class VolumeButton : public QToolButton
|
class VolumeButton : public QToolButton
|
||||||
@@ -17,33 +16,36 @@ class VolumeButton : public QToolButton
|
|||||||
public:
|
public:
|
||||||
explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent)
|
explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent)
|
||||||
{
|
{
|
||||||
setPopupMode(QToolButton::InstantPopup);
|
|
||||||
setIcon(Icon::volumeHigh());
|
setIcon(Icon::volumeHigh());
|
||||||
|
|
||||||
auto *menu = new QMenu(this);
|
// Qt::Popup closes automatically when the user clicks outside.
|
||||||
auto *widget = new QWidget(menu);
|
m_popup = new QFrame(this, Qt::Popup);
|
||||||
widget->setMinimumWidth(72);
|
m_popup->setFrameShape(QFrame::StyledPanel);
|
||||||
auto *layout = new QVBoxLayout(widget);
|
m_popup->setFrameShadow(QFrame::Raised);
|
||||||
layout->setContentsMargins(6, 6, 6, 6);
|
|
||||||
|
|
||||||
m_label = new QLabel("80%", widget);
|
auto *layout = new QVBoxLayout(m_popup);
|
||||||
|
layout->setContentsMargins(10, 10, 10, 10);
|
||||||
|
layout->setSpacing(6);
|
||||||
|
|
||||||
|
m_label = new QLabel(QStringLiteral("80%"), m_popup);
|
||||||
m_label->setAlignment(Qt::AlignCenter);
|
m_label->setAlignment(Qt::AlignCenter);
|
||||||
|
layout->addWidget(m_label);
|
||||||
|
|
||||||
m_slider = new ClickableSlider(Qt::Vertical, widget);
|
m_slider = new ClickableSlider(Qt::Vertical, m_popup);
|
||||||
m_slider->setRange(0, 100);
|
m_slider->setRange(0, 100);
|
||||||
m_slider->setValue(80);
|
m_slider->setValue(80);
|
||||||
m_slider->setFixedHeight(120);
|
m_slider->setFixedHeight(120);
|
||||||
|
layout->addWidget(m_slider, 0, Qt::AlignHCenter);
|
||||||
|
|
||||||
layout->addWidget(m_label);
|
// Size the popup at its maximum (label = "100%") and lock it
|
||||||
layout->addWidget(m_slider);
|
m_label->setText(QStringLiteral("100%"));
|
||||||
|
m_popup->adjustSize();
|
||||||
auto *action = new QWidgetAction(menu);
|
m_popup->setFixedSize(m_popup->sizeHint());
|
||||||
action->setDefaultWidget(widget);
|
m_label->setText(QStringLiteral("80%"));
|
||||||
menu->addAction(action);
|
|
||||||
setMenu(menu);
|
|
||||||
|
|
||||||
|
connect(this, &QToolButton::clicked, this, &VolumeButton::togglePopup);
|
||||||
connect(m_slider, &QSlider::valueChanged, this, [this](int v) {
|
connect(m_slider, &QSlider::valueChanged, this, [this](int v) {
|
||||||
m_label->setText(QString::number(v) + "%");
|
m_label->setText(QString::number(v) + QStringLiteral("%"));
|
||||||
updateIcon(v);
|
updateIcon(v);
|
||||||
emit volumeChanged(v);
|
emit volumeChanged(v);
|
||||||
});
|
});
|
||||||
@@ -56,14 +58,31 @@ public:
|
|||||||
m_slider->blockSignals(true);
|
m_slider->blockSignals(true);
|
||||||
m_slider->setValue(v);
|
m_slider->setValue(v);
|
||||||
m_slider->blockSignals(false);
|
m_slider->blockSignals(false);
|
||||||
m_label->setText(QString::number(v) + "%");
|
m_label->setText(QString::number(v) + QStringLiteral("%"));
|
||||||
updateIcon(v);
|
updateIcon(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void volumeChanged(int volume);
|
void volumeChanged(int volume);
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void togglePopup()
|
||||||
|
{
|
||||||
|
if (m_popup->isVisible()) {
|
||||||
|
m_popup->hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Centre popup horizontally over button, place below it
|
||||||
|
const QPoint global = mapToGlobal(
|
||||||
|
QPoint(width() / 2 - m_popup->width() / 2,
|
||||||
|
height() + 4));
|
||||||
|
m_popup->move(global);
|
||||||
|
m_popup->show();
|
||||||
|
m_popup->raise();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
QFrame *m_popup = nullptr;
|
||||||
ClickableSlider *m_slider = nullptr;
|
ClickableSlider *m_slider = nullptr;
|
||||||
QLabel *m_label = nullptr;
|
QLabel *m_label = nullptr;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user