5 Commits

Author SHA1 Message Date
joren
373fc2b43c feat: queue panel skip-to-track and drag reorder; remove visualizer
Queue panel:
- Double-clicking an upcoming track skips to it immediately: drops all
  tracks before it from the queue and starts playback (skipToUpcoming)
- Items can be dragged to reorder; rowsMoved rebuilds the queue via
  setUpcomingOrder()
- Track JSON stored per-item so order survives drag operations
- New PlayQueue methods: skipToUpcoming(), setUpcomingOrder()
- New QueuePanel signal: skipToTrackRequested(qint64) wired to MainWindow

Remove visualizer:
- Drop VisualizerWidget, Qt6::OpenGLWidgets, projectM CMake detection
- Remove qobuz_backend_read_pcm FFI (Rust + C header + Qt wrapper)
- Remove pcm_visualizer from PlayerStatus and PCM tap from AudioOutput

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:12:30 +01:00
joren
74e43b9713 fix: store TypeRole in col 0 for search album results
When the H badge column was added, TypeRole was accidentally stored in
col 1 but the double-click handler reads it from col 0, breaking album
navigation from search. Move TypeRole back to col 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:12:22 +01:00
joren
7e2a04ecff fix: placeholder text color and H badge in search album results
- Add QPalette::PlaceholderText (#8C8C8C) to the dark palette so
  placeholder text in search box and settings fields is readable
- Add H column to search album results (same gold badge as album list view)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:25:42 +01:00
joren
4a9a7f9cbb feat: gold H badge on hi-res albums in album list view
Adds a narrow first column showing a small bold gold "H" for any album
with hires_streamable=true. Applies to both fav albums and artist pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:22:57 +01:00
joren
b9b47f80e7 feat: seeking support and Last.fm scrobbling
Seeking:
- Rust player: seek_requested/seek_target_secs atomics on PlayerStatus
- Decoder loop checks for seek each iteration, calls format.seek() and resets decoder
- New qobuz_backend_seek C FFI + QobuzBackend::seek(quint64)
- Progress slider onProgressReleased now seeks to the dragged position

Last.fm:
- LastFmScrobbler: now-playing + scrobble (50% or 240s threshold, min 30s)
- API signature follows Last.fm spec (sorted params, md5)
- Settings dialog: API key/secret, username/password, Connect button with status
- AppSettings: lastfm/enabled, api_key, api_secret, session_key
- Scrobbler wired to trackChanged, positionChanged, trackFinished in MainWindow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:13:06 +01:00
20 changed files with 486 additions and 32 deletions

View File

@@ -62,6 +62,7 @@ void qobuz_backend_pause(QobuzBackendOpaque *backend);
void qobuz_backend_resume(QobuzBackendOpaque *backend); void qobuz_backend_resume(QobuzBackendOpaque *backend);
void qobuz_backend_stop(QobuzBackendOpaque *backend); void qobuz_backend_stop(QobuzBackendOpaque *backend);
void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume); void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume);
void qobuz_backend_seek(QobuzBackendOpaque *backend, uint64_t position_secs);
uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend); uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend);
uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend); uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend);
uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend); uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend);

View File

