feat: add autoplay suggestions when queue runs out
Some checks failed
Build for Windows / build-windows (push) Has been cancelled

This commit is contained in:
joren
2026-03-30 23:14:43 +02:00
parent 2da934f3f6
commit cfd91f96b5
11 changed files with 208 additions and 0 deletions

View File

@@ -56,6 +56,17 @@ void QobuzBackend::mostPopularSearch(const QString &query, quint32 limit)
qobuz_backend_most_popular_search(m_backend, query.toUtf8().constData(), limit);
}
void QobuzBackend::getDynamicSuggestions(const QJsonArray &listenedTrackIds, const QJsonArray &tracksToAnalyze, quint32 limit)
{
const QByteArray listened = QJsonDocument(listenedTrackIds).toJson(QJsonDocument::Compact);
const QByteArray analyze = QJsonDocument(tracksToAnalyze).toJson(QJsonDocument::Compact);
qobuz_backend_get_dynamic_suggestions(
m_backend,
listened.constData(),
analyze.constData(),
limit);
}
void QobuzBackend::getAlbum(const QString &albumId)
{
qobuz_backend_get_album(m_backend, albumId.toUtf8().constData());
@@ -282,6 +293,9 @@ void QobuzBackend::onEvent(int eventType, const QString &json)
case 25: // EV_DEEP_SHUFFLE_OK
emit deepShuffleTracksLoaded(obj["tracks"].toArray());
break;
case 29: // EV_DYNAMIC_SUGGEST_OK
emit dynamicSuggestionsLoaded(obj);
break;
case 27: // EV_GENRES_OK
emit genresLoaded(obj);
break;

View File

@@ -29,6 +29,7 @@ public:
// --- catalog ---
void search(const QString &query, quint32 offset = 0, quint32 limit = 20);
void mostPopularSearch(const QString &query, quint32 limit = 8);
void getDynamicSuggestions(const QJsonArray &listenedTrackIds, const QJsonArray &tracksToAnalyze, quint32 limit = 50);
void getAlbum(const QString &albumId);
void getArtist(qint64 artistId);
void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0);
@@ -89,6 +90,7 @@ signals:
void artistLoaded(const QJsonObject &artist);
void artistReleasesLoaded(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset);
void deepShuffleTracksLoaded(const QJsonArray &tracks);
void dynamicSuggestionsLoaded(const QJsonObject &result);
void genresLoaded(const QJsonObject &result);
void featuredAlbumsLoaded(const QJsonObject &result);
void playlistLoaded(const QJsonObject &playlist);

View File

@@ -45,6 +45,10 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
m_gapless->setChecked(AppSettings::instance().gaplessEnabled());
playLayout->addRow(m_gapless);
m_autoplay = new QCheckBox(tr("Autoplay recommendations when queue ends"), playGroup);
m_autoplay->setChecked(AppSettings::instance().autoplayEnabled());
playLayout->addRow(m_autoplay);
layout->addWidget(playGroup);
// --- Last.fm group ---
@@ -101,6 +105,7 @@ void SettingsDialog::applyChanges()
AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt());
AppSettings::instance().setReplayGainEnabled(m_replayGain->isChecked());
AppSettings::instance().setGaplessEnabled(m_gapless->isChecked());
AppSettings::instance().setAutoplayEnabled(m_autoplay->isChecked());
AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked());
AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed());
AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed());

View File

@@ -20,6 +20,7 @@ private:
QComboBox *m_formatBox = nullptr;
QCheckBox *m_replayGain = nullptr;
QCheckBox *m_gapless = nullptr;
QCheckBox *m_autoplay = nullptr;
// Last.fm
QCheckBox *m_lastFmEnabled = nullptr;

View File

@@ -241,6 +241,17 @@ public:
emit queueChanged();
}
/// Append tracks to the main queue tail (autoplay/discovery).
void appendToContext(const QJsonArray &tracks)
{
for (const auto &v : tracks) {
const QJsonObject t = v.toObject();
if (t["streamable"].toBool(true))
m_queue.append(t);
}
emit queueChanged();
}
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
void moveUpcomingToTop(int upcomingIndex)
{

View File

@@ -41,6 +41,9 @@ public:
bool gaplessEnabled() const { return m_settings.value("playback/gapless", false).toBool(); }
void setGaplessEnabled(bool v) { m_settings.setValue("playback/gapless", v); }
bool autoplayEnabled() const { return m_settings.value("playback/autoplay", false).toBool(); }
void setAutoplayEnabled(bool v) { m_settings.setValue("playback/autoplay", v); }
// --- Last.fm ---
bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); }
void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); }

View File

