Files
qbz/crates/qbz-slint/src/discover_browse.rs
T
vicrodh c5c124fb0c feat(slint): Discover "View all" pages + reusable AlbumListRow + recently-played metadata
Per-module discover See-All pages (new releases, press accolades, ideal
discography, qobuzissimes, albums of the week, most streamed) reachable
from each carousel's "View all" link: a paginated grid OR list view with
client-side search, genre filter, and a fixed header. Adds a reusable
AlbumListRow (columns #/ITEM/TYPE/SOURCE/QUALITY/TRACKS/YEAR, SOURCE
hideable, reuses QualityBadgeFull) with play-on-hover + even/odd zebra.
Recently Played albums now carry genre + localized release date + quality
(captured at play time, no extra fetch). Shared home::map_album /
card_to_item reused; grid-only AlbumCardItem literals get ..Default.

cc
2026-05-26 01:07:08 -06:00

217 lines
8.8 KiB
Rust

//! Discover "View all" full-list controller.
//!
//! Opens a single album module (New Releases, Press Accolades, Ideal
//! Discography, Qobuzissimes, Albums of the Week, Most Streamed) as a
//! paginated full-grid (or list) page. The Carousel's "View all" link
//! fires `discover-view-all(endpoint, title)`; the shell records the
//! history entry, switches the view to ContentView::DiscoverBrowse, and
//! calls `navigate` here.
//!
//! Pagination is driven off the backend `has_more` flag (the discover
//! endpoints carry no `total`): each page advances `offset` by the
//! returned item count and stops once `has_more` is false. Reuses the
//! Discover home mappers (`crate::home::map_album` / `card_to_item`) so
//! the cards carry the same genre + localized release date as the home
//! carousels.
//!
//! Header tools (mirroring Tauri's DiscoverBrowseView): a client-side
//! search filter over the loaded albums (disables load-more while
//! active), the shared genre filter (re-fetches from offset 0 with the
//! selected genre ids), and a grid/list view toggle.
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::adapter::SlintAdapter;
use crate::artwork::{self, ArtworkJob, ArtworkTarget, ImageCache};
use crate::home::CardData;
use crate::{AlbumCardItem, AppWindow, ContentView, DiscoverBrowseState, NavState};
/// Page size — two carousel pages' worth, fetched per request.
const PAGE_SIZE: u32 = 50;
/// Open the full-list page for `endpoint` and load its first page, then
/// fan out artwork. `genre_ids` is the shared genre-filter selection
/// (None = no filter). Mirrors `navigate_favorites` in main.rs.
pub fn navigate(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: ImageCache,
endpoint: String,
title: String,
genre_ids: Option<Vec<u64>>,
) {
let endpoint_for_fetch = endpoint.clone();
let genre_for_fetch = genre_ids.clone();
handle.spawn(async move {
// Reset the page state and switch the view on the UI thread. The
// search query is cleared on a fresh navigation; the view mode is
// left as-is so it persists across pages.
{
let title = title.clone();
let endpoint = endpoint.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
let state = w.global::<DiscoverBrowseState>();
state.set_title(title.into());
state.set_endpoint(endpoint.into());
state.set_next_offset(0);
state.set_search_query("".into());
state.set_albums(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
state.set_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
state.set_loading(true);
state.set_loading_more(false);
state.set_has_more(true);
w.global::<NavState>().set_view(ContentView::DiscoverBrowse);
});
}
match runtime
.core()
.get_discover_albums(&endpoint_for_fetch, genre_for_fetch, 0, PAGE_SIZE)
.await
{
Ok(data) => {
let has_more = data.has_more;
// CardData is plain/Send — map it to the (non-Send)
// AlbumCardItem inside the event-loop closure below.
let cards: Vec<CardData> =
data.items.into_iter().map(crate::home::map_album).collect();
let next_offset = cards.len() as i32;
let jobs = artwork_jobs(&cards, 0);
let _ = weak.upgrade_in_event_loop(move |w| {
let items: Vec<AlbumCardItem> =
cards.into_iter().map(crate::home::card_to_item).collect();
let state = w.global::<DiscoverBrowseState>();
state.set_albums(ModelRc::new(VecModel::from(items)));
state.set_next_offset(next_offset);
state.set_has_more(has_more);
state.set_loading(false);
apply_filter(&w);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
}
Err(e) => {
log::error!("[qbz-slint] discover-browse load failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
let state = w.global::<DiscoverBrowseState>();
state.set_loading(false);
state.set_has_more(false);
});
}
}
});
}
/// Fetch the next page (offset = DiscoverBrowseState.next-offset) and
/// append it to the grid. Wired to DiscoverBrowseActions::load-more.
/// `genre_ids` is the shared genre-filter selection (None = no filter).
pub fn load_more(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: ImageCache,
genre_ids: Option<Vec<u64>>,
) {
let Some(w) = weak.upgrade() else {
return;
};
let state = w.global::<DiscoverBrowseState>();
if !state.get_has_more() || state.get_loading_more() {
return;
}
// A non-empty search filters the loaded set client-side; pulling more
// pages while filtering matches no UX (Tauri disables load-more too).
if !state.get_search_query().is_empty() {
return;
}
let endpoint = state.get_endpoint().to_string();
let offset = state.get_next_offset().max(0) as u32;
state.set_loading_more(true);
handle.spawn(async move {
match runtime
.core()
.get_discover_albums(&endpoint, genre_ids, offset, PAGE_SIZE)
.await
{
Ok(data) => {
let has_more = data.has_more;
let cards: Vec<CardData> =
data.items.into_iter().map(crate::home::map_album).collect();
let jobs = artwork_jobs(&cards, offset as usize);
let _ = weak.upgrade_in_event_loop(move |w| {
append_albums(&w, cards, has_more);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
}
Err(e) => {
log::error!("[qbz-slint] discover-browse load-more failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<DiscoverBrowseState>().set_loading_more(false);
});
}
}
});
}
/// Append a freshly-fetched page onto the existing grid, advancing the
/// offset by the new item count and updating has-more. UI thread only.
fn append_albums(window: &AppWindow, cards: Vec<CardData>, has_more: bool) {
let state = window.global::<DiscoverBrowseState>();
let model = state.get_albums();
let mut combined: Vec<AlbumCardItem> = (0..model.row_count())
.filter_map(|i| model.row_data(i))
.collect();
let added = cards.len() as i32;
combined.extend(cards.into_iter().map(crate::home::card_to_item));
state.set_albums(ModelRc::new(VecModel::from(combined)));
state.set_next_offset(state.get_next_offset() + added);
state.set_has_more(has_more);
state.set_loading_more(false);
apply_filter(window);
}
/// Rebuild `visible` from `albums` honoring the current search query
/// (case-insensitive substring over title + artist). UI thread only.
/// Wired to DiscoverBrowseActions::search-changed and called after every
/// model mutation so the rendered list stays consistent.
pub fn apply_filter(window: &AppWindow) {
let state = window.global::<DiscoverBrowseState>();
let query = state.get_search_query().trim().to_lowercase();
let albums = state.get_albums();
if query.is_empty() {
// No filter — share the SAME model so artwork-pipeline updates
// (which mutate `albums[index]`) propagate to the rendered list
// without rebuilding it. This is the common case.
state.set_visible(albums);
return;
}
let visible: Vec<AlbumCardItem> = (0..albums.row_count())
.filter_map(|i| albums.row_data(i))
.filter(|a| {
a.title.to_lowercase().contains(&query)
|| a.artist.to_lowercase().contains(&query)
})
.collect();
state.set_visible(ModelRc::new(VecModel::from(visible)));
}
/// Artwork jobs for a page of cards, targeting their absolute indices
/// (`base_index` is the offset of the first card in the model).
fn artwork_jobs(cards: &[CardData], base_index: usize) -> Vec<ArtworkJob> {
cards
.iter()
.enumerate()
.filter(|(_, card)| !card.artwork_url.is_empty())
.map(|(i, card)| ArtworkJob {
url: card.artwork_url.clone(),
target: ArtworkTarget::DiscoverBrowseAlbum {
index: base_index + i,
},
})
.collect()
}