feat: playlist management, gapless playback, ReplayGain, Qobuz theme

Playlist management:
- Add/remove tracks from playlists via right-click context menu
- Create new playlists (right-click Playlists sidebar header)
- Delete playlists with confirmation dialog (right-click playlist item)
- Playlist view removes track immediately on delete (optimistic)
- Deleting currently-open playlist clears the track view

Gapless playback:
- Single long-running audio thread owns AudioOutput; CPAL stream stays
  open between tracks eliminating device teardown/startup gap
- Decode runs inline on the audio thread; command channel polled via
  try_recv() so Pause/Resume/Seek/Stop/Play all work without spawning
- New Play command arriving mid-decode is handled immediately,
  reusing the same audio output for zero-gap transition
- Position timer reduced from 500 ms to 50 ms for faster track-end detection
- URL/metadata prefetch: when gapless is enabled Qt pre-fetches the next
  track while the current one is still playing

ReplayGain:
- Toggled in Settings → Playback
- replaygain_track_gain (dB) from track audio_info converted to linear
  gain factor and applied per-sample alongside volume

Qobuz dark theme:
- Background #191919, base #141414, accent #FFB232 (yellow-orange)
- Selection highlight, slider fill, scrollbar hover all use #FFB232
- Links use Qobuz blue #46B3EE
- Hi-res H badges updated to #FFB232 (from #FFD700)
- Now-playing row uses #FFB232 (was Spotify green)
- QSS stylesheet for scrollbars, menus, inputs, buttons, groups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-24 11:19:32 +01:00
parent c8086e1f2f
commit f30e2058c1
24 changed files with 757 additions and 135 deletions

View File

@@ -3,6 +3,9 @@
#include <QHeaderView>
#include <QJsonArray>
#include <QJsonObject>
#include <QInputDialog>
#include <QMenu>
#include <QMessageBox>
namespace List
{
@@ -24,16 +27,29 @@ Library::Library(QobuzBackend *backend, QWidget *parent)
{
setHeaderHidden(true);
setRootIsDecorated(true);
setContextMenuPolicy(Qt::NoContextMenu);
setContextMenuPolicy(Qt::CustomContextMenu);
buildStaticNodes();
connect(m_backend, &QobuzBackend::userPlaylistsLoaded,
this, &Library::onUserPlaylistsLoaded);
connect(m_backend, &QobuzBackend::playlistCreated,
this, [this](const QJsonObject &) { refresh(); });
connect(m_backend, &QobuzBackend::playlistDeleted,
this, [this](const QJsonObject &result) {
const qint64 deletedId = static_cast<qint64>(result["playlist_id"].toDouble());
if (deletedId == m_openPlaylistId) {
m_openPlaylistId = 0;
emit openPlaylistDeleted();
}
refresh();
});
connect(this, &QTreeWidget::itemClicked,
this, &Library::onItemClicked);
connect(this, &QTreeWidget::itemDoubleClicked,
this, &Library::onItemDoubleClicked);
connect(this, &QTreeWidget::customContextMenuRequested,
this, &Library::onContextMenuRequested);
}
void Library::buildStaticNodes()
@@ -70,6 +86,7 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
while (m_playlistsNode->childCount() > 0)
delete m_playlistsNode->takeChild(0);
QVector<QPair<qint64, QString>> playlists;
const QJsonArray items = result["items"].toArray();
for (const auto &v : items) {
const QJsonObject pl = v.toObject();
@@ -80,7 +97,55 @@ void Library::onUserPlaylistsLoaded(const QJsonObject &result)
item->setData(0, TypeRole, NodePlaylist);
item->setData(0, IdRole, id);
item->setData(0, NameRole, name);
playlists.append({id, name});
}
emit userPlaylistsChanged(playlists);
}
void Library::onContextMenuRequested(const QPoint &pos)
{
QTreeWidgetItem *item = itemAt(pos);
if (!item) return;
const bool isHeader = (item == m_playlistsNode);
const bool isPlaylistItem = (!isHeader && item->parent() == m_playlistsNode &&
item->data(0, TypeRole).toInt() == NodePlaylist);
if (!isHeader && !isPlaylistItem) return;
QMenu menu(this);
auto *newPl = menu.addAction(tr("New Playlist…"));
connect(newPl, &QAction::triggered, this, [this] {
bool ok = false;
const QString name = QInputDialog::getText(
this, tr("New Playlist"), tr("Playlist name:"),
QLineEdit::Normal, QString(), &ok);
if (ok && !name.trimmed().isEmpty())
m_backend->createPlaylist(name.trimmed());
});
if (isPlaylistItem) {
const qint64 plId = item->data(0, IdRole).toLongLong();
const QString plName = item->data(0, NameRole).toString();
menu.addSeparator();
auto *delPl = menu.addAction(tr("Delete \"%1\"").arg(plName));
connect(delPl, &QAction::triggered, this, [this, plId, plName] {
const auto answer = QMessageBox::question(
this,
tr("Delete Playlist"),
tr("Permanently delete \"%1\"? This cannot be undone.").arg(plName),
QMessageBox::Yes | QMessageBox::Cancel,
QMessageBox::Cancel);
if (answer == QMessageBox::Yes)
m_backend->deletePlaylist(plId);
});
}
menu.exec(viewport()->mapToGlobal(pos));
}
void Library::onItemClicked(QTreeWidgetItem *item, int)
@@ -95,6 +160,7 @@ void Library::onItemClicked(QTreeWidgetItem *item, int)
case NodePlaylist: {
const qint64 id = item->data(0, IdRole).toLongLong();
const QString name = item->data(0, NameRole).toString();
m_openPlaylistId = id;
emit playlistRequested(id, name);
break;
}

