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:
44
src/CMakeLists.txt
Normal file
44
src/CMakeLists.txt
Normal file
@@ -0,0 +1,44 @@
|
||||
target_sources(qobuz-qt PRIVATE
|
||||
# Entry point
|
||||
main.cpp
|
||||
|
||||
# Main window
|
||||
mainwindow.hpp
|
||||
mainwindow.cpp
|
||||
|
||||
# Backend (Qt wrapper around Rust FFI)
|
||||
backend/qobuzbackend.hpp
|
||||
backend/qobuzbackend.cpp
|
||||
|
||||
# Views
|
||||
view/maintoolbar.hpp
|
||||
view/maintoolbar.cpp
|
||||
view/maincontent.hpp
|
||||
view/maincontent.cpp
|
||||
view/sidepanel/view.hpp
|
||||
view/sidepanel/view.cpp
|
||||
|
||||
# Lists
|
||||
list/tracks.hpp
|
||||
list/tracks.cpp
|
||||
list/library.hpp
|
||||
list/library.cpp
|
||||
|
||||
# Models
|
||||
model/tracklistmodel.hpp
|
||||
model/tracklistmodel.cpp
|
||||
|
||||
# Dialogs
|
||||
dialog/login.hpp
|
||||
dialog/login.cpp
|
||||
dialog/settings.hpp
|
||||
dialog/settings.cpp
|
||||
|
||||
# Widgets (header-only)
|
||||
widget/clickableslider.hpp
|
||||
widget/volumebutton.hpp
|
||||
|
||||
# Utils (header-only)
|
||||
util/icon.hpp
|
||||
util/settings.hpp
|
||||
)
|
||||
221
src/backend/qobuzbackend.cpp
Normal file
221
src/backend/qobuzbackend.cpp
Normal file
@@ -0,0 +1,221 @@
|
||||
#include "qobuzbackend.hpp"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonArray>
|
||||
#include <QMetaObject>
|
||||
|
||||
QobuzBackend::QobuzBackend(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_backend = qobuz_backend_new(&QobuzBackend::eventTrampoline, this);
|
||||
|
||||
m_positionTimer = new QTimer(this);
|
||||
m_positionTimer->setInterval(500);
|
||||
connect(m_positionTimer, &QTimer::timeout, this, &QobuzBackend::onPositionTick);
|
||||
m_positionTimer->start();
|
||||
}
|
||||
|
||||
QobuzBackend::~QobuzBackend()
|
||||
{
|
||||
if (m_backend) {
|
||||
qobuz_backend_free(m_backend);
|
||||
m_backend = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- auth ----
|
||||
|
||||
void QobuzBackend::login(const QString &email, const QString &password)
|
||||
{
|
||||
qobuz_backend_login(m_backend, email.toUtf8().constData(), password.toUtf8().constData());
|
||||
}
|
||||
|
||||
void QobuzBackend::setToken(const QString &token)
|
||||
{
|
||||
qobuz_backend_set_token(m_backend, token.toUtf8().constData());
|
||||
}
|
||||
|
||||
// ---- catalog ----
|
||||
|
||||
void QobuzBackend::search(const QString &query, quint32 offset, quint32 limit)
|
||||
{
|
||||
qobuz_backend_search(m_backend, query.toUtf8().constData(), offset, limit);
|
||||
}
|
||||
|
||||
void QobuzBackend::getAlbum(const QString &albumId)
|
||||
{
|
||||
qobuz_backend_get_album(m_backend, albumId.toUtf8().constData());
|
||||
}
|
||||
|
||||
void QobuzBackend::getArtist(qint64 artistId)
|
||||
{
|
||||
qobuz_backend_get_artist(m_backend, artistId);
|
||||
}
|
||||
|
||||
void QobuzBackend::getPlaylist(qint64 playlistId, quint32 offset, quint32 limit)
|
||||
{
|
||||
qobuz_backend_get_playlist(m_backend, playlistId, offset, limit);
|
||||
}
|
||||
|
||||
// ---- favorites ----
|
||||
|
||||
void QobuzBackend::getFavTracks(quint32 offset, quint32 limit)
|
||||
{
|
||||
qobuz_backend_get_fav_tracks(m_backend, offset, limit);
|
||||
}
|
||||
|
||||
void QobuzBackend::getFavAlbums(quint32 offset, quint32 limit)
|
||||
{
|
||||
qobuz_backend_get_fav_albums(m_backend, offset, limit);
|
||||
}
|
||||
|
||||
void QobuzBackend::getFavArtists(quint32 offset, quint32 limit)
|
||||
{
|
||||
qobuz_backend_get_fav_artists(m_backend, offset, limit);
|
||||
}
|
||||
|
||||
void QobuzBackend::getUserPlaylists(quint32 offset, quint32 limit)
|
||||
{
|
||||
qobuz_backend_get_user_playlists(m_backend, offset, limit);
|
||||
}
|
||||
|
||||
// ---- fav modification ----
|
||||
|
||||
void QobuzBackend::addFavTrack(qint64 trackId)
|
||||
{
|
||||
qobuz_backend_add_fav_track(m_backend, trackId);
|
||||
}
|
||||
|
||||
void QobuzBackend::removeFavTrack(qint64 trackId)
|
||||
{
|
||||
qobuz_backend_remove_fav_track(m_backend, trackId);
|
||||
}
|
||||
|
||||
void QobuzBackend::addFavAlbum(const QString &albumId)
|
||||
{
|
||||
qobuz_backend_add_fav_album(m_backend, albumId.toUtf8().constData());
|
||||
}
|
||||
|
||||
void QobuzBackend::removeFavAlbum(const QString &albumId)
|
||||
{
|
||||
qobuz_backend_remove_fav_album(m_backend, albumId.toUtf8().constData());
|
||||
}
|
||||
|
||||
// ---- playback ----
|
||||
|
||||
void QobuzBackend::playTrack(qint64 trackId, int formatId)
|
||||
{
|
||||
qobuz_backend_play_track(m_backend, trackId, formatId);
|
||||
}
|
||||
|
||||
void QobuzBackend::pause()
|
||||
{
|
||||
qobuz_backend_pause(m_backend);
|
||||
}
|
||||
|
||||
void QobuzBackend::resume()
|
||||
{
|
||||
qobuz_backend_resume(m_backend);
|
||||
}
|
||||
|
||||
void QobuzBackend::stop()
|
||||
{
|
||||
qobuz_backend_stop(m_backend);
|
||||
}
|
||||
|
||||
void QobuzBackend::setVolume(int volume)
|
||||
{
|
||||
qobuz_backend_set_volume(m_backend, static_cast<quint8>(qBound(0, volume, 100)));
|
||||
}
|
||||
|
||||
quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); }
|
||||
quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); }
|
||||
int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); }
|
||||
int QobuzBackend::state() const { return qobuz_backend_get_state(m_backend); }
|
||||
|
||||
// ---- private slots ----
|
||||
|
||||
void QobuzBackend::onPositionTick()
|
||||
{
|
||||
emit positionChanged(position(), duration());
|
||||
|
||||
if (qobuz_backend_take_track_finished(m_backend))
|
||||
emit trackFinished();
|
||||
}
|
||||
|
||||
void QobuzBackend::onEvent(int eventType, const QString &json)
|
||||
{
|
||||
const QJsonObject obj = QJsonDocument::fromJson(json.toUtf8()).object();
|
||||
|
||||
switch (eventType) {
|
||||
case EV_LOGIN_OK:
|
||||
emit loginSuccess(obj["token"].toString(), obj["user"].toObject());
|
||||
break;
|
||||
case EV_LOGIN_ERR:
|
||||
emit loginError(obj["error"].toString());
|
||||
break;
|
||||
case EV_SEARCH_OK:
|
||||
emit searchResult(obj);
|
||||
break;
|
||||
case EV_SEARCH_ERR:
|
||||
emit error(obj["error"].toString());
|
||||
break;
|
||||
case EV_ALBUM_OK:
|
||||
emit albumLoaded(obj);
|
||||
break;
|
||||
case EV_ALBUM_ERR:
|
||||
emit error(obj["error"].toString());
|
||||
break;
|
||||
case EV_ARTIST_OK:
|
||||
emit artistLoaded(obj);
|
||||
break;
|
||||
case EV_ARTIST_ERR:
|
||||
emit error(obj["error"].toString());
|
||||
break;
|
||||
case EV_PLAYLIST_OK:
|
||||
emit playlistLoaded(obj);
|
||||
break;
|
||||
case EV_PLAYLIST_ERR:
|
||||
emit error(obj["error"].toString());
|
||||
break;
|
||||
case EV_FAV_TRACKS_OK:
|
||||
emit favTracksLoaded(obj);
|
||||
break;
|
||||
case EV_FAV_ALBUMS_OK:
|
||||
emit favAlbumsLoaded(obj);
|
||||
break;
|
||||
case EV_FAV_ARTISTS_OK:
|
||||
emit favArtistsLoaded(obj);
|
||||
break;
|
||||
case EV_PLAYLISTS_OK:
|
||||
emit userPlaylistsLoaded(obj);
|
||||
break;
|
||||
case EV_TRACK_CHANGED:
|
||||
emit trackChanged(obj);
|
||||
break;
|
||||
case EV_STATE_CHANGED:
|
||||
emit stateChanged(obj["state"].toString());
|
||||
break;
|
||||
case EV_GENERIC_ERR:
|
||||
case EV_TRACK_URL_ERR:
|
||||
emit error(obj["error"].toString());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- static trampoline ----
|
||||
|
||||
void QobuzBackend::eventTrampoline(void *userdata, int eventType, const char *json)
|
||||
{
|
||||
auto *self = static_cast<QobuzBackend *>(userdata);
|
||||
// Marshal from Rust thread → Qt main thread
|
||||
QMetaObject::invokeMethod(
|
||||
self,
|
||||
"onEvent",
|
||||
Qt::QueuedConnection,
|
||||
Q_ARG(int, eventType),
|
||||
Q_ARG(QString, QString::fromUtf8(json))
|
||||
);
|
||||
}
|
||||
94
src/backend/qobuzbackend.hpp
Normal file
94
src/backend/qobuzbackend.hpp
Normal file
@@ -0,0 +1,94 @@
|
||||
#pragma once
|
||||
|
||||
#include "qobuz_backend.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QJsonObject>
|
||||
#include <QTimer>
|
||||
|
||||
/// Qt wrapper around the Rust qobuz-backend static library.
|
||||
///
|
||||
/// All signals are emitted on the Qt main thread regardless of which thread
|
||||
/// the Rust callback fires on (marshalled via QMetaObject::invokeMethod with
|
||||
/// Qt::QueuedConnection).
|
||||
class QobuzBackend : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit QobuzBackend(QObject *parent = nullptr);
|
||||
~QobuzBackend() override;
|
||||
|
||||
// --- auth ---
|
||||
void login(const QString &email, const QString &password);
|
||||
void setToken(const QString &token);
|
||||
|
||||
// --- catalog ---
|
||||
void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
|
||||
void getAlbum(const QString &albumId);
|
||||
void getArtist(qint64 artistId);
|
||||
void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500);
|
||||
|
||||
// --- favorites ---
|
||||
void getFavTracks(quint32 offset = 0, quint32 limit = 500);
|
||||
void getFavAlbums(quint32 offset = 0, quint32 limit = 200);
|
||||
void getFavArtists(quint32 offset = 0, quint32 limit = 200);
|
||||
void getUserPlaylists(quint32 offset = 0, quint32 limit = 200);
|
||||
|
||||
// --- fav modification ---
|
||||
void addFavTrack(qint64 trackId);
|
||||
void removeFavTrack(qint64 trackId);
|
||||
void addFavAlbum(const QString &albumId);
|
||||
void removeFavAlbum(const QString &albumId);
|
||||
|
||||
// --- playback ---
|
||||
void playTrack(qint64 trackId, int formatId = 6);
|
||||
void pause();
|
||||
void resume();
|
||||
void stop();
|
||||
void setVolume(int volume);
|
||||
|
||||
quint64 position() const;
|
||||
quint64 duration() const;
|
||||
int volume() const;
|
||||
/// 1 = playing, 2 = paused, 0 = idle
|
||||
int state() const;
|
||||
|
||||
signals:
|
||||
// auth
|
||||
void loginSuccess(const QString &token, const QJsonObject &user);
|
||||
void loginError(const QString &error);
|
||||
|
||||
// catalog
|
||||
void searchResult(const QJsonObject &result);
|
||||
void albumLoaded(const QJsonObject &album);
|
||||
void artistLoaded(const QJsonObject &artist);
|
||||
void playlistLoaded(const QJsonObject &playlist);
|
||||
|
||||
// favorites
|
||||
void favTracksLoaded(const QJsonObject &result);
|
||||
void favAlbumsLoaded(const QJsonObject &result);
|
||||
void favArtistsLoaded(const QJsonObject &result);
|
||||
void userPlaylistsLoaded(const QJsonObject &result);
|
||||
|
||||
// playback
|
||||
void trackChanged(const QJsonObject &track);
|
||||
void stateChanged(const QString &state);
|
||||
void positionChanged(quint64 position, quint64 duration);
|
||||
void trackFinished();
|
||||
|
||||
// errors
|
||||
void error(const QString &message);
|
||||
|
||||
private slots:
|
||||
Q_INVOKABLE void onEvent(int eventType, const QString &json);
|
||||
void onPositionTick();
|
||||
|
||||
private:
|
||||
QobuzBackendOpaque *m_backend = nullptr;
|
||||
QTimer *m_positionTimer = nullptr;
|
||||
|
||||
// Static trampoline called from Rust threads
|
||||
static void eventTrampoline(void *userdata, int eventType, const char *json);
|
||||
};
|
||||
80
src/dialog/login.cpp
Normal file
80
src/dialog/login.cpp
Normal file
@@ -0,0 +1,80 @@
|
||||
#include "login.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QDialogButtonBox>
|
||||
|
||||
LoginDialog::LoginDialog(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Sign in to Qobuz"));
|
||||
setMinimumWidth(360);
|
||||
setModal(true);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setSpacing(12);
|
||||
|
||||
// Logo / title
|
||||
auto *title = new QLabel(QStringLiteral("<h2>Qobuz</h2>"), this);
|
||||
title->setAlignment(Qt::AlignCenter);
|
||||
layout->addWidget(title);
|
||||
|
||||
// Form
|
||||
auto *form = new QFormLayout;
|
||||
form->setLabelAlignment(Qt::AlignRight);
|
||||
|
||||
m_email = new QLineEdit(this);
|
||||
m_password = new QLineEdit(this);
|
||||
m_password->setEchoMode(QLineEdit::Password);
|
||||
|
||||
form->addRow(tr("E-mail:"), m_email);
|
||||
form->addRow(tr("Password:"), m_password);
|
||||
layout->addLayout(form);
|
||||
|
||||
// Remember checkbox
|
||||
m_remember = new QCheckBox(tr("Remember me"), this);
|
||||
m_remember->setChecked(true);
|
||||
layout->addWidget(m_remember);
|
||||
|
||||
// Error label (hidden until needed)
|
||||
m_errorLbl = new QLabel(this);
|
||||
m_errorLbl->setStyleSheet("color: red;");
|
||||
m_errorLbl->setWordWrap(true);
|
||||
m_errorLbl->hide();
|
||||
layout->addWidget(m_errorLbl);
|
||||
|
||||
// Buttons
|
||||
m_loginBtn = new QPushButton(tr("Sign in"), this);
|
||||
m_loginBtn->setDefault(true);
|
||||
auto *cancelBtn = new QPushButton(tr("Cancel"), this);
|
||||
|
||||
auto *btnLayout = new QHBoxLayout;
|
||||
btnLayout->addStretch();
|
||||
btnLayout->addWidget(cancelBtn);
|
||||
btnLayout->addWidget(m_loginBtn);
|
||||
layout->addLayout(btnLayout);
|
||||
|
||||
connect(m_loginBtn, &QPushButton::clicked, this, [this] {
|
||||
m_errorLbl->hide();
|
||||
emit loginRequested(m_email->text().trimmed(), m_password->text());
|
||||
});
|
||||
connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
|
||||
|
||||
// Allow pressing Enter in the password field
|
||||
connect(m_password, &QLineEdit::returnPressed, m_loginBtn, &QPushButton::click);
|
||||
}
|
||||
|
||||
void LoginDialog::setError(const QString &msg)
|
||||
{
|
||||
m_errorLbl->setText(msg);
|
||||
m_errorLbl->show();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
void LoginDialog::setBusy(bool busy)
|
||||
{
|
||||
m_loginBtn->setEnabled(!busy);
|
||||
m_loginBtn->setText(busy ? tr("Signing in…") : tr("Sign in"));
|
||||
m_email->setEnabled(!busy);
|
||||
m_password->setEnabled(!busy);
|
||||
}
|
||||
32
src/dialog/login.hpp
Normal file
32
src/dialog/login.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QLabel>
|
||||
#include <QCheckBox>
|
||||
|
||||
class LoginDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit LoginDialog(QWidget *parent = nullptr);
|
||||
|
||||
QString email() const { return m_email->text(); }
|
||||
QString password() const { return m_password->text(); }
|
||||
bool remember() const { return m_remember->isChecked(); }
|
||||
|
||||
void setError(const QString &msg);
|
||||
void setBusy(bool busy);
|
||||
|
||||
signals:
|
||||
void loginRequested(const QString &email, const QString &password);
|
||||
|
||||
private:
|
||||
QLineEdit *m_email = nullptr;
|
||||
QLineEdit *m_password = nullptr;
|
||||
QCheckBox *m_remember = nullptr;
|
||||
QPushButton *m_loginBtn = nullptr;
|
||||
QLabel *m_errorLbl = nullptr;
|
||||
};
|
||||
54
src/dialog/settings.cpp
Normal file
54
src/dialog/settings.cpp
Normal file
@@ -0,0 +1,54 @@
|
||||
#include "settings.hpp"
|
||||
#include "../util/settings.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QFormLayout>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGroupBox>
|
||||
|
||||
SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Settings"));
|
||||
setMinimumWidth(400);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
|
||||
// --- Playback group ---
|
||||
auto *playGroup = new QGroupBox(tr("Playback"), this);
|
||||
auto *playLayout = new QFormLayout(playGroup);
|
||||
|
||||
m_formatBox = new QComboBox(playGroup);
|
||||
m_formatBox->addItem(tr("Hi-Res 24-bit/192kHz"), 27);
|
||||
m_formatBox->addItem(tr("Hi-Res 24-bit/96kHz"), 7);
|
||||
m_formatBox->addItem(tr("CD 16-bit"), 6);
|
||||
m_formatBox->addItem(tr("MP3 320 kbps"), 5);
|
||||
|
||||
const int currentFormat = AppSettings::instance().preferredFormat();
|
||||
for (int i = 0; i < m_formatBox->count(); ++i) {
|
||||
if (m_formatBox->itemData(i).toInt() == currentFormat) {
|
||||
m_formatBox->setCurrentIndex(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
playLayout->addRow(tr("Preferred quality:"), m_formatBox);
|
||||
layout->addWidget(playGroup);
|
||||
|
||||
layout->addStretch();
|
||||
|
||||
auto *buttons = new QDialogButtonBox(
|
||||
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, [this] {
|
||||
applyChanges();
|
||||
accept();
|
||||
});
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
}
|
||||
|
||||
void SettingsDialog::applyChanges()
|
||||
{
|
||||
AppSettings::instance().setPreferredFormat(
|
||||
m_formatBox->currentData().toInt());
|
||||
}
|
||||
18
src/dialog/settings.hpp
Normal file
18
src/dialog/settings.hpp
Normal file
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
|
||||
class SettingsDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SettingsDialog(QWidget *parent = nullptr);
|
||||
|
||||
private:
|
||||
QComboBox *m_formatBox = nullptr;
|
||||
|
||||
void applyChanges();
|
||||
};
|
||||
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();
|
||||
};
|
||||
}
|
||||
39
src/main.cpp
Normal file
39
src/main.cpp
Normal file
@@ -0,0 +1,39 @@
|
||||
#include "mainwindow.hpp"
|
||||
#include "backend/qobuzbackend.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QStyleFactory>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
app.setApplicationName(QStringLiteral("qobuz-qt"));
|
||||
app.setOrganizationName(QStringLiteral("qobuz-qt"));
|
||||
app.setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
|
||||
// Dark palette consistent with spotify-qt style
|
||||
app.setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
|
||||
QPalette darkPalette;
|
||||
darkPalette.setColor(QPalette::Window, QColor(35, 35, 35));
|
||||
darkPalette.setColor(QPalette::WindowText, Qt::white);
|
||||
darkPalette.setColor(QPalette::Base, QColor(25, 25, 25));
|
||||
darkPalette.setColor(QPalette::AlternateBase, QColor(45, 45, 45));
|
||||
darkPalette.setColor(QPalette::ToolTipBase, Qt::white);
|
||||
darkPalette.setColor(QPalette::ToolTipText, Qt::white);
|
||||
darkPalette.setColor(QPalette::Text, Qt::white);
|
||||
darkPalette.setColor(QPalette::Button, QColor(53, 53, 53));
|
||||
darkPalette.setColor(QPalette::ButtonText, Qt::white);
|
||||
darkPalette.setColor(QPalette::BrightText, Qt::red);
|
||||
darkPalette.setColor(QPalette::Link, QColor(42, 130, 218));
|
||||
darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218));
|
||||
darkPalette.setColor(QPalette::HighlightedText, Qt::black);
|
||||
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(127, 127, 127));
|
||||
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127));
|
||||
app.setPalette(darkPalette);
|
||||
|
||||
auto *backend = new QobuzBackend;
|
||||
MainWindow window(backend);
|
||||
window.show();
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
251
src/mainwindow.cpp
Normal file
251
src/mainwindow.cpp
Normal file
@@ -0,0 +1,251 @@
|
||||
#include "mainwindow.hpp"
|
||||
|
||||
#include "dialog/login.hpp"
|
||||
#include "dialog/settings.hpp"
|
||||
#include "util/settings.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QMenuBar>
|
||||
#include <QMenu>
|
||||
#include <QAction>
|
||||
#include <QStatusBar>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QTimer>
|
||||
#include <QJsonArray>
|
||||
|
||||
MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
, m_backend(backend)
|
||||
{
|
||||
setWindowTitle(QStringLiteral("Qobuz"));
|
||||
setMinimumSize(800, 500);
|
||||
resize(defaultSize());
|
||||
|
||||
// ---- Toolbar ----
|
||||
m_toolBar = new MainToolBar(m_backend, this);
|
||||
addToolBar(Qt::TopToolBarArea, m_toolBar);
|
||||
|
||||
// ---- Central content ----
|
||||
m_content = new MainContent(m_backend, this);
|
||||
setCentralWidget(m_content);
|
||||
|
||||
// ---- Library dock (left) ----
|
||||
m_library = new List::Library(m_backend, this);
|
||||
m_libraryDock = new QDockWidget(tr("Library"), this);
|
||||
m_libraryDock->setObjectName(QStringLiteral("libraryDock"));
|
||||
m_libraryDock->setFeatures(QDockWidget::DockWidgetMovable);
|
||||
m_libraryDock->setWidget(m_library);
|
||||
m_libraryDock->setMinimumWidth(200);
|
||||
addDockWidget(Qt::LeftDockWidgetArea, m_libraryDock);
|
||||
|
||||
// ---- Search side panel (right) ----
|
||||
m_sidePanel = new SidePanel::View(m_backend, this);
|
||||
m_sidePanel->hide();
|
||||
addDockWidget(Qt::RightDockWidgetArea, m_sidePanel);
|
||||
|
||||
// ---- Menu bar ----
|
||||
setupMenuBar();
|
||||
|
||||
// ---- Status bar ----
|
||||
statusBar()->showMessage(tr("Ready"));
|
||||
|
||||
// ---- Wire signals ----
|
||||
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
|
||||
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
|
||||
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
|
||||
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
|
||||
connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded);
|
||||
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
|
||||
connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded);
|
||||
connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) {
|
||||
statusBar()->showMessage(tr("Error: %1").arg(msg), 5000);
|
||||
});
|
||||
|
||||
// Library → backend
|
||||
connect(m_library, &List::Library::favTracksRequested, m_backend, [this] {
|
||||
m_backend->getFavTracks();
|
||||
statusBar()->showMessage(tr("Loading favorite tracks…"));
|
||||
});
|
||||
connect(m_library, &List::Library::favAlbumsRequested, m_backend, [this] {
|
||||
m_backend->getFavAlbums();
|
||||
statusBar()->showMessage(tr("Loading favorite albums…"));
|
||||
});
|
||||
connect(m_library, &List::Library::favArtistsRequested, m_backend, [this] {
|
||||
m_backend->getFavArtists();
|
||||
statusBar()->showMessage(tr("Loading favorite artists…"));
|
||||
});
|
||||
connect(m_library, &List::Library::playlistRequested,
|
||||
this, [this](qint64 id, const QString &name) {
|
||||
m_backend->getPlaylist(id);
|
||||
statusBar()->showMessage(tr("Loading playlist: %1…").arg(name));
|
||||
});
|
||||
|
||||
// Track list → playback
|
||||
connect(m_content->tracksList(), &List::Tracks::playTrackRequested,
|
||||
this, &MainWindow::onPlayTrackRequested);
|
||||
|
||||
// Search panel → backend
|
||||
connect(m_sidePanel, &SidePanel::View::albumSelected,
|
||||
this, &MainWindow::onSearchAlbumSelected);
|
||||
connect(m_sidePanel, &SidePanel::View::artistSelected,
|
||||
this, &MainWindow::onSearchArtistSelected);
|
||||
connect(m_sidePanel, &SidePanel::View::trackPlayRequested,
|
||||
this, &MainWindow::onPlayTrackRequested);
|
||||
|
||||
// Toolbar search toggle → side panel visibility
|
||||
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
|
||||
|
||||
// Restore or prompt login
|
||||
tryRestoreSession();
|
||||
}
|
||||
|
||||
void MainWindow::setupMenuBar()
|
||||
{
|
||||
auto *fileMenu = menuBar()->addMenu(tr("&File"));
|
||||
fileMenu->addAction(tr("&Sign in…"), this, &MainWindow::showLoginDialog);
|
||||
fileMenu->addSeparator();
|
||||
fileMenu->addAction(tr("&Settings…"), this, &MainWindow::showSettingsDialog);
|
||||
fileMenu->addSeparator();
|
||||
fileMenu->addAction(tr("&Quit"), qApp, &QApplication::quit, QKeySequence::Quit);
|
||||
|
||||
auto *viewMenu = menuBar()->addMenu(tr("&View"));
|
||||
viewMenu->addAction(m_libraryDock->toggleViewAction());
|
||||
viewMenu->addAction(m_sidePanel->toggleViewAction());
|
||||
|
||||
auto *helpMenu = menuBar()->addMenu(tr("&Help"));
|
||||
helpMenu->addAction(tr("&About"), this, [this] {
|
||||
QMessageBox::about(this, tr("About Qobuz"),
|
||||
tr("<h3>qobuz-qt</h3>"
|
||||
"<p>A lightweight Qt client for the Qobuz streaming service.</p>"
|
||||
"<p>Audio decoding powered by Symphonia (Rust).</p>"));
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::tryRestoreSession()
|
||||
{
|
||||
const QString token = AppSettings::instance().authToken();
|
||||
if (!token.isEmpty()) {
|
||||
m_backend->setToken(token);
|
||||
m_library->refresh();
|
||||
const QString name = AppSettings::instance().displayName();
|
||||
statusBar()->showMessage(tr("Signed in as %1").arg(
|
||||
name.isEmpty() ? AppSettings::instance().userEmail() : name));
|
||||
} else {
|
||||
// No saved session — prompt login after a short delay so the window is visible first
|
||||
QTimer::singleShot(200, this, &MainWindow::showLoginDialog);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- slots ----
|
||||
|
||||
void MainWindow::showLoginDialog()
|
||||
{
|
||||
auto *dlg = new LoginDialog(this);
|
||||
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
// Pre-fill if remembered
|
||||
if (AppSettings::instance().rememberLogin())
|
||||
dlg->findChild<QLineEdit *>(); // just show with empty fields
|
||||
|
||||
connect(dlg, &LoginDialog::loginRequested,
|
||||
this, [this, dlg](const QString &email, const QString &password) {
|
||||
dlg->setBusy(true);
|
||||
m_backend->login(email, password);
|
||||
});
|
||||
|
||||
connect(m_backend, &QobuzBackend::loginSuccess, dlg, [dlg](const QString &, const QJsonObject &) {
|
||||
dlg->accept();
|
||||
});
|
||||
connect(m_backend, &QobuzBackend::loginError, dlg, [dlg](const QString &err) {
|
||||
dlg->setError(err);
|
||||
});
|
||||
|
||||
dlg->exec();
|
||||
}
|
||||
|
||||
void MainWindow::showSettingsDialog()
|
||||
{
|
||||
SettingsDialog dlg(this);
|
||||
dlg.exec();
|
||||
}
|
||||
|
||||
void MainWindow::onLoginSuccess(const QString &token, const QJsonObject &user)
|
||||
{
|
||||
AppSettings::instance().setAuthToken(token);
|
||||
const QString displayName = user["display_name"].toString();
|
||||
const QString email = user["email"].toString();
|
||||
AppSettings::instance().setDisplayName(displayName);
|
||||
AppSettings::instance().setUserEmail(email);
|
||||
|
||||
statusBar()->showMessage(tr("Signed in as %1").arg(
|
||||
displayName.isEmpty() ? email : displayName));
|
||||
|
||||
m_library->refresh();
|
||||
}
|
||||
|
||||
void MainWindow::onLoginError(const QString &error)
|
||||
{
|
||||
statusBar()->showMessage(tr("Login failed: %1").arg(error), 6000);
|
||||
}
|
||||
|
||||
void MainWindow::onFavTracksLoaded(const QJsonObject &result)
|
||||
{
|
||||
m_content->showFavTracks(result);
|
||||
statusBar()->showMessage(tr("Favorite tracks loaded."), 3000);
|
||||
}
|
||||
|
||||
void MainWindow::onFavAlbumsLoaded(const QJsonObject &result)
|
||||
{
|
||||
// Show albums as a track list (each album as a row)
|
||||
// For now just show the raw tracks from each album
|
||||
// A proper album grid view would go here in a future iteration
|
||||
const QJsonArray albums = result["items"].toArray();
|
||||
statusBar()->showMessage(tr("%1 favorite albums").arg(albums.size()), 3000);
|
||||
// TODO: show album grid
|
||||
}
|
||||
|
||||
void MainWindow::onAlbumLoaded(const QJsonObject &album)
|
||||
{
|
||||
m_content->showAlbum(album);
|
||||
const QString title = album["title"].toString();
|
||||
statusBar()->showMessage(tr("Album: %1").arg(title), 3000);
|
||||
}
|
||||
|
||||
void MainWindow::onArtistLoaded(const QJsonObject &artist)
|
||||
{
|
||||
// TODO: show artist view with top tracks + discography
|
||||
statusBar()->showMessage(tr("Artist: %1").arg(artist["name"].toString()), 3000);
|
||||
}
|
||||
|
||||
void MainWindow::onPlaylistLoaded(const QJsonObject &playlist)
|
||||
{
|
||||
m_content->showPlaylist(playlist);
|
||||
const QString name = playlist["name"].toString();
|
||||
statusBar()->showMessage(tr("Playlist: %1").arg(name), 3000);
|
||||
}
|
||||
|
||||
void MainWindow::onPlayTrackRequested(qint64 trackId)
|
||||
{
|
||||
const int format = AppSettings::instance().preferredFormat();
|
||||
m_backend->playTrack(trackId, format);
|
||||
statusBar()->showMessage(tr("Loading track…"));
|
||||
}
|
||||
|
||||
void MainWindow::onSearchAlbumSelected(const QString &albumId)
|
||||
{
|
||||
m_backend->getAlbum(albumId);
|
||||
statusBar()->showMessage(tr("Loading album…"));
|
||||
}
|
||||
|
||||
void MainWindow::onSearchArtistSelected(qint64 artistId)
|
||||
{
|
||||
m_backend->getArtist(artistId);
|
||||
statusBar()->showMessage(tr("Loading artist…"));
|
||||
}
|
||||
|
||||
void MainWindow::onSearchToggled(bool visible)
|
||||
{
|
||||
m_sidePanel->setVisible(visible);
|
||||
}
|
||||
52
src/mainwindow.hpp
Normal file
52
src/mainwindow.hpp
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include "backend/qobuzbackend.hpp"
|
||||
#include "view/maintoolbar.hpp"
|
||||
#include "view/maincontent.hpp"
|
||||
#include "view/sidepanel/view.hpp"
|
||||
#include "list/library.hpp"
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QDockWidget>
|
||||
#include <QJsonObject>
|
||||
|
||||
class MainWindow : public QMainWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainWindow(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
|
||||
static QSize defaultSize() { return {1100, 700}; }
|
||||
|
||||
private slots:
|
||||
void onLoginSuccess(const QString &token, const QJsonObject &user);
|
||||
void onLoginError(const QString &error);
|
||||
|
||||
void onFavTracksLoaded(const QJsonObject &result);
|
||||
void onFavAlbumsLoaded(const QJsonObject &result);
|
||||
void onAlbumLoaded(const QJsonObject &album);
|
||||
void onArtistLoaded(const QJsonObject &artist);
|
||||
void onPlaylistLoaded(const QJsonObject &playlist);
|
||||
|
||||
void onPlayTrackRequested(qint64 trackId);
|
||||
void onSearchAlbumSelected(const QString &albumId);
|
||||
void onSearchArtistSelected(qint64 artistId);
|
||||
|
||||
void onSearchToggled(bool visible);
|
||||
|
||||
void showLoginDialog();
|
||||
void showSettingsDialog();
|
||||
|
||||
private:
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
MainToolBar *m_toolBar = nullptr;
|
||||
MainContent *m_content = nullptr;
|
||||
List::Library *m_library = nullptr;
|
||||
SidePanel::View *m_sidePanel = nullptr;
|
||||
|
||||
QDockWidget *m_libraryDock = nullptr;
|
||||
|
||||
void setupMenuBar();
|
||||
void tryRestoreSession();
|
||||
};
|
||||
113
src/model/tracklistmodel.cpp
Normal file
113
src/model/tracklistmodel.cpp
Normal file
@@ -0,0 +1,113 @@
|
||||
#include "tracklistmodel.hpp"
|
||||
|
||||
#include <QJsonValue>
|
||||
#include <QColor>
|
||||
|
||||
TrackListModel::TrackListModel(QObject *parent)
|
||||
: QAbstractTableModel(parent)
|
||||
{}
|
||||
|
||||
void TrackListModel::setTracks(const QJsonArray &tracks)
|
||||
{
|
||||
beginResetModel();
|
||||
m_tracks.clear();
|
||||
m_tracks.reserve(tracks.size());
|
||||
|
||||
for (const QJsonValue &v : tracks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
TrackItem item;
|
||||
item.id = static_cast<qint64>(t["id"].toDouble());
|
||||
item.number = t["track_number"].toInt();
|
||||
item.title = t["title"].toString();
|
||||
item.duration = static_cast<qint64>(t["duration"].toDouble());
|
||||
item.hiRes = t["hires_streamable"].toBool();
|
||||
item.streamable = t["streamable"].toBool();
|
||||
item.raw = t;
|
||||
|
||||
// Performer / artist
|
||||
const QJsonObject performer = t["performer"].toObject();
|
||||
item.artist = performer["name"].toString();
|
||||
if (item.artist.isEmpty()) {
|
||||
const QJsonObject album = t["album"].toObject();
|
||||
const QJsonObject artist = album["artist"].toObject();
|
||||
item.artist = artist["name"].toString();
|
||||
}
|
||||
|
||||
// Album
|
||||
const QJsonObject album = t["album"].toObject();
|
||||
item.album = album["title"].toString();
|
||||
item.albumId = album["id"].toString();
|
||||
|
||||
m_tracks.append(item);
|
||||
}
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void TrackListModel::clear()
|
||||
{
|
||||
beginResetModel();
|
||||
m_tracks.clear();
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
int TrackListModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid()) return 0;
|
||||
return m_tracks.size();
|
||||
}
|
||||
|
||||
int TrackListModel::columnCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid()) return 0;
|
||||
return ColCount;
|
||||
}
|
||||
|
||||
QVariant TrackListModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() >= m_tracks.size())
|
||||
return {};
|
||||
|
||||
const TrackItem &t = m_tracks.at(index.row());
|
||||
|
||||
if (role == Qt::DisplayRole) {
|
||||
switch (index.column()) {
|
||||
case ColNumber: return t.number > 0 ? QString::number(t.number) : QString();
|
||||
case ColTitle: return t.title;
|
||||
case ColArtist: return t.artist;
|
||||
case ColAlbum: return t.album;
|
||||
case ColDuration: return formatDuration(t.duration);
|
||||
}
|
||||
}
|
||||
|
||||
if (role == Qt::ForegroundRole && !t.streamable) {
|
||||
return QColor(Qt::gray);
|
||||
}
|
||||
|
||||
if (role == TrackIdRole) return t.id;
|
||||
if (role == TrackJsonRole) return t.raw;
|
||||
if (role == HiResRole) return t.hiRes;
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
QVariant TrackListModel::headerData(int section, Qt::Orientation orientation, int role) const
|
||||
{
|
||||
if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
|
||||
return {};
|
||||
|
||||
switch (section) {
|
||||
case ColNumber: return tr("#");
|
||||
case ColTitle: return tr("Title");
|
||||
case ColArtist: return tr("Artist");
|
||||
case ColAlbum: return tr("Album");
|
||||
case ColDuration: return tr("Duration");
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QString TrackListModel::formatDuration(qint64 secs)
|
||||
{
|
||||
const int m = static_cast<int>(secs / 60);
|
||||
const int s = static_cast<int>(secs % 60);
|
||||
return QStringLiteral("%1:%2").arg(m).arg(s, 2, 10, QLatin1Char('0'));
|
||||
}
|
||||
57
src/model/tracklistmodel.hpp
Normal file
57
src/model/tracklistmodel.hpp
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractTableModel>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QVector>
|
||||
|
||||
/// Flat data stored for each row in the track list.
|
||||
struct TrackItem {
|
||||
qint64 id = 0;
|
||||
int number = 0;
|
||||
QString title;
|
||||
QString artist;
|
||||
QString album;
|
||||
QString albumId;
|
||||
qint64 duration = 0; // seconds
|
||||
bool hiRes = false;
|
||||
bool streamable = false;
|
||||
QJsonObject raw; // full JSON for context menus / playback
|
||||
};
|
||||
|
||||
class TrackListModel : public QAbstractTableModel
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum Column {
|
||||
ColNumber = 0,
|
||||
ColTitle = 1,
|
||||
ColArtist = 2,
|
||||
ColAlbum = 3,
|
||||
ColDuration = 4,
|
||||
ColCount
|
||||
};
|
||||
|
||||
enum Role {
|
||||
TrackIdRole = Qt::UserRole + 1,
|
||||
TrackJsonRole = Qt::UserRole + 2,
|
||||
HiResRole = Qt::UserRole + 3,
|
||||
};
|
||||
|
||||
explicit TrackListModel(QObject *parent = nullptr);
|
||||
|
||||
void setTracks(const QJsonArray &tracks);
|
||||
void clear();
|
||||
|
||||
const TrackItem &trackAt(int row) const { return m_tracks.at(row); }
|
||||
int rowCount(const QModelIndex &parent = {}) const override;
|
||||
int columnCount(const QModelIndex &parent = {}) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||
|
||||
static QString formatDuration(qint64 secs);
|
||||
|
||||
private:
|
||||
QVector<TrackItem> m_tracks;
|
||||
};
|
||||
32
src/util/icon.hpp
Normal file
32
src/util/icon.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <QIcon>
|
||||
#include <QString>
|
||||
|
||||
namespace Icon
|
||||
{
|
||||
inline QIcon get(const QString &name)
|
||||
{
|
||||
// Try theme icon first, fall back to resource
|
||||
if (QIcon::hasThemeIcon(name))
|
||||
return QIcon::fromTheme(name);
|
||||
return QIcon(QStringLiteral(":/icons/%1.svg").arg(name));
|
||||
}
|
||||
|
||||
// Convenient aliases for common icons used throughout the app
|
||||
inline QIcon play() { return get("media-playback-start"); }
|
||||
inline QIcon pause() { return get("media-playback-pause"); }
|
||||
inline QIcon stop() { return get("media-playback-stop"); }
|
||||
inline QIcon next() { return get("media-skip-forward"); }
|
||||
inline QIcon previous() { return get("media-skip-backward"); }
|
||||
inline QIcon shuffle() { return get("media-playlist-shuffle"); }
|
||||
inline QIcon repeat() { return get("media-playlist-repeat"); }
|
||||
inline QIcon volumeHigh() { return get("audio-volume-high"); }
|
||||
inline QIcon volumeMid() { return get("audio-volume-medium"); }
|
||||
inline QIcon volumeMute() { return get("audio-volume-muted"); }
|
||||
inline QIcon search() { return get("edit-find"); }
|
||||
inline QIcon heart() { return get("emblem-favorite"); }
|
||||
inline QIcon album() { return get("media-optical"); }
|
||||
inline QIcon artist() { return get("system-users"); }
|
||||
inline QIcon playlist() { return get("view-list-symbolic"); }
|
||||
}
|
||||
37
src/util/settings.hpp
Normal file
37
src/util/settings.hpp
Normal file
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QSettings>
|
||||
|
||||
class AppSettings
|
||||
{
|
||||
public:
|
||||
static AppSettings &instance()
|
||||
{
|
||||
static AppSettings inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
QString authToken() const { return m_settings.value("auth/token").toString(); }
|
||||
void setAuthToken(const QString &t) { m_settings.setValue("auth/token", t); }
|
||||
|
||||
QString userEmail() const { return m_settings.value("auth/email").toString(); }
|
||||
void setUserEmail(const QString &e) { m_settings.setValue("auth/email", e); }
|
||||
|
||||
QString displayName() const { return m_settings.value("user/display_name").toString(); }
|
||||
void setDisplayName(const QString &n) { m_settings.setValue("user/display_name", n); }
|
||||
|
||||
// 5 = MP3, 6 = CD, 7 = HiRes96, 27 = HiRes192
|
||||
int preferredFormat() const { return m_settings.value("playback/format", 6).toInt(); }
|
||||
void setPreferredFormat(int f) { m_settings.setValue("playback/format", f); }
|
||||
|
||||
int volume() const { return m_settings.value("playback/volume", 80).toInt(); }
|
||||
void setVolume(int v) { m_settings.setValue("playback/volume", v); }
|
||||
|
||||
bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); }
|
||||
void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); }
|
||||
|
||||
private:
|
||||
AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {}
|
||||
QSettings m_settings;
|
||||
};
|
||||
58
src/view/maincontent.cpp
Normal file
58
src/view/maincontent.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#include "maincontent.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QJsonArray>
|
||||
|
||||
MainContent::MainContent(QobuzBackend *backend, QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_backend(backend)
|
||||
{
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
m_stack = new QStackedWidget(this);
|
||||
layout->addWidget(m_stack);
|
||||
|
||||
m_welcome = new QLabel(
|
||||
tr("<h2>Welcome to Qobuz</h2>"
|
||||
"<p>Select something from the library on the left to get started.</p>"),
|
||||
this);
|
||||
m_welcome->setAlignment(Qt::AlignCenter);
|
||||
|
||||
m_tracks = new List::Tracks(m_backend, this);
|
||||
|
||||
m_stack->addWidget(m_welcome); // index 0
|
||||
m_stack->addWidget(m_tracks); // index 1
|
||||
|
||||
m_stack->setCurrentIndex(0);
|
||||
}
|
||||
|
||||
void MainContent::showWelcome()
|
||||
{
|
||||
m_stack->setCurrentIndex(0);
|
||||
}
|
||||
|
||||
void MainContent::showAlbum(const QJsonObject &album)
|
||||
{
|
||||
m_tracks->loadAlbum(album);
|
||||
m_stack->setCurrentIndex(1);
|
||||
}
|
||||
|
||||
void MainContent::showPlaylist(const QJsonObject &playlist)
|
||||
{
|
||||
m_tracks->loadPlaylist(playlist);
|
||||
m_stack->setCurrentIndex(1);
|
||||
}
|
||||
|
||||
void MainContent::showFavTracks(const QJsonObject &result)
|
||||
{
|
||||
const QJsonArray items = result["items"].toArray();
|
||||
m_tracks->loadTracks(items);
|
||||
m_stack->setCurrentIndex(1);
|
||||
}
|
||||
|
||||
void MainContent::showSearchTracks(const QJsonArray &tracks)
|
||||
{
|
||||
m_tracks->loadSearchTracks(tracks);
|
||||
m_stack->setCurrentIndex(1);
|
||||
}
|
||||
34
src/view/maincontent.hpp
Normal file
34
src/view/maincontent.hpp
Normal file
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "../list/tracks.hpp"
|
||||
#include "../backend/qobuzbackend.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QLabel>
|
||||
#include <QStackedWidget>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
/// Central content widget — mirrors MainContent from spotify-qt.
|
||||
/// Displays either a track list, an album cover + tracks, or a search result.
|
||||
class MainContent : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainContent(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
|
||||
List::Tracks *tracksList() const { return m_tracks; }
|
||||
|
||||
void showWelcome();
|
||||
void showAlbum(const QJsonObject &album);
|
||||
void showPlaylist(const QJsonObject &playlist);
|
||||
void showFavTracks(const QJsonObject &result);
|
||||
void showSearchTracks(const QJsonArray &tracks);
|
||||
|
||||
private:
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
QStackedWidget *m_stack = nullptr;
|
||||
QLabel *m_welcome = nullptr;
|
||||
List::Tracks *m_tracks = nullptr;
|
||||
};
|
||||
173
src/view/maintoolbar.cpp
Normal file
173
src/view/maintoolbar.cpp
Normal file
@@ -0,0 +1,173 @@
|
||||
#include "maintoolbar.hpp"
|
||||
#include "../util/settings.hpp"
|
||||
#include "../model/tracklistmodel.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QHBoxLayout>
|
||||
|
||||
MainToolBar::MainToolBar(QobuzBackend *backend, QWidget *parent)
|
||||
: QToolBar(parent)
|
||||
, m_backend(backend)
|
||||
{
|
||||
setMovable(false);
|
||||
setFloatable(false);
|
||||
setContextMenuPolicy(Qt::PreventContextMenu);
|
||||
|
||||
// Previous
|
||||
m_previous = addAction(Icon::previous(), tr("Previous"));
|
||||
connect(m_previous, &QAction::triggered, this, &MainToolBar::onPrevious);
|
||||
|
||||
// Play/Pause
|
||||
m_playPause = addAction(Icon::play(), tr("Play"));
|
||||
m_playPause->setCheckable(false);
|
||||
connect(m_playPause, &QAction::triggered, this, [this](bool) { onPlayPause(false); });
|
||||
|
||||
// Next
|
||||
m_next = addAction(Icon::next(), tr("Next"));
|
||||
connect(m_next, &QAction::triggered, this, &MainToolBar::onNext);
|
||||
|
||||
addSeparator();
|
||||
|
||||
// Track label
|
||||
m_trackLabel = new QLabel(tr("Not playing"), this);
|
||||
m_trackLabel->setMinimumWidth(200);
|
||||
m_trackLabel->setMaximumWidth(320);
|
||||
addWidget(m_trackLabel);
|
||||
|
||||
addSeparator();
|
||||
|
||||
// Progress slider
|
||||
m_progress = new ClickableSlider(Qt::Horizontal, this);
|
||||
m_progress->setRange(0, 1000);
|
||||
m_progress->setValue(0);
|
||||
m_progress->setMinimumWidth(180);
|
||||
m_progress->setMaximumWidth(400);
|
||||
addWidget(m_progress);
|
||||
|
||||
connect(m_progress, &QSlider::sliderPressed, this, [this] { m_seeking = true; });
|
||||
connect(m_progress, &QSlider::sliderReleased, this, &MainToolBar::onProgressReleased);
|
||||
|
||||
// Time label
|
||||
m_timeLabel = new QLabel("0:00 / 0:00", this);
|
||||
addWidget(m_timeLabel);
|
||||
|
||||
addSeparator();
|
||||
|
||||
// Volume button
|
||||
m_volume = new VolumeButton(this);
|
||||
m_volume->setValue(AppSettings::instance().volume());
|
||||
addWidget(m_volume);
|
||||
connect(m_volume, &VolumeButton::volumeChanged, this, &MainToolBar::onVolumeChanged);
|
||||
|
||||
addSeparator();
|
||||
|
||||
// Search toggle
|
||||
m_search = addAction(Icon::search(), tr("Search"));
|
||||
m_search->setCheckable(true);
|
||||
connect(m_search, &QAction::toggled, this, &MainToolBar::searchToggled);
|
||||
|
||||
// Connect to backend signals
|
||||
connect(m_backend, &QobuzBackend::stateChanged, this, &MainToolBar::onBackendStateChanged);
|
||||
connect(m_backend, &QobuzBackend::trackChanged, this, &MainToolBar::onTrackChanged);
|
||||
connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged);
|
||||
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
|
||||
}
|
||||
|
||||
void MainToolBar::setPlaying(bool playing)
|
||||
{
|
||||
m_playing = playing;
|
||||
m_playPause->setIcon(playing ? Icon::pause() : Icon::play());
|
||||
m_playPause->setText(playing ? tr("Pause") : tr("Play"));
|
||||
}
|
||||
|
||||
void MainToolBar::setCurrentTrack(const QJsonObject &track)
|
||||
{
|
||||
const QString title = track["title"].toString();
|
||||
const QJsonObject performer = track["performer"].toObject();
|
||||
const QString artist = performer["name"].toString();
|
||||
|
||||
if (title.isEmpty()) {
|
||||
m_trackLabel->setText(tr("Not playing"));
|
||||
} else if (artist.isEmpty()) {
|
||||
m_trackLabel->setText(title);
|
||||
} else {
|
||||
m_trackLabel->setText(QStringLiteral("%1 — %2").arg(artist, title));
|
||||
}
|
||||
}
|
||||
|
||||
void MainToolBar::updateProgress(quint64 position, quint64 duration)
|
||||
{
|
||||
if (m_seeking) return;
|
||||
|
||||
const int sliderPos = duration > 0
|
||||
? static_cast<int>(position * 1000 / duration)
|
||||
: 0;
|
||||
m_progress->blockSignals(true);
|
||||
m_progress->setValue(sliderPos);
|
||||
m_progress->blockSignals(false);
|
||||
|
||||
const QString pos = TrackListModel::formatDuration(static_cast<qint64>(position));
|
||||
const QString dur = TrackListModel::formatDuration(static_cast<qint64>(duration));
|
||||
m_timeLabel->setText(QStringLiteral("%1 / %2").arg(pos, dur));
|
||||
}
|
||||
|
||||
void MainToolBar::setVolume(int volume)
|
||||
{
|
||||
m_volume->setValue(volume);
|
||||
}
|
||||
|
||||
// --- private slots ---
|
||||
|
||||
void MainToolBar::onPlayPause(bool)
|
||||
{
|
||||
if (m_playing) {
|
||||
m_backend->pause();
|
||||
} else {
|
||||
m_backend->resume();
|
||||
}
|
||||
}
|
||||
|
||||
void MainToolBar::onPrevious()
|
||||
{
|
||||
// TODO: wire up to a queue / playlist
|
||||
}
|
||||
|
||||
void MainToolBar::onNext()
|
||||
{
|
||||
// TODO: wire up to a queue / playlist
|
||||
}
|
||||
|
||||
void MainToolBar::onProgressReleased()
|
||||
{
|
||||
m_seeking = false;
|
||||
// Seek is not yet implemented in the Rust player, but we can at least
|
||||
// update the displayed position optimistically.
|
||||
}
|
||||
|
||||
void MainToolBar::onVolumeChanged(int volume)
|
||||
{
|
||||
m_backend->setVolume(volume);
|
||||
AppSettings::instance().setVolume(volume);
|
||||
}
|
||||
|
||||
void MainToolBar::onBackendStateChanged(const QString &state)
|
||||
{
|
||||
setPlaying(state == QStringLiteral("playing"));
|
||||
}
|
||||
|
||||
void MainToolBar::onTrackChanged(const QJsonObject &track)
|
||||
{
|
||||
setCurrentTrack(track);
|
||||
}
|
||||
|
||||
void MainToolBar::onPositionChanged(quint64 position, quint64 duration)
|
||||
{
|
||||
updateProgress(position, duration);
|
||||
}
|
||||
|
||||
void MainToolBar::onTrackFinished()
|
||||
{
|
||||
setPlaying(false);
|
||||
m_progress->setValue(0);
|
||||
m_timeLabel->setText("0:00 / 0:00");
|
||||
}
|
||||
63
src/view/maintoolbar.hpp
Normal file
63
src/view/maintoolbar.hpp
Normal file
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include "../backend/qobuzbackend.hpp"
|
||||
#include "../widget/volumebutton.hpp"
|
||||
#include "../widget/clickableslider.hpp"
|
||||
#include "../util/icon.hpp"
|
||||
|
||||
#include <QToolBar>
|
||||
#include <QToolButton>
|
||||
#include <QLabel>
|
||||
#include <QAction>
|
||||
#include <QJsonObject>
|
||||
|
||||
/// Main playback toolbar — mirrors MainToolBar from spotify-qt.
|
||||
class MainToolBar : public QToolBar
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainToolBar(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
|
||||
void setPlaying(bool playing);
|
||||
void setCurrentTrack(const QJsonObject &track);
|
||||
void updateProgress(quint64 position, quint64 duration);
|
||||
void setVolume(int volume);
|
||||
|
||||
signals:
|
||||
void searchToggled(bool visible);
|
||||
|
||||
private slots:
|
||||
void onPlayPause(bool checked);
|
||||
void onPrevious();
|
||||
void onNext();
|
||||
void onProgressReleased();
|
||||
void onVolumeChanged(int volume);
|
||||
|
||||
void onBackendStateChanged(const QString &state);
|
||||
void onTrackChanged(const QJsonObject &track);
|
||||
void onPositionChanged(quint64 position, quint64 duration);
|
||||
void onTrackFinished();
|
||||
|
||||
private:
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
|
||||
QAction *m_previous = nullptr;
|
||||
QAction *m_playPause = nullptr;
|
||||
QAction *m_next = nullptr;
|
||||
QAction *m_search = nullptr;
|
||||
|
||||
ClickableSlider *m_progress = nullptr;
|
||||
QLabel *m_timeLabel = nullptr;
|
||||
QLabel *m_trackLabel = nullptr;
|
||||
VolumeButton *m_volume = nullptr;
|
||||
|
||||
bool m_playing = false;
|
||||
bool m_seeking = false;
|
||||
|
||||
// Playback queue (track IDs) for next/prev
|
||||
QVector<qint64> m_queue;
|
||||
int m_queueIdx = -1;
|
||||
|
||||
void addSpacerWidget();
|
||||
};
|
||||
139
src/view/sidepanel/view.cpp
Normal file
139
src/view/sidepanel/view.cpp
Normal file
@@ -0,0 +1,139 @@
|
||||
#include "view.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPushButton>
|
||||
#include <QJsonArray>
|
||||
|
||||
static constexpr int IdRole = Qt::UserRole + 1;
|
||||
static constexpr int TypeRole = Qt::UserRole + 2;
|
||||
|
||||
namespace SidePanel
|
||||
{
|
||||
|
||||
// ---- SearchTab ----
|
||||
|
||||
SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, m_backend(backend)
|
||||
{
|
||||
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_trackResults = new QTreeWidget(this);
|
||||
m_trackResults->setHeaderLabels({tr("Title"), tr("Artist"), tr("Album")});
|
||||
m_trackResults->setRootIsDecorated(false);
|
||||
|
||||
m_albumResults = new QTreeWidget(this);
|
||||
m_albumResults->setHeaderLabels({tr("Album"), tr("Artist")});
|
||||
m_albumResults->setRootIsDecorated(false);
|
||||
|
||||
m_artistResults = new QTreeWidget(this);
|
||||
m_artistResults->setHeaderLabels({tr("Artist")});
|
||||
m_artistResults->setRootIsDecorated(false);
|
||||
|
||||
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_trackResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
|
||||
connect(m_albumResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
|
||||
connect(m_artistResults, &QTreeWidget::itemDoubleClicked, this, &SearchTab::onItemDoubleClicked);
|
||||
}
|
||||
|
||||
void SearchTab::onSearchSubmit()
|
||||
{
|
||||
const QString q = m_searchBox->text().trimmed();
|
||||
if (!q.isEmpty())
|
||||
m_backend->search(q, 0, 20);
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
// Populate albums
|
||||
m_albumResults->clear();
|
||||
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();
|
||||
auto *item = new QTreeWidgetItem(m_albumResults,
|
||||
QStringList{a["title"].toString(), artist});
|
||||
item->setData(0, IdRole, a["id"].toString());
|
||||
item->setData(0, TypeRole, QStringLiteral("album"));
|
||||
}
|
||||
|
||||
// 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(0, IdRole).toString());
|
||||
} else if (type == QStringLiteral("artist")) {
|
||||
emit artistSelected(item->data(0, IdRole).toLongLong());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- View ----
|
||||
|
||||
View::View(QobuzBackend *backend, QWidget *parent)
|
||||
: QDockWidget(tr("Search"), parent)
|
||||
{
|
||||
setObjectName(QStringLiteral("searchPanel"));
|
||||
setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetClosable);
|
||||
|
||||
m_search = new SearchTab(backend, 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);
|
||||
}
|
||||
|
||||
} // namespace SidePanel
|
||||
55
src/view/sidepanel/view.hpp
Normal file
55
src/view/sidepanel/view.hpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../backend/qobuzbackend.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QDockWidget>
|
||||
#include <QTabWidget>
|
||||
#include <QLineEdit>
|
||||
#include <QTreeWidget>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace SidePanel
|
||||
{
|
||||
class SearchTab : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit SearchTab(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void albumSelected(const QString &albumId);
|
||||
void artistSelected(qint64 artistId);
|
||||
void trackPlayRequested(qint64 trackId);
|
||||
|
||||
private slots:
|
||||
void onSearchResult(const QJsonObject &result);
|
||||
void onSearchSubmit();
|
||||
void onItemDoubleClicked(QTreeWidgetItem *item, int column);
|
||||
|
||||
private:
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
QLineEdit *m_searchBox = nullptr;
|
||||
QTabWidget *m_resultTabs = nullptr;
|
||||
QTreeWidget *m_trackResults = nullptr;
|
||||
QTreeWidget *m_albumResults = nullptr;
|
||||
QTreeWidget *m_artistResults = nullptr;
|
||||
};
|
||||
|
||||
class View : public QDockWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit View(QobuzBackend *backend, QWidget *parent = nullptr);
|
||||
|
||||
SearchTab *searchTab() const { return m_search; }
|
||||
|
||||
signals:
|
||||
void albumSelected(const QString &albumId);
|
||||
void artistSelected(qint64 artistId);
|
||||
void trackPlayRequested(qint64 trackId);
|
||||
|
||||
private:
|
||||
SearchTab *m_search = nullptr;
|
||||
};
|
||||
}
|
||||
32
src/widget/clickableslider.hpp
Normal file
32
src/widget/clickableslider.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <QSlider>
|
||||
#include <QMouseEvent>
|
||||
|
||||
/// A QSlider that jumps to the clicked position instead of only moving one step.
|
||||
class ClickableSlider : public QSlider
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ClickableSlider(Qt::Orientation orientation, QWidget *parent = nullptr)
|
||||
: QSlider(orientation, parent) {}
|
||||
|
||||
protected:
|
||||
void mousePressEvent(QMouseEvent *event) override
|
||||
{
|
||||
if (event->button() == Qt::LeftButton) {
|
||||
int val;
|
||||
if (orientation() == Qt::Horizontal) {
|
||||
val = minimum() + (maximum() - minimum()) * event->pos().x() / width();
|
||||
} else {
|
||||
val = minimum() + (maximum() - minimum()) * (height() - event->pos().y()) / height();
|
||||
}
|
||||
setValue(val);
|
||||
emit sliderMoved(val);
|
||||
event->accept();
|
||||
} else {
|
||||
QSlider::mousePressEvent(event);
|
||||
}
|
||||
}
|
||||
};
|
||||
75
src/widget/volumebutton.hpp
Normal file
75
src/widget/volumebutton.hpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include "clickableslider.hpp"
|
||||
#include "../util/icon.hpp"
|
||||
|
||||
#include <QToolButton>
|
||||
#include <QWidgetAction>
|
||||
#include <QMenu>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
/// A toolbar button that shows a volume slider popup when clicked.
|
||||
class VolumeButton : public QToolButton
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit VolumeButton(QWidget *parent = nullptr) : QToolButton(parent)
|
||||
{
|
||||
setPopupMode(QToolButton::InstantPopup);
|
||||
setIcon(Icon::volumeHigh());
|
||||
|
||||
auto *menu = new QMenu(this);
|
||||
auto *widget = new QWidget(menu);
|
||||
auto *layout = new QVBoxLayout(widget);
|
||||
layout->setContentsMargins(6, 6, 6, 6);
|
||||
|
||||
m_label = new QLabel("80%", widget);
|
||||
m_label->setAlignment(Qt::AlignCenter);
|
||||
|
||||
m_slider = new ClickableSlider(Qt::Vertical, widget);
|
||||
m_slider->setRange(0, 100);
|
||||
m_slider->setValue(80);
|
||||
m_slider->setFixedHeight(120);
|
||||
|
||||
layout->addWidget(m_label);
|
||||
layout->addWidget(m_slider);
|
||||
|
||||
auto *action = new QWidgetAction(menu);
|
||||
action->setDefaultWidget(widget);
|
||||
menu->addAction(action);
|
||||
setMenu(menu);
|
||||
|
||||
connect(m_slider, &QSlider::valueChanged, this, [this](int v) {
|
||||
m_label->setText(QString::number(v) + "%");
|
||||
updateIcon(v);
|
||||
emit volumeChanged(v);
|
||||
});
|
||||
}
|
||||
|
||||
int value() const { return m_slider->value(); }
|
||||
|
||||
void setValue(int v)
|
||||
{
|
||||
m_slider->blockSignals(true);
|
||||
m_slider->setValue(v);
|
||||
m_slider->blockSignals(false);
|
||||
m_label->setText(QString::number(v) + "%");
|
||||
updateIcon(v);
|
||||
}
|
||||
|
||||
signals:
|
||||
void volumeChanged(int volume);
|
||||
|
||||
private:
|
||||
ClickableSlider *m_slider = nullptr;
|
||||
QLabel *m_label = nullptr;
|
||||
|
||||
void updateIcon(int v)
|
||||
{
|
||||
if (v == 0) setIcon(Icon::volumeMute());
|
||||
else if (v < 50) setIcon(Icon::volumeMid());
|
||||
else setIcon(Icon::volumeHigh());
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user