diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp index 61f1b6d..461a4ba 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -1,5 +1,6 @@ #include "tracks.hpp" #include "../util/settings.hpp" +#include "../util/trackinfo.hpp" #include #include @@ -237,6 +238,13 @@ void Tracks::onContextMenu(const QPoint &pos) } } + // Track info + menu.addSeparator(); + auto *infoAction = menu.addAction(tr("Track info...")); + connect(infoAction, &QAction::triggered, this, [this, trackJson] { + TrackInfoDialog::show(trackJson, this); + }); + menu.exec(viewport()->mapToGlobal(pos)); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3362def..cfe260a 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -54,7 +54,7 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) addDockWidget(Qt::RightDockWidgetArea, m_queuePanel); // ---- Search side panel (right) ---- - m_sidePanel = new SidePanel::View(m_backend, this); + m_sidePanel = new SidePanel::View(m_backend, m_queue, this); m_sidePanel->hide(); addDockWidget(Qt::RightDockWidgetArea, m_sidePanel); @@ -155,6 +155,11 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) this, &MainWindow::onSearchArtistSelected); connect(m_sidePanel, &SidePanel::View::trackPlayRequested, this, &MainWindow::onPlayTrackRequested); + connect(m_sidePanel, &SidePanel::View::addToPlaylistRequested, + this, [this](qint64 trackId, qint64 playlistId) { + m_backend->addTrackToPlaylist(playlistId, trackId); + statusBar()->showMessage(tr("Adding track to playlist..."), 3000); + }); // ---- Album / artist navigation from content views ---- connect(m_content, &MainContent::albumRequested, @@ -410,5 +415,6 @@ void MainWindow::onUserPlaylistsChanged(const QVector> &p { m_userPlaylists = playlists; m_content->tracksList()->setUserPlaylists(playlists); + m_sidePanel->searchTab()->setUserPlaylists(playlists); } diff --git a/src/util/trackinfo.hpp b/src/util/trackinfo.hpp new file mode 100644 index 0000000..bdc3d05 --- /dev/null +++ b/src/util/trackinfo.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace TrackInfoDialog +{ + +inline void show(const QJsonObject &track, QWidget *parent) +{ + auto *dlg = new QDialog(parent); + dlg->setWindowTitle(QObject::tr("Track Info")); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->setMinimumWidth(360); + + auto *form = new QFormLayout(dlg); + + auto addRow = [&](const QString &label, const QString &value) { + if (value.isEmpty()) return; + auto *val = new QLabel(value, dlg); + val->setTextInteractionFlags(Qt::TextSelectableByMouse); + val->setWordWrap(true); + form->addRow(QStringLiteral("%1").arg(label), val); + }; + + const QString title = track["title"].toString(); + const QString version = track["version"].toString().trimmed(); + addRow(QObject::tr("Title"), + version.isEmpty() ? title : title + QStringLiteral(" (%1)").arg(version)); + + addRow(QObject::tr("Performer"), track["performer"].toObject()["name"].toString()); + + const QJsonObject composer = track["composer"].toObject(); + if (!composer.isEmpty()) + addRow(QObject::tr("Composer"), composer["name"].toString()); + + const QJsonObject album = track["album"].toObject(); + addRow(QObject::tr("Album"), album["title"].toString()); + addRow(QObject::tr("Album artist"), album["artist"].toObject()["name"].toString()); + + const int trackNum = track["track_number"].toInt(); + const int discNum = track["media_number"].toInt(); + if (trackNum > 0) { + const QString pos = discNum > 1 + ? QStringLiteral("%1-%2").arg(discNum).arg(trackNum) + : QString::number(trackNum); + addRow(QObject::tr("Track #"), pos); + } + + const qint64 dur = static_cast(track["duration"].toDouble()); + if (dur > 0) { + const int m = static_cast(dur / 60); + const int s = static_cast(dur % 60); + addRow(QObject::tr("Duration"), + QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0'))); + } + + const int bitDepth = track["maximum_bit_depth"].toInt(); + const double sampleRate = track["maximum_sampling_rate"].toDouble(); + if (bitDepth > 0 && sampleRate > 0) { + addRow(QObject::tr("Quality"), + QStringLiteral("%1-bit / %2 kHz").arg(bitDepth).arg(sampleRate, 0, 'f', 1)); + } else if (bitDepth > 0) { + addRow(QObject::tr("Bit depth"), QStringLiteral("%1-bit").arg(bitDepth)); + } + + const bool hiRes = track["hires_streamable"].toBool() || track["hires"].toBool(); + addRow(QObject::tr("Hi-Res"), hiRes ? QObject::tr("Yes") : QObject::tr("No")); + + const bool streamable = track["streamable"].toBool(true); + if (!streamable) + addRow(QObject::tr("Streamable"), QObject::tr("No")); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, dlg); + form->addRow(buttons); + QObject::connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::close); + + dlg->show(); +} + +} // namespace TrackInfoDialog diff --git a/src/view/artistview.cpp b/src/view/artistview.cpp index abe0f70..e7d9d56 100644 --- a/src/view/artistview.cpp +++ b/src/view/artistview.cpp @@ -148,15 +148,20 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) btnRow->setSpacing(8); btnRow->setContentsMargins(0, 4, 0, 0); + static const QString kOutlineBtn = kBtnBase + + QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }" + "QPushButton:pressed { background: #333; }"); + m_playBtn = new QPushButton(tr("▶ Play"), info); m_playBtn->setStyleSheet(kBtnBase + QStringLiteral("QPushButton { background: #FFB232; color: #000; }" "QPushButton:pressed { background: #e09e28; }")); + m_shuffleTopBtn = new QPushButton(tr("⇄ Shuffle"), info); + m_shuffleTopBtn->setStyleSheet(kOutlineBtn); + m_shuffleBtn = new QPushButton(tr("⇄ Shuffle All"), info); - m_shuffleBtn->setStyleSheet(kBtnBase + - QStringLiteral("QPushButton { background: #2a2a2a; color: #FFB232; border: 1px solid #FFB232; }" - "QPushButton:pressed { background: #333; }")); + m_shuffleBtn->setStyleSheet(kOutlineBtn); m_favBtn = new QPushButton(tr("♡ Favourite"), info); m_favBtn->setStyleSheet(kBtnBase + @@ -164,6 +169,7 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) "QPushButton:pressed { background: #333; }")); btnRow->addWidget(m_playBtn); + btnRow->addWidget(m_shuffleTopBtn); btnRow->addWidget(m_shuffleBtn); btnRow->addWidget(m_favBtn); btnRow->addStretch(); @@ -246,17 +252,14 @@ ArtistView::ArtistView(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) scroll->setWidget(content); outerLayout->addWidget(scroll, 1); - // Play top tracks - connect(m_playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); }); + // Play / shuffle top tracks + connect(m_playBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(false); }); + connect(m_shuffleTopBtn, &QPushButton::clicked, m_topTracks, [this] { m_topTracks->playAll(true); }); // Deep shuffle: fetch all album tracks, combine, shuffle, play connect(m_shuffleBtn, &QPushButton::clicked, this, [this] { const QStringList ids = allAlbumIds(); - if (ids.isEmpty()) { - // Fallback: just shuffle popular tracks - m_topTracks->playAll(true); - return; - } + if (ids.isEmpty()) return; m_shuffleBtn->setEnabled(false); m_shuffleBtn->setText(tr("Loading…")); m_backend->getAlbumsTracks(ids); diff --git a/src/view/artistview.hpp b/src/view/artistview.hpp index 3d881dc..77897df 100644 --- a/src/view/artistview.hpp +++ b/src/view/artistview.hpp @@ -67,9 +67,10 @@ private: QLabel *m_artLabel = nullptr; QLabel *m_nameLabel = nullptr; QTextEdit *m_bioEdit = nullptr; - QPushButton *m_playBtn = nullptr; - QPushButton *m_shuffleBtn = nullptr; - QPushButton *m_favBtn = nullptr; + QPushButton *m_playBtn = nullptr; + QPushButton *m_shuffleTopBtn = nullptr; + QPushButton *m_shuffleBtn = nullptr; + QPushButton *m_favBtn = nullptr; QNetworkAccessManager *m_nam = nullptr; QString m_currentArtUrl; bool m_isFaved = false; diff --git a/src/view/maincontent.cpp b/src/view/maincontent.cpp index 7d29b02..195d5ba 100644 --- a/src/view/maincontent.cpp +++ b/src/view/maincontent.cpp @@ -16,7 +16,7 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren m_welcome = new QLabel( tr("

