Initial implementation of qobuz-qt

- Rust backend (qobuz-backend static lib): Qobuz API client (reqwest/tokio),
  Symphonia audio decoder, CPAL audio output, extern "C" FFI bridge
- Qt 6 frontend mirroring spotify-qt layout: toolbar with playback controls,
  left library dock, central track list, right search panel
- Auth: email/password login with MD5-signed requests; session token persisted
  via QSettings
- Playback: double-click a track → Rust fetches stream URL → Symphonia decodes
  → CPAL outputs to default audio device
- Dark Fusion palette matching spotify-qt feel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-23 23:34:23 +01:00
commit 9402dca7ed
40 changed files with 3963 additions and 0 deletions

110
src/list/library.cpp Normal file
View File

@@ -0,0 +1,110 @@
#include "library.hpp"
#include <QHeaderView>
#include <QJsonArray>
#include <QJsonObject>
namespace List
{
static constexpr int TypeRole = Qt::UserRole + 1;
static constexpr int IdRole = Qt::UserRole + 2;
static constexpr int NameRole = Qt::UserRole + 3;
enum NodeType {
NodeFavTracks,
NodeFavAlbums,
NodeFavArtists,
NodePlaylist,
};
Library::Library(QobuzBackend *backend, QWidget *parent)
: QTreeWidget(parent)
, m_backend(backend)
{
setHeaderHidden(true);
setRootIsDecorated(true);
setContextMenuPolicy(Qt::NoContextMenu);
buildStaticNodes();
connect(m_backend, &QobuzBackend::userPlaylistsLoaded,
this, &Library::onUserPlaylistsLoaded);
connect(this, &QTreeWidget::itemClicked,
this, &Library::onItemClicked);
connect(this, &QTreeWidget::itemDoubleClicked,
this, &Library::onItemDoubleClicked);
}
void Library::buildStaticNodes()
{
// My Library
m_myLibNode = new QTreeWidgetItem(this, QStringList{tr("My Library")});
m_myLibNode->setExpanded(true);
auto *tracksItem = new QTreeWidgetItem(m_myLibNode, QStringList{tr("Favorite Tracks")});
tracksItem->setData(0, TypeRole, NodeFavTracks);
auto *albumsItem = new QTreeWidgetItem(m_myLibNode, QStringList{tr("Favorite Albums")});
albumsItem->setData(0, TypeRole, NodeFavAlbums);
auto *artistsItem = new QTreeWidgetItem(m_myLibNode, QStringList{tr("Favorite Artists")});
artistsItem->setData(0, TypeRole, NodeFavArtists);
// Playlists
m_playlistsNode = new QTreeWidgetItem(this, QStringList{tr("Playlists")});
m_playlistsNode->setExpanded(true);
}
void Library::refresh()
{
// Remove old playlist children
while (m_playlistsNode->childCount() > 0)
delete m_playlistsNode->takeChild(0);
m_backend->getUserPlaylists();
}
void Library::onUserPlaylistsLoaded(const QJsonObject &result)
{
while (m_playlistsNode->childCount() > 0)
delete m_playlistsNode->takeChild(0);
const QJsonArray items = result["items"].toArray();
for (const auto &v : items) {
const QJsonObject pl = v.toObject();
const QString name = pl["name"].toString();
const qint64 id = static_cast<qint64>(pl["id"].toDouble());
auto *item = new QTreeWidgetItem(m_playlistsNode, QStringList{name});
item->setData(0, TypeRole, NodePlaylist);
item->setData(0, IdRole, id);
item->setData(0, NameRole, name);
}
}
void Library::onItemClicked(QTreeWidgetItem *item, int)
{
if (!item) return;
const int type = item->data(0, TypeRole).toInt();
switch (type) {
case NodeFavTracks: emit favTracksRequested(); break;
case NodeFavAlbums: emit favAlbumsRequested(); break;
case NodeFavArtists: emit favArtistsRequested(); break;
case NodePlaylist: {
const qint64 id = item->data(0, IdRole).toLongLong();
const QString name = item->data(0, NameRole).toString();
emit playlistRequested(id, name);
break;
}
default: break;
}
}
void Library::onItemDoubleClicked(QTreeWidgetItem *item, int column)
{
onItemClicked(item, column);
}
} // namespace List

40
src/list/library.hpp Normal file
View File

