Files
qobuz-qt/src/view/sidepanel/view.cpp
joren d1b9cb1210 refactor: centralize hardcoded color values into Colors namespace
Add src/util/colors.hpp with named constants for all QColor values
(brand accents, badge colors, text shades, surface backgrounds) and
replace scattered QColor constructor calls across 7 source files.
Stylesheet string colors are intentionally left inline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:01:45 +02:00

365 lines
14 KiB
C++

#include "view.hpp"
#include "../../util/colors.hpp"
#include "../../util/trackinfo.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QPushButton>
#include <QHeaderView>
#include <QFont>
#include <QJsonArray>
#include <QMenu>
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, PlayQueue *queue, QWidget *parent)
: QWidget(parent)
, m_backend(backend)
, m_queue(queue)
{
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
// Search bar
auto *barLayout = new QHBoxLayout;
m_searchBox = new QLineEdit(this);
m_searchBox->setPlaceholderText(tr("Search Qobuz..."));
m_searchBox->setClearButtonEnabled(true);
auto *searchBtn = new QPushButton(tr("Go"), this);
barLayout->addWidget(m_searchBox);
barLayout->addWidget(searchBtn);
layout->addLayout(barLayout);
// Result tabs
m_resultTabs = new QTabWidget(this);
m_topResults = new QTreeWidget(this);
m_topResults->setHeaderLabels({tr(""), tr("Top Result"), tr("Info")});
m_topResults->setRootIsDecorated(false);
m_topResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_topResults->header()->setSectionResizeMode(1, QHeaderView::Stretch);
m_topResults->header()->setSectionResizeMode(2, QHeaderView::Stretch);
m_topResults->header()->setStretchLastSection(false);
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")});
m_albumResults->setRootIsDecorated(false);
m_albumResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
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")});
m_artistResults->setRootIsDecorated(false);
m_resultTabs->addTab(m_topResults, tr("Top Results"));
m_resultTabs->addTab(m_trackResults, tr("Tracks"));
m_resultTabs->addTab(m_albumResults, tr("Albums"));
m_resultTabs->addTab(m_artistResults, tr("Artists"));
layout->addWidget(m_resultTabs);
connect(searchBtn, &QPushButton::clicked, this, &SearchTab::onSearchSubmit);
connect(m_searchBox, &QLineEdit::returnPressed, this, &SearchTab::onSearchSubmit);
connect(m_backend, &QobuzBackend::searchResult, this, &SearchTab::onSearchResult);
connect(m_backend, &QobuzBackend::mostPopularResult, this, &SearchTab::onMostPopularResult);
connect(m_topResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
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<QPair<qint64, QString>> &playlists)
{
m_userPlaylists = playlists;
}
void SearchTab::onSearchSubmit()
{
const QString q = m_searchBox->text().trimmed();
if (!q.isEmpty()) {
m_backend->mostPopularSearch(q, 8);
m_backend->search(q, 0, 20);
m_resultTabs->setCurrentIndex(0);
}
}
void SearchTab::onMostPopularResult(const QJsonObject &result)
{
m_topResults->clear();
QFont badgeFont;
badgeFont.setBold(true);
const QJsonArray items = result["most_popular"].toObject()["items"].toArray();
for (const auto &value : items) {
const QJsonObject itemObj = value.toObject();
const QString type = itemObj["type"].toString();
const QJsonObject content = itemObj["content"].toObject();
auto *item = new QTreeWidgetItem(m_topResults);
item->setData(0, JsonRole, content);
if (type == QStringLiteral("tracks")) {
const QString title = content["title"].toString();
const QString artist = content["performer"].toObject()["name"].toString();
const QString album = content["album"].toObject()["title"].toString();
item->setText(0, QStringLiteral("T"));
item->setForeground(0, Colors::BadgeGreen);
item->setFont(0, badgeFont);
item->setTextAlignment(0, Qt::AlignCenter);
item->setText(1, title);
item->setText(2, artist.isEmpty() ? album : QStringLiteral("%1 - %2").arg(artist, album));
item->setData(0, TypeRole, QStringLiteral("track"));
item->setData(0, IdRole, static_cast<qint64>(content["id"].toDouble()));
} else if (type == QStringLiteral("albums")) {
const QString title = content["title"].toString();
const QString artist = content["artist"].toObject()["name"].toString();
const bool hiRes = content["hires_streamable"].toBool()
|| content["rights"].toObject()["hires_streamable"].toBool();
item->setText(0, hiRes ? QStringLiteral("H") : QStringLiteral("A"));
item->setForeground(0, hiRes
? Colors::QobuzOrange
: Colors::BadgeGray);
item->setFont(0, badgeFont);
item->setTextAlignment(0, Qt::AlignCenter);
item->setText(1, title);
item->setText(2, artist);
item->setData(0, TypeRole, QStringLiteral("album"));
item->setData(1, IdRole, content["id"].toString());
} else if (type == QStringLiteral("artists")) {
item->setText(0, QStringLiteral("A"));
item->setForeground(0, Colors::BadgeBlue);
item->setFont(0, badgeFont);
item->setTextAlignment(0, Qt::AlignCenter);
item->setText(1, content["name"].toString());
item->setText(2, tr("Artist"));
item->setData(0, TypeRole, QStringLiteral("artist"));
item->setData(0, IdRole, static_cast<qint64>(content["id"].toDouble()));
}
}
}
void SearchTab::onSearchResult(const QJsonObject &result)
{
// Populate tracks
m_trackResults->clear();
const QJsonArray tracks = result["tracks"].toObject()["items"].toArray();
for (const auto &v : tracks) {
const QJsonObject t = v.toObject();
const QString performer = t["performer"].toObject()["name"].toString();
const QString album = t["album"].toObject()["title"].toString();
auto *item = new QTreeWidgetItem(m_trackResults,
QStringList{t["title"].toString(), performer, album});
item->setData(0, IdRole, static_cast<qint64>(t["id"].toDouble()));
item->setData(0, TypeRole, QStringLiteral("track"));
item->setData(0, JsonRole, t);
}
// Populate albums
m_albumResults->clear();
{
QFont hiResFont;
hiResFont.setBold(true);
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
const QJsonArray albums = result["albums"].toObject()["items"].toArray();
for (const auto &v : albums) {
const QJsonObject a = v.toObject();
const QString artist = a["artist"].toObject()["name"].toString();
const bool hiRes = a["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(m_albumResults,
QStringList{QString(), a["title"].toString(), artist});
if (hiRes) {
item->setText(0, QStringLiteral("H"));
item->setForeground(0, Colors::QobuzOrange);
item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter);
}
item->setData(0, TypeRole, QStringLiteral("album"));
item->setData(1, IdRole, a["id"].toString());
item->setData(0, JsonRole, a);
}
}
// Populate artists
m_artistResults->clear();
const QJsonArray artists = result["artists"].toObject()["items"].toArray();
for (const auto &v : artists) {
const QJsonObject ar = v.toObject();
auto *item = new QTreeWidgetItem(m_artistResults,
QStringList{ar["name"].toString()});
item->setData(0, IdRole, static_cast<qint64>(ar["id"].toDouble()));
item->setData(0, TypeRole, QStringLiteral("artist"));
}
}
void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int)
{
if (!item) return;
const QString type = item->data(0, TypeRole).toString();
if (type == QStringLiteral("track")) {
emit trackPlayRequested(item->data(0, IdRole).toLongLong());
} else if (type == QStringLiteral("album")) {
emit albumSelected(item->data(1, IdRole).toString());
} else if (type == QStringLiteral("artist")) {
emit artistSelected(item->data(0, IdRole).toLongLong());
}
}
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<qint64>(
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(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
emit albumSelected(albumId);
});
}
if (artistId > 0) {
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
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<qint64>(
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(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
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, PlayQueue *queue, QWidget *parent)
: QDockWidget(tr("Search"), parent)
{
setObjectName(QStringLiteral("searchPanel"));
setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable);
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::addToPlaylistRequested, this, &View::addToPlaylistRequested);
}
} // namespace SidePanel