Files
qbz/crates/qbz-slint/src/favorites.rs
T
vicrodh 43b0c28aee feat(slint): favorites tracks Play all + Shuffle header actions
Retain the loaded favorite tracks as a play-ready Vec<Track> and add
Play all + Shuffle CircleAction buttons by the title (Tracks tab only),
wired to playback::play_tracks. Mirrors the Mix/Playlist transport
(those expose exactly these two; no batch play-next path exists yet).

cc
2026-05-26 22:28:19 -06:00

497 lines
16 KiB
Rust

//! Library > Favorites controller — fetches the user's saved
//! tracks / albums / artists via `QbzCore::get_favorites` and pushes
//! them into `FavoritesState`. Mirrors Tauri's FavoritesView.svelte
//! data flow: each tab is fetched lazily the first time it is opened.
//!
//! `get_favorites` returns a raw JSON value shaped
//! `{ <type>: { items: [...], total: N } }`; this module parses the
//! relevant branch into typed qbz-models items and maps them to the
//! Slint row/card structs.
use std::sync::{Arc, LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use qbz_models::{Album, Artist, Playlist, Track};
use serde::Deserialize;
use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::album_map::{self, map_album, to_item, AlbumCard};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::{
AlbumCardItem, AppWindow, FavoriteArtistItem, FavoriteLabelItem, FavoritePlaylistItem,
FavoritesState, TrackItem,
};
/// Page size — matches Tauri's FAVORITES_PAGE_SIZE. We fetch one
/// page on tab open (favorites lists are typically small; full
/// pagination can come later).
pub const PAGE_SIZE: u32 = 500;
/// Hard ceiling on favorites pulled across all pages (mirrors Tauri's
/// FAVORITES_PAGE_SIZE * FAVORITES_MAX_PAGES ceiling).
const MAX_ITEMS: usize = 10_000;
/// The loaded favorite tracks as a play-ready queue source (Play all /
/// Shuffle). Set on the UI thread by `apply_favorites`.
static FAV_CURRENT: LazyLock<Mutex<Vec<Track>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// Which favorites tab to load.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum FavTab {
Tracks,
Albums,
Artists,
Playlists,
Labels,
}
impl FavTab {
pub fn from_route(route: &str) -> Option<Self> {
Self::from_tab_id(route.strip_prefix("favorites-")?)
}
pub fn from_tab_id(id: &str) -> Option<Self> {
match id {
"tracks" => Some(Self::Tracks),
"albums" => Some(Self::Albums),
"artists" => Some(Self::Artists),
"playlists" => Some(Self::Playlists),
"labels" => Some(Self::Labels),
_ => None,
}
}
/// The Qobuz favType string + the JSON branch key (for the
/// get_favorites-backed tabs).
fn key(self) -> &'static str {
match self {
Self::Tracks => "tracks",
Self::Albums => "albums",
Self::Artists => "artists",
Self::Playlists => "playlists",
Self::Labels => "labels",
}
}
}
/// Favorites-labels response item — the qbz-models `Label` is just
/// {id, name}, but the favorites payload carries an image + count,
/// so parse into this richer local shape.
#[derive(Deserialize)]
struct FavLabel {
#[serde(default)]
id: u64,
#[serde(default)]
name: String,
#[serde(default)]
albums_count: Option<u32>,
}
pub enum FavData {
Tracks { items: Vec<TrackCard>, play: Vec<Track>, total: usize },
Albums { items: Vec<AlbumCard>, total: usize },
Artists { items: Vec<ArtistCard>, total: usize },
Playlists { items: Vec<PlaylistCard>, total: usize },
Labels { items: Vec<LabelCard>, total: usize },
}
#[derive(Clone)]
pub struct TrackCard {
pub id: String,
pub title: String,
pub artist: String,
pub artist_id: String,
pub album: String,
pub album_id: String,
pub duration: String,
pub quality_tier: String,
pub explicit: bool,
pub artwork_url: String,
}
#[derive(Clone)]
pub struct ArtistCard {
pub id: String,
pub name: String,
pub albums_line: String,
pub image_url: String,
}
#[derive(Clone)]
pub struct PlaylistCard {
pub id: String,
pub name: String,
pub owner: String,
pub tracks_line: String,
pub cover_url: String,
}
#[derive(Clone)]
pub struct LabelCard {
pub id: String,
pub name: String,
pub albums_line: String,
}
/// Fetch + parse one favorites tab.
pub async fn load_favorites<A>(
runtime: &Arc<AppRuntime<A>>,
tab: FavTab,
) -> Result<FavData, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
// Playlists come from /playlist/getUserPlaylists, not the
// getUserFavorites envelope — handle them first.
if tab == FavTab::Playlists {
let playlists = runtime
.core()
.get_user_playlists()
.await
.map_err(|e| e.to_string())?;
let total = playlists.len();
return Ok(FavData::Playlists {
items: playlists.into_iter().map(map_playlist).collect(),
total,
});
}
// Page through the favorites until the API is exhausted (mirrors
// Tauri's fetchAllFavorites: keep pulling until a short page or
// offset >= total), capped at MAX_ITEMS so a pathological library
// can't loop forever.
let mut total: usize;
let mut all_items: Vec<serde_json::Value> = Vec::new();
let mut offset = 0u32;
loop {
let value = runtime
.core()
.get_favorites(tab.key(), PAGE_SIZE, offset)
.await
.map_err(|e| e.to_string())?;
let branch = value.get(tab.key());
total = branch
.and_then(|b| b.get("total"))
.and_then(|t| t.as_u64())
.unwrap_or(0) as usize;
let page: Vec<serde_json::Value> = branch
.and_then(|b| b.get("items"))
.and_then(|i| i.as_array())
.cloned()
.unwrap_or_default();
let page_len = page.len();
all_items.extend(page);
offset += page_len as u32;
let exhausted = page_len < PAGE_SIZE as usize
|| (total > 0 && offset as usize >= total)
|| all_items.len() >= MAX_ITEMS;
if exhausted {
break;
}
}
let items = serde_json::Value::Array(all_items);
Ok(match tab {
FavTab::Tracks => {
let tracks: Vec<Track> = serde_json::from_value(items).unwrap_or_default();
let play = tracks.clone();
FavData::Tracks {
items: tracks.into_iter().map(map_track).collect(),
play,
total,
}
}
FavTab::Albums => {
let albums: Vec<Album> = serde_json::from_value(items).unwrap_or_default();
FavData::Albums {
items: albums.into_iter().map(map_album).collect(),
total,
}
}
FavTab::Artists => {
let artists: Vec<Artist> = serde_json::from_value(items).unwrap_or_default();
FavData::Artists {
items: artists.into_iter().map(map_artist).collect(),
total,
}
}
FavTab::Labels => {
let labels: Vec<FavLabel> = serde_json::from_value(items).unwrap_or_default();
FavData::Labels {
items: labels.into_iter().map(map_label).collect(),
total,
}
}
FavTab::Playlists => unreachable!("handled above"),
})
}
fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn map_track(track: Track) -> TrackCard {
let mut title = track.title;
if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({version})");
}
let artwork_url = track
.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.unwrap_or_default();
let album = track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default();
let album_id = track.album.as_ref().map(|a| a.id.clone()).unwrap_or_default();
let (artist, artist_id) = track
.performer
.map(|p| (p.name, p.id.to_string()))
.unwrap_or_default();
TrackCard {
id: track.id.to_string(),
title,
artist,
artist_id,
album,
album_id,
duration: mmss(track.duration),
quality_tier: album_map::tier(track.maximum_bit_depth).to_string(),
explicit: track.parental_warning,
artwork_url,
}
}
fn map_artist(artist: Artist) -> ArtistCard {
let albums_line = match artist.albums_count {
Some(n) if n > 0 => format!("{} albums", n),
_ => String::new(),
};
ArtistCard {
id: artist.id.to_string(),
name: artist.name,
albums_line,
image_url: artist
.image
.and_then(|img| img.best().cloned())
.unwrap_or_default(),
}
}
fn map_playlist(playlist: Playlist) -> PlaylistCard {
// The highest-resolution non-empty cover list wins (images300 >
// images150 > images), first entry as the tile cover.
let cover_url = [&playlist.images300, &playlist.images150, &playlist.images]
.into_iter()
.flatten()
.find(|v| !v.is_empty())
.and_then(|list| list.first().cloned())
.unwrap_or_default();
PlaylistCard {
id: playlist.id.to_string(),
name: playlist.name,
owner: playlist.owner.name,
tracks_line: format!("{} tracks", playlist.tracks_count),
cover_url,
}
}
fn map_label(label: FavLabel) -> LabelCard {
let albums_line = match label.albums_count {
Some(n) if n > 0 => format!("{} releases", n),
_ => String::new(),
};
LabelCard {
id: label.id.to_string(),
name: label.name,
albums_line,
}
}
pub fn apply_favorites(window: &AppWindow, data: FavData) {
let state = window.global::<FavoritesState>();
match data {
FavData::Tracks { items, play, total } => {
if let Ok(mut current) = FAV_CURRENT.lock() {
*current = play;
}
let rows: Vec<TrackItem> = items
.into_iter()
.map(|t| TrackItem {
id: t.id.into(),
number: "".into(),
title: t.title.into(),
artist: t.artist.into(),
album: t.album.into(),
duration: t.duration.into(),
quality_tier: t.quality_tier.into(),
explicit: t.explicit,
selected: false,
artwork_url: t.artwork_url.into(),
artwork: slint::Image::default(),
// Everything in the Favorites > Tracks tab is, by
// definition, a favorite.
is_favorite: true,
artist_id: t.artist_id.into(),
album_id: t.album_id.into(),
})
.collect();
// `tracks` is the full set the artwork pipeline targets;
// `tracks-visible` (what the list renders) shares the same
// model until a search filter forks it, so artwork stays live.
let model = ModelRc::new(VecModel::from(rows));
state.set_tracks(model.clone());
state.set_tracks_visible(model);
state.set_tracks_total(total as i32);
state.set_tracks_search("".into());
}
FavData::Albums { items, total } => {
let cards: Vec<AlbumCardItem> = items.into_iter().map(to_item).collect();
state.set_albums(ModelRc::new(VecModel::from(cards)));
state.set_albums_total(total as i32);
}
FavData::Artists { items, total } => {
let cards: Vec<FavoriteArtistItem> = items
.into_iter()
.map(|a| FavoriteArtistItem {
id: a.id.into(),
name: a.name.into(),
albums_line: a.albums_line.into(),
image_url: a.image_url.into(),
image: slint::Image::default(),
})
.collect();
state.set_artists(ModelRc::new(VecModel::from(cards)));
state.set_artists_total(total as i32);
}
FavData::Playlists { items, total } => {
let cards: Vec<FavoritePlaylistItem> = items
.into_iter()
.map(|p| FavoritePlaylistItem {
id: p.id.into(),
name: p.name.into(),
owner: p.owner.into(),
tracks_line: p.tracks_line.into(),
cover_url: p.cover_url.into(),
cover: slint::Image::default(),
})
.collect();
state.set_playlists(ModelRc::new(VecModel::from(cards)));
state.set_playlists_total(total as i32);
}
FavData::Labels { items, total } => {
let rows: Vec<FavoriteLabelItem> = items
.into_iter()
.map(|l| FavoriteLabelItem {
id: l.id.into(),
name: l.name.into(),
albums_line: l.albums_line.into(),
})
.collect();
state.set_labels(ModelRc::new(VecModel::from(rows)));
state.set_labels_total(total as i32);
}
}
state.set_loading(false);
}
/// Re-derive the rendered Tracks list (`tracks-visible`) from the full
/// `tracks` set and the search query. An empty query shares the full
/// model so artwork keeps updating in place (the LabelState albums/visible
/// pattern); a query forks a filtered clone (each row carries its already
/// decoded artwork, so no re-fetch).
pub fn derive_tracks(window: &AppWindow) {
let state = window.global::<FavoritesState>();
let query_owned = state.get_tracks_search().to_lowercase();
let query = query_owned.trim();
let all = state.get_tracks();
if query.is_empty() {
state.set_tracks_visible(all);
return;
}
let filtered: Vec<TrackItem> = (0..all.row_count())
.filter_map(|i| all.row_data(i))
.filter(|t| {
t.title.to_lowercase().contains(query)
|| t.artist.to_lowercase().contains(query)
|| t.album.to_lowercase().contains(query)
})
.collect();
state.set_tracks_visible(ModelRc::new(VecModel::from(filtered)));
}
/// The loaded favorite tracks as a play-ready queue (Play all).
pub fn play_tracks() -> Vec<Track> {
FAV_CURRENT.lock().map(|c| c.clone()).unwrap_or_default()
}
/// The favorite tracks in a fresh random order (Shuffle). Mirrors
/// playlist::shuffled_tracks (time-seeded xorshift Fisher-Yates).
pub fn shuffled_tracks() -> Vec<Track> {
let mut tracks = play_tracks();
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
| 1;
for i in (1..tracks.len()).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let j = (seed % (i as u64 + 1)) as usize;
tracks.swap(i, j);
}
tracks
}
pub fn reset_loading(window: &AppWindow) {
window.global::<FavoritesState>().set_loading(true);
}
/// Artwork jobs for the freshly loaded tab.
pub fn artwork_jobs(data: &FavData) -> Vec<ArtworkJob> {
match data {
FavData::Tracks { items, .. } => items
.iter()
.enumerate()
.filter(|(_, t)| !t.artwork_url.is_empty())
.map(|(i, t)| ArtworkJob {
url: t.artwork_url.clone(),
target: ArtworkTarget::FavoriteTrack { index: i },
})
.collect(),
FavData::Albums { items, .. } => items
.iter()
.enumerate()
.filter(|(_, a)| !a.artwork_url.is_empty())
.map(|(i, a)| ArtworkJob {
url: a.artwork_url.clone(),
target: ArtworkTarget::FavoriteAlbum { index: i },
})
.collect(),
FavData::Artists { items, .. } => items
.iter()
.enumerate()
.filter(|(_, a)| !a.image_url.is_empty())
.map(|(i, a)| ArtworkJob {
url: a.image_url.clone(),
target: ArtworkTarget::FavoriteArtist { index: i },
})
.collect(),
FavData::Playlists { items, .. } => items
.iter()
.enumerate()
.filter(|(_, p)| !p.cover_url.is_empty())
.map(|(i, p)| ArtworkJob {
url: p.cover_url.clone(),
target: ArtworkTarget::FavoritePlaylist { index: i },
})
.collect(),
// Labels render an icon, no remote artwork.
FavData::Labels { .. } => Vec::new(),
}
}