feat(slint): ArtistView overhaul — server-driven buckets, dedicated discography, sort/load-more, Magazine, image fixes

Bucketing now trusts the server release_type: drop the foreign-artist filter
that hid band/guest releases (e.g. Vicky Psarakis' Albums were credited to
Sicksense), render every non-empty bucket, hide 'Purchase Only' (download),
and move 'Other' to the end, collapsed by default. Drop the misleading
per-section item counts.

New 'Latest release' highlight (last_release) and 'Appears On' track section
(tracks_appears_on, previously mislabeled as Compilations).

Per-section persisted 5-mode sort (artist_prefs) + per-section load-more
capped at 4 pages, reusing get_releases_grid. New dedicated discography page
(ArtistReleasesView + artist_releases controller) with sort + infinite scroll,
reached via each section's 'See discography' link.

Magazine/Stories sidebar tab (artist/story) with 1:1 thumbnails and a
Network|Magazine tab switcher; hide the empty similar-artists block to remove
the 'no similar' over populated-discovery incoherence. Header Share action.
Per-release award ribbons.

Fix custom artist AND album images: decode + apply immediately on upload and
resolve the local override on navigation, so a custom image now shows for
artists/albums with no Qobuz artwork.

Data layer: type last_release as PageArtistRelease, add /artist/story endpoint
+ core/client methods + ArtistStory* models, export ArtistReleases* globals.

cc
This commit is contained in:
vicrodh committed 2026-06-21 21:42:22 -06:00
1 parent 9d6b3533bc
commit 391cbb86b9
18 files changed
+1894 -225

No files matched your search

