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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user