feat(slint): favorites Playlists two-source load (Library + Following)

Replace the single merged get_user_playlists load with the two real
sources mirroring Tauri: Following = playlists the user follows but does
not own (get_user_playlists filtered owner.id != current_user_id);
Library = locally-favorited ids (SQLite get_favorite_playlist_ids) in
favorited order, intersected with the fetched set and falling back to a
single get_playlist for ids not present. Both map to the rich
SearchPlaylistItem (4-cover collage) via search::map_playlist/playlist_item,
replacing the single-cover PlaylistCard. derive_playlists picks the active
sub-tab source and applies the name|owner search filter; the 4-cover
artwork pipeline (FavPlaylistCover{following,index,slot}) fills the collage
and propagates into the search-filtered visible model. The tab badge now
counts the local Library set. Adds library_db::current_user_id.

cc
This commit is contained in:
vicrodh committed 2026-05-27 13:43:12 -06:00
1 parent bfb3c9b9b5
commit 2d2f65a1cb
4 files changed
+147 -75

No files matched your search

+23 -9
View File
@@ -80,10 +80,11 @@ pub enum ArtworkTarget {
DiscoverBrowseAlbum { index: usize },
/// A card in FavoritesState.artists[index].
FavoriteArtist { index: usize },
/// A card in FavoritesState.playlists[index].
FavoritePlaylist { index: usize },
/// A card in FavoritesState.labels[index].
FavoriteLabel { index: usize },
/// One collage cover slot (0-3) of a favorites playlist card. `following`
/// picks the sub-tab source model (Following vs Library/favorites).
FavPlaylistCover { following: bool, index: usize, slot: usize },
/// A card in ForYouState.release-watch.albums[index].
ForYouReleaseWatch { index: usize },
/// A card in ForYouState.recent-albums.albums[index].
@@ -536,13 +537,6 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::FavoritePlaylist { index } => {
let model = window.global::<crate::FavoritesState>().get_playlists();
if let Some(mut item) = model.row_data(index) {
item.cover = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::FavoriteLabel { index } => {
let model = window.global::<crate::FavoritesState>().get_labels();
if let Some(mut item) = model.row_data(index) {
@@ -550,6 +544,26 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::FavPlaylistCover { following, index, slot } => {
let st = window.global::<crate::FavoritesState>();
let model = if following {
st.get_playlists_following()
} else {
st.get_playlists_favorites()
};
if let Some(mut item) = model.row_data(index) {
let id = item.id.to_string();
match slot {
0 => item.cover1 = image.clone(),
1 => item.cover2 = image.clone(),
2 => item.cover3 = image.clone(),
_ => item.cover4 = image.clone(),
}
model.set_row_data(index, item);
// Also reach the rendered (possibly search-filtered) model.
crate::favorites::set_playlist_cover(window, &id, slot, image);
}
}
ArtworkTarget::ForYouReleaseWatch { index } => {
let model = window.global::<crate::ForYouState>().get_release_watch().albums;
if let Some(mut item) = model.row_data(index) {
+118 -65
View File
@@ -20,9 +20,10 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::album_map::{self, map_album, to_item, AlbumCard};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::search::{self, PlaylistRow};
use crate::{
AlbumCardItem, AlphaJump, AppWindow, DiscoverSection, FavoriteArtistItem, FavoriteLabelItem,
FavoritePlaylistItem, FavoritesState, TrackItem,
FavoritesState, SearchPlaylistItem, TrackItem,
};
/// Page size — matches Tauri's FAVORITES_PAGE_SIZE. We fetch one
@@ -105,7 +106,7 @@ 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 },
Playlists { favorites: Vec<PlaylistRow>, following: Vec<PlaylistRow> },
Labels { items: Vec<LabelCard>, total: usize },
}
@@ -132,15 +133,6 @@ 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,
@@ -157,19 +149,41 @@ pub async fn load_favorites<A>(
where
A: FrontendAdapter + Send + Sync + 'static,
{
// Playlists come from /playlist/getUserPlaylists, not the
// getUserFavorites envelope — handle them first.
// Playlists has two sub-tabs from two different sources (mirror Tauri):
// - Following = playlists the user follows on Qobuz but does NOT own
// (`get_user_playlists` filtered by `owner.id != current_user_id`).
// - Library = LOCALLY-favorited playlist ids (SQLite), in favorited
// order. We intersect the already-fetched `get_user_playlists` set
// (cheap, no extra fetch); for a favorited id not in that set we fall
// back to a single `get_playlist`.
if tab == FavTab::Playlists {
let playlists = runtime
let all = 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 uid = crate::library_db::current_user_id();
let following: Vec<PlaylistRow> = match uid {
Some(uid) => all
.iter()
.filter(|p| p.owner.id != uid)
.cloned()
.map(search::map_playlist)
.collect(),
None => Vec::new(),
};
let fav_ids =
crate::library_db::with_db(|db| db.get_favorite_playlist_ids()).unwrap_or_default();
let by_id: HashMap<u64, &Playlist> = all.iter().map(|p| (p.id, p)).collect();
let mut favorites: Vec<PlaylistRow> = Vec::with_capacity(fav_ids.len());
for fid in fav_ids {
if let Some(p) = by_id.get(&fid) {
favorites.push(search::map_playlist((**p).clone()));
} else if let Ok(p) = runtime.core().get_playlist(fid).await {
favorites.push(search::map_playlist(p));
}
}
return Ok(FavData::Playlists { favorites, following });
}
// Page through the favorites until the API is exhausted (mirrors
@@ -279,11 +293,10 @@ where
let albums = total_for(runtime, "albums").await;
let artists = total_for(runtime, "artists").await;
let labels = total_for(runtime, "labels").await;
let playlists = runtime
.core()
.get_user_playlists()
.await
.map(|p| p.len() as i32)
// The tab badge counts the LOCAL Library (favorited) playlists, matching
// the "favorites" semantics (cheap local read, no network).
let playlists = crate::library_db::with_db(|db| db.get_favorite_playlist_ids())
.map(|ids| ids.len() as i32)
.unwrap_or(0);
FavCounts {
tracks,
@@ -365,24 +378,6 @@ 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 {
// Tauri's favorites label card says "{n} albums" (library.albumCount),
// matching the sibling FavArtistCard's "{n} albums".
@@ -478,20 +473,20 @@ 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<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::Playlists { favorites, following } => {
let fav_items: Vec<SearchPlaylistItem> =
favorites.into_iter().map(search::playlist_item).collect();
let following_items: Vec<SearchPlaylistItem> =
following.into_iter().map(search::playlist_item).collect();
// Tab badge = Library (favorited) count; Following badge separate.
state.set_playlists_total(fav_items.len() as i32);
state.set_playlists_following_count(following_items.len() as i32);
state.set_playlists_favorites(ModelRc::new(VecModel::from(fav_items)));
state.set_playlists_following(ModelRc::new(VecModel::from(following_items)));
state.set_playlists_search("".into());
// Seed `playlists-visible` for the current sub-tab (shares the
// source model until a search forks it, so collage artwork stays live).
derive_playlists(window);
}
FavData::Labels { items, total } => {
let rows: Vec<FavoriteLabelItem> = items
@@ -601,6 +596,34 @@ pub fn derive_labels(window: &AppWindow) {
state.set_labels_visible(ModelRc::new(VecModel::from(filtered)));
}
/// Re-derive the rendered Playlists grid/list (`playlists-visible`) from the
/// active sub-tab source (`playlists-favorites` / `playlists-following`) and
/// the search query. Empty query shares the source model so collage artwork
/// stays live; a query forks a name/owner-filtered clone (mirrors Tauri's
/// filteredPlaylists, which matches name OR owner — the owner is part of the
/// item subtitle). No sort, no group (Tauri has none).
pub fn derive_playlists(window: &AppWindow) {
let state = window.global::<FavoritesState>();
let source = if state.get_playlists_sub_tab().as_str() == "following" {
state.get_playlists_following()
} else {
state.get_playlists_favorites()
};
let query_owned = state.get_playlists_search().to_lowercase();
let query = query_owned.trim();
if query.is_empty() {
state.set_playlists_visible(source);
return;
}
let filtered: Vec<SearchPlaylistItem> = (0..source.row_count())
.filter_map(|i| source.row_data(i))
.filter(|p| {
p.title.to_lowercase().contains(query) || p.subtitle.to_lowercase().contains(query)
})
.collect();
state.set_playlists_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()
@@ -861,6 +884,27 @@ pub fn set_album_artwork(window: &AppWindow, id: &str, image: slint::Image) {
}
}
/// Set a freshly-decoded collage cover (by id + slot) on the rendered
/// favorites playlists model (`playlists-visible`), which is a clone of the
/// active sub-tab source whenever a search filter is active.
pub fn set_playlist_cover(window: &AppWindow, id: &str, slot: usize, image: slint::Image) {
let model = window.global::<FavoritesState>().get_playlists_visible();
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id.as_str() == id {
match slot {
0 => item.cover1 = image,
1 => item.cover2 = image,
2 => item.cover3 = image,
_ => item.cover4 = image,
}
model.set_row_data(i, item);
break;
}
}
}
}
/// Same for the rendered favorites tracks model (`tracks-visible`).
pub fn set_track_artwork(window: &AppWindow, id: &str, image: slint::Image) {
let model = window.global::<FavoritesState>().get_tracks_visible();
@@ -995,15 +1039,24 @@ pub fn artwork_jobs(data: &FavData) -> Vec<ArtworkJob> {
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(),
FavData::Playlists { favorites, following } => {
fn push(rows: &[PlaylistRow], following: bool, jobs: &mut Vec<ArtworkJob>) {
for (index, row) in rows.iter().enumerate() {
for (slot, url) in row.cover_urls.iter().enumerate().take(4) {
if !url.is_empty() {
jobs.push(ArtworkJob {
url: url.clone(),
target: ArtworkTarget::FavPlaylistCover { following, index, slot },
});
}
}
}
}
let mut jobs: Vec<ArtworkJob> = Vec::new();
push(favorites, false, &mut jobs);
push(following, true, &mut jobs);
jobs
}
FavData::Labels { items, .. } => items
.iter()
.enumerate()
+5
View File
@@ -29,6 +29,11 @@ fn user_id() -> Option<u64> {
CURRENT_USER_ID.lock().ok().and_then(|g| *g)
}
/// The active user id (for the favorites Following filter `owner.id != uid`).
pub fn current_user_id() -> Option<u64> {
user_id()
}
/// `<data_dir>/qbz/users/<user_id>/library.db` — matches the Tauri
/// per-user path so the local organization data is shared.
fn db_path() -> Option<PathBuf> {
+1 -1
View File
@@ -387,7 +387,7 @@ fn artist_item(row: ArtistRow) -> SlimItem {
}
}
fn playlist_item(row: PlaylistRow) -> SearchPlaylistItem {
pub(crate) fn playlist_item(row: PlaylistRow) -> SearchPlaylistItem {
let url = |i: usize| -> slint::SharedString {
row.cover_urls.get(i).cloned().unwrap_or_default().into()
};