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

View 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'));
}