feat(slint): favorites Artists sidepanel controller (select-artist + view toggle)

Wire the sidepanel backend ahead of its UI: select-artist loads the chosen
artist via artist::load_artist (reusing the /artist/page release_type
classifier per the user's decision) and apply_selected_artist maps its
release sections into selected-artist-sections (DiscoverSections) + total;
a FavoriteArtistAlbum artwork target fills the section covers. The
artists-set-view handler flips grid<->sidepanel (persisted as artists_view)
and clears the selection on the way back to grid. card_to_item is now
pub(crate) for reuse.

cc
This commit is contained in:
vicrodh committed 2026-05-27 15:40:18 -06:00
1 parent 02b63d2cd2
commit 832f257e5a
5 files changed
+126 -1

No files matched your search

+1 -1
View File
@@ -440,7 +440,7 @@ fn mmss(secs: u32) -> String {
// previous artist-local helper produced one long paragraph for
// multi-paragraph biographies — gone with this refactor.
fn card_to_item(card: CardData) -> AlbumCardItem {
pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem {
// On the artist page the card subtitle slot should show the
// release year — the artist is redundant since we're already on
// their page. The AlbumCard reads `artist` for its subtitle line,
+14
View File
@@ -85,6 +85,9 @@ pub enum ArtworkTarget {
/// 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 },
/// An album card in the favorites Artists sidepanel — section `section`
/// of `FavoritesState.selected-artist-sections`, album `index`.
FavoriteArtistAlbum { section: usize, index: usize },
/// A card in ForYouState.release-watch.albums[index].
ForYouReleaseWatch { index: usize },
/// A card in ForYouState.recent-albums.albums[index].
@@ -564,6 +567,17 @@ fn apply_artwork(
crate::favorites::set_playlist_cover(window, &id, slot, image);
}
}
ArtworkTarget::FavoriteArtistAlbum { section, index } => {
let sections = window
.global::<crate::FavoritesState>()
.get_selected_artist_sections();
if let Some(sec) = sections.row_data(section) {
if let Some(mut item) = sec.albums.row_data(index) {
item.artwork = image;
sec.albums.set_row_data(index, item);
}
}
}
ArtworkTarget::ForYouReleaseWatch { index } => {
let model = window.global::<crate::ForYouState>().get_release_watch().albums;
if let Some(mut item) = model.row_data(index) {
+45
View File
@@ -694,6 +694,51 @@ pub fn derive_artists(window: &AppWindow) {
state.set_artists_grouped(ModelRc::new(VecModel::from(sections)));
}
/// Apply the selected artist's albums to the sidepanel right column. Reuses
/// the standalone artist page's `/artist/page` `release_type` classifier
/// (`artist::load_artist` → `ReleaseSection`s), per the user's decision, so
/// the sections are server-authoritative (Discography / EPs & Singles / Live
/// / Compilations / Others).
pub fn apply_selected_artist(window: &AppWindow, sections: Vec<crate::artist::ReleaseSection>) {
let st = window.global::<FavoritesState>();
let mut total = 0i32;
let ds: Vec<DiscoverSection> = sections
.into_iter()
.map(|s| {
let items: Vec<AlbumCardItem> =
s.cards.into_iter().map(crate::artist::card_to_item).collect();
total += items.len() as i32;
DiscoverSection {
title: s.title.into(),
endpoint: "".into(),
albums: ModelRc::new(VecModel::from(items)),
}
})
.collect();
st.set_selected_artist_sections(ModelRc::new(VecModel::from(ds)));
st.set_selected_albums_total(total);
st.set_selected_albums_loading(false);
st.set_selected_albums_error("".into());
}
/// Artwork jobs for the selected artist's sidepanel album cards.
pub fn selected_artist_artwork_jobs(
sections: &[crate::artist::ReleaseSection],
) -> Vec<ArtworkJob> {
let mut jobs = Vec::new();
for (section, sec) in sections.iter().enumerate() {
for (index, card) in sec.cards.iter().enumerate() {
if !card.artwork_url.is_empty() {
jobs.push(ArtworkJob {
url: card.artwork_url.clone(),
target: ArtworkTarget::FavoriteArtistAlbum { section, index },
});
}
}
}
jobs
}
/// 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()
+5
View File
@@ -24,6 +24,8 @@ struct Prefs {
playlists_view: String,
#[serde(default)]
artists_group: bool,
#[serde(default = "d_grid")]
artists_view: String,
}
impl Default for Prefs {
@@ -35,6 +37,7 @@ impl Default for Prefs {
tracks_group: d_off(),
playlists_view: d_grid(),
artists_group: false,
artists_view: d_grid(),
}
}
}
@@ -73,6 +76,7 @@ pub fn load(window: &AppWindow) {
st.set_tracks_group_mode(p.tracks_group.into());
st.set_playlists_view_mode(p.playlists_view.into());
st.set_artists_group_enabled(p.artists_group);
st.set_artists_view_mode(p.artists_view.into());
}
/// Persist the current toolbar choices read from `FavoritesState`.
@@ -88,6 +92,7 @@ pub fn save(window: &AppWindow) {
tracks_group: st.get_tracks_group_mode().into(),
playlists_view: st.get_playlists_view_mode().into(),
artists_group: st.get_artists_group_enabled(),
artists_view: st.get_artists_view_mode().into(),
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
+61
View File
@@ -4390,6 +4390,67 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
// Artists grid <-> sidepanel view toggle (persisted). Switching back to
// grid clears the sidepanel selection (matches Tauri).
let weak = window.as_weak();
window
.global::<FavoritesActions>()
.on_artists_set_view(move |v| {
if let Some(w) = weak.upgrade() {
let st = w.global::<FavoritesState>();
st.set_artists_view_mode(v.clone());
if v == "grid" {
st.set_selected_artist_id("".into());
}
favorites_prefs::save(&w);
}
});
}
{
// Sidepanel: load + show the selected artist's albums, reusing the
// standalone artist page's /artist/page release_type classifier.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<FavoritesActions>()
.on_select_artist(move |id, name| {
let Some(w) = weak.upgrade() else {
return;
};
let st = w.global::<FavoritesState>();
st.set_selected_artist_id(id.clone());
st.set_selected_artist_name(name);
st.set_selected_albums_loading(true);
st.set_selected_albums_error("".into());
let runtime = runtime.clone();
let weak2 = weak.clone();
let image_cache = image_cache.clone();
let id_s = id.to_string();
handle.spawn(async move {
match artist::load_artist(&runtime, &id_s).await {
Ok(data) => {
let sections = data.release_sections;
let jobs = favorites::selected_artist_artwork_jobs(&sections);
let _ = weak2.upgrade_in_event_loop(move |w| {
favorites::apply_selected_artist(&w, sections);
});
artwork::spawn_loads(jobs, weak2.clone(), image_cache.clone());
}
Err(e) => {
log::error!("[qbz-slint] sidepanel artist {id_s} load failed: {e}");
let _ = weak2.upgrade_in_event_loop(move |w| {
let st = w.global::<FavoritesState>();
st.set_selected_albums_loading(false);
st.set_selected_albums_error(e.into());
});
}
}
});
});
}
{
// Playlist card actions: play / play-next / queue / share / favorite.
let runtime = app_runtime.clone();