@@ -94,6 +94,11 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
m_shuffle->setCheckable(true);
connect(m_shuffle, &QAction::toggled, this, &MainToolBar::onShuffleToggled);
m_autoplay = addAction(Icon::repeat(), tr("Autoplay"));
m_autoplay->setCheckable(true);
m_autoplay->setChecked(AppSettings::instance().autoplayEnabled());
connect(m_autoplay, &QAction::toggled, this, &MainToolBar::onAutoplayToggled);
// ---- Volume ----
m_volume = new VolumeButton(this);
addWidget(m_volume);
@@ -118,6 +123,8 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
connect(m_backend, &QobuzBackend::positionChanged, this, &MainToolBar::onPositionChanged);
connect(m_backend, &QobuzBackend::trackFinished, this, &MainToolBar::onTrackFinished);
connect(m_backend, &QobuzBackend::trackTransitioned, this, &MainToolBar::onTrackTransitioned);
connect(m_backend, &QobuzBackend::dynamicSuggestionsLoaded,
this, &MainToolBar::onDynamicSuggestionsLoaded);
// ---- Queue signals ----
connect(m_queue, &PlayQueue::queueChanged, this, &MainToolBar::onQueueChanged);
@@ -232,6 +239,12 @@ void MainToolBar::onBackendStateChanged(const QString &state)
void MainToolBar::onTrackChanged(const QJsonObject &track)
{
setCurrentTrack(track);
if (track.contains("id")) {
m_recentTracks.append(track);
while (m_recentTracks.size() > 32)
m_recentTracks.removeFirst();
}
}
void MainToolBar::onPositionChanged(quint64 position, quint64 duration)
@@ -243,6 +256,8 @@ void MainToolBar::onTrackFinished()
{
if (m_queue->canGoNext()) {
onNext();
} else if (AppSettings::instance().autoplayEnabled()) {
requestAutoplaySuggestions();
} else {
setPlaying(false);
m_progress->setValue(0);
@@ -272,6 +287,81 @@ void MainToolBar::onShuffleToggled(bool checked)
m_queue->setShuffle(checked);
}
void MainToolBar::onAutoplayToggled(bool checked)
{
AppSettings::instance().setAutoplayEnabled(checked);
}
void MainToolBar::requestAutoplaySuggestions()
{
if (m_fetchingAutoplay)
return;
QJsonArray listenedIds;
QJsonArray analyze;
const int n = m_recentTracks.size();
for (int i = 0; i < n; ++i) {
const QJsonObject t = m_recentTracks.at(i);
const qint64 trackId = static_cast<qint64>(t["id"].toDouble());
if (trackId <= 0)
continue;
listenedIds.append(trackId);
if (i < qMax(0, n - 5))
continue;
const qint64 artistId = static_cast<qint64>(
t["performer"].toObject()["id"].toDouble());
const qint64 genreId = static_cast<qint64>(
t["album"].toObject()["genre"].toObject()["id"].toDouble());
const qint64 labelId = static_cast<qint64>(
t["album"].toObject()["label"].toObject()["id"].toDouble());
analyze.append(QJsonObject{
{"track_id", trackId},
{"artist_id", artistId},
{"genre_id", genreId},
{"label_id", labelId},
});
}
if (listenedIds.isEmpty() || analyze.isEmpty()) {
setPlaying(false);
m_progress->setValue(0);
m_position->setText(QStringLiteral("0:00 / 0:00"));
return;
}
m_fetchingAutoplay = true;
m_backend->getDynamicSuggestions(listenedIds, analyze, 50);
}
void MainToolBar::onDynamicSuggestionsLoaded(const QJsonObject &result)
{
m_fetchingAutoplay = false;
QJsonArray items;
if (result["tracks"].isObject())
items = result["tracks"].toObject()["items"].toArray();
if (items.isEmpty() && result["dynamic"].isObject())
items = result["dynamic"].toObject()["items"].toArray();
if (items.isEmpty())
items = result["items"].toArray();
if (items.isEmpty()) {
setPlaying(false);
m_progress->setValue(0);
m_position->setText(QStringLiteral("0:00 / 0:00"));
return;
}
m_queue->appendToContext(items);
if (m_queue->canGoNext())
onNext();
}
void MainToolBar::fetchAlbumArt(const QString &url)
{
m_nam->get(QNetworkRequest(QUrl(url)));

View File

@@ -12,6 +12,7 @@
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonObject>
#include <QVector>
class MainToolBar : public QToolBar
{
@@ -47,6 +48,8 @@ private slots:
void onTrackTransitioned();
void onQueueChanged();
void onShuffleToggled(bool checked);
void onAutoplayToggled(bool checked);
void onDynamicSuggestionsLoaded(const QJsonObject &result);
void fetchAlbumArt(const QString &url);
void onAlbumArtReady(QNetworkReply *reply);
@@ -65,6 +68,7 @@ private:
QLabel *m_position = nullptr;
QWidget *m_rightSpacer = nullptr;
QAction *m_shuffle = nullptr;
QAction *m_autoplay = nullptr;
VolumeButton *m_volume = nullptr;
QAction *m_queueBtn = nullptr;
QAction *m_search = nullptr;
@@ -72,6 +76,10 @@ private:
QNetworkAccessManager *m_nam = nullptr;
QString m_currentArtUrl;
QJsonObject m_currentTrack;
QVector<QJsonObject> m_recentTracks;
bool m_playing = false;
bool m_seeking = false;
bool m_fetchingAutoplay = false;
void requestAutoplaySuggestions();
};