feat: add autoplay suggestions when queue runs out
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
Some checks failed
Build for Windows / build-windows (push) Has been cancelled
This commit is contained in:
@@ -40,6 +40,7 @@ enum QobuzEvent {
|
||||
EV_MOST_POPULAR_OK = 26,
|
||||
EV_GENRES_OK = 27,
|
||||
EV_FEATURED_ALBUMS_OK = 28,
|
||||
EV_DYNAMIC_SUGGEST_OK = 29,
|
||||
};
|
||||
|
||||
// Callback signature
|
||||
@@ -57,6 +58,7 @@ void qobuz_backend_get_user(QobuzBackendOpaque *backend);
|
||||
// Catalog
|
||||
void qobuz_backend_search(QobuzBackendOpaque *backend, const char *query, uint32_t offset, uint32_t limit);
|
||||
void qobuz_backend_most_popular_search(QobuzBackendOpaque *backend, const char *query, uint32_t limit);
|
||||
void qobuz_backend_get_dynamic_suggestions(QobuzBackendOpaque *backend, const char *listened_track_ids_json, const char *tracks_to_analyze_json, uint32_t limit);
|
||||
void qobuz_backend_get_album(QobuzBackendOpaque *backend, const char *album_id);
|
||||
void qobuz_backend_get_artist(QobuzBackendOpaque *backend, int64_t artist_id);
|
||||
void qobuz_backend_get_playlist(QobuzBackendOpaque *backend, int64_t playlist_id, uint32_t offset, uint32_t limit);
|
||||
|
||||
@@ -471,6 +471,24 @@ impl QobuzClient {
|
||||
Self::check_response(resp).await
|
||||
}
|
||||
|
||||
pub async fn get_dynamic_suggestions(
|
||||
&self,
|
||||
limit: u32,
|
||||
listened_tracks_ids: Value,
|
||||
tracks_to_analyze: Value,
|
||||
) -> Result<Value> {
|
||||
let resp = self
|
||||
.post_request("dynamic/suggest")
|
||||
.json(&serde_json::json!({
|
||||
"limit": limit,
|
||||
"listened_tracks_ids": listened_tracks_ids,
|
||||
"track_to_analysed": tracks_to_analyze,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
Self::check_response(resp).await
|
||||
}
|
||||
|
||||
async fn search_tracks(
|
||||
&self,
|
||||
query: &str,
|
||||
|
||||
@@ -48,6 +48,7 @@ pub const EV_DEEP_SHUFFLE_OK: c_int = 25;
|
||||
pub const EV_MOST_POPULAR_OK: c_int = 26;
|
||||
pub const EV_GENRES_OK: c_int = 27;
|
||||
pub const EV_FEATURED_ALBUMS_OK: c_int = 28;
|
||||
pub const EV_DYNAMIC_SUGGEST_OK: c_int = 29;
|
||||
|
||||
// ---------- Callback ----------
|
||||
|
||||
@@ -234,6 +235,59 @@ pub unsafe extern "C" fn qobuz_backend_most_popular_search(
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_get_dynamic_suggestions(
|
||||
ptr: *mut Backend,
|
||||
listened_track_ids_json: *const c_char,
|
||||
tracks_to_analyze_json: *const c_char,
|
||||
limit: u32,
|
||||
) {
|
||||
let inner = &(*ptr).0;
|
||||
let client = inner.client.clone();
|
||||
let cb = inner.cb;
|
||||
let ud = inner.ud;
|
||||
|
||||
let listened_str = CStr::from_ptr(listened_track_ids_json)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let analyze_str = CStr::from_ptr(tracks_to_analyze_json)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let listened: serde_json::Value = match serde_json::from_str(&listened_str) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let to_analyze: serde_json::Value = match serde_json::from_str(&analyze_str) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
spawn(inner, async move {
|
||||
let result = client
|
||||
.lock()
|
||||
.await
|
||||
.get_dynamic_suggestions(limit, listened, to_analyze)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => call_cb(
|
||||
cb,
|
||||
ud,
|
||||
EV_DYNAMIC_SUGGEST_OK,
|
||||
&serde_json::to_string(&r).unwrap_or_default(),
|
||||
),
|
||||
Err(e) => call_cb(cb, ud, EV_GENERIC_ERR, &err_json(&e.to_string())),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Album ----------
|
||||
|
||||
#[no_mangle]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user