@@ -402,6 +402,11 @@ pub unsafe extern "C" fn qobuz_backend_set_volume(ptr: *mut Backend, volume: u8)
(*ptr).0.player.set_volume(volume); (*ptr).0.player.set_volume(volume);
} }
#[no_mangle]
pub unsafe extern "C" fn qobuz_backend_seek(ptr: *mut Backend, position_secs: u64) {
(*ptr).0.player.seek(position_secs);
}
#[no_mangle] #[no_mangle]
pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 { pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 {
(*ptr).0.player.status.get_position() (*ptr).0.player.status.get_position()

View File

@@ -8,10 +8,11 @@ use std::sync::{
use symphonia::core::{ use symphonia::core::{
codecs::DecoderOptions, codecs::DecoderOptions,
errors::Error as SymphoniaError, errors::Error as SymphoniaError,
formats::FormatOptions, formats::{FormatOptions, SeekMode, SeekTo},
io::{MediaSource, MediaSourceStream}, io::{MediaSource, MediaSourceStream},
meta::MetadataOptions, meta::MetadataOptions,
probe::Hint, probe::Hint,
units::Time,
}; };
use crate::player::{output::AudioOutput, PlayerStatus}; use crate::player::{output::AudioOutput, PlayerStatus};
@@ -172,6 +173,21 @@ pub fn play_track(
} }
} }
if status.seek_requested.load(Ordering::SeqCst) {
status.seek_requested.store(false, Ordering::SeqCst);
let target = status.seek_target_secs.load(Ordering::Relaxed);
let seeked = format.seek(
SeekMode::Coarse,
SeekTo::Time { time: Time::from(target), track_id: None },
);
if let Ok(s) = seeked {
let actual = s.actual_ts / sample_rate as u64;
status.position_secs.store(actual, Ordering::Relaxed);
}
decoder.reset();
continue;
}
let packet = match format.next_packet() { let packet = match format.next_packet() {
Ok(p) => p, Ok(p) => p,
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => { Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {

View File

@@ -44,6 +44,9 @@ pub struct PlayerStatus {
pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>, pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>,
/// Set to true by the decode thread when a track finishes naturally. /// Set to true by the decode thread when a track finishes naturally.
pub track_finished: Arc<AtomicBool>, pub track_finished: Arc<AtomicBool>,
/// Set by the player loop when a seek command arrives; cleared by the decode thread.
pub seek_requested: Arc<AtomicBool>,
pub seek_target_secs: Arc<AtomicU64>,
} }
impl PlayerStatus { impl PlayerStatus {
@@ -55,6 +58,8 @@ impl PlayerStatus {
volume: Arc::new(AtomicU8::new(80)), volume: Arc::new(AtomicU8::new(80)),
current_track: Arc::new(std::sync::Mutex::new(None)), current_track: Arc::new(std::sync::Mutex::new(None)),
track_finished: Arc::new(AtomicBool::new(false)), track_finished: Arc::new(AtomicBool::new(false)),
seek_requested: Arc::new(AtomicBool::new(false)),
seek_target_secs: Arc::new(AtomicU64::new(0)),
} }
} }
@@ -121,6 +126,11 @@ impl Player {
self.status.volume.store(vol, Ordering::Relaxed); self.status.volume.store(vol, Ordering::Relaxed);
self.send(PlayerCommand::SetVolume(vol)); self.send(PlayerCommand::SetVolume(vol));
} }
pub fn seek(&self, secs: u64) {
self.status.seek_target_secs.store(secs, Ordering::Relaxed);
self.status.seek_requested.store(true, Ordering::SeqCst);
}
} }
fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) { fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) {
@@ -177,7 +187,11 @@ fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatu
status.position_secs.store(0, Ordering::Relaxed); status.position_secs.store(0, Ordering::Relaxed);
status.duration_secs.store(0, Ordering::Relaxed); status.duration_secs.store(0, Ordering::Relaxed);
} }
PlayerCommand::SetVolume(_) | PlayerCommand::Seek(_) => {} PlayerCommand::SetVolume(_) => {}
PlayerCommand::Seek(secs) => {
status.seek_target_secs.store(secs, Ordering::Relaxed);
status.seek_requested.store(true, Ordering::SeqCst);
}
}, },
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,

View File

@@ -44,6 +44,9 @@ target_sources(qobuz-qt PRIVATE
dialog/settings.hpp dialog/settings.hpp
dialog/settings.cpp dialog/settings.cpp
# Scrobbler (header-only)
scrobbler/lastfm.hpp
# Widgets (header-only) # Widgets (header-only)
widget/clickableslider.hpp widget/clickableslider.hpp
widget/volumebutton.hpp widget/volumebutton.hpp

View File

@@ -128,6 +128,11 @@ void QobuzBackend::setVolume(int volume)
qobuz_backend_set_volume(m_backend, static_cast<quint8>(qBound(0, volume, 100))); qobuz_backend_set_volume(m_backend, static_cast<quint8>(qBound(0, volume, 100)));
} }
void QobuzBackend::seek(quint64 positionSecs)
{
qobuz_backend_seek(m_backend, positionSecs);
}
quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); } quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); }
quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); } quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); }
int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); } int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); }

View File

@@ -48,6 +48,7 @@ public:
void resume(); void resume();
void stop(); void stop();
void setVolume(int volume); void setVolume(int volume);
void seek(quint64 positionSecs);
quint64 position() const; quint64 position() const;
quint64 duration() const; quint64 duration() const;

View File

