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>
This commit is contained in:
@@ -44,6 +44,9 @@ target_sources(qobuz-qt PRIVATE
|
||||
dialog/settings.hpp
|
||||
dialog/settings.cpp
|
||||
|
||||
# Scrobbler (header-only)
|
||||
scrobbler/lastfm.hpp
|
||||
|
||||
# Widgets (header-only)
|
||||
widget/clickableslider.hpp
|
||||
widget/volumebutton.hpp
|
||||
|
||||
@@ -128,6 +128,11 @@ void QobuzBackend::setVolume(int volume)
|
||||
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::duration() const { return qobuz_backend_get_duration(m_backend); }
|
||||
int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); }
|
||||
|
||||
@@ -48,6 +48,7 @@ public:
|
||||
void resume();
|
||||
void stop();
|
||||
void setVolume(int volume);
|
||||
void seek(quint64 positionSecs);
|
||||
|
||||
quint64 position() const;
|
||||
quint64 duration() const;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
#include "settings.hpp"
|
||||
#include "../util/settings.hpp"
|
||||
#include "../scrobbler/lastfm.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QFormLayout>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QNetworkReply>
|
||||
|
||||
SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Settings"));
|
||||
setMinimumWidth(400);
|
||||
setMinimumWidth(420);
|
||||
|
||||
m_nam = new QNetworkAccessManager(this);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
|
||||
@@ -30,25 +35,98 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
playLayout->addRow(tr("Preferred quality:"), m_formatBox);
|
||||
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();
|
||||
|
||||
auto *buttons = new QDialogButtonBox(
|
||||
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, [this] {
|
||||
applyChanges();
|
||||
accept();
|
||||
});
|
||||
connect(m_lastFmConnect, &QPushButton::clicked, this, &SettingsDialog::onLastFmConnect);
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, [this] { applyChanges(); accept(); });
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
}
|
||||
|
||||
void SettingsDialog::applyChanges()
|
||||
{
|
||||
AppSettings::instance().setPreferredFormat(
|
||||
m_formatBox->currentData().toInt());
|
||||
AppSettings::instance().setPreferredFormat(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;");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include <QDialog>
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QCheckBox>
|
||||
#include <QPushButton>
|
||||
#include <QNetworkAccessManager>
|
||||
|
||||
class SettingsDialog : public QDialog
|
||||
{
|
||||
@@ -12,7 +16,19 @@ public:
|
||||
explicit SettingsDialog(QWidget *parent = nullptr);
|
||||
|
||||
private:
|
||||
// Playback
|
||||
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 onLastFmConnect();
|
||||
};
|
||||
|
||||
@@ -60,6 +60,15 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
setupMenuBar();
|
||||
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 ----
|
||||
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
|
||||
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "view/queuepanel.hpp"
|
||||
#include "view/sidepanel/view.hpp"
|
||||
#include "list/library.hpp"
|
||||
#include "scrobbler/lastfm.hpp"
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QDockWidget>
|
||||
@@ -51,6 +52,7 @@ private:
|
||||
QueuePanel *m_queuePanel = nullptr;
|
||||
SidePanel::View *m_sidePanel = nullptr;
|
||||
QDockWidget *m_libraryDock = nullptr;
|
||||
LastFmScrobbler *m_scrobbler = nullptr;
|
||||
|
||||
void setupMenuBar();
|
||||
void tryRestoreSession();
|
||||
|
||||
179
src/scrobbler/lastfm.hpp
Normal file
179
src/scrobbler/lastfm.hpp
Normal 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> ¶ms, 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> ¶ms)
|
||||
{
|
||||
QUrlQuery q;
|
||||
for (auto it = params.constBegin(); it != params.constEnd(); ++it)
|
||||
q.addQueryItem(it.key(), it.value());
|
||||
return q.toString(QUrl::FullyEncoded).toUtf8();
|
||||
}
|
||||
};
|
||||
@@ -31,6 +31,19 @@ public:
|
||||
bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); }
|
||||
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:
|
||||
AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {}
|
||||
QSettings m_settings;
|
||||
|
||||
@@ -177,6 +177,11 @@ void MainToolBar::onNext()
|
||||
void MainToolBar::onProgressReleased()
|
||||
{
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user