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;
}