@@ -1,15 +1,20 @@
#include "settings.hpp" #include "settings.hpp"
#include "../util/settings.hpp" #include "../util/settings.hpp"
#include "../scrobbler/lastfm.hpp"
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QFormLayout> #include <QFormLayout>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QGroupBox> #include <QGroupBox>
#include <QHBoxLayout>
#include <QNetworkReply>
SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent) SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
{ {
setWindowTitle(tr("Settings")); setWindowTitle(tr("Settings"));
setMinimumWidth(400); setMinimumWidth(420);
m_nam = new QNetworkAccessManager(this);
auto *layout = new QVBoxLayout(this); auto *layout = new QVBoxLayout(this);
@@ -30,25 +35,98 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
break; break;
} }
} }
playLayout->addRow(tr("Preferred quality:"), m_formatBox); playLayout->addRow(tr("Preferred quality:"), m_formatBox);
layout->addWidget(playGroup); layout->addWidget(playGroup);
// --- Last.fm group ---
auto *lfmGroup = new QGroupBox(tr("Last.fm Scrobbling"), this);
auto *lfmLayout = new QFormLayout(lfmGroup);
m_lastFmEnabled = new QCheckBox(tr("Enable scrobbling"), lfmGroup);
m_lastFmEnabled->setChecked(AppSettings::instance().lastFmEnabled());
lfmLayout->addRow(m_lastFmEnabled);
m_lastFmApiKey = new QLineEdit(AppSettings::instance().lastFmApiKey(), lfmGroup);
m_lastFmApiKey->setPlaceholderText(tr("Get one at last.fm/api"));
lfmLayout->addRow(tr("API Key:"), m_lastFmApiKey);
m_lastFmApiSecret = new QLineEdit(AppSettings::instance().lastFmApiSecret(), lfmGroup);
m_lastFmApiSecret->setEchoMode(QLineEdit::Password);
lfmLayout->addRow(tr("API Secret:"), m_lastFmApiSecret);
m_lastFmUsername = new QLineEdit(lfmGroup);
m_lastFmUsername->setPlaceholderText(tr("Last.fm username"));
lfmLayout->addRow(tr("Username:"), m_lastFmUsername);
m_lastFmPassword = new QLineEdit(lfmGroup);
m_lastFmPassword->setEchoMode(QLineEdit::Password);
m_lastFmPassword->setPlaceholderText(tr("Last.fm password"));
lfmLayout->addRow(tr("Password:"), m_lastFmPassword);
auto *connectRow = new QHBoxLayout;
m_lastFmConnect = new QPushButton(tr("Connect"), lfmGroup);
m_lastFmStatus = new QLabel(lfmGroup);
const bool hasSession = !AppSettings::instance().lastFmSessionKey().isEmpty();
m_lastFmStatus->setText(hasSession ? tr("Connected ✓") : tr("Not connected"));
m_lastFmStatus->setStyleSheet(hasSession ? "color: green;" : "color: gray;");
connectRow->addWidget(m_lastFmConnect);
connectRow->addWidget(m_lastFmStatus, 1);
lfmLayout->addRow(connectRow);
layout->addWidget(lfmGroup);
layout->addStretch(); layout->addStretch();
auto *buttons = new QDialogButtonBox( auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
layout->addWidget(buttons); layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::accepted, this, [this] { connect(m_lastFmConnect, &QPushButton::clicked, this, &SettingsDialog::onLastFmConnect);
applyChanges(); connect(buttons, &QDialogButtonBox::accepted, this, [this] { applyChanges(); accept(); });
accept();
});
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
} }
void SettingsDialog::applyChanges() void SettingsDialog::applyChanges()
{ {
AppSettings::instance().setPreferredFormat( AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt());
m_formatBox->currentData().toInt()); AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked());
AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed());
AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed());
}
void SettingsDialog::onLastFmConnect()
{
const QString apiKey = m_lastFmApiKey->text().trimmed();
const QString apiSecret = m_lastFmApiSecret->text().trimmed();
const QString username = m_lastFmUsername->text().trimmed();
const QString password = m_lastFmPassword->text();
if (apiKey.isEmpty() || apiSecret.isEmpty() || username.isEmpty() || password.isEmpty()) {
m_lastFmStatus->setText(tr("Fill in all fields first."));
m_lastFmStatus->setStyleSheet("color: red;");
return;
}
// Temporarily apply so the scrobbler util can use them for signing
AppSettings::instance().setLastFmApiKey(apiKey);
AppSettings::instance().setLastFmApiSecret(apiSecret);
m_lastFmConnect->setEnabled(false);
m_lastFmStatus->setText(tr("Connecting…"));
m_lastFmStatus->setStyleSheet("color: gray;");
// Reuse LastFmScrobbler::authenticate as a utility (temp instance)
auto *tmp = new LastFmScrobbler(this);
tmp->authenticate(username, password, [this, tmp](bool ok, const QString &err) {
tmp->deleteLater();
m_lastFmConnect->setEnabled(true);
if (ok) {
m_lastFmStatus->setText(tr("Connected ✓"));
m_lastFmStatus->setStyleSheet("color: green;");
} else {
m_lastFmStatus->setText(tr("Error: %1").arg(err));
m_lastFmStatus->setStyleSheet("color: red;");
}
});
} }

View File

@@ -3,6 +3,10 @@
#include <QDialog> #include <QDialog>
#include <QComboBox> #include <QComboBox>
#include <QLabel> #include <QLabel>
#include <QLineEdit>
#include <QCheckBox>
#include <QPushButton>
#include <QNetworkAccessManager>
class SettingsDialog : public QDialog class SettingsDialog : public QDialog
{ {
@@ -12,7 +16,19 @@ public:
explicit SettingsDialog(QWidget *parent = nullptr); explicit SettingsDialog(QWidget *parent = nullptr);
private: private:
// Playback
QComboBox *m_formatBox = nullptr; QComboBox *m_formatBox = nullptr;
// Last.fm
QCheckBox *m_lastFmEnabled = nullptr;
QLineEdit *m_lastFmApiKey = nullptr;
QLineEdit *m_lastFmApiSecret = nullptr;
QLineEdit *m_lastFmUsername = nullptr;
QLineEdit *m_lastFmPassword = nullptr;
QPushButton *m_lastFmConnect = nullptr;
QLabel *m_lastFmStatus = nullptr;
QNetworkAccessManager *m_nam = nullptr;
void applyChanges(); void applyChanges();
void onLastFmConnect();
}; };

