diff --git a/crates/qbz-slint/src/artist.rs b/crates/qbz-slint/src/artist.rs index 779108f1f..a130fff69 100644 --- a/crates/qbz-slint/src/artist.rs +++ b/crates/qbz-slint/src/artist.rs @@ -4,7 +4,7 @@ //! data on the worker thread, and applies it to the `ArtistState` //! global on the Slint event loop. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; use qbz_app::shell::AppRuntime; @@ -18,8 +18,8 @@ use crate::album::TrackData; use crate::artwork::{ArtworkJob, ArtworkTarget}; use crate::home::CardData; use crate::{ - AlbumCardItem, AlbumTrackItem, AppWindow, ArtistState, DiscoverSection, MbOriginData, - NetworkSidebarState, + AlbumCardItem, AlbumTrackItem, AppWindow, ArtistState, DiscoverSection, LabelEntry, + MbOriginData, NetworkSidebarState, SimilarEntry, }; /// Plain, `Send` artist data produced on the worker thread. @@ -36,6 +36,23 @@ pub struct ArtistData { pub top_tracks: Vec, /// Releases grouped into titled sections (Albums, EPs & Singles, ...). pub release_sections: Vec, + /// Labels collected from the artist's own album releases (deduped + /// by id, sorted by name) — sidebar Labels section. + pub labels: Vec, + /// Similar artists from /artist/page — sidebar Similar Artists. + pub similar_artists: Vec, +} + +#[derive(Clone)] +pub struct LabelData { + pub id: String, + pub name: String, +} + +#[derive(Clone)] +pub struct SimilarArtistData { + pub id: String, + pub name: String, } /// One titled group of artist releases. @@ -122,9 +139,15 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { let mut live_albums: Vec = Vec::new(); let mut others: Vec = Vec::new(); let mut seen_release_ids: HashSet = HashSet::new(); + // Labels collected while iterating the artist's own album releases. + // Tauri's extractLabelsFromPageReleases: only group.type == "album" + // (not the bucket result after the download re-categorization), + // only own releases, dedupe by label id. + let mut labels_by_id: BTreeMap = BTreeMap::new(); for group in page.releases.into_iter().flatten() { let group_bucket = map_release_type(&group.release_type); + let is_album_group = group.release_type == "album"; for release in group.items.into_iter() { // Foreign-artist filter — the page sometimes surfaces "appears // on" entries inside release groups; those don't belong here. @@ -138,6 +161,15 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { } seen_release_ids.insert(release.id.clone()); + // Label collection — before consuming `release` into the card. + if is_album_group { + if let Some(label) = release.label.as_ref() { + labels_by_id + .entry(label.id) + .or_insert_with(|| label.name.clone()); + } + } + let item_bucket = if group.release_type == "download" { release .release_type @@ -158,6 +190,28 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { } } + let mut labels: Vec = labels_by_id + .into_iter() + .map(|(id, name)| LabelData { + id: id.to_string(), + name, + }) + .collect(); + labels.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + let similar_artists: Vec = page + .similar_artists + .map(|s| { + s.items + .into_iter() + .map(|item| SimilarArtistData { + id: item.id.to_string(), + name: item.name.display, + }) + .collect() + }) + .unwrap_or_default(); + // Compilations come from tracks_appears_on — albums by other artists // that include this artist. Dedupe by album id. let mut compilations: Vec = Vec::new(); @@ -212,6 +266,8 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { artwork_url, top_tracks, release_sections, + labels, + similar_artists, } } @@ -423,6 +479,23 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { }) .collect(); + let labels: Vec = data + .labels + .into_iter() + .map(|label| LabelEntry { + id: label.id.into(), + name: label.name.into(), + }) + .collect(); + let similar_artists: Vec = data + .similar_artists + .into_iter() + .map(|sa| SimilarEntry { + id: sa.id.into(), + name: sa.name.into(), + }) + .collect(); + let state = window.global::(); state.set_name(data.name.into()); state.set_bio(data.bio.into()); @@ -431,6 +504,8 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { state.set_bio_source(data.bio_source.into()); state.set_top_tracks(ModelRc::new(VecModel::from(top_tracks))); state.set_release_sections(ModelRc::new(VecModel::from(release_sections))); + state.set_labels(ModelRc::new(VecModel::from(labels))); + state.set_similar_artists(ModelRc::new(VecModel::from(similar_artists))); } /// Clear artist state before loading a new artist. @@ -438,6 +513,8 @@ pub fn reset_artist(window: &AppWindow) { let state = window.global::(); state.set_top_tracks(ModelRc::new(VecModel::from(Vec::::new()))); state.set_release_sections(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_labels(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_similar_artists(ModelRc::new(VecModel::from(Vec::::new()))); state.set_artwork(slint::Image::default()); state.set_name("".into()); state.set_bio("".into()); diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index d04d898d4..1731cff08 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -1152,11 +1152,31 @@ fn main() -> Result<(), Box> { .on_label_clicked(|id, name| { log::info!("[qbz-slint] network sidebar: label clicked id={id} name={name}"); }); - window - .global::() - .on_artist_clicked(|id| { - log::info!("[qbz-slint] network sidebar: artist row clicked qobuz_id={id}"); - }); + // artist-clicked actually navigates — the target view (artist page) + // already exists in Slint, unlike LabelReleases / ArtistsByLocation / + // MusicianPage. Same flow as the top-level on_open_artist handler. + { + 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_artist_clicked(move |id| { + let artist_id = id.to_string(); + nav::record(nav::NavEntry::Artist(artist_id.clone())); + navigate_artist( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + artist_id, + ); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + }); + } window .global::() .on_musician_clicked(|name, role| { diff --git a/crates/qbz-slint/ui/artist/ArtistPageView.slint b/crates/qbz-slint/ui/artist/ArtistPageView.slint index 5b8b20080..6f27d2f48 100644 --- a/crates/qbz-slint/ui/artist/ArtistPageView.slint +++ b/crates/qbz-slint/ui/artist/ArtistPageView.slint @@ -211,6 +211,55 @@ component SectionTitle inherits Text { font-weight: Typography.semibold; } +// One row in the network sidebar — small leading icon + name. Used by +// Labels (Disc icon), Similar Artists / Members / Discovery (User +// icon), and Member-Of groups (Music icon). Mirrors Tauri's +// `.sidebar-artist-link` rendering. +component SidebarLink inherits Rectangle { + in property label; + in property icon; + in property tooltip; + callback clicked(); + + height: 28px; + border-radius: 4px; + background: link-ta.has-hover ? Theme.surface-elevated : transparent; + + HorizontalLayout { + padding-left: 8px; + padding-right: 8px; + spacing: 8px; + alignment: stretch; + + VerticalLayout { + alignment: center; + horizontal-stretch: 0; + QbzIcon { + source: root.icon; + width: 12px; + height: 12px; + tint: link-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + + Text { + text: root.label; + color: link-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + font-size: 13px; + overflow: elide; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + + link-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} + export component ArtistPageView inherits Rectangle { callback open-album(string); callback media-action(string, string, string); @@ -768,6 +817,76 @@ export component ArtistPageView inherits Rectangle { } } } + + // --- Labels (Qobuz, deduped from own albums) --- + VerticalLayout { + padding-left: 14px; + padding-right: 14px; + padding-top: 12px; + padding-bottom: 6px; + spacing: 4px; + + Text { + text: @tr("LABELS"); + color: Theme.text-muted; + font-size: 11px; + font-weight: Typography.semibold; + letter-spacing: 0.5px; + } + + if ArtistState.labels.length == 0: Text { + text: @tr("No label info"); + color: Theme.text-muted; + font-size: 12px; + } + for label in ArtistState.labels: SidebarLink { + label: label.name; + icon: @image-url("../assets/icons/disc.svg"); + tooltip: label.name; + clicked => { + NetworkSidebarActions.label-clicked( + label.id, label.name); + } + } + } + + // --- Similar Artists (Qobuz) --- + VerticalLayout { + padding-left: 14px; + padding-right: 14px; + padding-top: 12px; + padding-bottom: 6px; + spacing: 4px; + + Text { + text: @tr("SIMILAR ARTISTS"); + color: Theme.text-muted; + font-size: 11px; + font-weight: Typography.semibold; + letter-spacing: 0.5px; + } + + if ArtistState.similar-artists.length == 0 + && !ArtistState.loading: Text { + text: @tr("No similar artists found"); + color: Theme.text-muted; + font-size: 12px; + } + if ArtistState.similar-artists.length == 0 + && ArtistState.loading: Text { + text: @tr("Loading…"); + color: Theme.text-muted; + font-size: 12px; + } + for similar in ArtistState.similar-artists: SidebarLink { + label: similar.name; + icon: @image-url("../assets/icons/user.svg"); + tooltip: similar.name; + clicked => { + NetworkSidebarActions.artist-clicked(similar.id); + } + } + } } } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index f28f56817..6dd5852ed 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -194,6 +194,19 @@ export global NavState { } // Artist detail screen state. +// One label row in the artist network sidebar's Labels section. +export struct LabelEntry { + id: string, + name: string, +} + +// One similar-artist row in the artist network sidebar's Similar +// Artists section. +export struct SimilarEntry { + id: string, + name: string, +} + export global ArtistState { in property name; in property bio; @@ -210,6 +223,13 @@ export global ArtistState { in property <[AlbumTrackItem]> top-tracks: []; // Releases grouped into titled sections (Albums, EPs & Singles, ...). in property <[DiscoverSection]> release-sections: []; + // Labels collected from the artist's own album releases (deduped by + // id, alphabetically sorted) — feed the Labels section of the + // network sidebar. + in property <[LabelEntry]> labels: []; + // Similar artists from the /artist/page response — feed the Similar + // Artists section of the network sidebar. + in property <[SimilarEntry]> similar-artists: []; in property loading: false; }