feat: qbz-1 streaming, gapless prefetch, accurate scrobbling, Range-seek

Port proven playback architecture from qbqt fork:
- Bounded VecDeque buffer with condvar backpressure (4MB cap)
- decrypt_and_extract_frames for clean FLAC frame extraction from ISOBMFF
- Cancel+restart seeking with sub-segment sample skipping
- start_prefetch / QueueNext for gapless transitions with pre-started downloads
- track_transitioned signaling for scrobbler during gapless playback
- Range-request HTTP seeking for non-segmented (MP3) tracks
- OnceLock HTTP client singleton with cancel-aware chunked downloads
- Accumulated listening time scrobbling (prevents false scrobbles from seeking)
- Array-format Last.fm scrobble params (artist[0], track[0], etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
joren
2026-03-30 20:12:23 +02:00
parent 6c8d032ce9
commit 70810bd4b1
16 changed files with 1539 additions and 382 deletions

View File

@@ -209,21 +209,6 @@ quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_bac
int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); }
int QobuzBackend::state() const { return qobuz_backend_get_state(m_backend); }
quint32 QobuzBackend::vizRead(float *buf, quint32 maxSamples)
{
return qobuz_backend_viz_read(m_backend, buf, maxSamples);
}
quint32 QobuzBackend::vizSampleRate() const
{
return qobuz_backend_viz_sample_rate(m_backend);
}
quint32 QobuzBackend::vizChannels() const
{
return qobuz_backend_viz_channels(m_backend);
}
// ---- private slots ----
void QobuzBackend::onPositionTick()
@@ -232,6 +217,9 @@ void QobuzBackend::onPositionTick()
if (qobuz_backend_take_track_finished(m_backend))
emit trackFinished();
if (qobuz_backend_take_track_transitioned(m_backend))
emit trackTransitioned();
}
void QobuzBackend::onEvent(int eventType, const QString &json)

View File

@@ -73,11 +73,6 @@ public:
/// 1 = playing, 2 = paused, 0 = idle
int state() const;
// --- visualizer PCM ---
quint32 vizRead(float *buf, quint32 maxSamples);
quint32 vizSampleRate() const;
quint32 vizChannels() const;
signals:
// auth
void loginSuccess(const QString &token, const QJsonObject &user);
@@ -106,6 +101,7 @@ signals:
void stateChanged(const QString &state);
void positionChanged(quint64 position, quint64 duration);
void trackFinished();
void trackTransitioned();
// errors
void error(const QString &message);

View File

@@ -189,7 +189,7 @@ void Tracks::onContextMenu(const QPoint &pos)
menu.addSeparator();
auto *openAlbum = menu.addAction(
QIcon(":/res/icons/view-media-album-cover.svg"),
tr("Open album: %1").arg(m_model->trackAt(index.row()).album));
tr("Open album: %1").arg(QString(m_model->trackAt(index.row()).album).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
m_backend->getAlbum(albumId);
});
@@ -202,7 +202,7 @@ void Tracks::onContextMenu(const QPoint &pos)
const QString artistName = trackJson["performer"].toObject()["name"].toString();
auto *openArtist = menu.addAction(
QIcon(":/res/icons/view-media-artist.svg"),
tr("Open artist: %1").arg(artistName));
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
m_backend->getArtist(artistId);
});
@@ -216,7 +216,7 @@ void Tracks::onContextMenu(const QPoint &pos)
for (const auto &pl : m_userPlaylists) {
const qint64 plId = pl.first;
const QString plName = pl.second;
auto *act = addToPlMenu->addAction(plName);
auto *act = addToPlMenu->addAction(QString(plName).replace(QLatin1Char('&'), QStringLiteral("&&")));
connect(act, &QAction::triggered, this, [this, id, plId] {
emit addToPlaylistRequested(id, plId);
});

View File

@@ -40,8 +40,7 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
m_libraryDock->setObjectName(QStringLiteral("libraryDock"));
m_libraryDock->setFeatures(QDockWidget::DockWidgetMovable);
m_libraryDock->setWidget(m_library);
m_libraryDock->setMinimumWidth(180);
m_library->setFixedWidth(220);
m_libraryDock->setMinimumWidth(150);
addDockWidget(Qt::LeftDockWidgetArea, m_libraryDock);
// ---- Now-playing context dock (left, below library) ----
@@ -70,6 +69,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
connect(m_backend, &QobuzBackend::trackFinished,
m_scrobbler, &LastFmScrobbler::onTrackFinished);
// Scrobble the finished track during a gapless transition
connect(m_backend, &QobuzBackend::trackTransitioned,
m_scrobbler, &LastFmScrobbler::onTrackFinished);
// ---- Backend signals ----
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);

