diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 93fd42672..a8e985384 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use tokio::sync::RwLock; use qbz_models::{ + ArtistStoryResponse, AssetOrigin, ExternalStreamAsset, StreamQualityInfo, Album, Artist, ArtistAlbums, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, FrontendAdapter, GenreInfo, LabelExploreResponse, LabelGetListResponse, @@ -1668,6 +1669,22 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Get an artist's Magazine stories (editorial articles). Web client: offset=0 limit=2. + pub async fn get_artist_story( + &self, + artist_id: u64, + offset: u32, + limit: u32, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .get_artist_story(artist_id, offset, limit) + .await + .map_err(CoreError::Api) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { let client = self.client.read().await; diff --git a/crates/qbz-models/src/lib.rs b/crates/qbz-models/src/lib.rs index b6e736372..6972ea896 100644 --- a/crates/qbz-models/src/lib.rs +++ b/crates/qbz-models/src/lib.rs @@ -85,6 +85,10 @@ pub use types::{ AwardPageData, AwardPageGenericList, // Artist page types + ArtistStoryResponse, + ArtistStoryItem, + ArtistStoryImage, + ArtistStoryAuthor, PageArtistAward, PageArtistBiography, PageArtistImages, diff --git a/crates/qbz-models/src/types.rs b/crates/qbz-models/src/types.rs index 7bd88ad3d..b437f9512 100644 --- a/crates/qbz-models/src/types.rs +++ b/crates/qbz-models/src/types.rs @@ -996,7 +996,7 @@ pub struct PageArtistResponse { pub images: Option, pub similar_artists: Option, pub top_tracks: Option>, - pub last_release: Option, + pub last_release: Option, pub releases: Option>, pub tracks_appears_on: Option>, pub playlists: Option, @@ -1221,3 +1221,50 @@ pub struct ReleasesGridResponse { pub has_more: bool, pub items: Vec, } + +// ============ Artist Story Types (/artist/story) ============ + +/// Response from /artist/story (Magazine / editorial articles about the artist). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistStoryResponse { + pub has_more: bool, + #[serde(default)] + pub items: Vec, +} + +/// A single Magazine story. `image`/`images[].url` are ready-to-use signed +/// arc-cdn URLs — do NOT run them through the portrait hash/format builder. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistStoryItem { + pub id: String, + pub title: String, + /// Epoch SECONDS, not an ISO string. + #[serde(default)] + pub display_date: Option, + #[serde(default)] + pub image: Option, + #[serde(default)] + pub images: Option>, + #[serde(default)] + pub description_short: Option, + #[serde(default)] + pub authors: Option>, + #[serde(default)] + pub section_slugs: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistStoryImage { + #[serde(default)] + pub format: Option, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistStoryAuthor { + pub name: String, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub slug: Option, +} diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 7a5bfda70..86a54d6ab 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -2606,6 +2606,36 @@ impl QobuzClient { Ok(serde_json::from_value(response)?) } + /// Get artist Magazine stories (editorial articles about the artist). + /// REST header-auth only (no per-op signing). Web client calls offset=0 limit=2. + pub async fn get_artist_story( + &self, + artist_id: u64, + offset: u32, + limit: u32, + ) -> Result { + let url = endpoints::build_url(paths::ARTIST_STORY); + let query = vec![ + ("artist_id", artist_id.to_string()), + ("offset", offset.to_string()), + ("limit", limit.to_string()), + ]; + + log::debug!( + "[API] get_artist_story({}) offset={} limit={}", + artist_id, + offset, + limit + ); + let response: serde_json::Value = self + .signed_get(&url, "artiststory", &query.iter().map(|(k, v)| (*k, v.clone())).collect::>()) + .await? + .json() + .await?; + + Ok(serde_json::from_value(response)?) + } + // === CMAF streaming endpoints === /// Ensure we have a valid CMAF session, renewing if expired. diff --git a/crates/qbz-qobuz/src/endpoints.rs b/crates/qbz-qobuz/src/endpoints.rs index 7582900e4..a8eadb8f0 100644 --- a/crates/qbz-qobuz/src/endpoints.rs +++ b/crates/qbz-qobuz/src/endpoints.rs @@ -39,6 +39,7 @@ pub mod paths { pub const ARTIST_PAGE: &str = "/artist/page"; pub const ARTIST_RELEASES_GRID: &str = "/artist/getReleasesGrid"; pub const ARTIST_RELEASES_LIST: &str = "/artist/getReleasesList"; + pub const ARTIST_STORY: &str = "/artist/story"; // Playlist pub const PLAYLIST_GET: &str = "/playlist/get"; diff --git a/crates/qbz-slint/src/artist.rs b/crates/qbz-slint/src/artist.rs index 2f71bdfd4..047cb69ae 100644 --- a/crates/qbz-slint/src/artist.rs +++ b/crates/qbz-slint/src/artist.rs @@ -4,13 +4,13 @@ //! data on the worker thread, and applies it to the `ArtistState` //! global on the Slint event loop. -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use qbz_app::shell::AppRuntime; use qbz_core::FrontendAdapter; use qbz_models::{ - PageArtistRelease, PageArtistResponse, PageArtistTrack, PageArtistTrackAlbum, + ArtistStoryItem, PageArtistRelease, PageArtistResponse, PageArtistTrack, }; use slint::{ComponentHandle, Model, ModelRc, VecModel}; @@ -18,9 +18,9 @@ use crate::album::TrackData; use crate::artwork::{ArtworkJob, ArtworkTarget}; use crate::home::CardData; use crate::{ - AlbumCardItem, TrackItem, AppWindow, ArtistState, DiscoverSection, DiscoveryArtist, + AlbumCardItem, TrackItem, AppWindow, ArtistReleaseSection, ArtistState, DiscoveryArtist, JumpNavTab, LabelEntry, MbOriginData, MbRelationship, MbRelationshipsData, - NetworkSidebarState, ShellState, SimilarEntry, + NetworkSidebarState, ShellState, SimilarEntry, StoryItem, }; /// Plain, `Send` artist data produced on the worker thread. @@ -35,6 +35,12 @@ pub struct ArtistData { pub bio_source: String, pub artwork_url: String, pub top_tracks: Vec, + /// "Novedad más reciente" — the single highlighted latest release + /// (`last_release` in /artist/page). None when the API omits it. + pub last_release: Option, + /// "Appears On" (`tracks_appears_on`) — tracks where the artist guests, + /// rendered as a flat track section (NOT albums). + pub appears_on: Vec, /// Releases grouped into titled sections (Albums, EPs & Singles, ...). pub release_sections: Vec, /// Labels collected from the artist's own album releases (deduped @@ -58,10 +64,49 @@ pub struct SimilarArtistData { /// One titled group of artist releases. pub struct ReleaseSection { + /// Raw server `release_type` key (album/epSingle/live/…). Stable id + /// for jump-tabs, sort persistence (Phase 2) and "see discography". + pub release_type: String, pub title: String, + /// Server `has_more` for this bucket — gates the per-section load-more. + pub has_more: bool, pub cards: Vec, } +/// Official on-screen order of release buckets, with their display titles +/// (webplayer-faithful). `release_type` keys come straight from the server. +const RELEASE_SECTION_ORDER: &[(&str, &str)] = &[ + ("album", "Albums"), + ("epSingle", "EPs & Singles"), + ("ep", "EPs & Singles"), + ("single", "EPs & Singles"), + ("live", "Live"), + ("compilation", "Compilations"), + ("download", "Purchase Only"), + ("composer", "Composer"), + ("other", "Other"), + ("awardedRelease", "Critics' Picks"), + ("next", "Upcoming"), +]; + +/// Display title for a release_type (the dedicated discography page header). +pub fn release_type_title(release_type: &str) -> String { + RELEASE_SECTION_ORDER + .iter() + .find(|(rt, _)| *rt == release_type) + .map(|(_, title)| title.to_string()) + .unwrap_or_else(|| title_case(release_type)) +} + +/// Title-case a raw release_type key for unknown buckets (fallback only). +fn title_case(key: &str) -> String { + let mut chars = key.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + /// Fetch and map an artist page by id. pub async fn load_artist( runtime: &Arc>, @@ -81,8 +126,113 @@ where Ok(map_artist(page)) } +/// Fetch one more page of an artist's releases for a given bucket via +/// `get_releases_grid` (the reused, already-wired endpoint). Returns the +/// mapped cards + the server `has_more` flag. +pub async fn load_release_page( + runtime: &Arc>, + artist_id: &str, + release_type: &str, + offset: u32, +) -> Result<(Vec, bool), String> +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let id: u64 = artist_id + .parse() + .map_err(|_| format!("invalid artist id: {artist_id}"))?; + let resp = runtime + .core() + .get_releases_grid(id, release_type, RELEASE_PAGE_SIZE, offset, Some("release_date")) + .await + .map_err(|e| e.to_string())?; + let has_more = resp.has_more; + let cards = resp.items.into_iter().map(map_release).collect(); + Ok((cards, has_more)) +} + +/// One Magazine/Stories teaser for the sidebar. +pub struct StoryData { + pub title: String, + pub author: String, + pub excerpt: String, + pub url: String, + pub image_url: String, +} + +fn map_story(item: ArtistStoryItem) -> StoryData { + let author = item + .authors + .and_then(|list| list.into_iter().next()) + .map(|a| a.name) + .unwrap_or_default(); + // `image` is a ready-to-use arc-cdn URL; fall back to the first `images[]`. + let image_url = item + .image + .or_else(|| { + item.images + .and_then(|list| list.into_iter().next()) + .map(|img| img.url) + }) + .unwrap_or_default(); + StoryData { + url: format!("https://play.qobuz.com/magazine/story/{}", item.id), + title: item.title, + author, + excerpt: item.description_short.unwrap_or_default(), + image_url, + } +} + +/// Fetch the artist's Magazine stories (limit 2, like the official client). +/// Returns an empty list on any failure (the section just stays hidden). +pub async fn load_stories(runtime: &Arc>, artist_id: &str) -> Vec +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let Ok(id) = artist_id.parse::() else { + return Vec::new(); + }; + match runtime.core().get_artist_story(id, 0, 2).await { + Ok(resp) => resp.items.into_iter().map(map_story).collect(), + Err(e) => { + log::warn!("[qbz-slint] artist story load failed: {e}"); + Vec::new() + } + } +} + +/// Apply fetched stories to the sidebar Magazine tab. Returns artwork jobs +/// for the thumbnails (caller spawns them). UI thread. +pub fn apply_stories(window: &AppWindow, stories: Vec) -> Vec { + let mut jobs = Vec::new(); + let items: Vec = stories + .into_iter() + .enumerate() + .map(|(index, s)| { + if !s.image_url.is_empty() { + jobs.push(ArtworkJob { + target: ArtworkTarget::ArtistStory { index }, + url: s.image_url.clone(), + }); + } + StoryItem { + title: s.title.into(), + author: s.author.into(), + excerpt: s.excerpt.into(), + url: s.url.into(), + image_url: s.image_url.into(), + image: slint::Image::default(), + } + }) + .collect(); + let st = window.global::(); + st.set_stories(ModelRc::new(VecModel::from(items))); + st.set_stories_loading(false); + jobs +} + fn map_artist(page: PageArtistResponse) -> ArtistData { - let main_artist_id = page.id; let name = page.name.display; // Biography: content (HTML-stripped) + source name (when present). The @@ -125,38 +275,34 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { .map(|(index, track)| map_track(index, track)) .collect(); - // Releases: bucket into 4 categories per Tauri's convertPageArtist: - // album → Discography - // ep | single | epSingle → EPs & Singles - // live → Live Albums - // compilation | boxset | download | other | _ → Others - // The "download" group items are re-categorized by their individual - // release_type because download is a distribution channel, not a - // content type. Foreign-artist releases are filtered out (only the - // main artist's own pieces stay) and IDs are deduped across groups - // so a release listed in multiple groups appears once. - let mut albums: Vec = Vec::new(); - let mut eps_singles: Vec = Vec::new(); - let mut live_albums: Vec = Vec::new(); - let mut others: Vec = Vec::new(); + // Releases: server-driven bucketing (the official webplayer is the + // source of truth). Each `releases[]` group is keyed by its own + // `type` (release_type) — we render EVERY non-empty bucket, in the + // official order, trusting the server's key. We never re-derive + // buckets by heuristic and never collapse them into a curated few. + // Foreign-artist releases (guest spots that surface inside a group) + // are filtered out, and ids are deduped across groups so a release + // listed in more than one group appears once. The `awardedRelease` + // bucket can appear twice in the array (a server quirk) — keying by + // release_type naturally folds the two into one section. + let mut bucket_cards: HashMap> = HashMap::new(); + let mut bucket_has_more: HashMap = HashMap::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. + // Only group.type == "album", 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"; + let release_type = group.release_type.clone(); + let is_album_group = release_type == "album"; + *bucket_has_more.entry(release_type.clone()).or_insert(false) |= group.has_more; 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. - if let Some(artist_ref) = release.artist.as_ref() { - if artist_ref.id != main_artist_id { - continue; - } - } + // NO foreign-artist filter: the official webplayer renders every + // item the server placed in the bucket — including releases + // credited to the artist's band or where they only guest (e.g. + // Vicky Psarakis' albums are credited to "Sicksense"). The old + // `artist.id == page.id` filter dropped exactly those, hiding a + // real Albums section. Trust the server's bucketing (D3). if seen_release_ids.contains(&release.id) { continue; } @@ -171,23 +317,10 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { } } - let item_bucket = if group.release_type == "download" { - release - .release_type - .as_deref() - .map(map_release_type) - .unwrap_or(group_bucket) - } else { - group_bucket - }; - - let card = map_release(release); - match item_bucket { - ReleaseBucket::Albums => albums.push(card), - ReleaseBucket::Eps => eps_singles.push(card), - ReleaseBucket::Live => live_albums.push(card), - ReleaseBucket::Others => others.push(card), - } + bucket_cards + .entry(release_type.clone()) + .or_default() + .push(map_release(release)); } } @@ -213,48 +346,58 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { }) .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(); - let mut seen_compilation_albums: HashSet = HashSet::new(); - for track in page.tracks_appears_on.into_iter().flatten() { - let Some(album) = track.album else { continue }; - if seen_compilation_albums.contains(&album.id) { + // "Novedad más reciente" — the single latest-release highlight. + let last_release = page.last_release.map(map_release); + + // "Appears On" — tracks where the artist guests (tracks_appears_on). + // These are TRACKS, not albums; rendered as a flat track section. + let appears_on: Vec = page + .tracks_appears_on + .into_iter() + .flatten() + .enumerate() + .map(|(index, track)| map_track(index, track)) + .collect(); + + // Emit one section per non-empty bucket in the official on-screen + // order. Buckets the server adds in the future that aren't in this + // list are appended at the end (still rendered, just untitled-mapped). + let mut release_sections: Vec = Vec::new(); + for &(rt, title) in RELEASE_SECTION_ORDER { + // "download" ("Purchase Only") is intentionally hidden — drain it so + // it can't resurface in the leftovers pass, but emit no section. + if rt == "download" { + bucket_cards.remove(rt); continue; } - seen_compilation_albums.insert(album.id.clone()); - compilations.push(map_compilation_album(album)); + // `.remove` drains the bucket so the leftovers pass below can't + // re-emit an already-rendered type. + if let Some(cards) = bucket_cards.remove(rt) { + if cards.is_empty() { + continue; + } + release_sections.push(ReleaseSection { + release_type: rt.to_string(), + title: title.to_string(), + has_more: bucket_has_more.get(rt).copied().unwrap_or(false), + cards, + }); + } } - - let mut release_sections: Vec = Vec::new(); - if !albums.is_empty() { + // Any unknown bucket types the order list doesn't cover — append them + // last, titled from their raw key (rare; keeps D3 faithful). + let mut leftovers: Vec<(String, Vec)> = bucket_cards + .into_iter() + .filter(|(_, cards)| !cards.is_empty()) + .collect(); + leftovers.sort_by(|a, b| a.0.cmp(&b.0)); + for (rt, cards) in leftovers { + let has_more = bucket_has_more.get(&rt).copied().unwrap_or(false); release_sections.push(ReleaseSection { - title: "Discography".to_string(), - cards: albums, - }); - } - if !eps_singles.is_empty() { - release_sections.push(ReleaseSection { - title: "EPs & Singles".to_string(), - cards: eps_singles, - }); - } - if !live_albums.is_empty() { - release_sections.push(ReleaseSection { - title: "Live Albums".to_string(), - cards: live_albums, - }); - } - if !compilations.is_empty() { - release_sections.push(ReleaseSection { - title: "Compilations".to_string(), - cards: compilations, - }); - } - if !others.is_empty() { - release_sections.push(ReleaseSection { - title: "Others".to_string(), - cards: others, + title: title_case(&rt), + release_type: rt, + has_more, + cards, }); } @@ -266,36 +409,14 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { bio_source, artwork_url, top_tracks, + last_release, + appears_on, release_sections, labels, similar_artists, } } -/// UI bucket a Qobuz release_type maps to. Mirrors Tauri's adapter so the -/// section headings stay 1:1 with the WebKit build. -#[derive(Debug, Clone, Copy)] -enum ReleaseBucket { - Albums, - Eps, - Live, - Others, -} - -/// Map a Qobuz release_type string to its UI bucket. Anything not in the -/// curated three categories (album / ep* / live) lands in Others so the -/// page never grows a long tail of one-off section titles. -fn map_release_type(release_type: &str) -> ReleaseBucket { - match release_type { - "album" => ReleaseBucket::Albums, - "ep" | "single" | "epSingle" => ReleaseBucket::Eps, - "live" => ReleaseBucket::Live, - // compilation, boxset, download, other, and anything new the API - // adds in the future. - _ => ReleaseBucket::Others, - } -} - /// Truncate text at the last word boundary within `max` characters, /// appending an ellipsis. Returns the text unchanged when it already /// fits. @@ -312,6 +433,14 @@ fn truncate_words(text: &str, max: usize) -> String { /// fills in once the images decode. pub fn artwork_jobs(data: &ArtistData) -> Vec { let mut jobs = Vec::new(); + if let Some(card) = data.last_release.as_ref() { + if !card.artwork_url.is_empty() { + jobs.push(ArtworkJob { + target: ArtworkTarget::ArtistLastRelease, + url: card.artwork_url.clone(), + }); + } + } for (section_idx, section) in data.release_sections.iter().enumerate() { for (album_idx, card) in section.cards.iter().enumerate() { if !card.artwork_url.is_empty() { @@ -328,31 +457,6 @@ pub fn artwork_jobs(data: &ArtistData) -> Vec { jobs } -/// Build a CardData for the Compilations section out of a track's album -/// (tracks_appears_on entries — albums by other artists that feature the -/// main artist). The PageArtistTrackAlbum payload is lighter than a full -/// release (no year/dates), so the year stays empty. -fn map_compilation_album(album: PageArtistTrackAlbum) -> CardData { - let artwork_url = album - .image - .and_then(|img| img.best().cloned()) - .unwrap_or_default(); - CardData { - id: album.id, - title: album.title, - artist: String::new(), - artist_id: String::new(), - genre: album.genre.map(|g| g.name).unwrap_or_default(), - year: String::new(), - quality_tier: String::new(), - quality_label: String::new(), - ribbon: String::new(), - ribbon_kind: String::new(), - artwork_url, - ..CardData::default() - } -} - fn map_track(index: usize, track: PageArtistTrack) -> TrackData { let mut title = track.title; if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) { @@ -408,6 +512,14 @@ fn map_release(release: PageArtistRelease) -> CardData { (Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr), _ => String::new(), }; + // Per-release press award → gold ribbon (the AlbumCard "press" ribbon + // is already styled). First award name wins. + let (ribbon, ribbon_kind) = release + .awards + .as_ref() + .and_then(|list| list.first()) + .map(|award| (award.name.clone(), "press".to_string())) + .unwrap_or_default(); CardData { id: release.id, title: release.title, @@ -417,8 +529,8 @@ fn map_release(release: PageArtistRelease) -> CardData { year, quality_tier, quality_label, - ribbon: String::new(), - ribbon_kind: String::new(), + ribbon, + ribbon_kind, artwork_url, ..CardData::default() } @@ -458,6 +570,7 @@ pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem { artist: card.year.clone().into(), artist_id: "".into(), genre: card.genre.into(), + plain_year: card.year.clone().into(), year: card.year.into(), quality_tier: card.quality_tier.into(), quality_label: card.quality_label.into(), @@ -475,8 +588,51 @@ pub(crate) fn card_to_item(card: CardData) -> AlbumCardItem { thread_local! { static FULL_TOP_TRACKS: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); - static FULL_RELEASE_SECTIONS: std::cell::RefCell> = + static FULL_APPEARS_ON: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + static FULL_RELEASE_SECTIONS: std::cell::RefCell> = + std::cell::RefCell::new(Vec::new()); + // Per-release-type pages already loaded into the index (1 = the initial + // /artist/page bucket). The index caps at MAX_INDEX_PAGES; beyond that + // the dedicated discography page takes over. + static LOADED_PAGES: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); +} + +/// Index-page load-more cap (the user asked EPs & Singles / Live to page +/// up to 4). Page 1 is the embedded bucket; 3 more loads reach the cap. +pub const MAX_INDEX_PAGES: u32 = 4; +/// Items fetched per `get_releases_grid` load-more page. +pub const RELEASE_PAGE_SIZE: u32 = 20; + +/// Build a Slint `TrackItem` from a controller `TrackData`, stamping +/// favorite/cache status. Shared by Popular Tracks and Appears On (both +/// flat cross-album lists — no disc headers, no per-row blacklist greyout). +fn track_data_to_item(track: TrackData) -> TrackItem { + TrackItem { + is_blacklisted: false, + id: track.id.clone().into(), + number: track.number.into(), + title: track.title.into(), + artist: track.artist.into(), + album: "".into(), + duration: track.duration.into(), + quality_tier: track.quality_tier.into(), + quality_detail: track.quality_detail.into(), + explicit: track.explicit, + selected: false, + artwork_url: "".into(), + artwork: slint::Image::default(), + is_favorite: crate::fav_cache::is_favorite(&track.id), + artist_id: track.artist_id.into(), + album_id: track.album_id.into(), + removing: false, + cache_status: if crate::offline_cache::is_cached(&track.id) { 3 } else { 0 }, + cache_progress: 0.0, + source: "qobuz".into(), + unlocking: false, + disc_header_number: 0, + } } /// Apply artist data to the `ArtistState` global. Runs on the Slint event loop. @@ -490,51 +646,58 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { .map(|s| (s.title.clone(), s.cards.len())) .collect(); + let appears_on_count = data.appears_on.len(); + let has_last_release = data.last_release.is_some(); + let top_tracks: Vec = data .top_tracks .into_iter() - .map(|track| TrackItem { - // Out of Task 6 row-stamping scope: ArtistView gets the T9 - // hidden-artist banner, not per-row greyout. Never stamped here. - is_blacklisted: false, - id: track.id.clone().into(), - number: track.number.into(), - title: track.title.into(), - artist: track.artist.into(), - album: "".into(), - duration: track.duration.into(), - quality_tier: track.quality_tier.into(), - quality_detail: track.quality_detail.into(), - explicit: track.explicit, - selected: false, - artwork_url: "".into(), - artwork: slint::Image::default(), - is_favorite: crate::fav_cache::is_favorite(&track.id), - artist_id: track.artist_id.into(), - album_id: track.album_id.into(), - removing: false, - cache_status: if crate::offline_cache::is_cached(&track.id) { 3 } else { 0 }, - cache_progress: 0.0, - source: "qobuz".into(), - unlocking: false, - // Disc grouping is album-detail only; flat lists carry none. - disc_header_number: 0, - }) + .map(track_data_to_item) .collect(); - let release_sections: Vec = data + let appears_on: Vec = data + .appears_on + .into_iter() + .map(track_data_to_item) + .collect(); + let last_release_item = data + .last_release + .map(card_to_item) + .unwrap_or_default(); + let release_sections: Vec = data .release_sections .into_iter() - .map(|section| DiscoverSection { - title: section.title.into(), - // Artist release sections have no Discover full-list page. - endpoint: "".into(), - albums: ModelRc::new(VecModel::from( - section.cards.into_iter().map(card_to_item).collect::>(), - )), + .map(|section| { + // Apply the persisted per-bucket sort up front so the first paint + // already honors the user's choice. + let sort = crate::artist_prefs::get_sort(§ion.release_type); + let mut albums: Vec = + section.cards.into_iter().map(card_to_item).collect(); + crate::album_map::sort_album_items(&mut albums, &sort); + ArtistReleaseSection { + release_type: section.release_type.into(), + title: section.title.into(), + albums: ModelRc::new(VecModel::from(albums)), + has_more: section.has_more, + sort_by: sort.into(), + } }) .collect(); - let jump_tabs = build_jump_tabs(top_tracks_count, §ion_counts); + // Reset the per-bucket page counters to 1 (the embedded bucket). + LOADED_PAGES.with(|cell| { + let mut m = cell.borrow_mut(); + m.clear(); + for s in &release_sections { + m.insert(s.release_type.to_string(), 1); + } + }); + + let jump_tabs = build_jump_tabs( + top_tracks_count, + has_last_release, + §ion_counts, + appears_on_count, + ); let labels: Vec = data .labels @@ -558,6 +721,9 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { FULL_TOP_TRACKS.with(|cell| { *cell.borrow_mut() = top_tracks.clone(); }); + FULL_APPEARS_ON.with(|cell| { + *cell.borrow_mut() = appears_on.clone(); + }); FULL_RELEASE_SECTIONS.with(|cell| { *cell.borrow_mut() = release_sections.clone(); }); @@ -574,6 +740,9 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { state.set_bio_truncated(data.bio_truncated); state.set_bio_source(data.bio_source.into()); state.set_top_tracks(ModelRc::new(VecModel::from(top_tracks))); + state.set_has_last_release(has_last_release); + state.set_last_release(last_release_item); + state.set_appears_on(ModelRc::new(VecModel::from(appears_on))); 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))); @@ -590,7 +759,9 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { /// estimates land the user inside the right section. fn build_jump_tabs( top_tracks_count: usize, + has_last_release: bool, sections: &[(String, usize)], + appears_on_count: usize, ) -> Vec { // Layout constants — keep in sync with ArtistPageView.slint. const BODY_ROW_TOP_GUESS: f32 = 320.0; @@ -603,6 +774,8 @@ fn build_jump_tabs( const POPULAR_HEADER_GAP: f32 = 10.0; const POPULAR_ROW: f32 = 52.0; const POPULAR_TAIL: f32 = 32.0; + // "Novedad más reciente" highlight block (header + one card row). + const LAST_RELEASE_BLOCK: f32 = 172.0; let mut tabs: Vec = Vec::new(); tabs.push(JumpNavTab { @@ -623,13 +796,26 @@ fn build_jump_tabs( POPULAR_HEADER + POPULAR_HEADER_GAP + visible_rows * POPULAR_ROW + POPULAR_TAIL; } + // The latest-release highlight has no jump tab (it's a highlight, not a + // browsable section) but it shifts every section below it. + if has_last_release { + cursor += LAST_RELEASE_BLOCK; + } + for (title, count) in sections { + // Route by display title → stable jump-tab id. Unknown titles still + // render as sections; they just don't get a jump tab. let id = match title.as_str() { - "Discography" => "discography", + "Albums" => "albums", "EPs & Singles" => "eps-singles", - "Live Albums" => "live-albums", + "Live" => "live", "Compilations" => "compilations", - "Others" => "others", + "Purchase Only" => "purchase-only", + "Composer" => "composer", + // "Other" is rendered LAST + collapsed (below Appears On), so it + // gets no jump tab and does not occupy main-flow height here. + "Critics' Picks" => "critics-picks", + "Upcoming" => "upcoming", _ => continue, }; tabs.push(JumpNavTab { @@ -644,6 +830,14 @@ fn build_jump_tabs( + (rows - 1.0).max(0.0) * RELEASE_ROW_GAP; } + if appears_on_count > 0 { + tabs.push(JumpNavTab { + id: "appears-on".into(), + label: "Appears On".into(), + anchor_y: cursor, + }); + } + tabs } @@ -664,7 +858,7 @@ pub fn filter_artist(window: &AppWindow, query: &str) { .cloned() .collect() }); - let filtered_sections: Vec = FULL_RELEASE_SECTIONS.with(|cell| { + let filtered_sections: Vec = FULL_RELEASE_SECTIONS.with(|cell| { cell.borrow() .iter() .filter_map(|section| { @@ -679,17 +873,34 @@ pub fn filter_artist(window: &AppWindow, query: &str) { if kept.is_empty() { return None; } - Some(DiscoverSection { + Some(ArtistReleaseSection { + release_type: section.release_type.clone(), title: section.title.clone(), - endpoint: section.endpoint.clone(), albums: ModelRc::new(VecModel::from(kept)), + // No load-more while a search filter is active (it would + // append unfiltered items); restore on empty query. + has_more: if needle.is_empty() { section.has_more } else { false }, + sort_by: section.sort_by.clone(), }) }) .collect() }); + let filtered_appears: Vec = FULL_APPEARS_ON.with(|cell| { + cell.borrow() + .iter() + .filter(|track| { + needle.is_empty() + || track.title.as_str().to_lowercase().contains(&needle) + || track.artist.as_str().to_lowercase().contains(&needle) + }) + .cloned() + .collect() + }); + let state = window.global::(); state.set_top_tracks(ModelRc::new(VecModel::from(filtered_tracks))); + state.set_appears_on(ModelRc::new(VecModel::from(filtered_appears))); state.set_release_sections(ModelRc::new(VecModel::from(filtered_sections))); } @@ -697,7 +908,13 @@ pub fn filter_artist(window: &AppWindow, query: &str) { 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_appears_on(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_has_last_release(false); + state.set_last_release(AlbumCardItem::default()); + state.set_stories(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_stories_loading(true); + state.set_release_sections(ModelRc::new(VecModel::from(Vec::::new()))); + LOADED_PAGES.with(|cell| cell.borrow_mut().clear()); state.set_labels(ModelRc::new(VecModel::from(Vec::::new()))); state.set_similar_artists(ModelRc::new(VecModel::from(Vec::::new()))); state.set_jump_tabs(ModelRc::new(VecModel::from(Vec::::new()))); @@ -712,6 +929,130 @@ pub fn reset_artist(window: &AppWindow) { state.set_loading(true); } +/// Re-sort one release bucket in place (operates on the LIVE model so loaded +/// artwork is preserved) and persist the choice. `sort` = default | newest | +/// oldest | title-asc | title-desc. +pub fn resort_section(window: &AppWindow, release_type: &str, sort: &str) { + crate::artist_prefs::set_sort(release_type, sort); + let model = window.global::().get_release_sections(); + for i in 0..model.row_count() { + let Some(row) = model.row_data(i) else { continue }; + if row.release_type.as_str() != release_type { + continue; + } + let mut albums: Vec = row.albums.iter().collect(); + crate::album_map::sort_album_items(&mut albums, sort); + let new_row = ArtistReleaseSection { + albums: ModelRc::new(VecModel::from(albums)), + sort_by: sort.into(), + ..row + }; + model.set_row_data(i, new_row); + break; + } + // Keep the FULL cache (in-page search source) in the same order. + FULL_RELEASE_SECTIONS.with(|cell| { + for s in cell.borrow_mut().iter_mut() { + if s.release_type.as_str() == release_type { + let mut albums: Vec = s.albums.iter().collect(); + crate::album_map::sort_album_items(&mut albums, sort); + s.albums = ModelRc::new(VecModel::from(albums)); + s.sort_by = sort.into(); + break; + } + } + }); +} + +/// Current loaded item count for a bucket — the offset for the next page. +pub fn section_loaded_count(window: &AppWindow, release_type: &str) -> usize { + let model = window.global::().get_release_sections(); + for i in 0..model.row_count() { + if let Some(row) = model.row_data(i) { + if row.release_type.as_str() == release_type { + return row.albums.row_count(); + } + } + } + 0 +} + +/// Whether a bucket may still load another page on the index (cap = 4). +pub fn section_can_load_more(release_type: &str) -> bool { + LOADED_PAGES.with(|c| c.borrow().get(release_type).copied().unwrap_or(1)) < MAX_INDEX_PAGES +} + +/// Append a freshly-fetched page to a bucket (dedupe by id, re-sort, update +/// has_more honoring the 4-page cap). Returns artwork jobs for the NEW cards +/// at their post-sort positions. Runs on the Slint event loop. +pub fn append_release_page( + window: &AppWindow, + release_type: &str, + new_cards: Vec, + server_has_more: bool, +) -> Vec { + let pages = LOADED_PAGES.with(|cell| { + let mut m = cell.borrow_mut(); + let e = m.entry(release_type.to_string()).or_insert(1); + *e += 1; + *e + }); + let mut jobs = Vec::new(); + let model = window.global::().get_release_sections(); + for i in 0..model.row_count() { + let Some(row) = model.row_data(i) else { continue }; + if row.release_type.as_str() != release_type { + continue; + } + let sort = row.sort_by.to_string(); + let mut items: Vec = row.albums.iter().collect(); + let mut seen: HashSet = items.iter().map(|a| a.id.to_string()).collect(); + let mut appended_ids: Vec = Vec::new(); + for card in new_cards { + let item = card_to_item(card); + let id = item.id.to_string(); + if seen.contains(&id) { + continue; + } + seen.insert(id.clone()); + appended_ids.push(id); + items.push(item); + } + crate::album_map::sort_album_items(&mut items, &sort); + let has_more = server_has_more && pages < MAX_INDEX_PAGES && !appended_ids.is_empty(); + for (idx, item) in items.iter().enumerate() { + if appended_ids.iter().any(|id| id == item.id.as_str()) + && !item.artwork_url.as_str().is_empty() + { + jobs.push(ArtworkJob { + target: ArtworkTarget::ArtistRelease { + section_idx: i, + album_idx: idx, + }, + url: item.artwork_url.to_string(), + }); + } + } + let new_row = ArtistReleaseSection { + albums: ModelRc::new(VecModel::from(items.clone())), + has_more, + ..row + }; + model.set_row_data(i, new_row); + FULL_RELEASE_SECTIONS.with(|cell| { + for s in cell.borrow_mut().iter_mut() { + if s.release_type.as_str() == release_type { + s.albums = ModelRc::new(VecModel::from(items.clone())); + s.has_more = has_more; + break; + } + } + }); + break; + } + jobs +} + // ============== Popular Tracks multi-select (mirrors AlbumState) ========== /// Toggle Popular Tracks multi-select; leaving the mode clears the selection. @@ -849,6 +1190,8 @@ pub fn reset_network_sidebar(window: &AppWindow) { let constrained = window.global::().get_content_constrained(); let state = window.global::(); state.set_open(!constrained); + // Always (re)open a new artist on the Network tab. + state.set_active_tab("network".into()); state.set_mb_available(true); state.set_mb_mbid("".into()); state.set_origin_loading(false); diff --git a/crates/qbz-slint/src/artist_prefs.rs b/crates/qbz-slint/src/artist_prefs.rs new file mode 100644 index 000000000..9e79f7037 --- /dev/null +++ b/crates/qbz-slint/src/artist_prefs.rs @@ -0,0 +1,78 @@ +//! Persistence for the artist page's per-section release sort, keyed by +//! release_type (album / epSingle / live / …) so a chosen sort sticks +//! across artists and restarts. Small json under +//! `/qbz/artist_ui.json`. Mirrors `favorites_prefs`. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Default, Serialize, Deserialize)] +struct Prefs { + /// release_type -> sort (default | newest | oldest | title-asc | title-desc). + #[serde(default)] + section_sort: HashMap, +} + +thread_local! { + // Lazily-loaded, process-local cache of the prefs file. + static CACHE: RefCell> = const { RefCell::new(None) }; +} + +fn store_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("artist_ui.json")) +} + +fn read_from_disk() -> Prefs { + let Some(path) = store_path() else { + return Prefs::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => Prefs::default(), + } +} + +fn with_cache(f: impl FnOnce(&mut Prefs) -> R) -> R { + CACHE.with(|cell| { + let mut slot = cell.borrow_mut(); + if slot.is_none() { + *slot = Some(read_from_disk()); + } + f(slot.as_mut().expect("prefs loaded above")) + }) +} + +/// Persisted sort for a release bucket, or "default" when unset. +pub fn get_sort(release_type: &str) -> String { + with_cache(|p| { + p.section_sort + .get(release_type) + .cloned() + .unwrap_or_else(|| "default".to_string()) + }) +} + +/// Persist a release bucket's sort. "default" removes the entry to keep the +/// file small. Writes through to disk. +pub fn set_sort(release_type: &str, sort: &str) { + with_cache(|p| { + if sort == "default" { + p.section_sort.remove(release_type); + } else { + p.section_sort.insert(release_type.to_string(), sort.to_string()); + } + }); + let Some(path) = store_path() else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let json = with_cache(|p| serde_json::to_vec_pretty(p).ok()); + if let Some(bytes) = json { + let _ = std::fs::write(&path, bytes); + } +} diff --git a/crates/qbz-slint/src/artist_releases.rs b/crates/qbz-slint/src/artist_releases.rs new file mode 100644 index 000000000..a959c3ed4 --- /dev/null +++ b/crates/qbz-slint/src/artist_releases.rs @@ -0,0 +1,96 @@ +//! Dedicated artist discography page — the full, paginated listing for a +//! single release bucket reached via "See discography" on the artist page. +//! Reuses `artist::load_release_page` (get_releases_grid) for fetching and +//! `artist::card_to_item` for mapping; sort is the same client-side 5-mode +//! as the index sections (persisted per release_type via `artist_prefs`). + +use slint::{ComponentHandle, Model, ModelRc, VecModel}; + +use crate::artwork::{ArtworkJob, ArtworkTarget}; +use crate::home::CardData; +use crate::{AlbumCardItem, AppWindow, ArtistReleasesState}; + +/// Reset the page state for a fresh open (sets header + clears the grid). +pub fn reset(window: &AppWindow, artist_id: &str, name: &str, release_type: &str, title: &str) { + let st = window.global::(); + st.set_id(artist_id.into()); + st.set_name(name.into()); + st.set_release_type(release_type.into()); + st.set_title(title.into()); + st.set_albums(ModelRc::new(VecModel::from(Vec::::new()))); + st.set_has_more(false); + st.set_loading(true); + st.set_load_more_loading(false); + st.set_load_error(false); + st.set_sort_by(crate::artist_prefs::get_sort(release_type).into()); +} + +/// Apply a fetched page. `replace` = the first page (clears the grid); +/// otherwise the cards are appended (deduped). Re-sorts the full set by the +/// current sort and returns artwork jobs for the NEW cards at their final +/// positions. +pub fn apply_page( + window: &AppWindow, + cards: Vec, + has_more: bool, + replace: bool, +) -> Vec { + let st = window.global::(); + let sort = st.get_sort_by().to_string(); + + let mut items: Vec = if replace { + Vec::new() + } else { + st.get_albums().iter().collect() + }; + let mut seen: std::collections::HashSet = + items.iter().map(|a| a.id.to_string()).collect(); + let mut new_ids: Vec = Vec::new(); + for card in cards { + let item = crate::artist::card_to_item(card); + let id = item.id.to_string(); + if seen.contains(&id) { + continue; + } + seen.insert(id.clone()); + new_ids.push(id); + items.push(item); + } + crate::album_map::sort_album_items(&mut items, &sort); + + let mut jobs = Vec::new(); + for (idx, item) in items.iter().enumerate() { + let is_new = new_ids.iter().any(|id| id == item.id.as_str()); + if is_new && !item.artwork_url.as_str().is_empty() { + jobs.push(ArtworkJob { + target: ArtworkTarget::ArtistReleasesAlbum { index: idx }, + url: item.artwork_url.to_string(), + }); + } + } + + st.set_albums(ModelRc::new(VecModel::from(items))); + st.set_has_more(has_more); + st.set_loading(false); + st.set_load_more_loading(false); + jobs +} + +/// Re-sort the loaded grid in place (preserves artwork) and persist. +pub fn resort(window: &AppWindow, sort: &str) { + let st = window.global::(); + let release_type = st.get_release_type().to_string(); + crate::artist_prefs::set_sort(&release_type, sort); + let mut items: Vec = st.get_albums().iter().collect(); + crate::album_map::sort_album_items(&mut items, sort); + st.set_albums(ModelRc::new(VecModel::from(items))); + st.set_sort_by(sort.into()); +} + +/// Current loaded item count — the offset for the next page. +pub fn loaded_count(window: &AppWindow) -> u32 { + window + .global::() + .get_albums() + .row_count() as u32 +} diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index 41044f347..e93ec311e 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -76,6 +76,12 @@ pub enum ArtworkTarget { /// A release card in `ArtistState.release-sections[section_idx] /// .albums[album_idx]`. ArtistRelease { section_idx: usize, album_idx: usize }, + /// The single "Novedad más reciente" highlight in `ArtistState.last-release`. + ArtistLastRelease, + /// A card in `ArtistReleasesState.albums[index]` (dedicated discography page). + ArtistReleasesAlbum { index: usize }, + /// A Magazine story thumbnail in `ArtistState.stories[index]`. + ArtistStory { index: usize }, /// A card in MusicianState.appearances[index]. MusicianAppearance { index: usize }, /// A card in LabelState.albums[index] (releases sub-view grid). @@ -940,6 +946,25 @@ fn apply_artwork( item.artwork = image; section.albums.set_row_data(album_idx, item); } + ArtworkTarget::ArtistLastRelease => { + let mut item = window.global::().get_last_release(); + item.artwork = image; + window.global::().set_last_release(item); + } + ArtworkTarget::ArtistReleasesAlbum { index } => { + let model = window.global::().get_albums(); + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } + ArtworkTarget::ArtistStory { index } => { + let model = window.global::().get_stories(); + if let Some(mut item) = model.row_data(index) { + item.image = image; + model.set_row_data(index, item); + } + } ArtworkTarget::MusicianAppearance { index } => { let model = window.global::().get_appearances(); if let Some(mut item) = model.row_data(index) { diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 3dae7444e..41eddf1ae 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -19,6 +19,8 @@ mod album; mod album_map; mod artist; mod artist_blacklist; +mod artist_prefs; +mod artist_releases; mod artwork; mod auth; mod award; @@ -1082,11 +1084,27 @@ fn navigate_album( match album::load_album(&runtime, &album_id).await { Ok(data) => { let artwork_url = data.artwork_url.clone(); + // A user-set custom cover (keyed by album id) wins and is the + // only image source for albums with no Qobuz cover. Same bug + // class as the artist portrait fix. + let custom_cover_path = crate::custom_artwork::album_cover(&album_id); let _ = weak.upgrade_in_event_loop(move |w| { album::apply_album(&w, data); w.global::().set_loading(false); }); - if !artwork_url.is_empty() { + if let Some(path) = custom_cover_path { + if let Some((pixels, width, height)) = artwork::fetch_and_decode_ref( + &qbz_models::ArtworkRef::LocalFile(path), + &image_cache, + 448, + ) + .await + { + let _ = weak.upgrade_in_event_loop(move |w| { + album::apply_artwork(&w, &pixels, width, height); + }); + } + } else if !artwork_url.is_empty() { if let Some((pixels, width, height)) = artwork::fetch_and_decode(&artwork_url, &image_cache, 448).await { @@ -1167,6 +1185,10 @@ fn navigate_artist( let artwork_url = data.artwork_url.clone(); let jobs = artist::artwork_jobs(&data); let artist_name = data.name.clone(); + // Resolve a user-set custom portrait (keyed by artist name) + // up front — it persists across navigation and is the ONLY + // image source for artists with no Qobuz portrait (Vicky). + let custom_image_path = crate::custom_artwork::artist_image(&artist_name); let similar_names_for_discovery: Vec = data .similar_artists .iter() @@ -1178,6 +1200,22 @@ fn navigate_artist( }); artwork::spawn_loads(jobs, weak.clone(), image_cache.clone()); + // Magazine / Stories — fetch the editorial teasers in + // parallel; the sidebar section stays hidden if there are none. + { + let runtime_story = runtime.clone(); + let weak_story = weak.clone(); + let artist_id_story = artist_id.clone(); + let image_cache_story = image_cache.clone(); + tokio::spawn(async move { + let stories = artist::load_stories(&runtime_story, &artist_id_story).await; + let _ = weak_story.upgrade_in_event_loop(move |w| { + let jobs = artist::apply_stories(&w, stories); + artwork::spawn_loads(jobs, w.as_weak(), image_cache_story); + }); + }); + } + // Network sidebar — kick the MB enrichment off in // parallel with artwork. Origin shows a loading state // until the resolve + metadata calls return; on success @@ -1249,7 +1287,21 @@ fn navigate_artist( } }); - if !artwork_url.is_empty() { + if let Some(path) = custom_image_path { + // User-set custom portrait wins (and is the only source + // for artists with no Qobuz image). + if let Some((pixels, width, height)) = artwork::fetch_and_decode_ref( + &qbz_models::ArtworkRef::LocalFile(path), + &image_cache, + 440, + ) + .await + { + let _ = weak.upgrade_in_event_loop(move |w| { + artist::apply_artwork(&w, &pixels, width, height); + }); + } + } else if !artwork_url.is_empty() { if let Some((pixels, width, height)) = artwork::fetch_and_decode(&artwork_url, &image_cache, 440).await { @@ -1506,6 +1558,7 @@ fn scope_for(entry: &nav::NavEntry) -> String { nav::NavEntry::LabelReleases { .. } => "label-releases".into(), nav::NavEntry::Award { .. } => "award".into(), nav::NavEntry::AwardAlbums { .. } => "award-albums".into(), + nav::NavEntry::ArtistReleases { .. } => "artist-releases".into(), nav::NavEntry::Location { .. } => "location".into(), } } @@ -1625,6 +1678,21 @@ fn apply_entry( name, ); } + nav::NavEntry::ArtistReleases { + id, + name, + release_type, + } => { + navigate_artist_releases( + runtime.clone(), + weak.clone(), + handle, + image_cache.clone(), + id, + name, + release_type, + ); + } nav::NavEntry::DiscoverBrowse { endpoint, title } => { discover_browse::navigate( runtime.clone(), @@ -1841,6 +1909,46 @@ fn navigate_label_releases( }); } +/// Open the dedicated discography page for one release bucket of an artist. +/// Reuses `artist::load_release_page` (get_releases_grid) for the first page. +fn navigate_artist_releases( + runtime: Arc>, + weak: slint::Weak, + handle: &tokio::runtime::Handle, + image_cache: artwork::ImageCache, + artist_id: String, + name: String, + release_type: String, +) { + handle.spawn(async move { + let title = artist::release_type_title(&release_type); + let aid = artist_id.clone(); + let nm = name.clone(); + let rt = release_type.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + artist_releases::reset(&w, &aid, &nm, &rt, &title); + w.global::().set_view(ContentView::ArtistReleases); + }); + match artist::load_release_page(&runtime, &artist_id, &release_type, 0).await { + Ok((cards, has_more)) => { + let image_cache = image_cache.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + let jobs = artist_releases::apply_page(&w, cards, has_more, true); + artwork::spawn_loads(jobs, w.as_weak(), image_cache); + }); + } + Err(e) => { + log::error!("[qbz-slint] artist releases load failed: {e}"); + let _ = weak.upgrade_in_event_loop(|w| { + let st = w.global::(); + st.set_loading(false); + st.set_load_error(true); + }); + } + } + }); +} + /// Open the AwardView landing for `award_id`: hero + first /award/getAlbums /// preview page + other-awards rail, then the header image. fn navigate_award( @@ -7403,6 +7511,21 @@ fn main() -> Result<(), Box> { // mutation, revert + error-toast on failure. Synchronous // on the event-loop thread, so there is no re-entrancy // window (a second click can't interleave mid-toggle). + ("artist", "share") => { + let artist_id = if id.is_empty() { + weak.upgrade() + .map(|w| w.global::().get_id().to_string()) + .unwrap_or_default() + } else { + id.clone() + }; + if !artist_id.is_empty() { + share::copy_to_clipboard(share::qobuz_artist_url(&artist_id)); + if let Some(w) = weak.upgrade() { + crate::toast::success(&w, "Link copied"); + } + } + } ("artist", "blacklist-toggle") => { if let Some(w) = weak.upgrade() { let st = w.global::(); @@ -8921,6 +9044,190 @@ fn main() -> Result<(), Box> { }); } + // Artist per-section sort (persisted by release_type). + { + let weak = window.as_weak(); + window + .global::() + .on_set_section_sort(move |rt, sort| { + if let Some(w) = weak.upgrade() { + artist::resort_section(&w, rt.as_str(), sort.as_str()); + } + }); + } + + // Artist per-section load-more (capped to 4 pages; reuses get_releases_grid). + { + 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_load_more_section(move |rt| { + let Some(w) = weak.upgrade() else { + return; + }; + let release_type = rt.to_string(); + if !artist::section_can_load_more(&release_type) { + return; + } + let artist_id = w.global::().get_id().to_string(); + if artist_id.is_empty() { + return; + } + let offset = artist::section_loaded_count(&w, &release_type) as u32; + let runtime = runtime.clone(); + let weak2 = weak.clone(); + let image_cache = image_cache.clone(); + handle.spawn(async move { + match artist::load_release_page(&runtime, &artist_id, &release_type, offset) + .await + { + Ok((cards, has_more)) => { + let image_cache = image_cache.clone(); + let rt2 = release_type.clone(); + let _ = weak2.upgrade_in_event_loop(move |w| { + let jobs = + artist::append_release_page(&w, &rt2, cards, has_more); + artwork::spawn_loads(jobs, w.as_weak(), image_cache); + }); + } + Err(e) => { + log::warn!("[qbz-slint] load-more {release_type} failed: {e}") + } + } + }); + }); + } + + // Artist "See discography" — open the dedicated releases page pre-filtered. + { + 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_releases(move |rt| { + let Some(w) = weak.upgrade() else { + return; + }; + let artist_id = w.global::().get_id().to_string(); + let artist_name = w.global::().get_name().to_string(); + if artist_id.is_empty() { + return; + } + let release_type = rt.to_string(); + nav::record(nav::NavEntry::ArtistReleases { + id: artist_id.clone(), + name: artist_name.clone(), + release_type: release_type.clone(), + }); + navigate_artist_releases( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + artist_id, + artist_name, + release_type, + ); + update_nav_flags(&w); + }); + } + + // Dedicated discography page — infinite load-more. + { + 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_load_more(move || { + let Some(w) = weak.upgrade() else { + return; + }; + let st = w.global::(); + if st.get_load_more_loading() || !st.get_has_more() { + return; + } + let artist_id = st.get_id().to_string(); + let release_type = st.get_release_type().to_string(); + if artist_id.is_empty() { + return; + } + let offset = artist_releases::loaded_count(&w); + st.set_load_more_loading(true); + let runtime = runtime.clone(); + let weak2 = weak.clone(); + let image_cache = image_cache.clone(); + handle.spawn(async move { + match artist::load_release_page(&runtime, &artist_id, &release_type, offset) + .await + { + Ok((cards, has_more)) => { + let image_cache = image_cache.clone(); + let _ = weak2.upgrade_in_event_loop(move |w| { + let jobs = artist_releases::apply_page(&w, cards, has_more, false); + artwork::spawn_loads(jobs, w.as_weak(), image_cache); + }); + } + Err(e) => { + log::warn!("[qbz-slint] artist releases load-more failed: {e}"); + let _ = weak2.upgrade_in_event_loop(|w| { + w.global::().set_load_more_loading(false); + }); + } + } + }); + }); + } + + // Dedicated discography page — sort change (persisted, shared with index). + { + let weak = window.as_weak(); + window + .global::() + .on_set_sort(move |sort| { + if let Some(w) = weak.upgrade() { + artist_releases::resort(&w, sort.as_str()); + } + }); + } + + // Dedicated discography page — retry after a failed load. + { + 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_retry(move || { + let Some(w) = weak.upgrade() else { + return; + }; + let st = w.global::(); + let artist_id = st.get_id().to_string(); + let name = st.get_name().to_string(); + let release_type = st.get_release_type().to_string(); + if artist_id.is_empty() { + return; + } + navigate_artist_releases( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + artist_id, + name, + release_type, + ); + }); + } + // Artist Popular Tracks multi-select — the section toggle. { let weak = window.as_weak(); @@ -14907,12 +15214,14 @@ fn main() -> Result<(), Box> { { let handle = tokio_rt.handle().clone(); let weak = window.as_weak(); + let image_cache = image_cache.clone(); window .global::() .on_add_custom(move |kind, key| { let kind = kind.to_string(); let key = key.to_string(); let weak = weak.clone(); + let image_cache = image_cache.clone(); handle.spawn(async move { let Some(file) = rfd::AsyncFileDialog::new() .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) @@ -14925,14 +15234,36 @@ fn main() -> Result<(), Box> { match kind.as_str() { "artist" => { custom_artwork::set_artist_image(&key, &path); - let _ = weak.upgrade_in_event_loop(|w| { + // Decode + apply immediately so the new image shows + // without a reload — critical for artists with no + // Qobuz portrait (e.g. Vicky Psarakis), where there + // is no network artwork to fall back on. + let decoded = artwork::fetch_and_decode_ref( + &qbz_models::ArtworkRef::LocalFile(path.clone()), + &image_cache, + 440, + ) + .await; + let _ = weak.upgrade_in_event_loop(move |w| { w.global::().set_has_custom_image(true); + if let Some((pixels, iw, ih)) = decoded { + artist::apply_artwork(&w, &pixels, iw, ih); + } }); } "album" => { custom_artwork::set_album_cover(&key, &path); - let _ = weak.upgrade_in_event_loop(|w| { + let decoded = artwork::fetch_and_decode_ref( + &qbz_models::ArtworkRef::LocalFile(path.clone()), + &image_cache, + 448, + ) + .await; + let _ = weak.upgrade_in_event_loop(move |w| { w.global::().set_has_custom_cover(true); + if let Some((pixels, iw, ih)) = decoded { + album::apply_artwork(&w, &pixels, iw, ih); + } }); } _ => log::warn!( diff --git a/crates/qbz-slint/src/nav.rs b/crates/qbz-slint/src/nav.rs index 335c8d449..372e7439a 100644 --- a/crates/qbz-slint/src/nav.rs +++ b/crates/qbz-slint/src/nav.rs @@ -127,6 +127,13 @@ pub enum NavEntry { id: String, name: String, }, + /// ArtistReleasesView — the dedicated discography listing for one + /// release bucket, reached via "See discography" on the artist page. + ArtistReleases { + id: String, + name: String, + release_type: String, + }, /// ArtistsByLocationView — opened by the Origin section's /// location link. Carries the full scene-discovery payload. Location { diff --git a/crates/qbz-slint/src/share.rs b/crates/qbz-slint/src/share.rs index d059f16cb..9ee7b1d7e 100644 --- a/crates/qbz-slint/src/share.rs +++ b/crates/qbz-slint/src/share.rs @@ -18,6 +18,11 @@ pub fn qobuz_album_url(album_id: &str) -> String { format!("https://play.qobuz.com/album/{album_id}") } +/// Qobuz web-player artist URL (header Share action). +pub fn qobuz_artist_url(artist_id: &str) -> String { + format!("https://play.qobuz.com/artist/{artist_id}") +} + /// Copy `text` to the system clipboard. Runs on a blocking thread — /// clipboard backends (X11/Wayland) can block, and arboard keeps an /// owner thread alive so the contents persist after this returns. diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 8d228aa15..71b1e04c8 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -26,7 +26,7 @@ export { Theme, ThemeColors } from "foundation/semantic-colors.slint"; export { ThemeState } from "foundation/theme-state.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, SuggestionsState, SuggestionsActions, SuggestionCard, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions } from "state.slint"; +export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, SuggestionsState, SuggestionsActions, SuggestionCard, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/artist/ArtistPageView.slint b/crates/qbz-slint/ui/artist/ArtistPageView.slint index e1a863883..4c0f27a35 100644 --- a/crates/qbz-slint/ui/artist/ArtistPageView.slint +++ b/crates/qbz-slint/ui/artist/ArtistPageView.slint @@ -12,6 +12,7 @@ import { ArtistState, ArtistActions, ArtworkActions, + StoryItem, TrackItem, NavState, ContentView, @@ -31,6 +32,7 @@ import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { ReleaseGrid } from "ReleaseGrid.slint"; +import { AlbumCard } from "../discover/AlbumCard.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint"; import { CircleAction } from "../primitives/CircleAction.slint"; @@ -113,6 +115,10 @@ export component ArtistPageView inherits Rectangle { // Popular Tracks shows the first 5; Load more expands to all. property top-tracks-expanded: false; property top-tracks-preview: 5; + // "Appears On" section uses the same 5-row preview + show-all toggle. + property appears-on-expanded: false; + // "Other" (often AI/junk covers) renders LAST, collapsed by default. + property other-expanded: false; // Popular Tracks multi-select now lives in ArtistState // (top-tracks-multi-select / top-tracks-selected-count), so it resets on // artist change and drives the shared MultiSelectBar. @@ -443,7 +449,7 @@ export component ArtistPageView inherits Rectangle { x: 0px; y: 38px; width: 224px; - height: 3 * 33px + 1px + 10px; + height: 4 * 33px + 1px + 10px; close-policy: close-on-click; ContextMenu { // Item 1 — Create Artist Collection. @@ -474,6 +480,15 @@ export component ArtistPageView inherits Rectangle { NetworkSidebarState.mb-mbid); } } + // Item 3 — Share (copy the Qobuz artist URL). + ContextMenuItem { + icon: @image-url("../assets/icons/link.svg"); + label: @tr("Share"); + clicked => { + root.media-action( + "artist", ArtistState.id, "share"); + } + } // Visible separator. Rectangle { height: 1px; @@ -769,11 +784,41 @@ export component ArtistPageView inherits Rectangle { } } + // --- Novedad más reciente (latest-release highlight) ------------- + // A single highlighted card between Popular Tracks and the + // discography buckets. Nullable on the API → hidden when absent. + if ArtistState.has-last-release: VerticalLayout { + spacing: 12px; + Rectangle { height: 32px; } + Text { + text: @tr("Latest release"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + } + HorizontalLayout { + alignment: start; + AlbumCard { + width: 200px; + height: 246px; + album: ArtistState.last-release; + clicked(id) => { + root.open-album(id); + } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + } + } + // --- Release sections (Albums, EPs & Singles, Live, ...) --------- + // "other" is excluded here and rendered LAST (collapsed), below + // Appears On — it is usually low-quality / AI cover junk. for section in ArtistState.release-sections: VerticalLayout { spacing: 0px; - Rectangle { height: 32px; } - ReleaseGrid { + if section.release-type != "other": Rectangle { height: 32px; } + if section.release-type != "other": ReleaseGrid { section: section; album-clicked(id) => { root.open-album(id); @@ -781,6 +826,124 @@ export component ArtistPageView inherits Rectangle { media-action(kind, id, action) => { root.media-action(kind, id, action); } + set-section-sort(rt, sort) => { + ArtistActions.set-section-sort(rt, sort); + } + load-more-section(rt) => { + ArtistActions.load-more-section(rt); + } + open-releases(rt) => { + ArtistActions.open-releases(rt); + } + } + } + + // --- Appears On (tracks_appears_on) — guest-appearance tracks ---- + // A flat cross-album track section (NOT albums). 5-row preview + + // show-all toggle, mirroring Popular Tracks. Hidden when empty. + if ArtistState.appears-on.length > 0: VerticalLayout { + spacing: 0px; + Rectangle { height: 32px; } + Text { + text: @tr("Appears On"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + } + Rectangle { height: 10px; } + for track[index] in ArtistState.appears-on: TrackRow { + visible: root.appears-on-expanded || index < root.top-tracks-preview; + height: self.visible ? 50px : 0px; + track: track; + index: index; + show-artwork: false; + multi-select-mode: false; + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + if ArtistState.appears-on.length > root.top-tracks-preview: Rectangle { height: 4px; } + if ArtistState.appears-on.length > root.top-tracks-preview: HorizontalLayout { + alignment: center; + Rectangle { + width: appears-more-text.preferred-width + 24px; + height: 28px; + appears-more-text := Text { + text: root.appears-on-expanded + ? @tr("View less") + : @tr("Load more"); + color: appears-more-ta.has-hover + ? Theme.text-primary + : Theme.text-secondary; + font-size: 13px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + appears-more-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.appears-on-expanded = !root.appears-on-expanded; + } + } + } + } + } + + // --- Other (rendered LAST, collapsed by default) ----------------- + // Usually low-quality / AI cover junk, so it sits below Appears On + // and stays hidden behind a Show/Hide toggle. + for section in ArtistState.release-sections: VerticalLayout { + spacing: 0px; + if section.release-type == "other": Rectangle { height: 32px; } + if section.release-type == "other": Rectangle { + height: other-header.preferred-height; + other-header := HorizontalLayout { + spacing: 8px; + Text { + text: section.title; + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + vertical-alignment: center; + horizontal-stretch: 1; + } + Text { + text: root.other-expanded ? @tr("Hide") : @tr("Show"); + color: other-toggle-ta.has-hover + ? Theme.text-primary + : Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + } + } + other-toggle-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.other-expanded = !root.other-expanded; + } + } + } + if section.release-type == "other" && root.other-expanded: Rectangle { + height: 12px; + } + if section.release-type == "other" && root.other-expanded: ReleaseGrid { + section: section; + hide-header: true; + album-clicked(id) => { + root.open-album(id); + } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + set-section-sort(rt, sort) => { + ArtistActions.set-section-sort(rt, sort); + } + load-more-section(rt) => { + ArtistActions.load-more-section(rt); + } + open-releases(rt) => { + ArtistActions.open-releases(rt); + } } } } // close main-col VerticalLayout @@ -906,17 +1069,76 @@ export component ArtistPageView inherits Rectangle { HorizontalLayout { padding-left: 12px; padding-right: 8px; - spacing: 8px; + spacing: 14px; - Text { - text: @tr("ARTIST NETWORK"); - color: Theme.text-muted; - font-size: 12px; - font-weight: Typography.semibold; - letter-spacing: 1.2px; - horizontal-stretch: 1; - vertical-alignment: center; + // Tab: Artist Network (primary). + VerticalLayout { + alignment: center; + Rectangle { + width: net-tab.preferred-width; + height: 28px; + net-tab := Text { + text: @tr("Network"); + color: NetworkSidebarState.active-tab == "network" + ? Theme.text-primary + : Theme.text-muted; + font-size: 12px; + font-weight: Typography.semibold; + letter-spacing: 0.8px; + x: 0; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + Rectangle { + visible: NetworkSidebarState.active-tab == "network"; + width: parent.width; + height: 2px; + y: parent.height - 2px; + background: Theme.accent; + border-radius: 1px; + } + net-tab-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + NetworkSidebarState.active-tab = "network"; + } + } + } } + // Tab: Magazine (Stories). + VerticalLayout { + alignment: center; + Rectangle { + width: mag-tab.preferred-width; + height: 28px; + mag-tab := Text { + text: @tr("Magazine"); + color: NetworkSidebarState.active-tab == "magazine" + ? Theme.text-primary + : Theme.text-muted; + font-size: 12px; + font-weight: Typography.semibold; + letter-spacing: 0.8px; + x: 0; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + Rectangle { + visible: NetworkSidebarState.active-tab == "magazine"; + width: parent.width; + height: 2px; + y: parent.height - 2px; + background: Theme.accent; + border-radius: 1px; + } + mag-tab-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + NetworkSidebarState.active-tab = "magazine"; + } + } + } + } + + Rectangle { horizontal-stretch: 1; } VerticalLayout { alignment: center; @@ -962,7 +1184,7 @@ export component ArtistPageView inherits Rectangle { // long sidebar (Labels + Relationships + Discovery) can // scroll. Phase 5 adds Origin only; phases 6-8 fill the // rest. - Flickable { + if NetworkSidebarState.active-tab == "network": Flickable { vertical-stretch: 1; viewport-width: self.width; viewport-height: sidebar-body.preferred-height; @@ -1129,7 +1351,14 @@ export component ArtistPageView inherits Rectangle { } // --- Similar Artists (Qobuz) --- - VerticalLayout { + // Gate the WHOLE block on having data (or loading) so + // an empty official-similar list no longer renders a + // "No similar artists found" header above a populated + // "You may also like" (the incoherence the owner + // flagged on Vicky Psarakis). When empty, the block is + // simply absent and only the discovery rail shows. + if ArtistState.similar-artists.length > 0 + || ArtistState.loading: VerticalLayout { padding-left: 14px; padding-right: 14px; padding-top: 12px; @@ -1144,12 +1373,6 @@ export component ArtistPageView inherits Rectangle { 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…"); @@ -1343,6 +1566,73 @@ export component ArtistPageView inherits Rectangle { } } } + + // --- Magazine tab — Qobuz editorial stories -------------- + if NetworkSidebarState.active-tab == "magazine": Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: magazine-body.preferred-height; + + magazine-body := VerticalLayout { + spacing: 10px; + padding: 12px; + alignment: start; + + if ArtistState.stories-loading + && ArtistState.stories.length == 0: Text { + text: @tr("Loading…"); + color: Theme.text-muted; + font-size: 12px; + } + if !ArtistState.stories-loading + && ArtistState.stories.length == 0: Text { + text: @tr("No stories for this artist"); + color: Theme.text-muted; + font-size: 12px; + } + + for story in ArtistState.stories: Rectangle { + border-radius: 8px; + height: story-col.preferred-height; + background: story-ta.has-hover ? Theme.surface-hover : transparent; + story-col := VerticalLayout { + padding: 6px; + spacing: 6px; + if story.image-url != "": Rectangle { + // 1:1 square thumbnail (height tracks the + // layout-stretched width). + height: self.width; + border-radius: 6px; + clip: true; + Image { + width: 100%; + height: 100%; + source: story.image; + image-fit: cover; + } + } + Text { + text: story.title; + color: Theme.text-primary; + font-size: 13px; + font-weight: Typography.semibold; + wrap: word-wrap; + } + if story.author != "": Text { + text: story.author; + color: Theme.text-muted; + font-size: 11px; + } + } + story-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + ArtworkActions.open-in-browser(story.url); + } + } + } + } + } } } diff --git a/crates/qbz-slint/ui/artist/ArtistReleasesView.slint b/crates/qbz-slint/ui/artist/ArtistReleasesView.slint new file mode 100644 index 000000000..c27e02716 --- /dev/null +++ b/crates/qbz-slint/ui/artist/ArtistReleasesView.slint @@ -0,0 +1,244 @@ +// Dedicated artist discography page — the full, paginated grid for one +// release bucket, reached via "See discography" on the artist page. +// Structure homologated with AwardAlbumsView: a fixed header (NavButtons + +// artist kicker + bucket title on the LEFT, a sort select on the RIGHT) then +// an infinite-scroll Flickable driving ArtistReleasesActions.load-more(). + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { ArtistReleasesState, ArtistReleasesActions, NavState, ShellState } from "../state.slint"; +import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint"; +import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; +import { ListScrollbar } from "../primitives/ListScrollbar.slint"; +import { NavButtons } from "../shell/NavButtons.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; + +export component ArtistReleasesView inherits Rectangle { + callback open-album(string); + callback open-artist(string); + callback media-action(string, string, string); + + property album-w: 200px; + property album-h: 266px; + property gap: 24px; + property sort-index: + ArtistReleasesState.sort-by == "newest" ? 1 : + ArtistReleasesState.sort-by == "oldest" ? 2 : + ArtistReleasesState.sort-by == "title-asc" ? 3 : + ArtistReleasesState.sort-by == "title-desc" ? 4 : 0; + + background: Theme.surface-main; + + VerticalLayout { + spacing: 0px; + + // --- Fixed header ------------------------------------------------ + Rectangle { + height: 56px; + background: Theme.surface-main; + + // Left cluster — nav buttons then the artist kicker + bucket title. + HorizontalLayout { + x: 32px; + y: 25px - self.height / 2; + width: self.preferred-width; + height: self.preferred-height; + spacing: 16px; + VerticalLayout { + alignment: center; + NavButtons { } + } + VerticalLayout { + alignment: center; + spacing: 2px; + // Kicker — the artist name, server text (NEVER @tr). + Text { + text: ArtistReleasesState.name; + color: Theme.text-muted; + font-size: 11px; + font-weight: Typography.semibold; + letter-spacing: 0.6px; + vertical-alignment: center; + } + Text { + text: ArtistReleasesState.title; + color: Theme.text-primary; + font-size: Typography.section; + font-weight: Typography.bold; + vertical-alignment: center; + } + } + } + + // Right cluster — the sort select, anchored right. + HorizontalLayout { + x: parent.width - self.preferred-width - 32px; + y: 25px - self.height / 2; + width: self.preferred-width; + height: self.preferred-height; + VerticalLayout { + alignment: center; + QbzSelect { + sm: true; + menu-width: 150px; + current-index: root.sort-index; + options: [ + @tr("Default"), + @tr("Newest"), + @tr("Oldest"), + @tr("A–Z"), + @tr("Z–A"), + ]; + selected(idx) => { + ArtistReleasesActions.set-sort( + idx == 1 ? "newest" + : idx == 2 ? "oldest" + : idx == 3 ? "title-asc" + : idx == 4 ? "title-desc" + : "default"); + } + } + } + } + } + + // --- Scrolling grid ---------------------------------------------- + flick := Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: page.preferred-height; + property sr-armed: NavState.restore-scope; + function sr-restore() { + if (NavState.restore-scope == "artist-releases" && self.viewport-height > self.height) { + self.viewport-y = NavState.scroll-restore; + NavState.restore-scope = ""; + } + } + init => { self.sr-restore(); } + changed sr-armed => { self.sr-restore(); } + changed viewport-height => { self.sr-restore(); } + + changed viewport-y => { + NavState.report-scroll(self.viewport-y); + if (-self.viewport-y + self.height >= self.viewport-height - 600px + && ArtistReleasesState.has-more + && !ArtistReleasesState.load-more-loading + && !ArtistReleasesState.loading) { + ArtistReleasesActions.load-more(); + } + } + + page := VerticalLayout { + width: parent.width; + padding-left: 32px; + padding-right: 32px; + padding-top: 8px; + padding-bottom: 100px; + spacing: 0px; + alignment: start; + + if ArtistReleasesState.loading: HorizontalLayout { + alignment: center; + VerticalLayout { + alignment: center; + spacing: 12px; + HorizontalLayout { + alignment: center; + LoadingSpinner { size: 36px; } + } + Text { + text: @tr("Loading…"); + color: Theme.text-muted; + font-size: 14px; + horizontal-alignment: center; + } + } + } + + if !ArtistReleasesState.loading && ArtistReleasesState.load-error: HorizontalLayout { + alignment: center; + VerticalLayout { + alignment: center; + spacing: 12px; + Text { + text: @tr("Failed to load Library"); + color: Theme.text-secondary; + font-size: 14px; + horizontal-alignment: center; + } + Rectangle { + width: retry-row.preferred-width; + height: 34px; + border-radius: 6px; + background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + retry-row := HorizontalLayout { + padding-left: 16px; + padding-right: 16px; + Text { + text: @tr("Retry"); + color: Theme.text-primary; + font-size: 13px; + font-weight: Typography.medium; + vertical-alignment: center; + } + } + retry-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + ArtistReleasesActions.retry(); + } + } + } + } + } + + if !ArtistReleasesState.loading && !ArtistReleasesState.load-error + && ArtistReleasesState.albums.length == 0: HorizontalLayout { + alignment: center; + Text { + text: @tr("No releases found."); + color: Theme.text-muted; + font-size: 14px; + } + } + + if !ArtistReleasesState.loading && !ArtistReleasesState.load-error: AlbumCollectionView { + albums: ArtistReleasesState.albums; + view-mode: "grid"; + is-grouped: false; + show-source: false; + card-width: root.album-w; + card-height: root.album-h; + card-gap: root.gap; + open-album(id) => { root.open-album(id); } + open-artist(id) => { root.open-artist(id); } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + + if ArtistReleasesState.load-more-loading: HorizontalLayout { + alignment: center; + Rectangle { height: 24px; } + LoadingSpinner { size: 28px; } + } + } + } + } + + ListScrollbar { + x: parent.width - self.width - 4px; + y: 56px; + width: 14px; + height: parent.height - 56px; + viewport-height: flick.viewport-height; + visible-height: flick.height; + viewport-y <=> flick.viewport-y; + auto-hide: true; + hover-active: ShellState.pointer-in-window + && ShellState.pointer-x >= flick.absolute-position.x + && ShellState.pointer-x <= flick.absolute-position.x + flick.width + && ShellState.pointer-y >= flick.absolute-position.y + && ShellState.pointer-y <= flick.absolute-position.y + flick.height; + } +} diff --git a/crates/qbz-slint/ui/artist/ReleaseGrid.slint b/crates/qbz-slint/ui/artist/ReleaseGrid.slint index fcc0a0388..09f090fcb 100644 --- a/crates/qbz-slint/ui/artist/ReleaseGrid.slint +++ b/crates/qbz-slint/ui/artist/ReleaseGrid.slint @@ -1,31 +1,48 @@ // Wrapping grid of AlbumCards for the artist page's release sections -// (Discography, EPs & Singles, Live Albums, Compilations, Others). +// (Albums, EPs & Singles, Live, Compilations, …). // -// The Tauri artist view lays these as grids — never as horizontally -// paginated carousels — so a long discography wraps into rows and the -// whole shape stays scannable. Pattern mirrors `discover/GridSection`: -// absolute positioning inside a Rectangle whose height comes from the -// row count, computed once per resize. +// The discography lays these as grids — never as horizontally paginated +// carousels — so a long discography wraps into rows and the whole shape +// stays scannable. Pattern mirrors `discover/GridSection`: absolute +// positioning inside a Rectangle whose height comes from the row count. +// +// Header carries a per-section sort + a "See discography" link to the +// dedicated page. No item COUNT is shown — the bucket preview is partial +// (has_more), so a count would mislead (e.g. "Live 7" when there are more). import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; -import { DiscoverSection } from "../state.slint"; +import { ArtistReleaseSection } from "../state.slint"; import { AlbumCard } from "../discover/AlbumCard.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; export component ReleaseGrid inherits VerticalLayout { - in property section; + in property section; + // Hide the title/sort/see-all header row — used by the collapsible + // "Other" section, which supplies its own header + collapse toggle. + in property hide-header: false; callback album-clicked(string); callback media-action(string, string, string); + callback set-section-sort(string /* release-type */, string /* sort */); + callback load-more-section(string /* release-type */); + callback open-releases(string /* release-type */); property card-width: 200px; property card-height: 246px; property gap: 24px; property count: root.section.albums.length; + // sort-by string -> QbzSelect index. + property sort-index: + root.section.sort-by == "newest" ? 1 : + root.section.sort-by == "oldest" ? 2 : + root.section.sort-by == "title-asc" ? 3 : + root.section.sort-by == "title-desc" ? 4 : 0; spacing: 12px; - // --- Header: title + count ------------------------------------------- - HorizontalLayout { + // --- Header: title + see-discography link + sort --------------------- + if !root.hide-header: HorizontalLayout { + spacing: 12px; Text { text: root.section.title; color: Theme.text-primary; @@ -34,12 +51,42 @@ export component ReleaseGrid inherits VerticalLayout { vertical-alignment: center; horizontal-stretch: 1; } - if root.count > 0: Text { - text: " " + root.count; - color: Theme.text-muted; - font-size: Typography.body; - font-weight: Typography.regular; - vertical-alignment: center; + Rectangle { + width: see-all-text.preferred-width; + height: 28px; + see-all-text := Text { + text: @tr("See discography"); + color: see-all-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + } + see-all-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.open-releases(root.section.release-type); + } + } + } + QbzSelect { + sm: true; + menu-width: 150px; + current-index: root.sort-index; + options: [ + @tr("Default"), + @tr("Newest"), + @tr("Oldest"), + @tr("A–Z"), + @tr("Z–A"), + ]; + selected(idx) => { + root.set-section-sort( + root.section.release-type, + idx == 1 ? "newest" + : idx == 2 ? "oldest" + : idx == 3 ? "title-asc" + : idx == 4 ? "title-desc" + : "default"); + } } } @@ -70,4 +117,26 @@ export component ReleaseGrid inherits VerticalLayout { } } } + + // --- Load more (per-section, capped to 4 pages on the index) --------- + if root.section.has-more: HorizontalLayout { + alignment: center; + Rectangle { + width: load-more-text.preferred-width + 24px; + height: 28px; + load-more-text := Text { + text: @tr("Load more"); + color: load-more-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + font-size: 13px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + load-more-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.load-more-section(root.section.release-type); + } + } + } + } } diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 0e495b2fb..22988e14a 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -49,6 +49,7 @@ import { DiscoverBrowseView } from "../discover/DiscoverBrowseView.slint"; import { AlbumPageView } from "../album/AlbumPageView.slint"; import { LocalAlbumView } from "../album/LocalAlbumView.slint"; import { ArtistPageView } from "../artist/ArtistPageView.slint"; +import { ArtistReleasesView } from "../artist/ArtistReleasesView.slint"; import { MusicianPageView } from "../musician/MusicianPageView.slint"; import { LabelReleasesView } from "../label/LabelReleasesView.slint"; import { LabelPageView } from "../label/LabelPageView.slint"; @@ -100,6 +101,7 @@ export component AppShell inherits Rectangle { || NavState.view == ContentView.favorites || NavState.view == ContentView.album || NavState.view == ContentView.artist + || NavState.view == ContentView.artist-releases || NavState.view == ContentView.musician || NavState.view == ContentView.label || NavState.view == ContentView.label-releases @@ -291,6 +293,17 @@ export component AppShell inherits Rectangle { root.media-action(kind, id, action); } } + if NavState.view == ContentView.artist-releases && !OfflineState.offline: ArtistReleasesView { + open-album(id) => { + root.open-album(id); + } + open-artist(id) => { + root.open-artist(id); + } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } if NavState.view == ContentView.musician && !OfflineState.offline: MusicianPageView { open-album(id) => { root.open-album(id); diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 8fd3b9b7d..e024ecaa7 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -46,6 +46,18 @@ export struct BulkAction { needs-selection: bool, // dimmed + disabled while nothing is selected } +// One titled discography bucket on the artist page (Albums, EPs & Singles, +// Live, …). Distinct from DiscoverSection so it can carry the raw server +// release_type key, the per-section sort, and the load-more flag without +// bleeding artist-only fields into Discover. +export struct ArtistReleaseSection { + release-type: string, // raw server key (album/epSingle/live/…) + title: string, + albums: [AlbumCardItem], + has-more: bool, // gates the per-section "Load more" affordance + sort-by: string, // default/newest/oldest/title-asc/title-desc +} + export struct DiscoverSection { title: string, // Discover endpoint path this section maps to (e.g. "/discover/newReleases"). @@ -709,7 +721,7 @@ export global DragActions { } // Which content view the shell shows. -export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, award, award-albums, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, blacklist-manager, local-library, local-album, mixtapes, collections, mixtape-detail, discography-builder } +export enum ContentView { home, album, artist, artist-releases, settings, search, musician, label, label-releases, award, award-albums, location, favorites, mix, playlist, discover-browse, playlist-manager, offline-manager, blacklist-manager, local-library, local-album, mixtapes, collections, mixtape-detail, discography-builder } // In-app toast (transient notification). Mirrors Tauri's toastStore: // a single toast at a time (a new one replaces the current), auto-hidden @@ -2827,6 +2839,16 @@ export struct SimilarEntry { name: string, } +// One Magazine/Stories teaser (from /artist/story) shown in the sidebar. +export struct StoryItem { + title: string, + author: string, + excerpt: string, + url: string, // https://play.qobuz.com/magazine/story/{id} + image-url: string, // ready-to-use arc-cdn thumbnail URL + image: image, // decoded by the artwork pipeline +} + export global ArtistState { in property id; in property name; @@ -2851,13 +2873,23 @@ export global ArtistState { // Artwork-derived tint for the header fallback gradient. in property header-color: #181820; in property <[TrackItem]> top-tracks: []; + // "Novedad más reciente" — the single latest-release highlight (last_release). + // `has-last-release` gates the block; nullable on the API (hidden for artists + // with no recent release, e.g. those with an empty last_release). + in property has-last-release: false; + in property last-release; + // "Appears On" (tracks_appears_on) — flat track section of guest appearances. + in property <[TrackItem]> appears-on: []; + // Magazine / Stories teasers (from /artist/story) — sidebar section. + in property <[StoryItem]> stories: []; + in property stories-loading: false; // Popular Tracks multi-select (track selection) mode + selected count — // mirrors AlbumState. in-out: the section toggle flips it from the UI; // Rust resets it on artist change. in-out property top-tracks-multi-select: false; in property top-tracks-selected-count: 0; // Releases grouped into titled sections (Albums, EPs & Singles, ...). - in property <[DiscoverSection]> release-sections: []; + in property <[ArtistReleaseSection]> release-sections: []; // Labels collected from the artist's own album releases (deduped by // id, alphabetically sorted) — feed the Labels section of the // network sidebar. @@ -2907,6 +2939,15 @@ export global ArtistActions { // Popular Tracks section "more" menu (id = "next-all" | "queue-all" | // "shuffle-all" | "playlist-all"). callback top-tracks-menu-action(string); + // Change the persisted sort for one release bucket (release-type, sort). + // sort = default | newest | oldest | title-asc | title-desc. + callback set-section-sort(string, string); + // Load one more page of releases for a bucket (release-type). Capped to + // 4 pages on the index; the full list lives on the dedicated page. + callback load-more-section(string); + // Open the dedicated discography page pre-filtered to a release-type + // ("all" for the whole discography). + callback open-releases(string); } // Artist network sidebar — Origin / Labels / Similar / Relationships / @@ -2978,6 +3019,10 @@ export global NetworkSidebarState { // Defaults to open — Rust overrides on startup from the persisted // value if any. in-out property open: true; + // Active sidebar tab: "network" (Artist Network) | "magazine" (Stories). + // Lives here (not ArtistState) so it survives sidebar close/reopen; reset + // to "network" on each new artist. + in-out property active-tab: "network"; // True until proven otherwise. Set to false when MB is disabled in // settings, or when the name → mbid resolution fails. Drives whether @@ -3210,6 +3255,30 @@ export global AwardActions { callback retry(); } +// Dedicated artist discography page (one release bucket, fully paginated) +// reached via "See discography" on the artist page. +export global ArtistReleasesState { + in property id; // artist id + in property name; // artist name (header) + in property release-type; // raw server key (album/epSingle/…) + in property title; // display title (header) + in property <[AlbumCardItem]> albums: []; + in property has-more: false; + in property loading: false; + in property load-more-loading: false; + in property load-error: false; + in-out property sort-by: "default"; +} + +export global ArtistReleasesActions { + // Fetch the next page (infinite scroll / Load more). + callback load-more(); + // Change the sort (default/newest/oldest/title-asc/title-desc). + callback set-sort(string); + // Retry after a failed load. + callback retry(); +} + // One artist in the scene-discovery grid (artists from the same // place as the source artist). export struct LocationArtistItem {