View File

@@ -3,6 +3,9 @@
#include "../backend/qobuzbackend.hpp"
#include <QTreeWidget>
#include <QVector>
#include <QPair>
#include <QString>
namespace List
{
@@ -23,17 +26,23 @@ namespace List
void favAlbumsRequested();
void favArtistsRequested();
void playlistRequested(qint64 playlistId, const QString &name);
/// Emitted after playlists are loaded so others can cache the list.
void userPlaylistsChanged(const QVector<QPair<qint64, QString>> &playlists);
/// Emitted when the currently open playlist was deleted.
void openPlaylistDeleted();
private slots:
void onUserPlaylistsLoaded(const QJsonObject &result);
void onItemClicked(QTreeWidgetItem *item, int column);
void onItemDoubleClicked(QTreeWidgetItem *item, int column);
void onContextMenuRequested(const QPoint &pos);
private:
QobuzBackend *m_backend = nullptr;
QTreeWidgetItem *m_myLibNode = nullptr;
QTreeWidgetItem *m_playlistsNode = nullptr;
qint64 m_openPlaylistId = 0;
void buildStaticNodes();
};

View File

@@ -39,26 +39,41 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
void Tracks::loadTracks(const QJsonArray &tracks)
{
setPlaylistContext(0);
m_model->setTracks(tracks, false, /*useSequential=*/true);
}
void Tracks::loadAlbum(const QJsonObject &album)
{
setPlaylistContext(0);
const QJsonArray items = album["tracks"].toObject()["items"].toArray();
m_model->setTracks(items); // album: use track_number
}
void Tracks::loadPlaylist(const QJsonObject &playlist)
{
const qint64 id = static_cast<qint64>(playlist["id"].toDouble());
setPlaylistContext(id);
const QJsonArray items = playlist["tracks"].toObject()["items"].toArray();
m_model->setTracks(items, /*usePosition=*/true);
}
void Tracks::loadSearchTracks(const QJsonArray &tracks)
{
setPlaylistContext(0);
m_model->setTracks(tracks, false, /*useSequential=*/true);
}
void Tracks::setPlaylistContext(qint64 playlistId)
{
m_playlistId = playlistId;
}
void Tracks::setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists)
{
m_userPlaylists = playlists;
}
void Tracks::setPlayingTrackId(qint64 id)
{
m_model->setPlayingId(id);
@@ -134,6 +149,36 @@ void Tracks::onContextMenu(const QPoint &pos)
});
}
// Playlist management
if (!m_userPlaylists.isEmpty()) {
menu.addSeparator();
auto *addToPlMenu = menu.addMenu(
QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to playlist"));
for (const auto &pl : m_userPlaylists) {
const qint64 plId = pl.first;
const QString plName = pl.second;
auto *act = addToPlMenu->addAction(plName);
connect(act, &QAction::triggered, this, [this, id, plId] {
emit addToPlaylistRequested(id, plId);
});
}
}
if (m_playlistId > 0) {
const qint64 playlistTrackId =
m_model->data(index, TrackListModel::PlaylistTrackIdRole).toLongLong();
if (playlistTrackId > 0) {
if (m_userPlaylists.isEmpty()) menu.addSeparator();
auto *remFromPl = menu.addAction(tr("Remove from this playlist"));
const qint64 curPlaylistId = m_playlistId;
const int curRow = index.row();
connect(remFromPl, &QAction::triggered, this, [this, curPlaylistId, playlistTrackId, curRow] {
emit removeFromPlaylistRequested(curPlaylistId, playlistTrackId);
m_model->removeTrack(curRow); // optimistic: remove immediately from view
});
}
}
menu.exec(viewport()->mapToGlobal(pos));
}

View File

@@ -7,6 +7,9 @@
#include <QTreeView>
#include <QJsonArray>
#include <QJsonObject>
#include <QVector>
#include <QPair>
#include <QString>
namespace List
{
@@ -25,13 +28,22 @@ namespace List
/// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted.
void setPlayingTrackId(qint64 id);
/// Set which playlist is currently displayed (0 = none).
void setPlaylistContext(qint64 playlistId);
/// Provide the user's playlist list for the "Add to playlist" submenu.
void setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists);
signals:
void playTrackRequested(qint64 trackId);
void addToPlaylistRequested(qint64 trackId, qint64 playlistId);
void removeFromPlaylistRequested(qint64 playlistId, qint64 playlistTrackId);
private:
TrackListModel *m_model = nullptr;
QobuzBackend *m_backend = nullptr;
PlayQueue *m_queue = nullptr;
qint64 m_playlistId = 0;
QVector<QPair<qint64, QString>> m_userPlaylists;
void onDoubleClicked(const QModelIndex &index);
void onContextMenu(const QPoint &pos);