From e453f8acf38011532c1a13c7802019ab1aedb621 Mon Sep 17 00:00:00 2001 From: joren Date: Tue, 31 Mar 2026 10:43:36 +0200 Subject: [PATCH] feat: add seamless lazy loading for genre and playlist views Introduce paged loading with early prefetch for genre albums/playlists and playlist tracks, while preserving full-data behavior for deep shuffle and playlist play-all actions. --- rust/include/qobuz_backend.h | 1 + rust/src/api/client.rs | 45 +++++++ rust/src/lib.rs | 30 +++++ src/backend/qobuzbackend.cpp | 5 + src/backend/qobuzbackend.hpp | 1 + src/list/tracks.cpp | 121 +++++++++++++++++- src/list/tracks.hpp | 8 ++ src/mainwindow.cpp | 10 ++ src/model/tracklistmodel.cpp | 86 +++++++++++++ src/model/tracklistmodel.hpp | 3 + src/view/genrebrowser.cpp | 229 ++++++++++++++++++++++++++++++++--- src/view/genrebrowser.hpp | 21 +++- 12 files changed, 543 insertions(+), 17 deletions(-) diff --git a/rust/include/qobuz_backend.h b/rust/include/qobuz_backend.h index 51ef63d..11274c6 100644 --- a/rust/include/qobuz_backend.h +++ b/rust/include/qobuz_backend.h @@ -67,6 +67,7 @@ void qobuz_backend_get_dynamic_suggestions(QobuzBackendOpaque *backend, const ch 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); +void qobuz_backend_get_playlist_all(QobuzBackendOpaque *backend, int64_t playlist_id); // Favorites void qobuz_backend_get_fav_tracks(QobuzBackendOpaque *backend, uint32_t offset, uint32_t limit); diff --git a/rust/src/api/client.rs b/rust/src/api/client.rs index 4988c49..1091817 100644 --- a/rust/src/api/client.rs +++ b/rust/src/api/client.rs @@ -639,6 +639,51 @@ impl QobuzClient { Ok(serde_json::from_value(body)?) } + pub async fn get_playlist_all(&self, playlist_id: i64) -> Result { + const PAGE_LIMIT: u32 = 500; + + let mut playlist = self.get_playlist(playlist_id, 0, PAGE_LIMIT).await?; + + let mut all_items = playlist + .tracks + .as_ref() + .and_then(|t| t.items.clone()) + .unwrap_or_default(); + + let mut total = playlist + .tracks + .as_ref() + .and_then(|t| t.total) + .unwrap_or(all_items.len() as i32); + if total < all_items.len() as i32 { + total = all_items.len() as i32; + } + + let mut offset = all_items.len() as u32; + while (offset as i32) < total { + let page = self.get_playlist(playlist_id, offset, PAGE_LIMIT).await?; + let mut page_items = page + .tracks + .as_ref() + .and_then(|t| t.items.clone()) + .unwrap_or_default(); + if page_items.is_empty() { + break; + } + all_items.append(&mut page_items); + offset = all_items.len() as u32; + } + + if let Some(tracks) = playlist.tracks.as_mut() { + tracks.items = Some(all_items); + tracks.total = Some(total); + tracks.offset = Some(0); + tracks.limit = Some(PAGE_LIMIT as i32); + } + + Ok(playlist) + } + /// Fetch all favorite IDs (tracks, albums, artists) in one call. async fn get_fav_ids(&self) -> Result { let resp = self diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 417465c..a446647 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -514,6 +514,8 @@ pub unsafe extern "C" fn qobuz_backend_get_featured_albums( "total": total, "type": kind_str, "genre_ids": genre_ids_str, + "offset": offset, + "limit": limit, }); call_cb( cb, @@ -557,6 +559,8 @@ pub unsafe extern "C" fn qobuz_backend_get_featured_playlists( "total": total, "type": kind_str, "genre_ids": genre_ids_str, + "offset": offset, + "limit": limit, }); call_cb( cb, @@ -600,6 +604,8 @@ pub unsafe extern "C" fn qobuz_backend_discover_playlists( "total": total, "genre_ids": genre_ids_str, "tags": tags_str, + "offset": offset, + "limit": limit, }); call_cb( cb, @@ -640,6 +646,8 @@ pub unsafe extern "C" fn qobuz_backend_search_playlists( "items": items, "total": total, "query": query_str, + "offset": offset, + "limit": limit, }); call_cb( cb, @@ -684,6 +692,28 @@ pub unsafe extern "C" fn qobuz_backend_get_playlist( }); } +#[no_mangle] +pub unsafe extern "C" fn qobuz_backend_get_playlist_all(ptr: *mut Backend, playlist_id: i64) { + let inner = &(*ptr).0; + let client = inner.client.clone(); + let cb = inner.cb; + let ud = inner.ud; + spawn(inner, async move { + let result = client.lock().await.get_playlist_all(playlist_id).await; + let (ev, json) = match result { + Ok(r) => { + let mut v = serde_json::to_value(&r).unwrap_or_default(); + if let serde_json::Value::Object(ref mut obj) = v { + obj.insert("full_load".to_string(), serde_json::Value::Bool(true)); + } + (EV_PLAYLIST_OK, serde_json::to_string(&v).unwrap_or_default()) + } + Err(e) => (EV_PLAYLIST_ERR, err_json(&e.to_string())), + }; + call_cb(cb, ud, ev, &json); + }); +} + // ---------- Favorites ---------- #[no_mangle] diff --git a/src/backend/qobuzbackend.cpp b/src/backend/qobuzbackend.cpp index 8153d9e..bd7090d 100644 --- a/src/backend/qobuzbackend.cpp +++ b/src/backend/qobuzbackend.cpp @@ -95,6 +95,11 @@ void QobuzBackend::getPlaylist(qint64 playlistId, quint32 offset, quint32 limit) qobuz_backend_get_playlist(m_backend, playlistId, offset, limit); } +void QobuzBackend::getPlaylistAll(qint64 playlistId) +{ + qobuz_backend_get_playlist_all(m_backend, playlistId); +} + void QobuzBackend::getGenres() { qobuz_backend_get_genres(m_backend); diff --git a/src/backend/qobuzbackend.hpp b/src/backend/qobuzbackend.hpp index 2399586..7d42670 100644 --- a/src/backend/qobuzbackend.hpp +++ b/src/backend/qobuzbackend.hpp @@ -35,6 +35,7 @@ public: void getArtistReleases(qint64 artistId, const QString &releaseType, quint32 limit = 50, quint32 offset = 0); void getAlbumsTracks(const QStringList &albumIds); void getPlaylist(qint64 playlistId, quint32 offset = 0, quint32 limit = 500); + void getPlaylistAll(qint64 playlistId); void getGenres(); void getFeaturedAlbums(const QString &genreIds, const QString &kind, quint32 limit = 50, quint32 offset = 0); void getFeaturedPlaylists(const QString &genreIds, const QString &kind, quint32 limit = 25, quint32 offset = 0); diff --git a/src/list/tracks.cpp b/src/list/tracks.cpp index ee1611d..e9d0118 100644 --- a/src/list/tracks.cpp +++ b/src/list/tracks.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace List { @@ -41,12 +42,45 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent) setFirstColumnSpanned(row, {}, true); setSortingEnabled(!m_model->hasMultipleDiscs()); }); + connect(verticalScrollBar(), &QScrollBar::valueChanged, + this, [this](int) { maybeLoadMorePlaylistTracks(); }); + + connect(m_backend, &QobuzBackend::playlistLoaded, this, + [this](const QJsonObject &playlist) { + if (!m_pendingPlayAll) + return; + + const qint64 id = static_cast(playlist["id"].toDouble()); + if (id != m_playlistId) + return; + + if (!playlist["full_load"].toBool()) + return; + + m_pendingPlayAll = false; + const bool shuffle = m_pendingPlayAllShuffle; + m_pendingPlayAllShuffle = false; + + const QJsonArray items = playlist["tracks"].toObject()["items"].toArray(); + if (items.isEmpty()) + return; + + m_queue->setContext(items, 0); + if (shuffle && !m_queue->shuffleEnabled()) + m_queue->shuffleNow(); + + const qint64 firstId = static_cast(m_queue->current()["id"].toDouble()); + if (firstId > 0) + emit playTrackRequested(firstId); + }); } void Tracks::loadTracks(const QJsonArray &tracks) { setPlaylistContext(0); + m_pendingPlayAll = false; + m_pendingPlayAllShuffle = false; setColumnHidden(TrackListModel::ColAlbum, false); m_model->setTracks(tracks, false, /*useSequential=*/true); } @@ -54,6 +88,8 @@ void Tracks::loadTracks(const QJsonArray &tracks) void Tracks::loadAlbum(const QJsonObject &album) { setPlaylistContext(0); + m_pendingPlayAll = false; + m_pendingPlayAllShuffle = false; setColumnHidden(TrackListModel::ColAlbum, true); const QJsonArray items = album["tracks"].toObject()["items"].toArray(); m_model->setTracks(items); // album: use track_number @@ -67,21 +103,72 @@ void Tracks::loadPlaylist(const QJsonObject &playlist) const qint64 myId = AppSettings::instance().userId(); const bool isOwned = (myId > 0 && ownId == myId); setPlaylistContext(id, isOwned); - const QJsonArray items = playlist["tracks"].toObject()["items"].toArray(); + const QJsonObject tracksObj = playlist["tracks"].toObject(); + const QJsonArray items = tracksObj["items"].toArray(); + const int offset = tracksObj["offset"].toInt(0); + m_playlistTrackTotal = tracksObj["total"].toInt(items.size()); + m_playlistLoadedCount = offset + items.size(); + m_playlistLoadingMore = false; m_model->setTracks(items, /*usePosition=*/true); + maybeLoadMorePlaylistTracks(); +} + +void Tracks::appendPlaylistPage(const QJsonObject &playlist) +{ + if (m_playlistId <= 0) + return; + + const qint64 id = static_cast(playlist["id"].toDouble()); + if (id != m_playlistId) + return; + + const QJsonObject tracksObj = playlist["tracks"].toObject(); + const QJsonArray items = tracksObj["items"].toArray(); + const int offset = tracksObj["offset"].toInt(m_playlistLoadedCount); + const int total = tracksObj["total"].toInt(m_playlistTrackTotal); + + if (total > 0) + m_playlistTrackTotal = total; + + // Ignore stale/duplicate pages. + if (offset < m_playlistLoadedCount) { + m_playlistLoadingMore = false; + return; + } + + if (!items.isEmpty()) { + m_model->appendTracks(items, /*usePosition=*/true); + m_playlistLoadedCount = offset + items.size(); + } else { + m_playlistLoadedCount = qMax(m_playlistLoadedCount, offset); + } + + m_playlistLoadingMore = false; + maybeLoadMorePlaylistTracks(); } void Tracks::loadSearchTracks(const QJsonArray &tracks) { setPlaylistContext(0); + m_pendingPlayAll = false; + m_pendingPlayAllShuffle = false; setColumnHidden(TrackListModel::ColAlbum, false); m_model->setTracks(tracks, false, /*useSequential=*/true); } void Tracks::setPlaylistContext(qint64 playlistId, bool isOwned) { + if (m_playlistId != playlistId) { + m_pendingPlayAll = false; + m_pendingPlayAllShuffle = false; + m_playlistLoadingMore = false; + } m_playlistId = playlistId; m_playlistIsOwned = isOwned; + if (playlistId <= 0) { + m_playlistTrackTotal = 0; + m_playlistLoadedCount = 0; + } } void Tracks::setUserPlaylists(const QVector> &playlists) @@ -113,6 +200,14 @@ void Tracks::playAll(bool shuffle) { const QJsonArray tracks = m_model->currentTracksJson(); if (tracks.isEmpty()) return; + + if (m_playlistId > 0 && m_playlistTrackTotal > tracks.size()) { + m_pendingPlayAll = true; + m_pendingPlayAllShuffle = shuffle; + m_backend->getPlaylistAll(m_playlistId); + return; + } + m_queue->setContext(tracks, 0); // Shuffle once without touching the global shuffle flag — so a subsequent // double-click on a track plays in normal order (unless global shuffle is on). @@ -123,6 +218,30 @@ void Tracks::playAll(bool shuffle) emit playTrackRequested(firstId); } +void Tracks::maybeLoadMorePlaylistTracks() +{ + if (m_playlistId <= 0) + return; + if (m_pendingPlayAll) + return; + if (m_playlistLoadingMore) + return; + if (m_playlistTrackTotal > 0 && m_playlistLoadedCount >= m_playlistTrackTotal) + return; + + QScrollBar *bar = verticalScrollBar(); + if (!bar) + return; + + // Start prefetching before the absolute bottom so paging feels seamless. + constexpr int kPrefetchPx = 180; + if (bar->maximum() > 0 && bar->value() < (bar->maximum() - kPrefetchPx)) + return; + + m_playlistLoadingMore = true; + m_backend->getPlaylist(m_playlistId, static_cast(m_playlistLoadedCount), 500); +} + void Tracks::onDoubleClicked(const QModelIndex &index) { diff --git a/src/list/tracks.hpp b/src/list/tracks.hpp index 77687ac..c220e32 100644 --- a/src/list/tracks.hpp +++ b/src/list/tracks.hpp @@ -24,6 +24,7 @@ namespace List void loadTracks(const QJsonArray &tracks); void loadAlbum(const QJsonObject &album); void loadPlaylist(const QJsonObject &playlist); + void appendPlaylistPage(const QJsonObject &playlist); void loadSearchTracks(const QJsonArray &tracks); /// Called when the backend fires EV_TRACK_CHANGED so the playing row is highlighted. @@ -55,9 +56,16 @@ namespace List QobuzBackend *m_backend = nullptr; PlayQueue *m_queue = nullptr; qint64 m_playlistId = 0; + int m_playlistTrackTotal = 0; + int m_playlistLoadedCount = 0; bool m_playlistIsOwned = false; + bool m_playlistLoadingMore = false; + bool m_pendingPlayAll = false; + bool m_pendingPlayAllShuffle = false; QVector> m_userPlaylists; + void maybeLoadMorePlaylistTracks(); + void onDoubleClicked(const QModelIndex &index); void onContextMenu(const QPoint &pos); }; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 70ab008..107dacc 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -150,6 +150,9 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent) connect(m_library, &List::Library::userPlaylistIdsChanged, this, [this](const QSet &playlistIds) { m_userPlaylistIds = playlistIds; + const qint64 currentPlaylistId = m_content->tracksList()->playlistId(); + if (currentPlaylistId > 0) + m_content->setCurrentPlaylistFollowed(m_userPlaylistIds.contains(currentPlaylistId)); }); connect(m_library, &List::Library::userPlaylistsChanged, this, &MainWindow::onUserPlaylistsChanged); @@ -537,6 +540,13 @@ void MainWindow::onArtistLoaded(const QJsonObject &artist) void MainWindow::onPlaylistLoaded(const QJsonObject &playlist) { + const bool fullLoad = playlist["full_load"].toBool(false); + const int trackOffset = playlist["tracks"].toObject()["offset"].toInt(0); + if (!fullLoad && trackOffset > 0) { + m_content->tracksList()->appendPlaylistPage(playlist); + return; + } + const qint64 id = static_cast(playlist["id"].toDouble()); const qint64 ownerId = static_cast(playlist["owner"].toObject()["id"].toDouble()); const qint64 myId = AppSettings::instance().userId(); diff --git a/src/model/tracklistmodel.cpp b/src/model/tracklistmodel.cpp index 07dc515..cb15734 100644 --- a/src/model/tracklistmodel.cpp +++ b/src/model/tracklistmodel.cpp @@ -109,6 +109,92 @@ void TrackListModel::setTracks(const QJsonArray &tracks, emit sortApplied(); } +void TrackListModel::appendTracks(const QJsonArray &tracks, + bool usePosition, + bool useSequential) +{ + if (tracks.isEmpty()) + return; + + // Keep append path simple and stable: disc-header mode is handled by reset path. + if (m_hasMultipleDiscs && !usePosition && !useSequential) { + QJsonArray all = currentTracksJson(); + for (const QJsonValue &v : tracks) + all.append(v); + setTracks(all, usePosition, useSequential); + return; + } + + int seq = 1; + if (useSequential || usePosition) { + for (const TrackItem &t : m_tracks) + if (!t.isDiscHeader) + ++seq; + } + + QVector parsed; + parsed.reserve(tracks.size()); + for (const QJsonValue &v : tracks) { + const QJsonObject t = v.toObject(); + TrackItem item; + item.id = static_cast(t["id"].toDouble()); + item.playlistTrackId = static_cast(t["playlist_track_id"].toDouble()); + item.discNumber = t["media_number"].toInt(1); + item.duration = static_cast(t["duration"].toDouble()); + item.streamable = t["streamable"].toBool(true); + item.hiRes = t["hires_streamable"].toBool(); + item.raw = t; + + const QString base = t["title"].toString(); + const QString version = t["version"].toString().trimmed(); + item.title = version.isEmpty() ? base + : base + QStringLiteral(" (") + version + QLatin1Char(')'); + + if (useSequential) { + item.number = seq++; + } else if (usePosition) { + const int pos = t["position"].toInt(); + item.number = pos > 0 ? pos : seq; + ++seq; + } else { + item.number = t["track_number"].toInt(); + } + + const QJsonObject performer = t["performer"].toObject(); + item.artist = performer["name"].toString(); + if (item.artist.isEmpty()) { + const QJsonValue n = t["album"].toObject()["artist"].toObject()["name"]; + item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString(); + } + if (item.artist.isEmpty()) { + const QJsonValue n = t["artist"].toObject()["name"]; + item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString(); + } + + const QJsonObject album = t["album"].toObject(); + item.album = album["title"].toString(); + item.albumId = album["id"].toString(); + + parsed.append(item); + } + + if (parsed.isEmpty()) + return; + + const int first = m_tracks.size(); + const int last = first + parsed.size() - 1; + beginInsertRows({}, first, last); + m_tracks += parsed; + endInsertRows(); + + if (!m_hasMultipleDiscs && m_sortColumn >= 0) { + emit layoutAboutToBeChanged(); + sortData(m_sortColumn, m_sortOrder); + emit layoutChanged(); + emit sortApplied(); + } +} + void TrackListModel::clear() { beginResetModel(); diff --git a/src/model/tracklistmodel.hpp b/src/model/tracklistmodel.hpp index dbe06b7..706541a 100644 --- a/src/model/tracklistmodel.hpp +++ b/src/model/tracklistmodel.hpp @@ -51,6 +51,9 @@ public: void setTracks(const QJsonArray &tracks, bool usePosition = false, bool useSequential = false); + void appendTracks(const QJsonArray &tracks, + bool usePosition = false, + bool useSequential = false); void clear(); void setPlayingId(qint64 id); qint64 playingId() const { return m_playingId; } diff --git a/src/view/genrebrowser.cpp b/src/view/genrebrowser.cpp index 4f79f89..de5a670 100644 --- a/src/view/genrebrowser.cpp +++ b/src/view/genrebrowser.cpp @@ -176,6 +176,10 @@ GenreBrowserView::GenreBrowserView(QobuzBackend *backend, PlayQueue *queue, QWid this, &GenreBrowserView::onPlaylistActivated); connect(m_playlistList, &QTreeWidget::customContextMenuRequested, this, &GenreBrowserView::onPlaylistContextMenu); + connect(m_albumList->verticalScrollBar(), &QScrollBar::valueChanged, + this, &GenreBrowserView::onAlbumScroll); + connect(m_playlistList->verticalScrollBar(), &QScrollBar::valueChanged, + this, &GenreBrowserView::onPlaylistScroll); m_kindCombo->setCurrentIndex(0); refreshModeUi(); @@ -358,40 +362,125 @@ void GenreBrowserView::onGenresLoaded(const QJsonObject &result) void GenreBrowserView::onFeaturedAlbumsLoaded(const QJsonObject &result) { m_resultsStack->setCurrentIndex(0); - m_albumList->setAlbums(result["items"].toArray()); + const QString genreIds = result["genre_ids"].toString(); + const QString type = result["type"].toString(); + const int offset = result["offset"].toInt(); + if (genreIds != m_lastAlbumGenreIds || type != m_lastAlbumType) + return; + + const QJsonArray items = result["items"].toArray(); + if (offset <= 0) + m_albumList->setAlbums(items); + else + m_albumList->addAlbums(items); + + m_albumTotal = result["total"].toInt(); + m_albumOffset = offset + items.size(); + if (items.isEmpty()) + m_albumTotal = m_albumOffset; + m_loadingAlbums = false; + + if (m_collectAlbumsForDeepShuffle) { + if (m_albumOffset < m_albumTotal) { + requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true); + return; + } + m_collectAlbumsForDeepShuffle = false; + startDeepShuffleFromLoadedAlbums(); + return; + } + + // If the viewport is not scrollable yet, eagerly fetch more pages. + QScrollBar *bar = m_albumList->verticalScrollBar(); + if (bar && bar->maximum() == 0 && m_albumOffset < m_albumTotal) + requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true); } void GenreBrowserView::onFeaturedPlaylistsLoaded(const QJsonObject &result) { m_resultsStack->setCurrentIndex(1); - setPlaylistItems(result["items"].toArray()); + const QString genreIds = result["genre_ids"].toString(); + const QString type = result["type"].toString(); + const int offset = result["offset"].toInt(); + if (genreIds != m_lastPlaylistGenreIds || type != m_lastPlaylistType) + return; + + const QJsonArray items = result["items"].toArray(); + setPlaylistItems(items, offset > 0); + m_playlistTotal = result["total"].toInt(); + m_playlistOffset = offset + items.size(); + if (items.isEmpty()) + m_playlistTotal = m_playlistOffset; + m_loadingPlaylists = false; + + QScrollBar *bar = m_playlistList->verticalScrollBar(); + if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal) + requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true); } void GenreBrowserView::onDiscoverPlaylistsLoaded(const QJsonObject &result) { m_resultsStack->setCurrentIndex(1); - setPlaylistItems(result["items"].toArray()); + const QString genreIds = result["genre_ids"].toString(); + const QString tags = result["tags"].toString(); + const int offset = result["offset"].toInt(); + if (genreIds != m_lastPlaylistGenreIds || tags != m_lastPlaylistTags) + return; + + const QJsonArray items = result["items"].toArray(); + setPlaylistItems(items, offset > 0); + m_playlistTotal = result["total"].toInt(); + m_playlistOffset = offset + items.size(); + if (items.isEmpty()) + m_playlistTotal = m_playlistOffset; + m_loadingPlaylists = false; + + QScrollBar *bar = m_playlistList->verticalScrollBar(); + if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal) + requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true); } void GenreBrowserView::onPlaylistSearchLoaded(const QJsonObject &result) { m_resultsStack->setCurrentIndex(1); - setPlaylistItems(result["items"].toArray()); + const QString query = result["query"].toString(); + const int offset = result["offset"].toInt(); + if (query != m_lastPlaylistQuery) + return; + + const QJsonArray items = result["items"].toArray(); + setPlaylistItems(items, offset > 0); + m_playlistTotal = result["total"].toInt(); + m_playlistOffset = offset + items.size(); + if (items.isEmpty()) + m_playlistTotal = m_playlistOffset; + m_loadingPlaylists = false; + + QScrollBar *bar = m_playlistList->verticalScrollBar(); + if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal) + requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true); } void GenreBrowserView::onSelectionChanged() { + m_collectAlbumsForDeepShuffle = false; + if (m_mode == BrowseMode::PlaylistSearch) { m_resultsStack->setCurrentIndex(1); m_playlistSearchLabel->setVisible(true); m_playlistSearchBox->setVisible(true); m_playlistSearchBtn->setVisible(true); m_deepShuffleBtn->setVisible(false); + m_deepShuffleBtn->setEnabled(true); + m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle")); const QString query = m_playlistSearchBox->text().trimmed(); if (query.size() < 2) { m_playlistList->clear(); + m_playlistOffset = 0; + m_playlistTotal = 0; + m_loadingPlaylists = false; } else { - m_backend->searchPlaylists(query, 8, 0); + requestPlaylistsPage(QString(), QStringLiteral("search"), QString(), query, 0, false); } return; } @@ -408,20 +497,25 @@ void GenreBrowserView::onSelectionChanged() if (kind == QStringLiteral("playlists")) { m_resultsStack->setCurrentIndex(1); + m_deepShuffleBtn->setVisible(false); + m_deepShuffleBtn->setEnabled(true); + m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle")); if (type == QStringLiteral("discover-new")) - m_backend->discoverPlaylists(genreIds, QStringLiteral("new"), 25, 0); + requestPlaylistsPage(genreIds, type, QStringLiteral("new"), QString(), 0, false); else if (type == QStringLiteral("discover-hires")) - m_backend->discoverPlaylists(genreIds, QStringLiteral("hi-res"), 25, 0); + requestPlaylistsPage(genreIds, type, QStringLiteral("hi-res"), QString(), 0, false); else if (type == QStringLiteral("discover-focus")) - m_backend->discoverPlaylists(genreIds, QStringLiteral("focus"), 25, 0); + requestPlaylistsPage(genreIds, type, QStringLiteral("focus"), QString(), 0, false); else if (type == QStringLiteral("discover-qobuzdigs")) - m_backend->discoverPlaylists(genreIds, QStringLiteral("qobuzdigs"), 25, 0); + requestPlaylistsPage(genreIds, type, QStringLiteral("qobuzdigs"), QString(), 0, false); else - m_backend->getFeaturedPlaylists(genreIds, type, 25, 0); + requestPlaylistsPage(genreIds, type, QString(), QString(), 0, false); } else { m_resultsStack->setCurrentIndex(0); m_deepShuffleBtn->setVisible(m_mode == BrowseMode::Genres); - m_backend->getFeaturedAlbums(genreIds, type, 50, 0); + m_deepShuffleBtn->setEnabled(true); + m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle")); + requestAlbumsPage(genreIds, type, 0, false); } } @@ -436,11 +530,14 @@ QStringList GenreBrowserView::currentAlbumIds() const return ids; } -void GenreBrowserView::onDeepShuffleClicked() +void GenreBrowserView::startDeepShuffleFromLoadedAlbums() { const QStringList albumIds = currentAlbumIds(); - if (albumIds.isEmpty()) + if (albumIds.isEmpty()) { + m_deepShuffleBtn->setEnabled(true); + m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle")); return; + } m_waitingDeepShuffle = true; m_deepShuffleBtn->setEnabled(false); @@ -448,12 +545,113 @@ void GenreBrowserView::onDeepShuffleClicked() m_backend->getAlbumsTracks(albumIds); } +void GenreBrowserView::requestAlbumsPage(const QString &genreIds, const QString &type, int offset, bool append) +{ + if (append && m_loadingAlbums) + return; + + if (!append) { + m_loadingAlbums = false; + m_albumOffset = 0; + m_albumTotal = 0; + } + + m_lastAlbumGenreIds = genreIds; + m_lastAlbumType = type; + m_loadingAlbums = true; + m_backend->getFeaturedAlbums(genreIds, type, 50, static_cast(offset)); +} + +void GenreBrowserView::requestPlaylistsPage(const QString &genreIds, const QString &type, const QString &tags, const QString &query, int offset, bool append) +{ + if (append && m_loadingPlaylists) + return; + + if (!append) { + m_loadingPlaylists = false; + m_playlistOffset = 0; + m_playlistTotal = 0; + } + + m_lastPlaylistGenreIds = genreIds; + m_lastPlaylistType = type; + m_lastPlaylistTags = tags; + m_lastPlaylistQuery = query; + m_loadingPlaylists = true; + + if (type == QStringLiteral("search")) { + m_backend->searchPlaylists(query, 8, static_cast(offset)); + } else if (type.startsWith(QStringLiteral("discover-"))) { + m_backend->discoverPlaylists(genreIds, tags, 25, static_cast(offset)); + } else { + m_backend->getFeaturedPlaylists(genreIds, type, 25, static_cast(offset)); + } +} + +void GenreBrowserView::onAlbumScroll(int value) +{ + if (m_mode != BrowseMode::Genres) + return; + if (m_kindCombo->currentData().toString() != QStringLiteral("albums")) + return; + if (m_loadingAlbums) + return; + if (m_albumOffset >= m_albumTotal) + return; + + QScrollBar *bar = m_albumList->verticalScrollBar(); + if (!bar || value < (bar->maximum() - 12)) + return; + + requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true); +} + +void GenreBrowserView::onPlaylistScroll(int value) +{ + if (m_loadingPlaylists) + return; + if (m_playlistOffset >= m_playlistTotal) + return; + + QScrollBar *bar = m_playlistList->verticalScrollBar(); + if (!bar || value < (bar->maximum() - 12)) + return; + + requestPlaylistsPage( + m_lastPlaylistGenreIds, + m_lastPlaylistType, + m_lastPlaylistTags, + m_lastPlaylistQuery, + m_playlistOffset, + true); +} + +void GenreBrowserView::onDeepShuffleClicked() +{ + m_deepShuffleBtn->setEnabled(false); + m_deepShuffleBtn->setText(tr("Loading…")); + + if (m_loadingAlbums) { + m_collectAlbumsForDeepShuffle = true; + return; + } + + if (m_albumOffset < m_albumTotal) { + m_collectAlbumsForDeepShuffle = true; + requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true); + return; + } + + startDeepShuffleFromLoadedAlbums(); +} + bool GenreBrowserView::tryHandleDeepShuffleTracks(const QJsonArray &tracks) { if (!m_waitingDeepShuffle) return false; m_waitingDeepShuffle = false; + m_collectAlbumsForDeepShuffle = false; m_deepShuffleBtn->setEnabled(true); m_deepShuffleBtn->setText(tr("⇄ Deep Shuffle")); @@ -523,9 +721,10 @@ void GenreBrowserView::onPlaylistContextMenu(const QPoint &pos) menu.exec(m_playlistList->viewport()->mapToGlobal(pos)); } -void GenreBrowserView::setPlaylistItems(const QJsonArray &items) +void GenreBrowserView::setPlaylistItems(const QJsonArray &items, bool append) { - m_playlistList->clear(); + if (!append) + m_playlistList->clear(); QFont tagFont; tagFont.setBold(true); diff --git a/src/view/genrebrowser.hpp b/src/view/genrebrowser.hpp index 8f144ba..6ce384b 100644 --- a/src/view/genrebrowser.hpp +++ b/src/view/genrebrowser.hpp @@ -14,6 +14,7 @@ #include #include #include +#include class GenreBrowserView : public QWidget { @@ -48,6 +49,8 @@ private slots: void onPlaylistActivated(QTreeWidgetItem *item, int column); void onPlaylistContextMenu(const QPoint &pos); void onDeepShuffleClicked(); + void onAlbumScroll(int value); + void onPlaylistScroll(int value); private: QobuzBackend *m_backend = nullptr; @@ -72,12 +75,28 @@ private: int m_lastGenreComboIndex = 0; QSet m_multiGenreIds; bool m_waitingDeepShuffle = false; + bool m_collectAlbumsForDeepShuffle = false; + bool m_loadingAlbums = false; + bool m_loadingPlaylists = false; + int m_albumOffset = 0; + int m_albumTotal = 0; + int m_playlistOffset = 0; + int m_playlistTotal = 0; + QString m_lastAlbumGenreIds; + QString m_lastAlbumType; + QString m_lastPlaylistGenreIds; + QString m_lastPlaylistType; + QString m_lastPlaylistTags; + QString m_lastPlaylistQuery; void refreshModeUi(); void refreshGenreTypeChoices(); QString currentGenreIds() const; QStringList currentAlbumIds() const; + void startDeepShuffleFromLoadedAlbums(); + void requestAlbumsPage(const QString &genreIds, const QString &type, int offset, bool append); + void requestPlaylistsPage(const QString &genreIds, const QString &type, const QString &tags, const QString &query, int offset, bool append); bool chooseMultiGenres(); void updateMultiGenreLabel(); - void setPlaylistItems(const QJsonArray &items); + void setPlaylistItems(const QJsonArray &items, bool append = false); };