feat: initial qobuz-qt source

Lightweight Qt6 desktop client for Qobuz with a Rust audio backend
(Symphonia/CPAL via staticlib FFI). Mirrors the spotify-qt layout:
toolbar with playback controls, library/context docks on the left,
tabbed search side panel on the right, queue panel, now-playing dock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-24 00:41:04 +01:00
parent 35ae649fc9
commit cb2323bc32
85 changed files with 4484 additions and 249 deletions

View File

@@ -2,45 +2,63 @@
#include <QJsonValue>
#include <QColor>
#include <QIcon>
#include <algorithm>
TrackListModel::TrackListModel(QObject *parent)
: QAbstractTableModel(parent)
{}
void TrackListModel::setTracks(const QJsonArray &tracks)
void TrackListModel::setTracks(const QJsonArray &tracks,
bool usePosition,
bool useSequential)
{
beginResetModel();
m_tracks.clear();
m_tracks.reserve(tracks.size());
int seq = 1;
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.streamable = t["streamable"].toBool(true);
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();
if (useSequential) {
item.number = seq++;
} else if (usePosition) {
const int pos = t["position"].toInt();
item.number = pos > 0 ? pos : seq;
++seq;
} else {
item.number = t["track_number"].toInt();
}
// Album
const QJsonObject performer = t["performer"].toObject();
item.artist = performer["name"].toString();
if (item.artist.isEmpty())
item.artist = t["album"].toObject()["artist"].toObject()["name"].toString();
const QJsonObject album = t["album"].toObject();
item.album = album["title"].toString();
item.album = album["title"].toString();
item.albumId = album["id"].toString();
m_tracks.append(item);
}
// Re-apply sort silently inside the reset (no layout signals needed here)
if (m_sortColumn >= 0)
sortData(m_sortColumn, m_sortOrder);
endResetModel();
// Tell external listeners the sorted order is ready (e.g. PlayQueue sync)
if (m_sortColumn >= 0)
emit sortApplied();
}
void TrackListModel::clear()
@@ -50,16 +68,22 @@ void TrackListModel::clear()
endResetModel();
}
void TrackListModel::setPlayingId(qint64 id)
{
m_playingId = id;
if (!m_tracks.isEmpty())
emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1),
{Qt::FontRole, Qt::DecorationRole});
}
int TrackListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) return 0;
return m_tracks.size();
return parent.isValid() ? 0 : m_tracks.size();
}
int TrackListModel::columnCount(const QModelIndex &parent) const
{
if (parent.isValid()) return 0;
return ColCount;
return parent.isValid() ? 0 : ColCount;
}
QVariant TrackListModel::data(const QModelIndex &index, int role) const
@@ -68,6 +92,7 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
return {};
const TrackItem &t = m_tracks.at(index.row());
const bool isPlaying = (t.id == m_playingId && m_playingId != 0);
if (role == Qt::DisplayRole) {
switch (index.column()) {
@@ -79,8 +104,19 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
}
}
if (role == Qt::ForegroundRole && !t.streamable) {
return QColor(Qt::gray);
if (role == Qt::FontRole && isPlaying) {
QFont f;
f.setBold(true);
return f;
}
if (role == Qt::ForegroundRole) {
if (!t.streamable) return QColor(Qt::gray);
if (isPlaying) return QColor(0x1d, 0xb9, 0x54); // Qobuz green
}
if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) {
return QIcon(QStringLiteral(":/res/icons/media-track-show-active.svg"));
}
if (role == TrackIdRole) return t.id;
@@ -92,19 +128,56 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
QVariant TrackListModel::headerData(int section, Qt::Orientation orientation, int role) const
{
if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
return {};
if (orientation != Qt::Horizontal) 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");
if (role == Qt::DisplayRole) {
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");
}
}
if (role == Qt::DecorationRole && section == m_sortColumn) {
return QIcon(QStringLiteral(":/res/icons/view-sort-ascending.svg"));
}
return {};
}
void TrackListModel::sortData(int column, Qt::SortOrder order)
{
auto cmp = [&](const TrackItem &a, const TrackItem &b) -> bool {
bool less = false;
switch (column) {
case ColNumber: less = a.number < b.number; break;
case ColTitle: less = a.title < b.title; break;
case ColArtist: less = a.artist < b.artist; break;
case ColAlbum: less = a.album < b.album; break;
case ColDuration: less = a.duration < b.duration; break;
default: less = false;
}
return order == Qt::AscendingOrder ? less : !less;
};
std::stable_sort(m_tracks.begin(), m_tracks.end(), cmp);
}
void TrackListModel::sort(int column, Qt::SortOrder order)
{
m_sortColumn = column;
m_sortOrder = order;
if (m_tracks.isEmpty()) return;
emit layoutAboutToBeChanged();
sortData(column, order);
emit layoutChanged();
emit sortApplied();
}
QString TrackListModel::formatDuration(qint64 secs)
{
const int m = static_cast<int>(secs / 60);