feat: initial qobuz-qt source

Lightweight Qt6 desktop client for Qobuz with a Rust audio backend
(Symphonia/CPAL via staticlib FFI). Mirrors the spotify-qt layout:
toolbar with playback controls, library/context docks on the left,
tabbed search side panel on the right, queue panel, now-playing dock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-24 00:41:04 +01:00
parent 35ae649fc9
commit cb2323bc32
85 changed files with 4484 additions and 249 deletions

111
src/view/queuepanel.cpp Normal file
View File

@@ -0,0 +1,111 @@
#include "queuepanel.hpp"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QMenu>
#include <QAction>
static constexpr int UpcomingIndexRole = Qt::UserRole + 1;
static constexpr int IsPlayNextRole = Qt::UserRole + 2;
QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
: QDockWidget(tr("Queue"), parent)
, m_queue(queue)
{
setObjectName(QStringLiteral("queuePanel"));
setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable);
auto *container = new QWidget(this);
auto *layout = new QVBoxLayout(container);
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
// Header row: count label + Clear button
auto *headerRow = new QHBoxLayout;
m_countLabel = new QLabel(tr("Up next: 0 tracks"), container);
m_clearBtn = new QPushButton(tr("Clear"), container);
m_clearBtn->setMaximumWidth(64);
headerRow->addWidget(m_countLabel, 1);
headerRow->addWidget(m_clearBtn);
layout->addLayout(headerRow);
m_list = new QListWidget(container);
m_list->setAlternatingRowColors(true);
m_list->setContextMenuPolicy(Qt::CustomContextMenu);
layout->addWidget(m_list, 1);
setWidget(container);
setMinimumWidth(200);
connect(m_queue, &PlayQueue::queueChanged, this, &QueuePanel::refresh);
connect(m_clearBtn, &QPushButton::clicked, this, [this] {
m_queue->clearUpcoming();
});
connect(m_list, &QListWidget::itemDoubleClicked,
this, &QueuePanel::onItemDoubleClicked);
connect(m_list, &QListWidget::customContextMenuRequested,
this, &QueuePanel::onContextMenu);
refresh();
}
void QueuePanel::refresh()
{
m_list->clear();
const QVector<QJsonObject> upcoming = m_queue->upcomingTracks();
const int playNextCount = m_queue->playNextCount();
m_countLabel->setText(tr("Up next: %1 track(s)").arg(upcoming.size()));
m_clearBtn->setEnabled(!upcoming.isEmpty());
for (int i = 0; i < upcoming.size(); ++i) {
const QJsonObject &t = upcoming.at(i);
const QString title = t["title"].toString();
const QString artist = t["performer"].toObject()["name"].toString().isEmpty()
? t["album"].toObject()["artist"].toObject()["name"].toString()
: t["performer"].toObject()["name"].toString();
const QString text = artist.isEmpty()
? title
: QStringLiteral("%1 — %2").arg(artist, title);
auto *item = new QListWidgetItem(text, m_list);
item->setData(UpcomingIndexRole, i);
item->setData(IsPlayNextRole, i < playNextCount);
// "Play Next" tracks shown slightly differently
if (i < playNextCount) {
QFont f = item->font();
f.setItalic(true);
item->setFont(f);
}
}
}
void QueuePanel::onItemDoubleClicked(QListWidgetItem *item)
{
// Double-clicking an upcoming item is not needed for now (could skip to it later)
Q_UNUSED(item)
}
void QueuePanel::onContextMenu(const QPoint &pos)
{
auto *item = m_list->itemAt(pos);
if (!item) return;
const int idx = item->data(UpcomingIndexRole).toInt();
QMenu menu(this);
auto *removeAct = menu.addAction(tr("Remove from queue"));
auto *toTopAct = menu.addAction(tr("Move to top (play next)"));
connect(removeAct, &QAction::triggered, this, [this, idx] {
m_queue->removeUpcoming(idx);
});
connect(toTopAct, &QAction::triggered, this, [this, idx] {
m_queue->moveUpcomingToTop(idx);
});
menu.exec(m_list->viewport()->mapToGlobal(pos));
}