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:
18 files changed
+1894
-225
No files matched your search
@@ -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;
|
||||
|
||||
@@ -85,6 +85,10 @@ pub use types::{
|
||||
AwardPageData,
|
||||
AwardPageGenericList,
|
||||
// Artist page types
|
||||
ArtistStoryResponse,
|
||||
ArtistStoryItem,
|
||||
ArtistStoryImage,
|
||||
ArtistStoryAuthor,
|
||||
PageArtistAward,
|
||||
PageArtistBiography,
|
||||
PageArtistImages,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(§ion.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, §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<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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user