Welcome to Qobuz

" "

Select something from the library on the left to get started,
" - "or use the search panel (🔍) to find music.

"), + "or use the search panel to find music.

"), this); m_welcome->setAlignment(Qt::AlignCenter); diff --git a/src/view/sidepanel/view.cpp b/src/view/sidepanel/view.cpp index 76c5325..26f98f1 100644 --- a/src/view/sidepanel/view.cpp +++ b/src/view/sidepanel/view.cpp @@ -1,4 +1,5 @@ #include "view.hpp" +#include "../../util/trackinfo.hpp" #include #include @@ -6,18 +7,21 @@ #include #include #include +#include static constexpr int IdRole = Qt::UserRole + 1; static constexpr int TypeRole = Qt::UserRole + 2; +static constexpr int JsonRole = Qt::UserRole + 3; namespace SidePanel { // ---- SearchTab ---- -SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent) +SearchTab::SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) : QWidget(parent) , m_backend(backend) + , m_queue(queue) { auto *layout = new QVBoxLayout(this); layout->setContentsMargins(4, 4, 4, 4); @@ -25,7 +29,7 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent) // Search bar auto *barLayout = new QHBoxLayout; m_searchBox = new QLineEdit(this); - m_searchBox->setPlaceholderText(tr("Search Qobuz…")); + m_searchBox->setPlaceholderText(tr("Search Qobuz...")); m_searchBox->setClearButtonEnabled(true); auto *searchBtn = new QPushButton(tr("Go"), this); barLayout->addWidget(m_searchBox); @@ -38,6 +42,7 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent) m_trackResults = new QTreeWidget(this); m_trackResults->setHeaderLabels({tr("Title"), tr("Artist"), tr("Album")}); m_trackResults->setRootIsDecorated(false); + m_trackResults->setContextMenuPolicy(Qt::CustomContextMenu); m_albumResults = new QTreeWidget(this); m_albumResults->setHeaderLabels({tr(""), tr("Album"), tr("Artist")}); @@ -46,6 +51,7 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent) m_albumResults->header()->setSectionResizeMode(1, QHeaderView::Stretch); m_albumResults->header()->setSectionResizeMode(2, QHeaderView::Stretch); m_albumResults->header()->setStretchLastSection(false); + m_albumResults->setContextMenuPolicy(Qt::CustomContextMenu); m_artistResults = new QTreeWidget(this); m_artistResults->setHeaderLabels({tr("Artist")}); @@ -64,6 +70,17 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent) connect(m_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked); + + // Context menus + connect(m_trackResults, &QTreeWidget::customContextMenuRequested, + this, &SearchTab::onTrackContextMenu); + connect(m_albumResults, &QTreeWidget::customContextMenuRequested, + this, &SearchTab::onAlbumContextMenu); +} + +void SearchTab::setUserPlaylists(const QVector> &playlists) +{ + m_userPlaylists = playlists; } void SearchTab::onSearchSubmit() @@ -86,6 +103,7 @@ void SearchTab::onSearchResult(const QJsonObject &result) QStringList{t["title"].toString(), performer, album}); item->setData(0, IdRole, static_cast(t["id"].toDouble())); item->setData(0, TypeRole, QStringLiteral("track")); + item->setData(0, JsonRole, t); } // Populate albums @@ -109,8 +127,9 @@ void SearchTab::onSearchResult(const QJsonObject &result) item->setFont(0, hiResFont); item->setTextAlignment(0, Qt::AlignCenter); } - item->setData(0, TypeRole, QStringLiteral("album")); // handler reads col 0 + item->setData(0, TypeRole, QStringLiteral("album")); item->setData(1, IdRole, a["id"].toString()); + item->setData(0, JsonRole, a); } } @@ -140,20 +159,135 @@ void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int) } } +void SearchTab::onTrackContextMenu(const QPoint &pos) +{ + auto *item = m_trackResults->itemAt(pos); + if (!item) return; + + const qint64 trackId = item->data(0, IdRole).toLongLong(); + const QJsonObject trackJson = item->data(0, JsonRole).toJsonObject(); + if (trackId <= 0) return; + + QMenu menu(this); + + auto *playNow = menu.addAction(tr("Play now")); + auto *playNext = menu.addAction(tr("Play next")); + auto *addQueue = menu.addAction(tr("Add to queue")); + menu.addSeparator(); + + auto *addFav = menu.addAction(tr("Add to favorites")); + + // Open album / artist + const QString albumId = trackJson["album"].toObject()["id"].toString(); + const qint64 artistId = static_cast( + trackJson["performer"].toObject()["id"].toDouble()); + const QString artistName = trackJson["performer"].toObject()["name"].toString(); + const QString albumTitle = trackJson["album"].toObject()["title"].toString(); + + menu.addSeparator(); + if (!albumId.isEmpty()) { + auto *openAlbum = menu.addAction(tr("Go to album: %1").arg(albumTitle)); + connect(openAlbum, &QAction::triggered, this, [this, albumId] { + emit albumSelected(albumId); + }); + } + if (artistId > 0) { + auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(artistName)); + connect(openArtist, &QAction::triggered, this, [this, artistId] { + emit artistSelected(artistId); + }); + } + + // Add to playlist submenu + if (!m_userPlaylists.isEmpty()) { + menu.addSeparator(); + auto *plMenu = menu.addMenu(tr("Add to playlist")); + for (const auto &pl : m_userPlaylists) { + auto *act = plMenu->addAction(pl.second); + connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] { + emit addToPlaylistRequested(trackId, plId); + }); + } + } + + // Track info + menu.addSeparator(); + auto *info = menu.addAction(tr("Track info...")); + + connect(playNow, &QAction::triggered, this, [this, trackId] { + emit trackPlayRequested(trackId); + }); + connect(playNext, &QAction::triggered, this, [this, trackJson] { + m_queue->playNext(trackJson); + }); + connect(addQueue, &QAction::triggered, this, [this, trackJson] { + m_queue->addToQueue(trackJson); + }); + connect(addFav, &QAction::triggered, this, [this, trackId] { + m_backend->addFavTrack(trackId); + }); + connect(info, &QAction::triggered, this, [this, trackJson] { + showTrackInfo(trackJson); + }); + + menu.exec(m_trackResults->viewport()->mapToGlobal(pos)); +} + +void SearchTab::onAlbumContextMenu(const QPoint &pos) +{ + auto *item = m_albumResults->itemAt(pos); + if (!item) return; + + const QString albumId = item->data(1, IdRole).toString(); + const QJsonObject albumJson = item->data(0, JsonRole).toJsonObject(); + if (albumId.isEmpty()) return; + + QMenu menu(this); + + auto *openAlbum = menu.addAction(tr("Open album")); + auto *addFav = menu.addAction(tr("Add to favorites")); + + const qint64 artistId = static_cast( + albumJson["artist"].toObject()["id"].toDouble()); + const QString artistName = albumJson["artist"].toObject()["name"].toString(); + if (artistId > 0) { + menu.addSeparator(); + auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(artistName)); + connect(openArtist, &QAction::triggered, this, [this, artistId] { + emit artistSelected(artistId); + }); + } + + connect(openAlbum, &QAction::triggered, this, [this, albumId] { + emit albumSelected(albumId); + }); + connect(addFav, &QAction::triggered, this, [this, albumId] { + m_backend->addFavAlbum(albumId); + }); + + menu.exec(m_albumResults->viewport()->mapToGlobal(pos)); +} + +void SearchTab::showTrackInfo(const QJsonObject &track) +{ + TrackInfoDialog::show(track, this); +} + // ---- View ---- -View::View(QobuzBackend *backend, QWidget *parent) +View::View(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) : QDockWidget(tr("Search"), parent) { setObjectName(QStringLiteral("searchPanel")); setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable); - m_search = new SearchTab(backend, this); + m_search = new SearchTab(backend, queue, this); setWidget(m_search); - connect(m_search, &SearchTab::albumSelected, this, &View::albumSelected); - connect(m_search, &SearchTab::artistSelected, this, &View::artistSelected); - connect(m_search, &SearchTab::trackPlayRequested, this, &View::trackPlayRequested); + connect(m_search, &SearchTab::albumSelected, this, &View::albumSelected); + connect(m_search, &SearchTab::artistSelected, this, &View::artistSelected); + connect(m_search, &SearchTab::trackPlayRequested, this, &View::trackPlayRequested); + connect(m_search, &SearchTab::addToPlaylistRequested, this, &View::addToPlaylistRequested); } } // namespace SidePanel diff --git a/src/view/sidepanel/view.hpp b/src/view/sidepanel/view.hpp index 31432ce..b19c8c3 100644 --- a/src/view/sidepanel/view.hpp +++ b/src/view/sidepanel/view.hpp @@ -1,6 +1,7 @@ #pragma once #include "../../backend/qobuzbackend.hpp" +#include "../../playqueue.hpp" #include #include @@ -8,6 +9,8 @@ #include #include #include +#include +#include namespace SidePanel { @@ -15,12 +18,15 @@ namespace SidePanel { Q_OBJECT public: - explicit SearchTab(QobuzBackend *backend, QWidget *parent = nullptr); + explicit SearchTab(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr); + + void setUserPlaylists(const QVector> &playlists); signals: void albumSelected(const QString &albumId); void artistSelected(qint64 artistId); void trackPlayRequested(qint64 trackId); + void addToPlaylistRequested(qint64 trackId, qint64 playlistId); private slots: void onSearchResult(const QJsonObject &result); @@ -29,18 +35,24 @@ namespace SidePanel private: QobuzBackend *m_backend = nullptr; + PlayQueue *m_queue = nullptr; QLineEdit *m_searchBox = nullptr; QTabWidget *m_resultTabs = nullptr; QTreeWidget *m_trackResults = nullptr; QTreeWidget *m_albumResults = nullptr; QTreeWidget *m_artistResults = nullptr; + QVector> m_userPlaylists; + + void onTrackContextMenu(const QPoint &pos); + void onAlbumContextMenu(const QPoint &pos); + void showTrackInfo(const QJsonObject &track); }; class View : public QDockWidget { Q_OBJECT public: - explicit View(QobuzBackend *backend, QWidget *parent = nullptr); + explicit View(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr); SearchTab *searchTab() const { return m_search; } @@ -48,6 +60,7 @@ namespace SidePanel void albumSelected(const QString &albumId); void artistSelected(qint64 artistId); void trackPlayRequested(qint64 trackId); + void addToPlaylistRequested(qint64 trackId, qint64 playlistId); private: SearchTab *m_search = nullptr;