From 56473cae6f09f06d47e3bee9062b7799f3f7a3fa Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 24 Mar 2026 17:56:47 +0100 Subject: [PATCH] feat: artist sections, fav indicator, art scaling fix, volume popup fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- rust/src/api/client.rs | 1 + rust/src/api/models.rs | 1 + src/CMakeLists.txt | 1 + src/list/tracks.cpp | 43 +++++++--- src/list/tracks.hpp | 6 ++ src/mainwindow.cpp | 9 +++ src/mainwindow.hpp | 2 + src/model/tracklistmodel.cpp | 34 ++++++++ src/model/tracklistmodel.hpp | 7 ++ src/playqueue.hpp | 8 ++ src/view/artistview.cpp | 153 +++++++++++++++++++++++++++++++++++ src/view/artistview.hpp | 80 ++++++++---------- src/view/context/view.cpp | 31 +------ src/view/context/view.hpp | 44 ++++++++-- src/widget/volumebutton.hpp | 59 +++++++++----- 15 files changed, 370 insertions(+), 109 deletions(-) create mode 100644 src/view/artistview.cpp diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index 37ab507..123d759 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -258,6 +258,7 @@ impl QobuzClient { .query(&[ ("artist_id", artist_id.to_string()), ("extra", "albums".to_string()), + ("albums_limit", "200".to_string()), ]) .send() .await?; diff --git a/rust/src/api/models.rs b/rust/src/api/models.rs index 33e0bd5..94a7ff6 100644 --- a/rust/src/api/models.rs +++ b/rust/src/api/models.rs @@ -98,6 +98,7 @@ pub struct AlbumDto { pub maximum_sampling_rate: Option, pub hires_streamable: Option, pub streamable: Option, + pub release_type: Option, pub tracks: Option, } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f494cc0..a9e52c2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,6 +25,7 @@ target_sources(qobuz-qt PRIVATE view/albumlistview.hpp view/artistlistview.hpp view/artistview.hpp + view/artistview.cpp view/sidepanel/view.hpp view/sidepanel/view.cpp diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp index 0f49387..539de62 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -84,12 +84,30 @@ void Tracks::setPlayingTrackId(qint64 id) m_model->setPlayingId(id); } +void Tracks::setFavTrackIds(const QSet &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) { const QJsonArray tracks = m_model->currentTracksJson(); if (tracks.isEmpty()) return; - m_queue->setShuffle(shuffle); 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(m_queue->current()["id"].toDouble()); if (firstId > 0) 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 *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue")); menu.addSeparator(); - auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites")); - auto *remFav = menu.addAction(QIcon(":/res/icons/non-starred-symbolic.svg"), tr("Remove from 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")); + 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(); 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] { 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 const QString albumId = m_model->trackAt(index.row()).albumId; diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 86f5095..77687ac 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -10,6 +10,7 @@ #include #include #include +#include namespace List { @@ -28,6 +29,11 @@ namespace List /// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted. void setPlayingTrackId(qint64 id); + /// Populate favorite track IDs so the star indicator and context menu reflect fav status. + void setFavTrackIds(const QSet &ids); + void addFavTrackId(qint64 id); + void removeFavTrackId(qint64 id); + /// Start playing all tracks in the current view from the beginning. /// If shuffle is true, enables shuffle mode before starting. void playAll(bool shuffle = false); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 0838733..5a14505 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -283,6 +283,15 @@ void MainWindow::onTrackChanged(const QJsonObject &track) void MainWindow::onFavTracksLoaded(const QJsonObject &result) { + // Cache fav IDs so the star indicator and context menu stay in sync + QSet ids; + const QJsonArray items = result["items"].toArray(); + for (const QJsonValue &v : items) { + const qint64 id = static_cast(v.toObject()["id"].toDouble()); + if (id > 0) ids.insert(id); + } + m_content->tracksList()->setFavTrackIds(ids); + m_content->showFavTracks(result); statusBar()->showMessage( tr("%1 favorite tracks").arg(result["total"].toInt()), 4000); diff --git a/src/mainwindow.hpp b/src/mainwindow.hpp index d5a0e02..ad1a15c 100644 --- a/src/mainwindow.hpp +++ b/src/mainwindow.hpp @@ -13,7 +13,9 @@ #include #include #include +#include #include +#include #include #include diff --git a/src/model/tracklistmodel.cpp b/src/model/tracklistmodel.cpp index 913cd81..2d5a32f 100644 --- a/src/model/tracklistmodel.cpp +++ b/src/model/tracklistmodel.cpp @@ -77,6 +77,36 @@ void TrackListModel::removeTrack(int row) endRemoveRows(); } +void TrackListModel::setFavIds(const QSet &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) { 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")); } + 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 == TrackJsonRole) return t.raw; if (role == HiResRole) return t.hiRes; diff --git a/src/model/tracklistmodel.hpp b/src/model/tracklistmodel.hpp index 21b0003..916180a 100644 --- a/src/model/tracklistmodel.hpp +++ b/src/model/tracklistmodel.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include struct TrackItem { @@ -52,6 +53,11 @@ public: void setPlayingId(qint64 id); qint64 playingId() const { return m_playingId; } + void setFavIds(const QSet &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). void removeTrack(int row); @@ -81,6 +87,7 @@ signals: private: QVector m_tracks; + QSet m_favIds; qint64 m_playingId = 0; int m_sortColumn = -1; Qt::SortOrder m_sortOrder = Qt::AscendingOrder; diff --git a/src/playqueue.hpp b/src/playqueue.hpp index e960f68..cbe6a63 100644 --- a/src/playqueue.hpp +++ b/src/playqueue.hpp @@ -107,6 +107,14 @@ public: 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" ---- void addToQueue(const QJsonObject &track) diff --git a/src/view/artistview.cpp b/src/view/artistview.cpp new file mode 100644 index 0000000..684bd24 --- /dev/null +++ b/src/view/artistview.cpp @@ -0,0 +1,153 @@ +#include "artistview.hpp" +#include "albumlistview.hpp" + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// 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()); +} diff --git a/src/view/artistview.hpp b/src/view/artistview.hpp index 8df1589..555dee0 100644 --- a/src/view/artistview.hpp +++ b/src/view/artistview.hpp @@ -3,62 +3,52 @@ #include "albumlistview.hpp" #include -#include #include -#include +#include #include #include -/// Artist detail page: name, biography summary, and their album list. -class ArtistView : public QWidget +class AlbumListView; + +/// One collapsible section (e.g. "Albums", "EPs & Singles") inside ArtistView. +class ArtistSection : public QWidget { Q_OBJECT - public: - explicit ArtistView(QWidget *parent = nullptr) : QWidget(parent) - { - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(8, 8, 8, 8); - layout->setSpacing(6); + explicit ArtistSection(const QString &title, QWidget *parent = nullptr); - m_nameLabel = new QLabel(this); - 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); - } + void setAlbums(const QJsonArray &albums); + bool isEmpty() const; signals: void albumSelected(const QString &albumId); private: - QLabel *m_nameLabel = nullptr; - QLabel *m_bioLabel = nullptr; - AlbumListView *m_albums = nullptr; + 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 +{ + Q_OBJECT + +public: + explicit ArtistView(QWidget *parent = nullptr); + + void setArtist(const QJsonObject &artist); + +signals: + void albumSelected(const QString &albumId); + +private: + QLabel *m_nameLabel = nullptr; + QLabel *m_bioLabel = nullptr; + ArtistSection *m_secAlbums = nullptr; + ArtistSection *m_secEps = nullptr; + ArtistSection *m_secOther = nullptr; }; diff --git a/src/view/context/view.cpp b/src/view/context/view.cpp index 381b653..e7490d8 100644 --- a/src/view/context/view.cpp +++ b/src/view/context/view.cpp @@ -22,11 +22,7 @@ View::View(QobuzBackend *backend, QWidget *parent) layout->setContentsMargins(8, 8, 8, 8); layout->setSpacing(6); - m_albumArt = new QLabel(container); - m_albumArt->setAlignment(Qt::AlignCenter); - m_albumArt->setStyleSheet(QStringLiteral( - "background: #1a1a1a; border-radius: 4px;")); - m_albumArt->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_albumArt = new ArtWidget(container); layout->addWidget(m_albumArt); m_title = new QLabel(tr("Not playing"), container); @@ -45,7 +41,6 @@ View::View(QobuzBackend *backend, QWidget *parent) layout->addStretch(); setWidget(container); - setMinimumWidth(160); 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_artist->setText(artist); - // Prefer "large" image, fall back to "small" const QJsonObject img = track["album"].toObject()["image"].toObject(); QString artUrl = img["large"].toString(); if (artUrl.isEmpty()) @@ -77,26 +71,9 @@ void View::onArtReady(QNetworkReply *reply) reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) return; - if (m_artPixmap.loadFromData(reply->readAll())) - scaleArtToWidth(); -} - -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)); + QPixmap pix; + if (pix.loadFromData(reply->readAll())) + m_albumArt->setPixmap(pix); } } // namespace Context diff --git a/src/view/context/view.hpp b/src/view/context/view.hpp index c2012b4..156ad88 100644 --- a/src/view/context/view.hpp +++ b/src/view/context/view.hpp @@ -3,15 +3,49 @@ #include "../../backend/qobuzbackend.hpp" #include +#include #include #include -#include +#include +#include #include #include #include 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 { Q_OBJECT @@ -23,18 +57,12 @@ namespace Context void onTrackChanged(const QJsonObject &track); void onArtReady(QNetworkReply *reply); - protected: - void resizeEvent(QResizeEvent *event) override; - private: - void scaleArtToWidth(); - QobuzBackend *m_backend = nullptr; - QLabel *m_albumArt = nullptr; + ArtWidget *m_albumArt = nullptr; QLabel *m_title = nullptr; QLabel *m_artist = nullptr; QNetworkAccessManager *m_nam = nullptr; QString m_currentArtUrl; - QPixmap m_artPixmap; }; } // namespace Context diff --git a/src/widget/volumebutton.hpp b/src/widget/volumebutton.hpp index 3a4bef5..09790ae 100644 --- a/src/widget/volumebutton.hpp +++ b/src/widget/volumebutton.hpp @@ -4,10 +4,9 @@ #include "../util/icon.hpp" #include -#include -#include -#include +#include #include +#include /// A toolbar button that shows a volume slider popup when clicked. class VolumeButton : public QToolButton @@ -17,33 +16,36 @@ class VolumeButton : public QToolButton public: explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent) { - setPopupMode(QToolButton::InstantPopup); setIcon(Icon::volumeHigh()); - auto *menu = new QMenu(this); - auto *widget = new QWidget(menu); - widget->setMinimumWidth(72); - auto *layout = new QVBoxLayout(widget); - layout->setContentsMargins(6, 6, 6, 6); + // Qt::Popup closes automatically when the user clicks outside. + m_popup = new QFrame(this, Qt::Popup); + m_popup->setFrameShape(QFrame::StyledPanel); + m_popup->setFrameShadow(QFrame::Raised); - 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); + 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->setValue(80); m_slider->setFixedHeight(120); + layout->addWidget(m_slider, 0, Qt::AlignHCenter); - layout->addWidget(m_label); - layout->addWidget(m_slider); - - auto *action = new QWidgetAction(menu); - action->setDefaultWidget(widget); - menu->addAction(action); - setMenu(menu); + // Size the popup at its maximum (label = "100%") and lock it + m_label->setText(QStringLiteral("100%")); + m_popup->adjustSize(); + m_popup->setFixedSize(m_popup->sizeHint()); + m_label->setText(QStringLiteral("80%")); + connect(this, &QToolButton::clicked, this, &VolumeButton::togglePopup); connect(m_slider, &QSlider::valueChanged, this, [this](int v) { - m_label->setText(QString::number(v) + "%"); + m_label->setText(QString::number(v) + QStringLiteral("%")); updateIcon(v); emit volumeChanged(v); }); @@ -56,14 +58,31 @@ public: m_slider->blockSignals(true); m_slider->setValue(v); m_slider->blockSignals(false); - m_label->setText(QString::number(v) + "%"); + m_label->setText(QString::number(v) + QStringLiteral("%")); updateIcon(v); } signals: 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: + QFrame *m_popup = nullptr; ClickableSlider *m_slider = nullptr; QLabel *m_label = nullptr;