View File

@@ -27,6 +27,7 @@ int main(int argc, char *argv[])
darkPalette.setColor(QPalette::Link, QColor(42, 130, 218)); darkPalette.setColor(QPalette::Link, QColor(42, 130, 218));
darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218)); darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218));
darkPalette.setColor(QPalette::HighlightedText, Qt::black); 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::Text, QColor(127, 127, 127));
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127)); darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127));
app.setPalette(darkPalette); app.setPalette(darkPalette);

View File

@@ -60,6 +60,15 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
setupMenuBar(); setupMenuBar();
statusBar()->showMessage(tr("Ready")); statusBar()->showMessage(tr("Ready"));
// ---- Scrobbler ----
m_scrobbler = new LastFmScrobbler(this);
connect(m_backend, &QobuzBackend::trackChanged,
m_scrobbler, &LastFmScrobbler::onTrackStarted);
connect(m_backend, &QobuzBackend::positionChanged,
m_scrobbler, &LastFmScrobbler::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished,
m_scrobbler, &LastFmScrobbler::onTrackFinished);
// ---- Backend signals ---- // ---- Backend signals ----
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess); connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError); connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
@@ -111,6 +120,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_content, &MainContent::artistRequested, connect(m_content, &MainContent::artistRequested,
this, &MainWindow::onSearchArtistSelected); this, &MainWindow::onSearchArtistSelected);
// ---- Queue panel ----
connect(m_queuePanel, &QueuePanel::skipToTrackRequested,
this, &MainWindow::onPlayTrackRequested);
// ---- Toolbar toggles ---- // ---- Toolbar toggles ----
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled); connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
connect(m_toolBar, &MainToolBar::queueToggled, connect(m_toolBar, &MainToolBar::queueToggled,

View File

@@ -8,6 +8,7 @@
#include "view/queuepanel.hpp" #include "view/queuepanel.hpp"
#include "view/sidepanel/view.hpp" #include "view/sidepanel/view.hpp"
#include "list/library.hpp" #include "list/library.hpp"
#include "scrobbler/lastfm.hpp"
#include <QMainWindow> #include <QMainWindow>
#include <QDockWidget> #include <QDockWidget>
@@ -51,6 +52,7 @@ private:
QueuePanel *m_queuePanel = nullptr; QueuePanel *m_queuePanel = nullptr;
SidePanel::View *m_sidePanel = nullptr; SidePanel::View *m_sidePanel = nullptr;
QDockWidget *m_libraryDock = nullptr; QDockWidget *m_libraryDock = nullptr;
LastFmScrobbler *m_scrobbler = nullptr;
void setupMenuBar(); void setupMenuBar();
void tryRestoreSession(); void tryRestoreSession();

View File

@@ -184,6 +184,39 @@ public:
int totalSize() const { return m_playNext.size() + m_queue.size(); } int totalSize() const { return m_playNext.size() + m_queue.size(); }
int currentIndex() const { return m_index; } int currentIndex() const { return m_index; }
/// Skip to upcoming[upcomingIndex]: removes everything before it, pops and returns it.
QJsonObject skipToUpcoming(int upcomingIndex)
{
// Remove items 0..upcomingIndex-1 from the front of upcoming
for (int i = 0; i < upcomingIndex; ++i) {
if (!m_playNext.isEmpty())
m_playNext.removeFirst();
else if (m_index + 1 < m_queue.size())
++m_index;
}
// Pop and return the target (now at upcoming[0])
if (!m_playNext.isEmpty()) {
const QJsonObject t = m_playNext.takeFirst();
emit queueChanged();
return t;
}
if (m_index + 1 < m_queue.size()) {
++m_index;
emit queueChanged();
return m_queue.at(m_index);
}
emit queueChanged();
return {};
}
/// Replace the upcoming list with a new order (used after drag-reorder in UI).
void setUpcomingOrder(const QVector<QJsonObject> &newOrder)
{
m_playNext = newOrder;
m_queue.resize(m_index + 1); // drop old main-queue tail
emit queueChanged();
}
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext. /// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
void moveUpcomingToTop(int upcomingIndex) void moveUpcomingToTop(int upcomingIndex)
{ {

179
src/scrobbler/lastfm.hpp Normal file
View File

@@ -0,0 +1,179 @@
#pragma once
#include "../util/settings.hpp"
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrlQuery>
#include <QUrl>
#include <QCryptographicHash>
#include <QDateTime>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <functional>
/// Handles Last.fm now-playing updates and scrobbling.
/// Connect to QobuzBackend signals and call onTrackStarted / onPositionChanged / onTrackFinished.
class LastFmScrobbler : public QObject
{
Q_OBJECT
public:
explicit LastFmScrobbler(QObject *parent = nullptr)
: QObject(parent)
, m_nam(new QNetworkAccessManager(this))
{}
/// Authenticate via auth.getMobileSession and store the session key.
/// callback(success, errorMessage)
void authenticate(const QString &username, const QString &password,
std::function<void(bool, const QString &)> callback)
{
const QString apiKey = AppSettings::instance().lastFmApiKey();
const QString apiSecret = AppSettings::instance().lastFmApiSecret();
if (apiKey.isEmpty() || apiSecret.isEmpty()) {
callback(false, tr("API key or secret is not set."));
return;
}
QMap<QString,QString> params;
params["method"] = QStringLiteral("auth.getMobileSession");
params["api_key"] = apiKey;
params["username"] = username;
params["password"] = password;
params["api_sig"] = buildSig(params, apiSecret);
params["format"] = QStringLiteral("json");
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
connect(reply, &QNetworkReply::finished, this, [reply, callback] {
reply->deleteLater();
const QJsonObject obj = QJsonDocument::fromJson(reply->readAll()).object();
const QString key = obj["session"].toObject()["key"].toString();
if (!key.isEmpty()) {
AppSettings::instance().setLastFmSessionKey(key);
callback(true, {});
} else {
callback(false, obj["message"].toString(tr("Authentication failed.")));
}
});
}
bool isEnabled() const
{
return AppSettings::instance().lastFmEnabled()
&& !AppSettings::instance().lastFmApiKey().isEmpty()
&& !AppSettings::instance().lastFmApiSecret().isEmpty()
&& !AppSettings::instance().lastFmSessionKey().isEmpty();
}
public slots:
void onTrackStarted(const QJsonObject &track)
{
m_title = track["title"].toString();
m_artist = track["performer"].toObject()["name"].toString();
if (m_artist.isEmpty())
m_artist = track["album"].toObject()["artist"].toObject()["name"].toString();
m_album = track["album"].toObject()["title"].toString();
m_duration = static_cast<qint64>(track["duration"].toDouble());
m_startTime = QDateTime::currentSecsSinceEpoch();
m_playedSecs = 0;
m_scrobbled = false;
if (!isEnabled() || m_title.isEmpty() || m_duration < 30) return;
updateNowPlaying();
}
void onPositionChanged(quint64 positionSecs, quint64 /*duration*/)
{
m_playedSecs = positionSecs;
if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return;
// Scrobble after 50% or 240 seconds played, whichever comes first, min 30 seconds.
const quint64 threshold = static_cast<quint64>(qMin((qint64)240, m_duration / 2));
if (positionSecs >= 30 && positionSecs >= threshold)
scrobble();
}
void onTrackFinished()
{
if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return;
if (m_playedSecs >= 30) scrobble();
}
private:
QNetworkAccessManager *m_nam = nullptr;
QString m_title;
QString m_artist;
QString m_album;
qint64 m_duration = 0;
qint64 m_startTime = 0;
quint64 m_playedSecs = 0;
bool m_scrobbled = false;
void updateNowPlaying()
{
QMap<QString,QString> params;
params["method"] = QStringLiteral("track.updateNowPlaying");
params["api_key"] = AppSettings::instance().lastFmApiKey();
params["sk"] = AppSettings::instance().lastFmSessionKey();
params["artist"] = m_artist;
params["track"] = m_title;
params["album"] = m_album;
params["duration"] = QString::number(m_duration);
params["api_sig"] = buildSig(params, AppSettings::instance().lastFmApiSecret());
params["format"] = QStringLiteral("json");
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
}
void scrobble()
{
m_scrobbled = true;
QMap<QString,QString> params;
params["method"] = QStringLiteral("track.scrobble");
params["api_key"] = AppSettings::instance().lastFmApiKey();
params["sk"] = AppSettings::instance().lastFmSessionKey();
params["artist"] = m_artist;
params["track"] = m_title;
params["album"] = m_album;
params["timestamp"] = QString::number(m_startTime);
params["duration"] = QString::number(m_duration);
params["api_sig"] = buildSig(params, AppSettings::instance().lastFmApiSecret());
params["format"] = QStringLiteral("json");
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
}
/// Build the Last.fm API signature: sort params, concatenate key+value, append secret, md5.
static QString buildSig(const QMap<QString,QString> &params, const QString &secret)
{
QString s;
for (auto it = params.constBegin(); it != params.constEnd(); ++it) {
if (it.key() != "format" && it.key() != "api_sig")
s += it.key() + it.value();
}
s += secret;
return QCryptographicHash::hash(s.toUtf8(), QCryptographicHash::Md5).toHex();
}
static QNetworkRequest apiRequest()
{
QNetworkRequest req(QUrl(QStringLiteral("https://ws.audioscrobbler.com/2.0/")));
req.setHeader(QNetworkRequest::ContentTypeHeader,
QStringLiteral("application/x-www-form-urlencoded"));
return req;
}
static QByteArray encodeBody(const QMap<QString,QString> &params)
{
QUrlQuery q;
for (auto it = params.constBegin(); it != params.constEnd(); ++it)
q.addQueryItem(it.key(), it.value());
return q.toString(QUrl::FullyEncoded).toUtf8();
}
};

View File

@@ -31,6 +31,19 @@ public:
bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); } bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); }
void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); } void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); }
// --- Last.fm ---
bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); }
void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); }
QString lastFmApiKey() const { return m_settings.value("lastfm/api_key").toString(); }
void setLastFmApiKey(const QString &v) { m_settings.setValue("lastfm/api_key", v); }
QString lastFmApiSecret() const { return m_settings.value("lastfm/api_secret").toString(); }
void setLastFmApiSecret(const QString &v) { m_settings.setValue("lastfm/api_secret", v); }
QString lastFmSessionKey() const { return m_settings.value("lastfm/session_key").toString(); }
void setLastFmSessionKey(const QString &v){ m_settings.setValue("lastfm/session_key", v); }
private: private:
AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {} AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {}
QSettings m_settings; QSettings m_settings;

