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:
110
src/list/library.cpp
Normal file
110
src/list/library.cpp
Normal 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
40
src/list/library.hpp
Normal 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
115
src/list/tracks.cpp
Normal 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
40
src/list/tracks.hpp
Normal 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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user