feat(slint): Library > Favorites view (Tracks / Albums / Artists)
Wires the Library header dropdown to a real Favorites view. Mirrors
Tauri's FavoritesView.svelte core: a tab bar over per-tab content,
each tab lazy-loaded on first open.
Slint:
- ContentView gains `favorites`. NavMenuEntry gains a `route`
field; the Library dropdown rows carry "favorites-tracks" /
"-albums" / "-artists" / "-playlists" / "-labels". The
HeaderMenuOverlay click bubbles the route up through AppShell ->
AppWindow -> Rust (new header-menu-navigate callback).
- FavoritesState / FavoritesActions globals. Tracks reuse
SearchTrackItem (so the shared SearchTrackRow renders them with
per-row cover art), albums reuse AlbumCardItem + the Discover
AlbumCard, artists use a new FavoriteArtistItem round-portrait
grid.
- New ui/favorites/FavoritesView.slint: title, tab bar with live
counts + active underline, and the three populated tabs.
Playlists / Labels show "Coming soon" pending their backing
views. AppShell routes ContentView.favorites.
Rust:
- crate::favorites controller — load_favorites parses the raw
get_favorites JSON (`{ <type>: { items, total } }`) into typed
qbz-models Track / Album / Artist, maps to the Slint structs.
Lazy per-tab: select-tab and the header route both call
navigate_favorites, which fetches only the opened tab.
- artwork::ArtworkTarget gains FavoriteTrack / FavoriteAlbum /
FavoriteArtist; covers stream in via the shared pipeline.
- main.rs: navigate_favorites + on_header_menu_navigate +
FavoritesActions (select-tab / open-album / open-artist /
play-track / track-action). No new qbz-core method —
get_favorites already existed.
Deferred: Playlists + Labels tab content, full pagination (one 500
page is fetched per tab), favorites preferences (tab order).
cc
This commit is contained in:
9 files changed
+831
-10
No files matched your search
@@ -0,0 +1,313 @@
|
||||
//! 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;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_core::FrontendAdapter;
|
||||
use qbz_models::{Album, Artist, Track};
|
||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
||||
use crate::{
|
||||
AlbumCardItem, AppWindow, FavoriteArtistItem, FavoritesState, SearchTrackItem,
|
||||
};
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Which favorites tab to load.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FavTab {
|
||||
Tracks,
|
||||
Albums,
|
||||
Artists,
|
||||
}
|
||||
|
||||
impl FavTab {
|
||||
pub fn from_route(route: &str) -> Option<Self> {
|
||||
match route {
|
||||
"favorites-tracks" => Some(Self::Tracks),
|
||||
"favorites-albums" => Some(Self::Albums),
|
||||
"favorites-artists" => Some(Self::Artists),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_tab_id(id: &str) -> Option<Self> {
|
||||
match id {
|
||||
"tracks" => Some(Self::Tracks),
|
||||
"albums" => Some(Self::Albums),
|
||||
"artists" => Some(Self::Artists),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Qobuz favType string + the JSON branch key.
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Tracks => "tracks",
|
||||
Self::Albums => "albums",
|
||||
Self::Artists => "artists",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum FavData {
|
||||
Tracks { items: Vec<TrackCard>, total: usize },
|
||||
Albums { items: Vec<AlbumCard>, total: usize },
|
||||
Artists { items: Vec<ArtistCard>, total: usize },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TrackCard {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub duration: String,
|
||||
pub quality_tier: String,
|
||||
pub explicit: bool,
|
||||
pub artwork_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AlbumCard {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub year: String,
|
||||
pub quality_tier: String,
|
||||
pub quality_label: String,
|
||||
pub artwork_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ArtistCard {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub albums_line: String,
|
||||
pub image_url: 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,
|
||||
{
|
||||
let value = runtime
|
||||
.core()
|
||||
.get_favorites(tab.key(), PAGE_SIZE, 0)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let branch = value.get(tab.key());
|
||||
let total = branch
|
||||
.and_then(|b| b.get("total"))
|
||||
.and_then(|t| t.as_u64())
|
||||
.unwrap_or(0) as usize;
|
||||
let items = branch
|
||||
.and_then(|b| b.get("items"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
Ok(match tab {
|
||||
FavTab::Tracks => {
|
||||
let tracks: Vec<Track> = serde_json::from_value(items).unwrap_or_default();
|
||||
FavData::Tracks {
|
||||
items: tracks.into_iter().map(map_track).collect(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tier(bit_depth: Option<u32>) -> &'static str {
|
||||
match bit_depth {
|
||||
Some(b) if b > 16 => "hires",
|
||||
Some(_) => "cd",
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
TrackCard {
|
||||
id: track.id.to_string(),
|
||||
title,
|
||||
artist: track.performer.map(|p| p.name).unwrap_or_default(),
|
||||
duration: mmss(track.duration),
|
||||
quality_tier: tier(track.maximum_bit_depth).to_string(),
|
||||
explicit: track.parental_warning,
|
||||
artwork_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_album(album: Album) -> AlbumCard {
|
||||
let year = album
|
||||
.release_date_original
|
||||
.as_deref()
|
||||
.and_then(|s| s.get(..4).map(|y| y.to_string()))
|
||||
.unwrap_or_default();
|
||||
let quality_label = match (album.maximum_bit_depth, album.maximum_sampling_rate) {
|
||||
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr),
|
||||
_ => String::new(),
|
||||
};
|
||||
AlbumCard {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
artist: album.artist.name,
|
||||
year,
|
||||
quality_tier: tier(album.maximum_bit_depth).to_string(),
|
||||
quality_label,
|
||||
artwork_url: album.image.best().cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_favorites(window: &AppWindow, data: FavData) {
|
||||
let state = window.global::<FavoritesState>();
|
||||
match data {
|
||||
FavData::Tracks { items, total } => {
|
||||
let rows: Vec<SearchTrackItem> = items
|
||||
.into_iter()
|
||||
.map(|t| SearchTrackItem {
|
||||
id: t.id.into(),
|
||||
title: t.title.into(),
|
||||
artist: t.artist.into(),
|
||||
duration: t.duration.into(),
|
||||
quality_tier: t.quality_tier.into(),
|
||||
explicit: t.explicit,
|
||||
artwork_url: t.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
})
|
||||
.collect();
|
||||
state.set_tracks(ModelRc::new(VecModel::from(rows)));
|
||||
state.set_tracks_total(total as i32);
|
||||
}
|
||||
FavData::Albums { items, total } => {
|
||||
let cards: Vec<AlbumCardItem> = items
|
||||
.into_iter()
|
||||
.map(|a| AlbumCardItem {
|
||||
id: a.id.into(),
|
||||
title: a.title.into(),
|
||||
artist: a.artist.into(),
|
||||
genre: "".into(),
|
||||
year: a.year.into(),
|
||||
quality_tier: a.quality_tier.into(),
|
||||
quality_label: a.quality_label.into(),
|
||||
ribbon: "".into(),
|
||||
ribbon_kind: "".into(),
|
||||
artwork_url: a.artwork_url.into(),
|
||||
artwork: slint::Image::default(),
|
||||
})
|
||||
.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);
|
||||
}
|
||||
}
|
||||
state.set_loading(false);
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user