From 93edce305c1d33ad6841e380feeba20b8efebcb4 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 20 May 2026 21:20:53 -0600 Subject: [PATCH] feat(slint): Favorites Playlists + Labels tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the five Favorites tabs. Both lazy-load like the others. Playlists: - Sourced from QbzCore::get_user_playlists (the getUserFavorites envelope has no playlists branch; Tauri splits owned-vs-following locally — the MVP shows the full user playlist list for now). - FavoritePlaylistItem card: cover (images300 > images150 > images, first entry), name, "N tracks". Cover streams via the artwork pipeline (new ArtworkTarget::FavoritePlaylist). Click is logged — no playlist detail view in the Slint MVP yet. Labels: - Parsed from get_favorites("labels"); the qbz-models Label is just {id,name}, so a local FavLabel shape picks up the albums_count. FavLabelRow (icon + name + "N releases") routes the click into the existing LabelReleasesView via FavoritesActions::open-label -> navigate_label. FavTab gains Playlists + Labels; from_route/from_tab_id now resolve them so the header dropdown and tab bar both lazy-load. Tab bar shows live counts for all five tabs. Still deferred (per "finish then polish one by one"): the richer Tauri options (sort, group-by-artist, owned/following split, per-row context menus, bulk select, download status). cc --- crates/qbz-slint/src/artwork.rs | 9 + crates/qbz-slint/src/favorites.rs | 144 +++++++++++++++- crates/qbz-slint/src/main.rs | 35 ++++ .../ui/favorites/FavoritesView.slint | 160 +++++++++++++++++- crates/qbz-slint/ui/state.slint | 23 +++ 5 files changed, 356 insertions(+), 15 deletions(-) diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index 9a772b5dc..8f2b1611b 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -64,6 +64,8 @@ pub enum ArtworkTarget { FavoriteAlbum { index: usize }, /// A card in FavoritesState.artists[index]. FavoriteArtist { index: usize }, + /// A card in FavoritesState.playlists[index]. + FavoritePlaylist { index: usize }, } /// An artwork download job: which card, and the image URL. @@ -394,5 +396,12 @@ fn apply_artwork( model.set_row_data(index, item); } } + ArtworkTarget::FavoritePlaylist { index } => { + let model = window.global::().get_playlists(); + if let Some(mut item) = model.row_data(index) { + item.cover = image; + model.set_row_data(index, item); + } + } } } diff --git a/crates/qbz-slint/src/favorites.rs b/crates/qbz-slint/src/favorites.rs index 91eeba546..dc12f844d 100644 --- a/crates/qbz-slint/src/favorites.rs +++ b/crates/qbz-slint/src/favorites.rs @@ -12,12 +12,14 @@ use std::sync::Arc; use qbz_app::shell::AppRuntime; use qbz_core::FrontendAdapter; -use qbz_models::{Album, Artist, Track}; +use qbz_models::{Album, Artist, Playlist, Track}; +use serde::Deserialize; use slint::{ComponentHandle, ModelRc, VecModel}; use crate::artwork::{ArtworkJob, ArtworkTarget}; use crate::{ - AlbumCardItem, AppWindow, FavoriteArtistItem, FavoritesState, SearchTrackItem, + AlbumCardItem, AppWindow, FavoriteArtistItem, FavoriteLabelItem, FavoritePlaylistItem, + FavoritesState, SearchTrackItem, }; /// Page size — matches Tauri's FAVORITES_PAGE_SIZE. We fetch one @@ -31,16 +33,13 @@ pub enum FavTab { Tracks, Albums, Artists, + Playlists, + Labels, } impl FavTab { pub fn from_route(route: &str) -> Option { - match route { - "favorites-tracks" => Some(Self::Tracks), - "favorites-albums" => Some(Self::Albums), - "favorites-artists" => Some(Self::Artists), - _ => None, - } + Self::from_tab_id(route.strip_prefix("favorites-")?) } pub fn from_tab_id(id: &str) -> Option { @@ -48,24 +47,44 @@ impl FavTab { "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. + /// 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, +} + pub enum FavData { Tracks { items: Vec, total: usize }, Albums { items: Vec, total: usize }, Artists { items: Vec, total: usize }, + Playlists { items: Vec, total: usize }, + Labels { items: Vec, total: usize }, } #[derive(Clone)] @@ -98,6 +117,22 @@ pub struct ArtistCard { 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( runtime: &Arc>, @@ -106,6 +141,21 @@ pub async fn load_favorites( 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, + }); + } + let value = runtime .core() .get_favorites(tab.key(), PAGE_SIZE, 0) @@ -144,6 +194,14 @@ where total, } } + FavTab::Labels => { + let labels: Vec = serde_json::from_value(items).unwrap_or_default(); + FavData::Labels { + items: labels.into_iter().map(map_label).collect(), + total, + } + } + FavTab::Playlists => unreachable!("handled above"), }) } @@ -217,6 +275,36 @@ fn map_artist(artist: Artist) -> ArtistCard { } } +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::(); match data { @@ -271,6 +359,33 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) { state.set_artists(ModelRc::new(VecModel::from(cards))); state.set_artists_total(total as i32); } + FavData::Playlists { items, total } => { + let cards: Vec = 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 = 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); } @@ -309,5 +424,16 @@ pub fn artwork_jobs(data: &FavData) -> Vec { 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(), } } diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 47fa2f116..61105689c 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -1840,6 +1840,41 @@ fn main() -> Result<(), Box> { } }); } + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_open_label(move |id, name| { + let Ok(label_id) = id.parse::() else { + return; + }; + let name = name.to_string(); + nav::record(nav::NavEntry::Label { + id: label_id, + name: name.clone(), + }); + navigate_label( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + label_id, + name, + ); + }); + } + { + // Favorite playlist click — no playlist detail view in the + // Slint MVP yet; logged until that lands. + window + .global::() + .on_open_playlist(move |id| { + log::info!("[qbz-slint] favorites: playlist clicked id={id} (no view yet)"); + }); + } { let weak = window.as_weak(); window diff --git a/crates/qbz-slint/ui/favorites/FavoritesView.slint b/crates/qbz-slint/ui/favorites/FavoritesView.slint index 6dcb4c4f5..664d14a77 100644 --- a/crates/qbz-slint/ui/favorites/FavoritesView.slint +++ b/crates/qbz-slint/ui/favorites/FavoritesView.slint @@ -7,12 +7,15 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; import { FavoritesState, FavoritesActions, AlbumCardItem, SearchTrackItem, FavoriteArtistItem, + FavoritePlaylistItem, + FavoriteLabelItem, } from "../state.slint"; import { AlbumCard } from "../discover/AlbumCard.slint"; import { SearchTrackRow } from "../search/SearchTrackRow.slint"; @@ -64,6 +67,117 @@ component FavTab inherits Rectangle { } } +component FavPlaylistCard inherits Rectangle { + in property item; + callback clicked(string); + + width: 180px; + height: 234px; + + VerticalLayout { + spacing: 8px; + Rectangle { + width: 180px; + height: 180px; + border-radius: Radius.sm; + background: Theme.surface-elevated; + clip: true; + Image { + width: 100%; + height: 100%; + source: root.item.cover; + image-fit: cover; + } + if root.item.cover-url == "": QbzIcon { + source: @image-url("../assets/icons/list-music.svg"); + width: 44px; + height: 44px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: Theme.text-muted; + } + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(root.item.id); + } + } + } + Text { + text: root.item.name; + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + overflow: elide; + width: 180px; + } + Text { + text: root.item.tracks-line; + color: Theme.text-muted; + font-size: Typography.legal; + overflow: elide; + width: 180px; + } + } +} + +component FavLabelRow inherits Rectangle { + in property item; + in property index; + callback clicked(string, string); + + height: 52px; + border-radius: 8px; + background: ta.has-hover + ? #ffffff16 + : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent); + + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 12px; + VerticalLayout { + alignment: center; + Rectangle { + width: 32px; + height: 32px; + border-radius: 6px; + background: Theme.surface-elevated; + QbzIcon { + source: @image-url("../assets/icons/disc-3.svg"); + width: 18px; + height: 18px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: Theme.text-muted; + } + } + } + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + Text { + text: root.item.name; + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + overflow: elide; + } + if root.item.albums-line != "": Text { + text: root.item.albums-line; + color: Theme.text-muted; + font-size: Typography.legal; + } + } + } + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(root.item.id, root.item.name); + } + } +} + component FavArtistCard inherits Rectangle { in property item; callback clicked(string); @@ -179,11 +293,13 @@ export component FavoritesView inherits Rectangle { FavTab { label: @tr("Playlists"); id: "playlists"; + count: FavoritesState.playlists-total; selected(id) => { FavoritesActions.select-tab(id); } } FavTab { label: @tr("Labels"); id: "labels"; + count: FavoritesState.labels-total; selected(id) => { FavoritesActions.select-tab(id); } } } @@ -265,13 +381,45 @@ export component FavoritesView inherits Rectangle { } } - // --- Playlists / Labels (pending) -------------------------- + // --- Playlists tab ----------------------------------------- if !FavoritesState.loading - && (FavoritesState.active-tab == "playlists" - || FavoritesState.active-tab == "labels"): Text { - text: @tr("Coming soon."); - color: Theme.text-muted; - font-size: Typography.body; + && FavoritesState.active-tab == "playlists": playlists-grid := Rectangle { + property pl-w: 180px; + property pl-h: 234px; + property columns: Math.max( + 1, + Math.floor((self.width + root.gap) / (self.pl-w + root.gap))); + property rows: Math.ceil(FavoritesState.playlists.length / self.columns); + height: self.rows > 0 + ? self.rows * self.pl-h + Math.max(0, self.rows - 1) * root.gap + : 0px; + if FavoritesState.playlists.length == 0: Text { + text: @tr("No favorite playlists yet."); + color: Theme.text-muted; + font-size: Typography.body; + } + for pl[i] in FavoritesState.playlists: FavPlaylistCard { + x: Math.mod(i, playlists-grid.columns) * (playlists-grid.pl-w + root.gap); + y: Math.floor(i / playlists-grid.columns) * (playlists-grid.pl-h + root.gap); + item: pl; + clicked(id) => { FavoritesActions.open-playlist(id); } + } + } + + // --- Labels tab -------------------------------------------- + if !FavoritesState.loading + && FavoritesState.active-tab == "labels": VerticalLayout { + spacing: 0px; + if FavoritesState.labels.length == 0: Text { + text: @tr("No favorite labels yet."); + color: Theme.text-muted; + font-size: Typography.body; + } + for label[i] in FavoritesState.labels: FavLabelRow { + item: label; + index: i; + clicked(id, name) => { FavoritesActions.open-label(id, name); } + } } } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index a8bd9a933..46af3d2a0 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -649,6 +649,23 @@ export struct FavoriteArtistItem { image: image, } +// One playlist tile in the Favorites > Playlists grid. +export struct FavoritePlaylistItem { + id: string, + name: string, + owner: string, + tracks-line: string, // "N tracks" + cover-url: string, + cover: image, +} + +// One label row in the Favorites > Labels list. +export struct FavoriteLabelItem { + id: string, + name: string, + albums-line: string, // "N releases" +} + // Library > Favorites state. The active tab decides which model is // shown; all are populated lazily by `crate::favorites` the first // time their tab is opened. Albums reuse AlbumCardItem, tracks reuse @@ -661,9 +678,13 @@ export global FavoritesState { in property <[SearchTrackItem]> tracks: []; in property <[AlbumCardItem]> albums: []; in property <[FavoriteArtistItem]> artists: []; + in property <[FavoritePlaylistItem]> playlists: []; + in property <[FavoriteLabelItem]> labels: []; in property tracks-total: 0; in property albums-total: 0; in property artists-total: 0; + in property playlists-total: 0; + in property labels-total: 0; in property loading: false; } @@ -673,6 +694,8 @@ export global FavoritesActions { callback select-tab(string); callback open-album(string); callback open-artist(string); + callback open-label(string /* label id */, string /* name */); + callback open-playlist(string /* playlist id */); callback play-track(string /* track id */); callback track-action(string /* track id */, string /* action */); }