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

View File

@@ -3,15 +3,14 @@
#include <QHeaderView>
#include <QMenu>
#include <QAction>
#include <QJsonArray>
#include <QJsonObject>
namespace List
{
Tracks::Tracks(QobuzBackend *backend, QWidget *parent)
Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
: QTreeView(parent)
, m_backend(backend)
, m_queue(queue)
{
m_model = new TrackListModel(this);
setModel(m_model);
@@ -22,54 +21,74 @@ Tracks::Tracks(QobuzBackend *backend, QWidget *parent)
setSelectionBehavior(QAbstractItemView::SelectRows);
setSortingEnabled(true);
setContextMenuPolicy(Qt::CustomContextMenu);
sortByColumn(TrackListModel::ColNumber, Qt::AscendingOrder);
header()->setStretchLastSection(false);
header()->setSectionResizeMode(TrackListModel::ColTitle, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColArtist, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColAlbum, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColTitle, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColArtist, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColAlbum, QHeaderView::Stretch);
header()->setSectionResizeMode(TrackListModel::ColNumber, QHeaderView::ResizeToContents);
header()->setSectionResizeMode(TrackListModel::ColDuration, QHeaderView::ResizeToContents);
connect(this, &QTreeView::doubleClicked, this, &Tracks::onDoubleClicked);
connect(this, &QTreeView::customContextMenuRequested, this, &Tracks::onContextMenu);
connect(this, &QTreeView::doubleClicked,
this, &Tracks::onDoubleClicked);
connect(this, &QTreeView::customContextMenuRequested,
this, &Tracks::onContextMenu);
// Re-sync queue order whenever the model is re-sorted (user clicked header
// or setTracks re-applied an existing sort).
connect(m_model, &TrackListModel::sortApplied,
this, &Tracks::syncQueueToModel);
}
void Tracks::loadTracks(const QJsonArray &tracks)
{
m_model->setTracks(tracks);
m_model->setTracks(tracks, false, /*useSequential=*/true);
m_queue->setContext(tracks, 0);
syncQueueToModel();
}
void Tracks::loadAlbum(const QJsonObject &album)
{
const QJsonObject tracksWrapper = album["tracks"].toObject();
const QJsonArray items = tracksWrapper["items"].toArray();
m_model->setTracks(items);
const QJsonArray items = album["tracks"].toObject()["items"].toArray();
m_model->setTracks(items); // album: use track_number
m_queue->setContext(items, 0);
syncQueueToModel();
}
void Tracks::loadPlaylist(const QJsonObject &playlist)
{
const QJsonObject tracksWrapper = playlist["tracks"].toObject();
const QJsonArray items = tracksWrapper["items"].toArray();
m_model->setTracks(items);
const QJsonArray items = playlist["tracks"].toObject()["items"].toArray();
m_model->setTracks(items, /*usePosition=*/true);
m_queue->setContext(items, 0);
syncQueueToModel();
}
void Tracks::loadSearchTracks(const QJsonArray &tracks)
{
m_model->setTracks(tracks);
m_model->setTracks(tracks, false, /*useSequential=*/true);
m_queue->setContext(tracks, 0);
syncQueueToModel();
}
void Tracks::setCurrentTrackId(qint64 id)
void Tracks::setPlayingTrackId(qint64 id)
{
m_currentTrackId = id;
// Trigger a repaint for the now-playing indicator
viewport()->update();
m_playingId = id;
m_model->setPlayingId(id);
}
void Tracks::syncQueueToModel()
{
m_queue->reorderContext(m_model->currentTracksJson(), m_playingId);
}
void Tracks::onDoubleClicked(const QModelIndex &index)
{
const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (trackId > 0)
emit playTrackRequested(trackId);
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (id > 0) {
m_queue->setCurrentById(id);
emit playTrackRequested(id);
}
}
void Tracks::onContextMenu(const QPoint &pos)
@@ -77,33 +96,55 @@ void Tracks::onContextMenu(const QPoint &pos)
const QModelIndex index = indexAt(pos);
if (!index.isValid()) return;
const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
const qint64 id = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
const QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject();
QMenu menu(this);
QAction *playNow = menu.addAction(tr("Play now"));
auto *playNow = menu.addAction(QIcon(":/res/icons/media-playback-start.svg"), tr("Play now"));
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();
QAction *addFav = menu.addAction(tr("Add to favorites"));
QAction *remFav = menu.addAction(tr("Remove from favorites"));
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"));
connect(playNow, &QAction::triggered, this, [this, trackId] {
emit playTrackRequested(trackId);
connect(playNow, &QAction::triggered, this, [this, id] {
m_queue->setCurrentById(id);
emit playTrackRequested(id);
});
connect(addFav, &QAction::triggered, this, [this, trackId] {
m_backend->addFavTrack(trackId);
connect(playNext, &QAction::triggered, this, [this, trackJson] {
m_queue->playNext(trackJson);
});
connect(remFav, &QAction::triggered, this, [this, trackId] {
m_backend->removeFavTrack(trackId);
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 artist page
const QJsonObject performer = trackJson["performer"].toObject();
const qint64 artistId = static_cast<qint64>(performer["id"].toDouble());
if (artistId > 0) {
// Open album
const QString albumId = m_model->trackAt(index.row()).albumId;
if (!albumId.isEmpty()) {
menu.addSeparator();
QAction *openArtist = menu.addAction(
tr("Open artist: %1").arg(performer["name"].toString()));
auto *openAlbum = menu.addAction(
QIcon(":/res/icons/view-media-album-cover.svg"),
tr("Open album: %1").arg(m_model->trackAt(index.row()).album));
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
m_backend->getAlbum(albumId);
});
}
// Open artist
const qint64 artistId = static_cast<qint64>(
trackJson["performer"].toObject()["id"].toDouble());
if (artistId > 0) {
const QString artistName = trackJson["performer"].toObject()["name"].toString();
auto *openArtist = menu.addAction(
QIcon(":/res/icons/view-media-artist.svg"),
tr("Open artist: %1").arg(artistName));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
m_backend->getArtist(artistId);
});

View File

@@ -2,6 +2,7 @@
#include "../model/tracklistmodel.hpp"
#include "../backend/qobuzbackend.hpp"
#include "../playqueue.hpp"
#include <QTreeView>
#include <QJsonArray>
@@ -9,32 +10,32 @@
namespace List
{
/// Track list view — mirrors the spotify-qt List::Tracks widget.
/// Displays a flat list of tracks for a playlist, album, search result, etc.
class Tracks : public QTreeView
{
Q_OBJECT
public:
explicit Tracks(QobuzBackend *backend, QWidget *parent = nullptr);
explicit Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent = nullptr);
void loadTracks(const QJsonArray &tracks);
void loadAlbum(const QJsonObject &album);
void loadPlaylist(const QJsonObject &playlist);
void loadSearchTracks(const QJsonArray &tracks);
void setCurrentTrackId(qint64 id);
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
void setPlayingTrackId(qint64 id);
signals:
void playTrackRequested(qint64 trackId);
private:
TrackListModel *m_model = nullptr;
QobuzBackend *m_backend = nullptr;
qint64 m_currentTrackId = 0;
TrackListModel *m_model = nullptr;
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
qint64 m_playingId = 0;
void onDoubleClicked(const QModelIndex &index);
void onContextMenu(const QPoint &pos);
void resizeColumnsToContent();
void syncQueueToModel();
};
}