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
497 lines
16 KiB
Rust
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(),
|
|
}
|
|
}
|