@@ -0,0 +1,40 @@
#pragma once
#include "../backend/qobuzbackend.hpp"
#include <QTreeWidget>
namespace List
{
/// Left-sidebar library tree — mirrors List::Library from spotify-qt.
/// Shows: Favorites (tracks, albums, artists) and Playlists.
class Library : public QTreeWidget
{
Q_OBJECT
public:
explicit Library(QobuzBackend *backend, QWidget *parent = nullptr);
void refresh();
signals:
/// Emitted when the user selects a node that should load tracks.
void favTracksRequested();
void favAlbumsRequested();
void favArtistsRequested();
void playlistRequested(qint64 playlistId, const QString &name);
private slots:
void onUserPlaylistsLoaded(const QJsonObject &result);
void onItemClicked(QTreeWidgetItem *item, int column);
void onItemDoubleClicked(QTreeWidgetItem *item, int column);
private:
QobuzBackend *m_backend = nullptr;
QTreeWidgetItem *m_myLibNode = nullptr;
QTreeWidgetItem *m_playlistsNode = nullptr;
void buildStaticNodes();
};
}

115
src/list/tracks.cpp Normal file
View File

@@ -0,0 +1,115 @@
#include "tracks.hpp"
#include <QHeaderView>
#include <QMenu>
#include <QAction>
#include <QJsonArray>
#include <QJsonObject>
namespace List
{
Tracks::Tracks(QobuzBackend *backend, QWidget *parent)
: QTreeView(parent)
, m_backend(backend)
{
m_model = new TrackListModel(this);
setModel(m_model);
setRootIsDecorated(false);
setAlternatingRowColors(true);
setSelectionMode(QAbstractItemView::ExtendedSelection);
setSelectionBehavior(QAbstractItemView::SelectRows);
setSortingEnabled(true);
setContextMenuPolicy(Qt::CustomContextMenu);
header()->setStretchLastSection(false);
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);
}
void Tracks::loadTracks(const QJsonArray &tracks)
{
m_model->setTracks(tracks);
}
void Tracks::loadAlbum(const QJsonObject &album)
{
const QJsonObject tracksWrapper = album["tracks"].toObject();
const QJsonArray items = tracksWrapper["items"].toArray();
m_model->setTracks(items);
}
void Tracks::loadPlaylist(const QJsonObject &playlist)
{
const QJsonObject tracksWrapper = playlist["tracks"].toObject();
const QJsonArray items = tracksWrapper["items"].toArray();
m_model->setTracks(items);
}
void Tracks::loadSearchTracks(const QJsonArray &tracks)
{
m_model->setTracks(tracks);
}
void Tracks::setCurrentTrackId(qint64 id)
{
m_currentTrackId = id;
// Trigger a repaint for the now-playing indicator
viewport()->update();
}
void Tracks::onDoubleClicked(const QModelIndex &index)
{
const qint64 trackId = m_model->data(index, TrackListModel::TrackIdRole).toLongLong();
if (trackId > 0)
emit playTrackRequested(trackId);
}
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 QJsonObject trackJson = m_model->data(index, TrackListModel::TrackJsonRole).toJsonObject();
QMenu menu(this);
QAction *playNow = menu.addAction(tr("Play now"));
menu.addSeparator();
QAction *addFav = menu.addAction(tr("Add to favorites"));
QAction *remFav = menu.addAction(tr("Remove from favorites"));
connect(playNow, &QAction::triggered, this, [this, trackId] {
emit playTrackRequested(trackId);
});
connect(addFav, &QAction::triggered, this, [this, trackId] {
m_backend->addFavTrack(trackId);
});
connect(remFav, &QAction::triggered, this, [this, trackId] {
m_backend->removeFavTrack(trackId);
});
// Open artist page
const QJsonObject performer = trackJson["performer"].toObject();
const qint64 artistId = static_cast<qint64>(performer["id"].toDouble());
if (artistId > 0) {
menu.addSeparator();
QAction *openArtist = menu.addAction(
tr("Open artist: %1").arg(performer["name"].toString()));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
m_backend->getArtist(artistId);
});
}
menu.exec(viewport()->mapToGlobal(pos));
}
} // namespace List

40
src/list/tracks.hpp Normal file
View File

@@ -0,0 +1,40 @@
#pragma once
#include "../model/tracklistmodel.hpp"
#include "../backend/qobuzbackend.hpp"
#include <QTreeView>
#include <QJsonArray>
#include <QJsonObject>
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);
void loadTracks(const QJsonArray &tracks);
void loadAlbum(const QJsonObject &album);
void loadPlaylist(const QJsonObject &playlist);
void loadSearchTracks(const QJsonArray &tracks);
void setCurrentTrackId(qint64 id);
signals:
void playTrackRequested(qint64 trackId);
private:
TrackListModel *m_model = nullptr;
QobuzBackend *m_backend = nullptr;
qint64 m_currentTrackId = 0;
void onDoubleClicked(const QModelIndex &index);
void onContextMenu(const QPoint &pos);
void resizeColumnsToContent();
};
}