View File

@@ -78,9 +78,11 @@ public slots:
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;
m_startTime = QDateTime::currentSecsSinceEpoch();
m_playedSecs = 0;
m_lastPosition = 0;
m_accumulatedSecs = 0;
m_scrobbled = false;
if (!isEnabled() || m_title.isEmpty() || m_duration < 30) return;
updateNowPlaying();
@@ -88,18 +90,29 @@ public slots:
void onPositionChanged(quint64 positionSecs, quint64 /*duration*/)
{
// Accumulate actual listening time to prevent false scrobbles from seeking
if (positionSecs > m_lastPosition && (positionSecs - m_lastPosition) <= 2) {
m_accumulatedSecs += (positionSecs - m_lastPosition);
}
m_lastPosition = positionSecs;
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)
if (m_accumulatedSecs >= 30 && m_accumulatedSecs >= threshold)
scrobble();
}
void onTrackFinished()
{
if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return;
if (m_playedSecs >= 30) scrobble();
const quint64 threshold = static_cast<quint64>(qMin((qint64)240, m_duration / 2));
if (m_accumulatedSecs >= 30 && m_accumulatedSecs >= threshold) {
scrobble();
}
}
private:
@@ -108,10 +121,12 @@ private:
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;
qint64 m_duration = 0;
qint64 m_startTime = 0;
quint64 m_playedSecs = 0;
quint64 m_lastPosition = 0;
quint64 m_accumulatedSecs = 0;
bool m_scrobbled = false;
void updateNowPlaying()
{
@@ -134,16 +149,18 @@ private:
{
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");
params["method"] = QStringLiteral("track.scrobble");
params["api_key"] = AppSettings::instance().lastFmApiKey();
params["sk"] = AppSettings::instance().lastFmSessionKey();
params["artist[0]"] = m_artist;
params["track[0]"] = m_title;
params["album[0]"] = m_album;
params["timestamp[0]"] = QString::number(m_startTime);
params["duration[0]"] = 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);

View File

@@ -96,9 +96,11 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
// ---- Volume ----
m_volume = new VolumeButton(this);
m_volume->setValue(AppSettings::instance().volume());
addWidget(m_volume);
connect(m_volume, &VolumeButton::volumeChanged, this, &MainToolBar::onVolumeChanged);
// Set volume after connecting so the backend receives the initial value
m_volume->setValue(AppSettings::instance().volume());
m_backend->setVolume(AppSettings::instance().volume());
// ---- Queue toggle ----
m_queueBtn = addAction(Icon::queue(), tr("Queue"));

View File

@@ -186,13 +186,13 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
menu.addSeparator();
if (!albumId.isEmpty()) {
auto *openAlbum = menu.addAction(tr("Go to album: %1").arg(albumTitle));
auto *openAlbum = menu.addAction(tr("Go to album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
emit albumSelected(albumId);
});
}
if (artistId > 0) {
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(artistName));
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
emit artistSelected(artistId);
});
@@ -252,7 +252,7 @@ void SearchTab::onAlbumContextMenu(const QPoint &pos)
const QString artistName = albumJson["artist"].toObject()["name"].toString();
if (artistId > 0) {
menu.addSeparator();
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(artistName));
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
connect(openArtist, &QAction::triggered, this, [this, artistId] {
emit artistSelected(artistId);
});