+17
View File
@@ -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<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.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<ArtistStoryResponse, CoreError> {
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<LabelPageData, CoreError> {
let client = self.client.read().await;
+4
View File
@@ -85,6 +85,10 @@ pub use types::{
AwardPageData,
AwardPageGenericList,
// Artist page types
ArtistStoryResponse,
ArtistStoryItem,
ArtistStoryImage,
ArtistStoryAuthor,
PageArtistAward,
PageArtistBiography,
PageArtistImages,
+48 -1
View File
@@ -996,7 +996,7 @@ pub struct PageArtistResponse {
pub images: Option<PageArtistImages>,
pub similar_artists: Option<PageArtistSimilar>,
pub top_tracks: Option<Vec<PageArtistTrack>>,
pub last_release: Option<serde_json::Value>,
pub last_release: Option<PageArtistRelease>,
pub releases: Option<Vec<PageArtistReleaseGroup>>,
pub tracks_appears_on: Option<Vec<PageArtistTrack>>,
pub playlists: Option<PageArtistPlaylists>,
@@ -1221,3 +1221,50 @@ pub struct ReleasesGridResponse {
pub has_more: bool,
pub items: Vec<PageArtistRelease>,
}
// ============ 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<ArtistStoryItem>,
}
/// 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<i64>,
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub images: Option<Vec<ArtistStoryImage>>,
#[serde(default)]
pub description_short: Option<String>,
#[serde(default)]
pub authors: Option<Vec<ArtistStoryAuthor>>,
#[serde(default)]
pub section_slugs: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistStoryImage {
#[serde(default)]
pub format: Option<String>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistStoryAuthor {
pub name: String,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub slug: Option<String>,
}
+30
View File
@@ -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<ArtistStoryResponse> {
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::<Vec<_>>())
.await?
.json()
.await?;
Ok(serde_json::from_value(response)?)
}
// === CMAF streaming endpoints ===
/// Ensure we have a valid CMAF session, renewing if expired.
+1
View File
@@ -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";
+524 -181
View File
@@ -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<TrackData>,
/// "Novedad más reciente" — the single highlighted latest release
/// (`last_release` in /artist/page). None when the API omits it.
pub last_release: Option<CardData>,
/// "Appears On" (`tracks_appears_on`) — tracks where the artist guests,
/// rendered as a flat track section (NOT albums).
pub appears_on: Vec<TrackData>,
/// Releases grouped into titled sections (Albums, EPs & Singles, ...).
pub release_sections: Vec<ReleaseSection>,
/// 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<CardData>,
}
/// 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::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Fetch and map an artist page by id.
pub async fn load_artist<A>(
runtime: &Arc<AppRuntime<A>>,
@@ -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<A>(
runtime: &Arc<AppRuntime<A>>,
artist_id: &str,
release_type: &str,
offset: u32,
) -> Result<(Vec<CardData>, 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<A>(runtime: &Arc<AppRuntime<A>>, artist_id: &str) -> Vec<StoryData>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let Ok(id) = artist_id.parse::<u64>() 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<StoryData>) -> Vec<ArtworkJob> {
let mut jobs = Vec::new();
let items: Vec<StoryItem> = 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::<ArtistState>();
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<CardData> = Vec::new();
let mut eps_singles: Vec<CardData> = Vec::new();
let mut live_albums: Vec<CardData> = Vec::new();
let mut others: Vec<CardData> = 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<String, Vec<CardData>> = HashMap::new();
let mut bucket_has_more: HashMap<String, bool> = HashMap::new();
let mut seen_release_ids: HashSet<String> = 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<u64, String> = 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<CardData> = Vec::new();
let mut seen_compilation_albums: HashSet<String> = 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<TrackData> = 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<ReleaseSection> = 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<ReleaseSection> = 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<CardData>)> = 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<ArtworkJob> {
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<ArtworkJob> {
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<Vec<TrackItem>> =
std::cell::RefCell::new(Vec::new());
static FULL_RELEASE_SECTIONS: std::cell::RefCell<Vec<DiscoverSection>> =
static FULL_APPEARS_ON: std::cell::RefCell<Vec<TrackItem>> =
std::cell::RefCell::new(Vec::new());
static FULL_RELEASE_SECTIONS: std::cell::RefCell<Vec<ArtistReleaseSection>> =
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<HashMap<String, u32>> =
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<TrackItem> = 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<DiscoverSection> = data
let appears_on: Vec<TrackItem> = 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<ArtistReleaseSection> = 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::<Vec<_>>(),
)),
.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(&section.release_type);
let mut albums: Vec<AlbumCardItem> =
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, &section_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,
&section_counts,
appears_on_count,
);
let labels: Vec<LabelEntry> = 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<JumpNavTab> {
// 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<JumpNavTab> = 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<DiscoverSection> = FULL_RELEASE_SECTIONS.with(|cell| {
let filtered_sections: Vec<ArtistReleaseSection> = 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<TrackItem> = 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::<ArtistState>();
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::<ArtistState>();
state.set_top_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
state.set_release_sections(ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new())));
state.set_appears_on(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
state.set_has_last_release(false);
state.set_last_release(AlbumCardItem::default());
state.set_stories(ModelRc::new(VecModel::from(Vec::<StoryItem>::new())));
state.set_stories_loading(true);
state.set_release_sections(ModelRc::new(VecModel::from(Vec::<ArtistReleaseSection>::new())));
LOADED_PAGES.with(|cell| cell.borrow_mut().clear());
state.set_labels(ModelRc::new(VecModel::from(Vec::<LabelEntry>::new())));
state.set_similar_artists(ModelRc::new(VecModel::from(Vec::<SimilarEntry>::new())));
state.set_jump_tabs(ModelRc::new(VecModel::from(Vec::<JumpNavTab>::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::<ArtistState>().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<AlbumCardItem> = 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<AlbumCardItem> = 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::<ArtistState>().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<CardData>,
server_has_more: bool,
) -> Vec<ArtworkJob> {
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::<ArtistState>().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<AlbumCardItem> = row.albums.iter().collect();
let mut seen: HashSet<String> = items.iter().map(|a| a.id.to_string()).collect();
let mut appended_ids: Vec<String> = 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::<ShellState>().get_content_constrained();
let state = window.global::<NetworkSidebarState>();
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);
+78
View File
@@ -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
//! `<data-dir>/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<String, String>,
}
thread_local! {
// Lazily-loaded, process-local cache of the prefs file.
static CACHE: RefCell<Option<Prefs>> = const { RefCell::new(None) };
}
fn store_path() -> Option<PathBuf> {
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<R>(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);
}
}
+96
View File
@@ -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::<ArtistReleasesState>();
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::<AlbumCardItem>::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<CardData>,
has_more: bool,
replace: bool,
) -> Vec<ArtworkJob> {
let st = window.global::<ArtistReleasesState>();
let sort = st.get_sort_by().to_string();
let mut items: Vec<AlbumCardItem> = if replace {
Vec::new()
} else {
st.get_albums().iter().collect()
};
let mut seen: std::collections::HashSet<String> =
items.iter().map(|a| a.id.to_string()).collect();
let mut new_ids: Vec<String> = 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::<ArtistReleasesState>();
let release_type = st.get_release_type().to_string();
crate::artist_prefs::set_sort(&release_type, sort);
let mut items: Vec<AlbumCardItem> = 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::<ArtistReleasesState>()
.get_albums()
.row_count() as u32
}
+25
View File
@@ -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::<ArtistState>().get_last_release();
item.artwork = image;
window.global::<ArtistState>().set_last_release(item);
}
ArtworkTarget::ArtistReleasesAlbum { index } => {
let model = window.global::<crate::ArtistReleasesState>().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::<ArtistState>().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::<crate::MusicianState>().get_appearances();
if let Some(mut item) = model.row_data(index) {
+335 -4
View File
@@ -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::<AlbumState>().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<String> = 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<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
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::<NavState>().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::<ArtistReleasesState>();
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<dyn std::error::Error>> {
// 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::<ArtistState>().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::<ArtistState>();
@@ -8921,6 +9044,190 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Artist per-section sort (persisted by release_type).
{
let weak = window.as_weak();
window
.global::<ArtistActions>()
.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::<ArtistActions>()
.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::<ArtistState>().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::<ArtistActions>()
.on_open_releases(move |rt| {
let Some(w) = weak.upgrade() else {
return;
};
let artist_id = w.global::<ArtistState>().get_id().to_string();
let artist_name = w.global::<ArtistState>().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::<ArtistReleasesActions>()
.on_load_more(move || {
let Some(w) = weak.upgrade() else {
return;
};
let st = w.global::<ArtistReleasesState>();
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::<ArtistReleasesState>().set_load_more_loading(false);
});
}
}
});
});
}
// Dedicated discography page — sort change (persisted, shared with index).
{
let weak = window.as_weak();
window
.global::<ArtistReleasesActions>()
.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::<ArtistReleasesActions>()
.on_retry(move || {
let Some(w) = weak.upgrade() else {
return;
};
let st = w.global::<ArtistReleasesState>();
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<dyn std::error::Error>> {
{
let handle = tokio_rt.handle().clone();
let weak = window.as_weak();
let image_cache = image_cache.clone();
window
.global::<ArtworkActions>()
.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<dyn std::error::Error>> {
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::<ArtistState>().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::<AlbumState>().set_has_custom_cover(true);
if let Some((pixels, iw, ih)) = decoded {
album::apply_artwork(&w, &pixels, iw, ih);
}
});
}
_ => log::warn!(
+7
View File
@@ -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 {
+5
View File
@@ -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.
+1 -1
View File
@@ -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`.
+310 -20
View File
@@ -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 <bool> top-tracks-expanded: false;
property <int> top-tracks-preview: 5;
// "Appears On" section uses the same 5-row preview + show-all toggle.
property <bool> appears-on-expanded: false;
// "Other" (often AI/junk covers) renders LAST, collapsed by default.
property <bool> 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);
}
}
}
}
}
}
}
@@ -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 <length> album-w: 200px;
property <length> album-h: 266px;
property <length> gap: 24px;
property <int> 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 <string> 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;
}
}
+85 -16
View File
@@ -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 <DiscoverSection> section;
in property <ArtistReleaseSection> section;
// Hide the title/sort/see-all header row — used by the collapsible
// "Other" section, which supplies its own header + collapse toggle.
in property <bool> 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 <length> card-width: 200px;
property <length> card-height: 246px;
property <length> gap: 24px;
property <int> count: root.section.albums.length;
// sort-by string -> QbzSelect index.
property <int> 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);
}
}
}
}
}
+13
View File
@@ -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);
+71 -2
View File
@@ -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 <string> id;
in property <string> name;
@@ -2851,13 +2873,23 @@ export global ArtistState {
// Artwork-derived tint for the header fallback gradient.
in property <color> 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 <bool> has-last-release: false;
in property <AlbumCardItem> 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 <bool> 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 <bool> top-tracks-multi-select: false;
in property <int> 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 <bool> 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 <string> 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 <string> id; // artist id
in property <string> name; // artist name (header)
in property <string> release-type; // raw server key (album/epSingle/…)
in property <string> title; // display title (header)
in property <[AlbumCardItem]> albums: [];
in property <bool> has-more: false;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
in property <bool> load-error: false;
in-out property <string> 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 {