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

@@ -11,27 +11,67 @@ int main(int argc, char *argv[])
app.setOrganizationName(QStringLiteral("qobuz-qt"));
app.setApplicationVersion(QStringLiteral("0.1.0"));
// Dark palette consistent with spotify-qt style
// Qobuz dark palette
// Accent: #FFB232 (yellow-orange), Blue: #46B3EE, Backgrounds: #191919 / #141414
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::PlaceholderText, QColor(140, 140, 140));
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(127, 127, 127));
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127));
darkPalette.setColor(QPalette::Window, QColor(0x19, 0x19, 0x19));
darkPalette.setColor(QPalette::WindowText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Base, QColor(0x14, 0x14, 0x14));
darkPalette.setColor(QPalette::AlternateBase, QColor(0x1e, 0x1e, 0x1e));
darkPalette.setColor(QPalette::ToolTipBase, QColor(0x19, 0x19, 0x19));
darkPalette.setColor(QPalette::ToolTipText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Text, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::Button, QColor(0x2a, 0x2a, 0x2a));
darkPalette.setColor(QPalette::ButtonText, QColor(0xe8, 0xe8, 0xe8));
darkPalette.setColor(QPalette::BrightText, QColor(0xFF, 0xB2, 0x32));
darkPalette.setColor(QPalette::Link, QColor(0x46, 0xB3, 0xEE)); // Qobuz blue
darkPalette.setColor(QPalette::Highlight, QColor(0xFF, 0xB2, 0x32)); // Qobuz orange
darkPalette.setColor(QPalette::HighlightedText, QColor(0x10, 0x10, 0x10)); // dark on orange
darkPalette.setColor(QPalette::PlaceholderText, QColor(0x66, 0x66, 0x66));
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(0x55, 0x55, 0x55));
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(0x55, 0x55, 0x55));
darkPalette.setColor(QPalette::Mid, QColor(0x2f, 0x2f, 0x2f));
darkPalette.setColor(QPalette::Dark, QColor(0x0e, 0x0e, 0x0e));
app.setPalette(darkPalette);
// Stylesheet tweaks: orange accent on scrollbars, focus rings, etc.
app.setStyleSheet(QStringLiteral(
"QScrollBar:vertical { width: 6px; background: #141414; border: none; }"
"QScrollBar::handle:vertical { background: #3a3a3a; border-radius: 3px; min-height: 20px; }"
"QScrollBar::handle:vertical:hover { background: #FFB232; }"
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"
"QScrollBar:horizontal { height: 6px; background: #141414; border: none; }"
"QScrollBar::handle:horizontal { background: #3a3a3a; border-radius: 3px; min-width: 20px; }"
"QScrollBar::handle:horizontal:hover { background: #FFB232; }"
"QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }"
"QToolBar { background: #111111; border-bottom: 1px solid #2a2a2a; spacing: 4px; }"
"QDockWidget { border: none; }"
"QDockWidget::title { background: #1e1e1e; padding: 4px 8px; font-weight: bold; }"
"QTreeView, QTreeWidget { border: none; outline: none; }"
"QTreeView::item:selected, QTreeWidget::item:selected { color: #101010; }"
"QHeaderView::section { background: #1e1e1e; border: none;"
" border-right: 1px solid #2a2a2a; padding: 4px 8px; }"
"QMenu { background: #1e1e1e; border: 1px solid #3a3a3a; }"
"QMenu::item:selected { background: #FFB232; color: #101010; }"
"QPushButton { background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 4px; padding: 4px 12px; }"
"QPushButton:hover { background: #333333; border-color: #FFB232; }"
"QPushButton:pressed { background: #FFB232; color: #101010; }"
"QComboBox { background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 4px; padding: 3px 8px; }"
"QComboBox:hover { border-color: #FFB232; }"
"QComboBox QAbstractItemView { background: #1e1e1e; selection-background-color: #FFB232; selection-color: #101010; }"
"QLineEdit { background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 4px; padding: 4px 8px; }"
"QLineEdit:focus { border-color: #FFB232; }"
"QGroupBox { border: 1px solid #2f2f2f; border-radius: 6px; margin-top: 8px; padding-top: 4px; }"
"QGroupBox::title { subcontrol-origin: margin; left: 8px; color: #FFB232; }"
"QCheckBox::indicator:checked { background: #FFB232; border: 1px solid #FFB232; border-radius: 2px; }"
"QSlider::groove:horizontal { height: 4px; background: #2a2a2a; border-radius: 2px; }"
"QSlider::handle:horizontal { background: #FFB232; width: 12px; height: 12px;"
" margin: -4px 0; border-radius: 6px; }"
"QSlider::sub-page:horizontal { background: #FFB232; border-radius: 2px; }"
"QStatusBar { background: #111111; border-top: 1px solid #2a2a2a; }"
));
auto *backend = new QobuzBackend;
MainWindow window(backend);
window.show();