View File

@@ -3,11 +3,13 @@
#include <QTreeWidget> #include <QTreeWidget>
#include <QTreeWidgetItem> #include <QTreeWidgetItem>
#include <QHeaderView> #include <QHeaderView>
#include <QFont>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
/// A simple list of albums (used for fav albums and artist detail pages). /// A simple list of albums (used for fav albums and artist detail pages).
/// Double-clicking an item emits albumSelected(albumId). /// Double-clicking an item emits albumSelected(albumId).
/// Column 0 shows a small gold "H" for hi-res streamable albums.
class AlbumListView : public QTreeWidget class AlbumListView : public QTreeWidget
{ {
Q_OBJECT Q_OBJECT
@@ -15,22 +17,23 @@ class AlbumListView : public QTreeWidget
public: public:
explicit AlbumListView(QWidget *parent = nullptr) : QTreeWidget(parent) explicit AlbumListView(QWidget *parent = nullptr) : QTreeWidget(parent)
{ {
setColumnCount(4); setColumnCount(5);
setHeaderLabels({tr("Title"), tr("Artist"), tr("Year"), tr("Tracks")}); setHeaderLabels({tr(""), tr("Title"), tr("Artist"), tr("Year"), tr("Tracks")});
setRootIsDecorated(false); setRootIsDecorated(false);
setAlternatingRowColors(true); setAlternatingRowColors(true);
setSelectionBehavior(QAbstractItemView::SelectRows); setSelectionBehavior(QAbstractItemView::SelectRows);
setSortingEnabled(true); setSortingEnabled(true);
header()->setStretchLastSection(false); header()->setStretchLastSection(false);
header()->setSectionResizeMode(0, QHeaderView::Stretch); header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); // H column
header()->setSectionResizeMode(1, QHeaderView::Stretch); header()->setSectionResizeMode(1, QHeaderView::Stretch);
header()->setSectionResizeMode(2, QHeaderView::ResizeToContents); header()->setSectionResizeMode(2, QHeaderView::Stretch);
header()->setSectionResizeMode(3, QHeaderView::ResizeToContents); header()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
header()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
connect(this, &QTreeWidget::itemDoubleClicked, connect(this, &QTreeWidget::itemDoubleClicked,
this, [this](QTreeWidgetItem *item, int) { this, [this](QTreeWidgetItem *item, int) {
const QString id = item->data(0, Qt::UserRole).toString(); const QString id = item->data(1, Qt::UserRole).toString();
if (!id.isEmpty()) emit albumSelected(id); if (!id.isEmpty()) emit albumSelected(id);
}); });
} }
@@ -38,6 +41,10 @@ public:
void setAlbums(const QJsonArray &albums) void setAlbums(const QJsonArray &albums)
{ {
clear(); clear();
QFont hiResFont;
hiResFont.setBold(true);
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
for (const auto &v : albums) { for (const auto &v : albums) {
const QJsonObject a = v.toObject(); const QJsonObject a = v.toObject();
const QString id = a["id"].toString(); const QString id = a["id"].toString();
@@ -46,13 +53,20 @@ public:
const QString date = a["release_date_original"].toString(); const QString date = a["release_date_original"].toString();
const QString year = date.left(4); const QString year = date.left(4);
const int tracks = a["tracks_count"].toInt(); const int tracks = a["tracks_count"].toInt();
const bool hiRes = a["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(this); auto *item = new QTreeWidgetItem(this);
item->setText(0, title); if (hiRes) {
item->setText(1, artist); item->setText(0, QStringLiteral("H"));
item->setText(2, year); item->setForeground(0, QColor(QStringLiteral("#FFD700")));
item->setText(3, tracks > 0 ? QString::number(tracks) : QString()); item->setFont(0, hiResFont);
item->setData(0, Qt::UserRole, id); item->setTextAlignment(0, Qt::AlignCenter);
}
item->setText(1, title);
item->setText(2, artist);
item->setText(3, year);
item->setText(4, tracks > 0 ? QString::number(tracks) : QString());
item->setData(1, Qt::UserRole, id);
} }
} }

View File

@@ -177,6 +177,11 @@ void MainToolBar::onNext()
void MainToolBar::onProgressReleased() void MainToolBar::onProgressReleased()
{ {
m_seeking = false; m_seeking = false;
const quint64 dur = m_backend->duration();
if (dur > 0) {
const quint64 target = dur * static_cast<quint64>(m_progress->value()) / 1000;
m_backend->seek(target);
}
} }
void MainToolBar::onVolumeChanged(int volume) void MainToolBar::onVolumeChanged(int volume)

View File

@@ -7,6 +7,7 @@
static constexpr int UpcomingIndexRole = Qt::UserRole + 1; static constexpr int UpcomingIndexRole = Qt::UserRole + 1;
static constexpr int IsPlayNextRole = Qt::UserRole + 2; static constexpr int IsPlayNextRole = Qt::UserRole + 2;
static constexpr int TrackJsonRole = Qt::UserRole + 3;
QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent) QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
: QDockWidget(tr("Queue"), parent) : QDockWidget(tr("Queue"), parent)
@@ -32,6 +33,8 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
m_list = new QListWidget(container); m_list = new QListWidget(container);
m_list->setAlternatingRowColors(true); m_list->setAlternatingRowColors(true);
m_list->setContextMenuPolicy(Qt::CustomContextMenu); m_list->setContextMenuPolicy(Qt::CustomContextMenu);
m_list->setDragDropMode(QAbstractItemView::InternalMove);
m_list->setDefaultDropAction(Qt::MoveAction);
layout->addWidget(m_list, 1); layout->addWidget(m_list, 1);
setWidget(container); setWidget(container);
@@ -45,12 +48,17 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
this, &QueuePanel::onItemDoubleClicked); this, &QueuePanel::onItemDoubleClicked);
connect(m_list, &QListWidget::customContextMenuRequested, connect(m_list, &QListWidget::customContextMenuRequested,
this, &QueuePanel::onContextMenu); this, &QueuePanel::onContextMenu);
connect(m_list->model(), &QAbstractItemModel::rowsMoved,
this, &QueuePanel::onRowsMoved);
refresh(); refresh();
} }
void QueuePanel::refresh() void QueuePanel::refresh()
{ {
if (m_refreshing) return;
m_refreshing = true;
m_list->clear(); m_list->clear();
const QVector<QJsonObject> upcoming = m_queue->upcomingTracks(); const QVector<QJsonObject> upcoming = m_queue->upcomingTracks();
@@ -73,6 +81,7 @@ void QueuePanel::refresh()
auto *item = new QListWidgetItem(text, m_list); auto *item = new QListWidgetItem(text, m_list);
item->setData(UpcomingIndexRole, i); item->setData(UpcomingIndexRole, i);
item->setData(IsPlayNextRole, i < playNextCount); item->setData(IsPlayNextRole, i < playNextCount);
item->setData(TrackJsonRole, QVariant::fromValue(t));
// "Play Next" tracks shown slightly differently // "Play Next" tracks shown slightly differently
if (i < playNextCount) { if (i < playNextCount) {
@@ -81,12 +90,33 @@ void QueuePanel::refresh()
item->setFont(f); item->setFont(f);
} }
} }
m_refreshing = false;
} }
void QueuePanel::onItemDoubleClicked(QListWidgetItem *item) void QueuePanel::onItemDoubleClicked(QListWidgetItem *item)
{ {
// Double-clicking an upcoming item is not needed for now (could skip to it later) const int idx = item->data(UpcomingIndexRole).toInt();
Q_UNUSED(item) const QJsonObject track = m_queue->skipToUpcoming(idx);
if (track.isEmpty()) return;
const qint64 id = static_cast<qint64>(track["id"].toDouble());
emit skipToTrackRequested(id);
}
void QueuePanel::onRowsMoved()
{
if (m_refreshing) return;
QVector<QJsonObject> newOrder;
newOrder.reserve(m_list->count());
for (int i = 0; i < m_list->count(); ++i) {
const QVariant v = m_list->item(i)->data(TrackJsonRole);
newOrder.append(v.value<QJsonObject>());
}
m_refreshing = true;
m_queue->setUpcomingOrder(newOrder);
m_refreshing = false;
} }
void QueuePanel::onContextMenu(const QPoint &pos) void QueuePanel::onContextMenu(const QPoint &pos)

View File

@@ -14,14 +14,19 @@ class QueuePanel : public QDockWidget
public: public:
explicit QueuePanel(PlayQueue *queue, QWidget *parent = nullptr); explicit QueuePanel(PlayQueue *queue, QWidget *parent = nullptr);
signals:
void skipToTrackRequested(qint64 trackId);
private slots: private slots:
void refresh(); void refresh();
void onItemDoubleClicked(QListWidgetItem *item); void onItemDoubleClicked(QListWidgetItem *item);
void onContextMenu(const QPoint &pos); void onContextMenu(const QPoint &pos);
void onRowsMoved();
private: private:
PlayQueue *m_queue = nullptr; PlayQueue *m_queue = nullptr;
QLabel *m_countLabel = nullptr; QLabel *m_countLabel = nullptr;
QListWidget *m_list = nullptr; QListWidget *m_list = nullptr;
QPushButton *m_clearBtn = nullptr; QPushButton *m_clearBtn = nullptr;
bool m_refreshing = false;
}; };

View File

@@ -3,6 +3,8 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QPushButton> #include <QPushButton>
#include <QHeaderView>
#include <QFont>
#include <QJsonArray> #include <QJsonArray>
static constexpr int IdRole = Qt::UserRole + 1; static constexpr int IdRole = Qt::UserRole + 1;
@@ -38,8 +40,12 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent)
m_trackResults->setRootIsDecorated(false); m_trackResults->setRootIsDecorated(false);
m_albumResults = new QTreeWidget(this); m_albumResults = new QTreeWidget(this);
m_albumResults->setHeaderLabels({tr("Album"), tr("Artist")}); m_albumResults->setHeaderLabels({tr(""), tr("Album"), tr("Artist")});
m_albumResults->setRootIsDecorated(false); m_albumResults->setRootIsDecorated(false);
m_albumResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_albumResults->header()->setSectionResizeMode(1, QHeaderView::Stretch);
m_albumResults->header()->setSectionResizeMode(2, QHeaderView::Stretch);
m_albumResults->header()->setStretchLastSection(false);
m_artistResults = new QTreeWidget(this); m_artistResults = new QTreeWidget(this);
m_artistResults->setHeaderLabels({tr("Artist")}); m_artistResults->setHeaderLabels({tr("Artist")});
@@ -84,14 +90,28 @@ void SearchTab::onSearchResult(const QJsonObject &result)
// Populate albums // Populate albums
m_albumResults->clear(); m_albumResults->clear();
{
QFont hiResFont;
hiResFont.setBold(true);
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
const QJsonArray albums = result["albums"].toObject()["items"].toArray(); const QJsonArray albums = result["albums"].toObject()["items"].toArray();
for (const auto &v : albums) { for (const auto &v : albums) {
const QJsonObject a = v.toObject(); const QJsonObject a = v.toObject();
const QString artist = a["artist"].toObject()["name"].toString(); const QString artist = a["artist"].toObject()["name"].toString();
const bool hiRes = a["hires_streamable"].toBool();
auto *item = new QTreeWidgetItem(m_albumResults, auto *item = new QTreeWidgetItem(m_albumResults,
QStringList{a["title"].toString(), artist}); QStringList{QString(), a["title"].toString(), artist});
item->setData(0, IdRole, a["id"].toString()); if (hiRes) {
item->setData(0, TypeRole, QStringLiteral("album")); item->setText(0, QStringLiteral("H"));
item->setForeground(0, QColor(QStringLiteral("#FFD700")));
item->setFont(0, hiResFont);
item->setTextAlignment(0, Qt::AlignCenter);
}
item->setData(0, TypeRole, QStringLiteral("album")); // handler reads col 0
item->setData(1, IdRole, a["id"].toString());
}
} }
// Populate artists // Populate artists
@@ -114,7 +134,7 @@ void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int)
if (type == QStringLiteral("track")) { if (type == QStringLiteral("track")) {
emit trackPlayRequested(item->data(0, IdRole).toLongLong()); emit trackPlayRequested(item->data(0, IdRole).toLongLong());
} else if (type == QStringLiteral("album")) { } else if (type == QStringLiteral("album")) {
emit albumSelected(item->data(0, IdRole).toString()); emit albumSelected(item->data(1, IdRole).toString());
} else if (type == QStringLiteral("artist")) { } else if (type == QStringLiteral("artist")) {
emit artistSelected(item->data(0, IdRole).toLongLong()); emit artistSelected(item->data(0, IdRole).toLongLong());
} }