diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index 823a9d91a..a12c00926 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -56,8 +56,20 @@ pub enum ArtworkTarget { ArtistRelease { section_idx: usize, album_idx: usize }, /// A card in MusicianState.appearances[index]. MusicianAppearance { index: usize }, - /// A card in LabelState.albums[index]. + /// A card in LabelState.albums[index] (releases sub-view grid). LabelAlbum { index: usize }, + /// A row in LabelState.top-tracks[index] (landing). + LabelTopTrack { index: usize }, + /// A card in LabelState.releases-section.albums[index] (landing). + LabelReleaseAlbum { index: usize }, + /// A card in LabelState.critics-section.albums[index] (landing). + LabelCriticsAlbum { index: usize }, + /// The cover of LabelState.playlists[index] (landing). + LabelPlaylistCover { index: usize }, + /// A card in LabelState.artists[index] (landing). + LabelArtist { index: usize }, + /// A card in LabelState.more-labels[index] (landing). + LabelMoreLabel { index: usize }, /// A card in LocationViewState.artists[index]. LocationArtist { index: usize }, /// A row in FavoritesState.tracks[index]. @@ -438,6 +450,48 @@ fn apply_artwork( model.set_row_data(index, item); } } + ArtworkTarget::LabelTopTrack { index } => { + let model = window.global::().get_top_tracks(); + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } + ArtworkTarget::LabelReleaseAlbum { index } => { + let model = window.global::().get_releases_section().albums; + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } + ArtworkTarget::LabelCriticsAlbum { index } => { + let model = window.global::().get_critics_section().albums; + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } + ArtworkTarget::LabelPlaylistCover { index } => { + let model = window.global::().get_playlists(); + if let Some(mut item) = model.row_data(index) { + item.cover1 = image; + model.set_row_data(index, item); + } + } + ArtworkTarget::LabelArtist { index } => { + let model = window.global::().get_artists(); + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } + ArtworkTarget::LabelMoreLabel { index } => { + let model = window.global::().get_more_labels(); + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } ArtworkTarget::LocationArtist { index } => { let model = window.global::().get_artists(); if let Some(mut item) = model.row_data(index) { diff --git a/crates/qbz-slint/src/label.rs b/crates/qbz-slint/src/label.rs index 049253bd0..a5b07a744 100644 --- a/crates/qbz-slint/src/label.rs +++ b/crates/qbz-slint/src/label.rs @@ -6,15 +6,21 @@ //! sort / filter / group-by-artist controls there are deferred; this //! port covers the header, the album grid, and load-more pagination. +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use qbz_app::shell::AppRuntime; use qbz_core::FrontendAdapter; -use qbz_models::Album; +use qbz_models::{Album, LabelExploreResponse, Track}; +use serde_json::Value; use slint::{ComponentHandle, Model, ModelRc, VecModel}; use crate::artwork::{ArtworkJob, ArtworkTarget}; -use crate::{AlbumCardItem, AppWindow, LabelState}; +use crate::{ + AlbumCardItem, AppWindow, DiscoverSection, JumpNavTab, LabelState, SearchPlaylistItem, + SlimItem, TrackItem, +}; /// Page size for the album catalog. Tauri pulls 500 at a time; keep /// the same so a typical label loads in one shot. @@ -40,6 +46,7 @@ pub struct AlbumCard { pub quality_label: String, pub artwork_url: String, // List-row extras (AlbumListRow columns; ignored by the grid card). + pub release_type: String, // "Album" | "EP" | "Single" (TYPE column) pub quality_detail: String, // "24-bit / 96 kHz" pub track_count: String, // "12" pub plain_year: String, // "1973" @@ -124,6 +131,7 @@ fn map_album(album: Album) -> AlbumCard { .filter(|n| *n > 0) .map(|n| n.to_string()) .unwrap_or_default(); + let release_type = classify_release_type(album.tracks_count).to_string(); AlbumCard { id: album.id, title: album.title, @@ -134,6 +142,7 @@ fn map_album(album: Album) -> AlbumCard { quality_tier, quality_label, artwork_url: album.image.best().cloned().unwrap_or_default(), + release_type, quality_detail, track_count, plain_year: year, @@ -148,6 +157,17 @@ fn tier(bit_depth: Option) -> &'static str { } } +/// Classify the list-row TYPE column from the album's track count. The +/// /label/getAlbums payload carries no explicit release_type, so this +/// mirrors home.rs's Discover heuristic (<=3 = Single, <=6 = EP, else Album). +fn classify_release_type(track_count: Option) -> &'static str { + match track_count { + Some(n) if n <= 3 => "Single", + Some(n) if n <= 6 => "EP", + _ => "Album", + } +} + /// Extract the best URL from /label/page's flexible image value. It /// can be a bare string or an object with mega/extralarge/large/... /// keys (mirrors the Svelte extraction order). @@ -180,9 +200,10 @@ fn to_item(card: AlbumCard) -> AlbumCardItem { ribbon_kind: "".into(), artwork_url: card.artwork_url.into(), artwork: slint::Image::default(), - // List-row extras — feed the AlbumListRow columns (QUALITY / + // List-row extras — feed the AlbumListRow columns (TYPE / QUALITY / // TRACKS / YEAR) for the list view toggle. SOURCE is hidden in // this single-source (Qobuz) context. + release_type: card.release_type.into(), quality_detail: card.quality_detail.into(), track_count: card.track_count.into(), plain_year: card.plain_year.into(), @@ -199,6 +220,100 @@ pub fn apply_label(window: &AppWindow, data: LabelData) { state.set_albums(ModelRc::new(VecModel::from(items))); state.set_total(data.total as i32); state.set_loading(false); + derive_releases(window); +} + +/// Re-derive the releases sub-view's rendered list (`visible` / `grouped`) +/// from the full loaded catalog + the toolbar state (sort / Hi-Res filter / +/// search / group-by-artist). Mirrors Tauri's client-side `$derived` +/// processing. Search is a local filter over the loaded catalog. Artwork +/// stays live in the common case because the no-filter, default-sort path +/// shares the `albums` model (the DiscoverBrowse pattern). +pub fn derive_releases(window: &AppWindow) { + let state = window.global::(); + let albums = state.get_albums(); + let count = albums.row_count(); + let full: Vec = (0..count).filter_map(|i| albums.row_data(i)).collect(); + + let sort = state.get_sort_by().to_string(); + let hires = state.get_filter_hires(); + let group = state.get_group_by_artist(); + let query_owned = state.get_search_query().to_lowercase(); + let query = query_owned.trim(); + + let hires_count = full + .iter() + .filter(|a| a.quality_tier.as_str() == "hires") + .count(); + + // Fast path: default order, no filter/search, flat → render the live + // `albums` model directly so artwork keeps updating in place. + if !hires && query.is_empty() && sort == "newest" && !group { + state.set_visible(albums.clone()); + state.set_grouped(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_shown(full.len() as i32); + state.set_hires_count(hires_count as i32); + return; + } + + let mut filtered: Vec = full + .into_iter() + .filter(|a| { + (!hires || a.quality_tier.as_str() == "hires") + && (query.is_empty() + || a.title.to_lowercase().contains(query) + || a.artist.to_lowercase().contains(query)) + }) + .collect(); + sort_album_items(&mut filtered, &sort); + let shown = filtered.len(); + + if group { + // One section per artist, in first-appearance (sorted) order. + let mut sections: Vec = Vec::new(); + let mut buckets: Vec> = Vec::new(); + let mut index: HashMap = HashMap::new(); + for item in filtered { + let key = item.artist.to_string(); + let idx = *index.entry(key.clone()).or_insert_with(|| { + buckets.push(Vec::new()); + sections.push(DiscoverSection { + title: if key.is_empty() { + "Unknown".into() + } else { + key.clone().into() + }, + endpoint: "".into(), + albums: ModelRc::new(VecModel::from(Vec::::new())), + }); + buckets.len() - 1 + }); + buckets[idx].push(item); + } + for (i, bucket) in buckets.into_iter().enumerate() { + sections[i].albums = ModelRc::new(VecModel::from(bucket)); + } + state.set_grouped(ModelRc::new(VecModel::from(sections))); + state.set_visible(ModelRc::new(VecModel::from(Vec::::new()))); + } else { + state.set_visible(ModelRc::new(VecModel::from(filtered))); + state.set_grouped(ModelRc::new(VecModel::from(Vec::::new()))); + } + state.set_shown(shown as i32); + state.set_hires_count(hires_count as i32); +} + +fn sort_album_items(items: &mut [AlbumCardItem], sort: &str) { + match sort { + "oldest" => items.sort_by(|a, b| a.plain_year.as_str().cmp(b.plain_year.as_str())), + "newest" => items.sort_by(|a, b| b.plain_year.as_str().cmp(a.plain_year.as_str())), + "title-asc" => items.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())), + "title-desc" => items.sort_by(|a, b| b.title.to_lowercase().cmp(&a.title.to_lowercase())), + "artist-asc" => { + items.sort_by(|a, b| a.artist.to_lowercase().cmp(&b.artist.to_lowercase())) + } + _ => {} + } } pub fn append_albums(window: &AppWindow, albums: Vec, total: usize) { @@ -211,6 +326,7 @@ pub fn append_albums(window: &AppWindow, albums: Vec, total: usize) { state.set_albums(ModelRc::new(VecModel::from(combined))); state.set_total(total as i32); state.set_load_more_loading(false); + derive_releases(window); } /// Apply the decoded label header image. Runs on the Slint event loop. @@ -231,9 +347,18 @@ pub fn reset_label(window: &AppWindow) { state.set_name("".into()); state.set_image_url("".into()); state.set_albums(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_visible(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_grouped(ModelRc::new(VecModel::from(Vec::::new()))); state.set_total(0); state.set_loading(true); state.set_load_more_loading(false); + // Reset the toolbar to defaults for the fresh label. + state.set_sort_by("newest".into()); + state.set_filter_hires(false); + state.set_group_by_artist(false); + state.set_search_query("".into()); + state.set_shown(0); + state.set_hires_count(0); } /// Artwork jobs for the label album grid — same pipeline the @@ -249,3 +374,717 @@ pub fn artwork_jobs(data: &LabelData) -> Vec { }) .collect() } + +// ====================================================================== +// Landing page (LabelPageView) — the rich label page: header + +// popular tracks + releases / critics / playlists / artists / +// more-labels carousels. Mirrors Tauri's LabelView.svelte. Data comes +// from /label/page (top_tracks/releases/playlists/top_artists), the +// first /label/getAlbums page (releases carousel), /label/explore +// (more labels), and the user's favorite-labels set (follow state). +// ====================================================================== + +/// Plain, `Send` payload for the rich label landing page. +pub struct LabelPagePayload { + pub id: String, + pub name: String, + pub image_url: String, + pub description: String, + pub description_short: String, + pub description_truncated: bool, + pub is_following: bool, + pub top_tracks: Vec, + pub releases: Vec, + pub critics: Vec, + pub playlists: Vec, + pub artists: Vec, + pub more_labels: Vec, + /// Catalog tracks kept for "Play all" — deserialized from the page + /// top_tracks and queued verbatim (mirrors Tauri's buildTopTracksQueue). + pub play_tracks: Vec, +} + +#[derive(Clone)] +pub struct TopTrack { + pub id: String, + pub title: String, + pub artist: String, + pub artist_id: String, + pub album_id: String, + pub artwork_url: String, + pub duration: String, + pub quality_tier: String, +} + +#[derive(Clone)] +pub struct PlaylistSlim { + pub id: String, + pub title: String, + pub subtitle: String, + pub image_url: String, +} + +#[derive(Clone)] +pub struct ArtistSlim { + pub id: String, + pub name: String, + pub image_url: String, +} + +#[derive(Clone)] +pub struct LabelSlim { + pub id: String, + pub name: String, + pub image_url: String, + pub following: bool, +} + +// Catalog tracks for the landing's "Play all", cached on the UI thread +// (set in `apply_label_page`, read by the play-top media action). +thread_local! { + static PLAY_TOP_TRACKS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// The label's popular tracks as a play-ready queue source. +pub fn top_tracks_for_play() -> Vec { + PLAY_TOP_TRACKS.with(|c| c.borrow().clone()) +} + +/// Fetch + map the full label landing page. +pub async fn load_label_page( + runtime: &Arc>, + label_id: u64, + fallback_name: &str, +) -> Result +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let page = runtime + .core() + .get_label_page(label_id) + .await + .map_err(|e| e.to_string())?; + + // Favorite-label ids — seed the header + per-card follow state. + let follow_ids = favorite_label_ids(runtime).await; + + let name = if page.name.is_empty() { + fallback_name.to_string() + } else { + page.name.clone() + }; + let image_url = extract_label_image(page.image.as_ref()); + + // Description (HTML-stripped) + truncation for the header read-more. + let description = page + .description + .as_deref() + .map(crate::strip_html::strip_html) + .unwrap_or_default(); + let description_short = truncate_words(&description, 360); + let description_truncated = description_short != description; + + // Popular tracks → display rows + play-all queue source. + let raw_top = page.top_tracks.clone().unwrap_or_default(); + let top_tracks: Vec = raw_top.iter().map(parse_top_track).collect(); + let play_tracks: Vec = raw_top + .into_iter() + .filter_map(|v| serde_json::from_value::(v).ok()) + .collect(); + + // Critics' Picks — the releases container whose id mentions + // award/critic/press (mirrors LabelView.svelte:402-413). + let critics: Vec = page + .releases + .as_ref() + .and_then(|containers| { + containers + .iter() + .find(|c| { + c.id + .as_deref() + .map(|id| { + let id = id.to_lowercase(); + id.contains("award") || id.contains("critic") || id.contains("press") + }) + .unwrap_or(false) + }) + .and_then(|c| c.data.as_ref()) + .and_then(|d| d.items.as_ref()) + .map(|items| { + items + .iter() + .filter_map(|v| serde_json::from_value::(v.clone()).ok()) + .map(map_album) + .collect() + }) + }) + .unwrap_or_default(); + + let playlists: Vec = page + .playlists + .as_ref() + .and_then(|p| p.items.as_ref()) + .map(|items| items.iter().map(parse_playlist).collect()) + .unwrap_or_default(); + + let artists: Vec = page + .top_artists + .as_ref() + .and_then(|a| a.items.as_ref()) + .map(|items| items.iter().map(parse_artist).collect()) + .unwrap_or_default(); + + // Releases carousel — first 20 from /label/getAlbums. + let releases: Vec = match runtime + .core() + .get_label_albums(label_id, 20, 0, None, None, None, None, None) + .await + { + Ok(p) => p.items.into_iter().map(map_album).collect(), + Err(e) => { + log::warn!("[qbz-slint] label releases carousel failed: {e}"); + Vec::new() + } + }; + + // More labels — /label/explore minus the current label; seed follow. + let more_labels: Vec = match runtime.core().get_label_explore(20, 0).await { + Ok(resp) => parse_more_labels(&resp, label_id, &follow_ids), + Err(e) => { + log::warn!("[qbz-slint] label explore failed: {e}"); + Vec::new() + } + }; + + Ok(LabelPagePayload { + id: label_id.to_string(), + name, + image_url, + description, + description_short, + description_truncated, + is_following: follow_ids.contains(&label_id), + top_tracks, + releases, + critics, + playlists, + artists, + more_labels, + play_tracks, + }) +} + +/// The user's favorite-label ids (for the header + more-labels follow +/// state). Best-effort: an error yields an empty set. +async fn favorite_label_ids(runtime: &Arc>) -> HashSet +where + A: FrontendAdapter + Send + Sync + 'static, +{ + match runtime.core().get_favorites("label", 500, 0).await { + Ok(v) => v + .get("labels") + .and_then(|l| l.get("items")) + .and_then(|i| i.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|it| it.get("id").and_then(|x| x.as_u64())) + .collect() + }) + .unwrap_or_default(), + Err(_) => HashSet::new(), + } +} + +fn parse_top_track(raw: &Value) -> TopTrack { + let id = raw + .get("id") + .and_then(|v| v.as_u64()) + .map(|n| n.to_string()) + .unwrap_or_default(); + let title = raw + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let duration = raw.get("duration").and_then(|v| v.as_u64()).unwrap_or(0); + let album = raw.get("album"); + let album_id = album + .and_then(|a| a.get("id")) + .map(value_to_string) + .unwrap_or_default(); + let artwork_url = album + .and_then(|a| a.get("image")) + .map(parse_image_value) + .unwrap_or_default(); + // performer OR artist (the page uses either). + let perf = raw.get("performer").or_else(|| raw.get("artist")); + let artist = perf + .and_then(|p| p.get("name")) + .map(name_display) + .unwrap_or_default(); + let artist_id = perf + .and_then(|p| p.get("id")) + .map(value_to_string) + .unwrap_or_default(); + let bit_depth = raw + .get("audio_info") + .and_then(|a| a.get("maximum_bit_depth")) + .and_then(|v| v.as_u64()) + .or_else(|| raw.get("maximum_bit_depth").and_then(|v| v.as_u64())); + TopTrack { + id, + title, + artist, + artist_id, + album_id, + artwork_url, + duration: mmss(duration as u32), + quality_tier: tier(bit_depth.map(|b| b as u32)).to_string(), + } +} + +fn parse_playlist(raw: &Value) -> PlaylistSlim { + let id = raw.get("id").map(value_to_string).unwrap_or_default(); + let title = raw + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let owner = raw + .get("owner") + .and_then(|o| o.get("name")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or("Qobuz") + .to_string(); + let track_count = raw.get("tracks_count").and_then(|v| v.as_u64()).unwrap_or(0); + let subtitle = format!("{owner} · {track_count}"); + PlaylistSlim { + id, + title, + subtitle, + image_url: parse_playlist_image(raw), + } +} + +fn parse_artist(raw: &Value) -> ArtistSlim { + ArtistSlim { + id: raw.get("id").map(value_to_string).unwrap_or_default(), + name: raw.get("name").map(name_display).unwrap_or_default(), + image_url: parse_artist_image(raw), + } +} + +fn parse_more_labels( + resp: &LabelExploreResponse, + current: u64, + follow: &HashSet, +) -> Vec { + let Some(items) = resp.items.as_ref() else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let id = item.get("id").and_then(|x| x.as_u64())?; + if id == current { + return None; + } + let name = item + .get("name") + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string(); + let image_url = item + .get("image") + .map(parse_explore_image) + .unwrap_or_default(); + Some(LabelSlim { + id: id.to_string(), + name, + image_url, + following: follow.contains(&id), + }) + }) + .collect() +} + +// --- Value helpers (mirror the Svelte getX helpers) ---------------------- + +/// String for an id-ish Value (string verbatim, number stringified). +fn value_to_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => String::new(), + } +} + +/// Display name: `{display}` object form or a bare string. +fn name_display(v: &Value) -> String { + if let Some(s) = v.as_str() { + return s.to_string(); + } + v.get("display") + .and_then(|d| d.as_str()) + .unwrap_or_default() + .to_string() +} + +/// Best URL out of an album `image` Value (string or {large|...}). +fn parse_image_value(v: &Value) -> String { + if let Some(s) = v.as_str() { + return s.to_string(); + } + for key in ["large", "extralarge", "medium", "thumbnail", "small"] { + if let Some(s) = v.get(key).and_then(|x| x.as_str()) { + return s.to_string(); + } + } + String::new() +} + +/// getArtistImageUrl: image{large|extralarge|medium|thumbnail|small} -> +/// picture(string) -> images.portrait hash (medium covers). +fn parse_artist_image(raw: &Value) -> String { + if let Some(image) = raw.get("image") { + let url = parse_image_value(image); + if !url.is_empty() { + return url; + } + } + if let Some(pic) = raw.get("picture").and_then(|v| v.as_str()) { + if !pic.is_empty() { + return pic.to_string(); + } + } + if let Some(portrait) = raw.get("images").and_then(|i| i.get("portrait")) { + if let (Some(hash), Some(format)) = ( + portrait.get("hash").and_then(|v| v.as_str()), + portrait.get("format").and_then(|v| v.as_str()), + ) { + return format!( + "https://static.qobuz.com/images/artists/covers/medium/{hash}.{format}" + ); + } + } + String::new() +} + +/// getPlaylistImage: image.rectangle -> image.covers[0] -> +/// image{large|thumbnail|small} -> images300[0] -> images150[0] -> +/// images[0]. +fn parse_playlist_image(raw: &Value) -> String { + if let Some(image) = raw.get("image") { + if let Some(rect) = image.get("rectangle").and_then(|v| v.as_str()) { + if !rect.is_empty() { + return rect.to_string(); + } + } + if let Some(cover) = image + .get("covers") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + { + return cover.to_string(); + } + for key in ["large", "thumbnail", "small"] { + if let Some(s) = image.get(key).and_then(|v| v.as_str()) { + return s.to_string(); + } + } + } + for key in ["images300", "images150", "images"] { + if let Some(s) = raw + .get(key) + .and_then(|a| a.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()) + { + return s.to_string(); + } + } + String::new() +} + +/// parseLabelExploreImage: string or {large|thumbnail|small}. +fn parse_explore_image(v: &Value) -> String { + if let Some(s) = v.as_str() { + return s.to_string(); + } + for key in ["large", "thumbnail", "small"] { + if let Some(s) = v.get(key).and_then(|x| x.as_str()) { + return s.to_string(); + } + } + String::new() +} + +fn mmss(secs: u32) -> String { + format!("{}:{:02}", secs / 60, secs % 60) +} + +/// Word-boundary truncation with an ellipsis (mirrors artist::truncate_words). +fn truncate_words(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_string(); + } + let truncated: String = text.chars().take(max).collect(); + let cut = truncated.rfind(' ').unwrap_or(truncated.len()); + format!("{}…", truncated[..cut].trim_end()) +} + +// --- Slint mapping ------------------------------------------------------- + +fn top_track_to_item(t: &TopTrack) -> TrackItem { + TrackItem { + id: t.id.clone().into(), + number: "".into(), + title: t.title.clone().into(), + artist: t.artist.clone().into(), + album: "".into(), + duration: t.duration.clone().into(), + quality_tier: t.quality_tier.clone().into(), + explicit: false, + selected: false, + artwork_url: t.artwork_url.clone().into(), + artwork: slint::Image::default(), + is_favorite: crate::fav_cache::is_favorite(&t.id), + artist_id: t.artist_id.clone().into(), + album_id: t.album_id.clone().into(), + } +} + +fn playlist_to_item(p: &PlaylistSlim) -> SearchPlaylistItem { + SearchPlaylistItem { + id: p.id.clone().into(), + title: p.title.clone().into(), + subtitle: p.subtitle.clone().into(), + cover_count: if p.image_url.is_empty() { 0 } else { 1 }, + url1: p.image_url.clone().into(), + url2: "".into(), + url3: "".into(), + url4: "".into(), + cover1: slint::Image::default(), + cover2: slint::Image::default(), + cover3: slint::Image::default(), + cover4: slint::Image::default(), + } +} + +fn artist_to_item(a: &ArtistSlim) -> SlimItem { + SlimItem { + id: a.id.clone().into(), + title: a.name.clone().into(), + subtitle: "".into(), + rank: "".into(), + artwork_url: a.image_url.clone().into(), + artwork: slint::Image::default(), + following: false, + } +} + +fn label_to_item(l: &LabelSlim) -> SlimItem { + SlimItem { + id: l.id.clone().into(), + title: l.name.clone().into(), + subtitle: "".into(), + rank: "".into(), + artwork_url: l.image_url.clone().into(), + artwork: slint::Image::default(), + following: l.following, + } +} + +fn section(title: &str, cards: &[AlbumCard]) -> DiscoverSection { + DiscoverSection { + title: title.into(), + endpoint: "".into(), + albums: ModelRc::new(VecModel::from( + cards.iter().cloned().map(to_item).collect::>(), + )), + } +} + +/// Apply the landing payload to `LabelState`. Runs on the Slint event loop. +pub fn apply_label_page(window: &AppWindow, payload: LabelPagePayload) { + PLAY_TOP_TRACKS.with(|c| *c.borrow_mut() = payload.play_tracks.clone()); + + let top_tracks: Vec = payload.top_tracks.iter().map(top_track_to_item).collect(); + let playlists: Vec = + payload.playlists.iter().map(playlist_to_item).collect(); + let artists: Vec = payload.artists.iter().map(artist_to_item).collect(); + let more_labels: Vec = payload.more_labels.iter().map(label_to_item).collect(); + let releases = section("Releases", &payload.releases); + let critics = section("Critics' Picks", &payload.critics); + let jump_tabs = build_label_jump_tabs(&payload); + + let state = window.global::(); + state.set_id(payload.id.into()); + state.set_name(payload.name.into()); + state.set_image_url(payload.image_url.into()); + state.set_description(payload.description.into()); + state.set_description_short(payload.description_short.into()); + state.set_description_truncated(payload.description_truncated); + state.set_is_following(payload.is_following); + state.set_top_tracks(ModelRc::new(VecModel::from(top_tracks))); + state.set_releases_section(releases); + state.set_critics_section(critics); + state.set_playlists(ModelRc::new(VecModel::from(playlists))); + state.set_artists(ModelRc::new(VecModel::from(artists))); + state.set_more_labels(ModelRc::new(VecModel::from(more_labels))); + state.set_jump_tabs(ModelRc::new(VecModel::from(jump_tabs))); + state.set_page_loaded(true); + state.set_loading(false); +} + +/// Build the JUMP TO tabs for the landing — only sections with content. +/// anchor-y values are layout-derived estimates (variable header/grid +/// heights make exact numbers impractical; the estimate lands the user +/// inside the right section). Mirrors artist::build_jump_tabs. +fn build_label_jump_tabs(payload: &LabelPagePayload) -> Vec { + const HEADER_GUESS: f32 = 360.0; + const SECTION_SPACER: f32 = 40.0; + const CAROUSEL: f32 = 320.0; + const POPULAR_HEADER: f32 = 46.0; + const POPULAR_ROW: f32 = 50.0; + const POPULAR_TAIL: f32 = 40.0; + + let mut tabs: Vec = Vec::new(); + tabs.push(JumpNavTab { + id: "about".into(), + label: "About".into(), + anchor_y: 0.0, + }); + let mut cursor = HEADER_GUESS; + + if !payload.top_tracks.is_empty() { + tabs.push(JumpNavTab { + id: "popular".into(), + label: "Popular Tracks".into(), + anchor_y: cursor, + }); + let rows = payload.top_tracks.len().min(5) as f32; + cursor += POPULAR_HEADER + rows * POPULAR_ROW + POPULAR_TAIL; + } + let push_carousel = |tabs: &mut Vec, id: &str, label: &str, present: bool, cursor: &mut f32| { + if present { + tabs.push(JumpNavTab { + id: id.into(), + label: label.into(), + anchor_y: *cursor, + }); + *cursor += SECTION_SPACER + CAROUSEL; + } + }; + push_carousel(&mut tabs, "releases", "Releases", !payload.releases.is_empty(), &mut cursor); + push_carousel(&mut tabs, "critics", "Critics' Picks", !payload.critics.is_empty(), &mut cursor); + push_carousel(&mut tabs, "playlists", "Playlists", !payload.playlists.is_empty(), &mut cursor); + push_carousel(&mut tabs, "artists", "Artists", !payload.artists.is_empty(), &mut cursor); + push_carousel(&mut tabs, "labels", "More Labels", !payload.more_labels.is_empty(), &mut cursor); + tabs +} + +/// Artwork jobs for the landing sections (top-track thumbs + carousels). +pub fn page_artwork_jobs(payload: &LabelPagePayload) -> Vec { + let mut jobs = Vec::new(); + let push = |jobs: &mut Vec, url: &str, target: ArtworkTarget| { + if !url.is_empty() { + jobs.push(ArtworkJob { + url: url.to_string(), + target, + }); + } + }; + for (i, t) in payload.top_tracks.iter().enumerate() { + push(&mut jobs, &t.artwork_url, ArtworkTarget::LabelTopTrack { index: i }); + } + for (i, a) in payload.releases.iter().enumerate() { + push(&mut jobs, &a.artwork_url, ArtworkTarget::LabelReleaseAlbum { index: i }); + } + for (i, a) in payload.critics.iter().enumerate() { + push(&mut jobs, &a.artwork_url, ArtworkTarget::LabelCriticsAlbum { index: i }); + } + for (i, p) in payload.playlists.iter().enumerate() { + push(&mut jobs, &p.image_url, ArtworkTarget::LabelPlaylistCover { index: i }); + } + for (i, a) in payload.artists.iter().enumerate() { + push(&mut jobs, &a.image_url, ArtworkTarget::LabelArtist { index: i }); + } + for (i, l) in payload.more_labels.iter().enumerate() { + push(&mut jobs, &l.image_url, ArtworkTarget::LabelMoreLabel { index: i }); + } + jobs +} + +/// Current follow state for `label_id` — the header when it's the open +/// label, else the matching More-Labels card. +pub fn label_following_state(window: &AppWindow, label_id: &str) -> bool { + let state = window.global::(); + if state.get_id().as_str() == label_id { + return state.get_is_following(); + } + let model = state.get_more_labels(); + for i in 0..model.row_count() { + if let Some(item) = model.row_data(i) { + if item.id.as_str() == label_id { + return item.following; + } + } + } + false +} + +/// Name of a More-Labels card by id (nav-history fallback for a card click). +pub fn more_label_name(window: &AppWindow, label_id: &str) -> String { + let model = window.global::().get_more_labels(); + for i in 0..model.row_count() { + if let Some(item) = model.row_data(i) { + if item.id.as_str() == label_id { + return item.title.to_string(); + } + } + } + String::new() +} + +/// Optimistically reflect a follow toggle — flips the header state when +/// it's the current label, and the matching more-labels card. +pub fn mark_label_followed(window: &AppWindow, label_id: &str, following: bool) { + let state = window.global::(); + if state.get_id().as_str() == label_id { + state.set_is_following(following); + } + let model = state.get_more_labels(); + for i in 0..model.row_count() { + if let Some(mut item) = model.row_data(i) { + if item.id.as_str() == label_id { + item.following = following; + model.set_row_data(i, item); + } + } + } +} + +/// Clear the landing state before loading a new label. +pub fn reset_label_page(window: &AppWindow) { + let state = window.global::(); + state.set_name("".into()); + state.set_image_url("".into()); + state.set_image(slint::Image::default()); + state.set_description("".into()); + state.set_description_short("".into()); + state.set_description_truncated(false); + state.set_is_following(false); + state.set_top_tracks(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_releases_section(DiscoverSection::default()); + state.set_critics_section(DiscoverSection::default()); + state.set_playlists(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_artists(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_more_labels(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_jump_tabs(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_page_loaded(false); + state.set_loading(true); +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 2335cbe46..c61bb872a 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -582,6 +582,16 @@ fn apply_entry( nav::NavEntry::Label { id, name } => { navigate_label(runtime.clone(), weak.clone(), handle, image_cache.clone(), id, name); } + nav::NavEntry::LabelReleases { id, name } => { + navigate_label_releases( + runtime.clone(), + weak.clone(), + handle, + image_cache.clone(), + id, + name, + ); + } nav::NavEntry::DiscoverBrowse { endpoint, title } => { discover_browse::navigate( runtime.clone(), @@ -661,8 +671,9 @@ fn navigate_location( }); } -/// Open a LabelReleasesView for `label_id`. Fetches the label header -/// + first album page, then the header image. +/// Open the LabelView landing — the rich label page (header + popular +/// tracks + releases/critics/playlists/artists/more-labels carousels). +/// Reached by clicking a label anywhere. fn navigate_label( runtime: Arc>, weak: slint::Weak, @@ -673,9 +684,54 @@ fn navigate_label( ) { handle.spawn(async move { let _ = weak.upgrade_in_event_loop(|w| { - label::reset_label(&w); + label::reset_label_page(&w); w.global::().set_view(ContentView::Label); }); + match label::load_label_page(&runtime, label_id, &name).await { + Ok(payload) => { + let jobs = label::page_artwork_jobs(&payload); + let image_url = payload.image_url.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + label::apply_label_page(&w, payload); + }); + artwork::spawn_loads(jobs, weak.clone(), image_cache.clone()); + if !image_url.is_empty() { + if let Some((pixels, width, height)) = + artwork::fetch_and_decode(&image_url, &image_cache, 240).await + { + let _ = weak.upgrade_in_event_loop(move |w| { + label::apply_image(&w, &pixels, width, height); + }); + } + } + } + Err(e) => { + log::error!("[qbz-slint] label page load failed: {e}"); + let _ = weak.upgrade_in_event_loop(|w| { + let s = w.global::(); + s.set_loading(false); + s.set_page_loaded(true); + }); + } + } + }); +} + +/// Open the full "See all releases" sub-view for `label_id`. Fetches the +/// label header + first album page, then the header image. +fn navigate_label_releases( + runtime: Arc>, + weak: slint::Weak, + handle: &tokio::runtime::Handle, + image_cache: artwork::ImageCache, + label_id: u64, + name: String, +) { + handle.spawn(async move { + let _ = weak.upgrade_in_event_loop(|w| { + label::reset_label(&w); + w.global::().set_view(ContentView::LabelReleases); + }); match label::load_label(&runtime, label_id, &name).await { Ok(data) => { let jobs = label::artwork_jobs(&data); @@ -695,7 +751,7 @@ fn navigate_label( } } Err(e) => { - log::error!("[qbz-slint] label load failed: {e}"); + log::error!("[qbz-slint] label releases load failed: {e}"); let _ = weak.upgrade_in_event_loop(|w| { w.global::().set_loading(false); }); @@ -2193,15 +2249,99 @@ fn main() -> Result<(), Box> { } }); } + // === Label landing actions =============================== + ("label", "follow") => { + // Toggle the label favorite, optimistically flipping the + // header + any matching More-Labels card. + if let Some(w) = weak.upgrade() { + let make = !label::label_following_state(&w, &id); + label::mark_label_followed(&w, &id, make); + let runtime = runtime.clone(); + let weak = weak.clone(); + let label_id = id.clone(); + handle.spawn(async move { + let res = if make { + runtime.core().add_favorite("label", &label_id).await + } else { + runtime.core().remove_favorite("label", &label_id).await + }; + if let Err(e) = res { + log::error!("[qbz-slint] toggle label favorite failed: {e}"); + let _ = weak.upgrade_in_event_loop(move |w| { + label::mark_label_followed(&w, &label_id, !make); + }); + } + }); + } + } + ("label", "play-top") => { + // Popular tracks are cached on the UI thread by + // apply_label_page; read them here (UI thread) + queue. + let tracks = label::top_tracks_for_play(); + if tracks.is_empty() { + crate::toast::error_weak(&weak, "No popular tracks for this label"); + } else { + playback::play_tracks( + runtime.clone(), + weak.clone(), + handle.clone(), + tracks, + 0, + ); + } + } + // More-Labels card click -> open that label's landing. + ("label", "open") => { + if let Ok(label_id) = id.parse::() { + let name = weak + .upgrade() + .map(|w| label::more_label_name(&w, &id)) + .unwrap_or_default(); + nav::record(nav::NavEntry::Label { + id: label_id, + name: name.clone(), + }); + navigate_label( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + label_id, + name, + ); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + } + } + // "See all" -> the full releases sub-view for the open label. + ("label", "see-all-releases") => { + if let (Some(w), Ok(label_id)) = (weak.upgrade(), id.parse::()) { + let name = w.global::().get_name().to_string(); + nav::record(nav::NavEntry::LabelReleases { + id: label_id, + name: name.clone(), + }); + navigate_label_releases( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + label_id, + name, + ); + update_nav_flags(&w); + } + } ("track", "toggle-select") => { // Flip `selected` on the matching row, in whichever - // multi-select surface is showing: the playlist - // detail or the artist Popular Tracks. + // multi-select surface is showing: the playlist detail, + // the artist Popular Tracks, or the label Popular Tracks. if let Some(w) = weak.upgrade() { - let model = if w.global::().get_view() == ContentView::Playlist { - w.global::().get_tracks() - } else { - w.global::().get_top_tracks() + let model = match w.global::().get_view() { + ContentView::Playlist => w.global::().get_tracks(), + ContentView::Label => w.global::().get_top_tracks(), + _ => w.global::().get_top_tracks(), }; if let Some(vm) = model .as_any() @@ -2936,6 +3076,43 @@ fn main() -> Result<(), Box> { }); } + // Label releases sub-view toolbar — sort / Hi-Res filter / + // group-by-artist / search. The markup updates the bound LabelState + // property first; each callback just re-derives the rendered list + // (local filter over the loaded catalog). + { + let weak = window.as_weak(); + window.global::().on_set_sort(move |_| { + if let Some(w) = weak.upgrade() { + label::derive_releases(&w); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_set_hires(move |_| { + if let Some(w) = weak.upgrade() { + label::derive_releases(&w); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_set_group(move |_| { + if let Some(w) = weak.upgrade() { + label::derive_releases(&w); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_search(move |_| { + if let Some(w) = weak.upgrade() { + label::derive_releases(&w); + } + }); + } + // Scene (location) view actions — open-artist routes to the // artist page, load-more validates the next page of candidates. { diff --git a/crates/qbz-slint/src/nav.rs b/crates/qbz-slint/src/nav.rs index 13a131cd1..397e9227c 100644 --- a/crates/qbz-slint/src/nav.rs +++ b/crates/qbz-slint/src/nav.rs @@ -57,12 +57,19 @@ pub enum NavEntry { name: String, role: String, }, - /// LabelReleasesView — opened by the artist network sidebar's - /// Labels section. Carries the label id + name fallback. + /// LabelView landing — the rich label page (header + popular tracks + + /// releases / critics / playlists / artists / more-labels carousels). + /// Reached by clicking a label anywhere. Carries the id + name fallback. Label { id: u64, name: String, }, + /// LabelReleasesView — the "See all releases" sub-view reached from the + /// landing's Releases carousel. Carries the label id + name fallback. + LabelReleases { + id: u64, + name: String, + }, /// ArtistsByLocationView — opened by the Origin section's /// location link. Carries the full scene-discovery payload. Location { diff --git a/crates/qbz-slint/ui/artist/ArtistPageView.slint b/crates/qbz-slint/ui/artist/ArtistPageView.slint index f98850f3a..e63c58464 100644 --- a/crates/qbz-slint/ui/artist/ArtistPageView.slint +++ b/crates/qbz-slint/ui/artist/ArtistPageView.slint @@ -30,43 +30,7 @@ import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { ReleaseGrid } from "ReleaseGrid.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint"; - -component CircleAction inherits Rectangle { - in property icon; - in property primary: false; - // Stays "pressed" — same surface as a hover, icon tinted accent. - // Used by the Network button while the sidebar is open. - in property active: false; - in property diameter: 40px; - callback clicked(); - - width: root.diameter; - height: root.diameter; - border-radius: root.diameter / 2; - // Ghost circle — translucent fill with a faint ring, never a solid - // accent fill (mirrors the Tauri transport controls). - background: (ta.has-hover || root.active) ? Theme.surface-hover : transparent; - border-width: 1px; - border-color: (ta.has-hover || root.active) ? Theme.text-disabled : Theme.border-subtle; - - QbzIcon { - source: root.icon; - width: root.primary ? 19px : 17px; - height: root.primary ? 19px : 17px; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; - tint: root.active - ? Theme.accent - : (ta.has-hover ? Theme.text-primary : Theme.text-secondary); - } - - ta := TouchArea { - mouse-cursor: pointer; - clicked => { - root.clicked(); - } - } -} +import { CircleAction } from "../primitives/CircleAction.slint"; component SectionTitle inherits Text { color: Theme.text-primary; diff --git a/crates/qbz-slint/ui/discover/ArtistCard.slint b/crates/qbz-slint/ui/discover/ArtistCard.slint index 74f91a555..2646b1c6e 100644 --- a/crates/qbz-slint/ui/discover/ArtistCard.slint +++ b/crates/qbz-slint/ui/discover/ArtistCard.slint @@ -12,8 +12,15 @@ import { QbzIcon } from "../primitives/QbzIcon.slint"; export component ArtistCard inherits Rectangle { in property artist; + // Follow-chip behavior: + // "auto" show only when NOT following (artist search default); + // "toggle" always show, reflecting Follow/Following (label more-labels); + // "none" never show (label artists carousel — no follow there). + in property follow-mode: "auto"; + // kind passed in the follow media-action ("artist" | "label"). + in property follow-kind: "artist"; callback clicked(string); - // kind ("artist"), artist id, action ("follow"). + // kind ("artist" | "label"), id, action ("follow"). callback media-action(string, string, string); width: 160px; @@ -73,9 +80,11 @@ export component ArtistCard inherits Rectangle { overflow: elide; } - // Rectangular Follow chip (ADR-008 — no pills). Shown only when - // the artist is not yet followed. - if !root.artist.following: HorizontalLayout { + // Rectangular Follow / Following chip (ADR-008 — no pills). See + // `follow-mode`: "auto" shows it only when not yet following, + // "toggle" always shows it (and reflects the state), "none" hides it. + if (root.follow-mode == "auto" && !root.artist.following) + || root.follow-mode == "toggle": HorizontalLayout { alignment: center; spacing: 0px; Rectangle { @@ -93,14 +102,16 @@ export component ArtistCard inherits Rectangle { VerticalLayout { alignment: center; QbzIcon { - source: @image-url("../assets/icons/user-plus.svg"); + source: root.artist.following + ? @image-url("../assets/icons/check.svg") + : @image-url("../assets/icons/user-plus.svg"); width: 13px; height: 13px; tint: Theme.text-primary; } } Text { - text: @tr("Follow"); + text: root.artist.following ? @tr("Following") : @tr("Follow"); color: Theme.text-primary; font-size: 12px; font-weight: Typography.medium; @@ -110,7 +121,7 @@ export component ArtistCard inherits Rectangle { follow-ta := TouchArea { mouse-cursor: pointer; clicked => { - root.media-action("artist", root.artist.id, "follow"); + root.media-action(root.follow-kind, root.artist.id, "follow"); } } } diff --git a/crates/qbz-slint/ui/discover/ArtistCarousel.slint b/crates/qbz-slint/ui/discover/ArtistCarousel.slint index b0e5c4ab5..31f0bd6b1 100644 --- a/crates/qbz-slint/ui/discover/ArtistCarousel.slint +++ b/crates/qbz-slint/ui/discover/ArtistCarousel.slint @@ -65,6 +65,10 @@ export component ArtistCarousel inherits VerticalLayout { in property title; in property <[SlimItem]> artists; in property show-view-all: false; + // Follow-chip behavior + kind for the cards (see ArtistCard). + // Defaults keep the artist-search usage unchanged. + in property card-follow-mode: "auto"; + in property card-follow-kind: "artist"; callback artist-clicked(string); callback media-action(string, string, string); callback view-all-clicked(); @@ -141,6 +145,8 @@ export component ArtistCarousel inherits VerticalLayout { spacing: root.gap; for artist in root.artists: ArtistCard { artist: artist; + follow-mode: root.card-follow-mode; + follow-kind: root.card-follow-kind; clicked(id) => { root.artist-clicked(id); } diff --git a/crates/qbz-slint/ui/label/LabelPageView.slint b/crates/qbz-slint/ui/label/LabelPageView.slint new file mode 100644 index 000000000..9850f560e --- /dev/null +++ b/crates/qbz-slint/ui/label/LabelPageView.slint @@ -0,0 +1,493 @@ +// Label landing page — the rich label view (Tauri's LabelView.svelte): +// a circular-portrait header with description + Follow, a sticky JUMP TO +// bar, a Popular Tracks list, and Releases / Critics' Picks / Playlists / +// Artists / More Labels carousels. +// +// Faithful 1:1 port assembled from existing primitives — the header, +// bio modal and Popular Tracks block mirror ArtistPageView; the carousels +// reuse Carousel / PlaylistCarousel / ArtistCarousel verbatim (More Labels +// reuses ArtistCarousel with the follow-toggle + label kind). No new +// widgets; the only new piece is this composition. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { LabelState, LabelActions, TrackItem } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { TrackRow } from "../primitives/TrackRow.slint"; +import { CircleAction } from "../primitives/CircleAction.slint"; +import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; +import { ListScrollbar } from "../primitives/ListScrollbar.slint"; +import { NavButtons } from "../shell/NavButtons.slint"; +import { JumpNavBar } from "../primitives/JumpNavBar.slint"; +import { Carousel } from "../discover/Carousel.slint"; +import { ArtistCarousel } from "../discover/ArtistCarousel.slint"; +import { PlaylistCarousel } from "../discover/PlaylistCarousel.slint"; + +component SectionTitle inherits Text { + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; +} + +export component LabelPageView inherits Rectangle { + callback open-album(string); + callback open-artist(string); + callback media-action(string, string, string); + + // Full-description modal (same pattern as ArtistPageView's bio modal). + property show-desc: false; + // Active JUMP TO tab (set on click; passive scroll-tracking is a + // follow-up, matching ArtistPageView). + property active-jump-tab: "about"; + // Popular Tracks progressive reveal: 5 -> 20 -> 50 (Tauri's steps). + property preview-count: 5; + property multi-select-mode: false; + + background: Theme.surface-main; + + page-flickable := Flickable { + viewport-width: self.width; + viewport-height: page.preferred-height; + + page := VerticalLayout { + width: parent.width; + padding-left: 32px; + padding-right: 32px; + padding-top: 11px; + padding-bottom: 100px; + spacing: 0px; + alignment: start; + + // --- History navigation -------------------------------------- + HorizontalLayout { + alignment: start; + NavButtons { } + } + + Rectangle { height: 22px; } + + // --- Label header -------------------------------------------- + HorizontalLayout { + spacing: 32px; + alignment: start; + + // Circular 180px portrait + Disc-3 placeholder. + Rectangle { + width: 180px; + height: 180px; + border-radius: 90px; + background: Theme.surface-elevated; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + drop-shadow-offset-y: 8px; + clip: true; + if LabelState.image-url == "": QbzIcon { + source: @image-url("../assets/icons/disc-3.svg"); + width: 60px; + height: 60px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: Theme.text-muted; + } + Image { + width: 100%; + height: 100%; + source: LabelState.image; + image-fit: cover; + } + } + + VerticalLayout { + alignment: start; + padding-top: 8px; + spacing: 0px; + horizontal-stretch: 1; + + Text { + text: @tr("LABEL"); + color: Theme.text-muted; + font-size: 10px; + font-weight: Typography.semibold; + letter-spacing: 1.5px; + } + Rectangle { height: 6px; } + Text { + text: LabelState.name; + color: Theme.text-primary; + font-size: Typography.section; + font-weight: Typography.bold; + overflow: elide; + } + + // Description (HTML-stripped) + READ MORE -> modal. + if LabelState.description != "": Rectangle { height: 12px; } + if LabelState.description != "": Text { + width: root.width - 296px; + text: LabelState.description-short; + color: Theme.text-secondary; + font-size: Typography.legal; + wrap: word-wrap; + } + if LabelState.description-truncated: Rectangle { height: 4px; } + if LabelState.description-truncated: read-more := TouchArea { + width: read-more-row.preferred-width; + height: 20px; + mouse-cursor: pointer; + clicked => { + root.show-desc = true; + } + read-more-row := HorizontalLayout { + Text { + text: @tr("Read more"); + color: read-more.has-hover ? Theme.accent-hover : Theme.accent; + font-size: Typography.legal; + vertical-alignment: center; + } + } + } + + Rectangle { height: 18px; } + + // Follow / Following — ghost circle, accent-tinted when + // followed. Toggles via the global media-action channel. + HorizontalLayout { + alignment: start; + CircleAction { + diameter: 44px; + active: LabelState.is-following; + icon: LabelState.is-following + ? @image-url("../assets/icons/check.svg") + : @image-url("../assets/icons/user-plus.svg"); + clicked => { + root.media-action("label", LabelState.id, "follow"); + } + } + } + } + } + + // Reservation for the sticky JUMP TO overlay (its 44px + gap). + // The overlay anchors its natural y to this element. + jump-anchor := Rectangle { height: 52px; } + + // --- Loading (first paint) ----------------------------------- + if LabelState.loading && LabelState.top-tracks.length == 0 + && !LabelState.page-loaded: Rectangle { + height: 280px; + VerticalLayout { + alignment: center; + spacing: 18px; + HorizontalLayout { + alignment: center; + LoadingSpinner { size: 36px; } + } + Text { + text: @tr("Loading…"); + color: Theme.text-muted; + font-size: 13px; + horizontal-alignment: center; + } + } + } + + // --- Popular Tracks ------------------------------------------ + if LabelState.top-tracks.length > 0: HorizontalLayout { + spacing: 12px; + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + SectionTitle { + text: @tr("Popular Tracks"); + } + } + HorizontalLayout { + horizontal-stretch: 0; + spacing: 12px; + CircleAction { + icon: @image-url("../assets/icons/play-fill.svg"); + primary: true; + clicked => { + root.media-action("label", LabelState.id, "play-top"); + } + } + CircleAction { + icon: @image-url("../assets/icons/square-check-big.svg"); + active: root.multi-select-mode; + clicked => { + root.multi-select-mode = !root.multi-select-mode; + } + } + CircleAction { + icon: @image-url("../assets/icons/ellipsis.svg"); + clicked => { + root.media-action("label", "", "top-tracks-menu"); + } + } + } + } + if LabelState.top-tracks.length > 0: Rectangle { height: 10px; } + + // Select-all row (multi-select mode). + if root.multi-select-mode && LabelState.top-tracks.length > 0: HorizontalLayout { + height: 36px; + padding-left: 12px; + padding-right: 12px; + spacing: 16px; + VerticalLayout { + alignment: center; + Rectangle { + width: 28px; + height: 28px; + Rectangle { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 22px; + height: 22px; + border-radius: 11px; + background: transparent; + border-width: 1.5px; + border-color: check-all-ta.has-hover + ? Theme.text-primary + : Theme.text-muted; + check-all-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.media-action("label", "", "select-all-top-tracks"); + } + } + } + } + } + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + Text { + text: @tr("Select all"); + color: Theme.text-secondary; + font-size: Typography.body; + font-weight: Typography.medium; + } + } + } + + for track[index] in LabelState.top-tracks: TrackRow { + visible: index < root.preview-count; + height: self.visible ? 50px : 0px; + track: track; + index: index; + // Label tracks carry the album thumbnail (Tauri shows it). + show-artwork: true; + multi-select-mode: root.multi-select-mode; + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + // Load more (5 -> 20 -> 50) / View less. + if LabelState.top-tracks.length > 5: Rectangle { height: 4px; } + if LabelState.top-tracks.length > 5: HorizontalLayout { + alignment: center; + Rectangle { + width: reveal-text.preferred-width + 24px; + height: 28px; + reveal-text := Text { + text: (root.preview-count < 50 + && LabelState.top-tracks.length > root.preview-count) + ? @tr("Load more") + : @tr("View less"); + color: reveal-ta.has-hover ? Theme.text-primary : Theme.text-secondary; + font-size: 13px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + reveal-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + if (root.preview-count < 50 + && LabelState.top-tracks.length > root.preview-count) { + root.preview-count = root.preview-count == 5 ? 20 : 50; + } else { + root.preview-count = 5; + } + } + } + } + } + + // --- Releases carousel (+ See all) --------------------------- + if LabelState.releases-section.albums.length > 0: Rectangle { height: 32px; } + if LabelState.releases-section.albums.length > 0: Carousel { + section: LabelState.releases-section; + show-view-all: true; + album-clicked(id) => { + root.open-album(id); + } + view-all-clicked => { + root.media-action("label", LabelState.id, "see-all-releases"); + } + media-action(kind, id, action) => { + if (kind == "artist") { + root.open-artist(id); + } else { + root.media-action(kind, id, action); + } + } + } + + // --- Critics' Picks carousel --------------------------------- + if LabelState.critics-section.albums.length > 0: Rectangle { height: 32px; } + if LabelState.critics-section.albums.length > 0: Carousel { + section: LabelState.critics-section; + show-view-all: false; + album-clicked(id) => { + root.open-album(id); + } + media-action(kind, id, action) => { + if (kind == "artist") { + root.open-artist(id); + } else { + root.media-action(kind, id, action); + } + } + } + + // --- Playlists carousel -------------------------------------- + if LabelState.playlists.length > 0: Rectangle { height: 32px; } + if LabelState.playlists.length > 0: PlaylistCarousel { + title: @tr("Playlists"); + playlists: LabelState.playlists; + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + + // --- Artists carousel ---------------------------------------- + if LabelState.artists.length > 0: Rectangle { height: 32px; } + if LabelState.artists.length > 0: ArtistCarousel { + title: @tr("Artists"); + artists: LabelState.artists; + // No follow chip in the label Artists carousel (matches Tauri). + card-follow-mode: "none"; + artist-clicked(id) => { + root.open-artist(id); + } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + + // --- More Labels carousel ------------------------------------ + if LabelState.more-labels.length > 0: Rectangle { height: 32px; } + if LabelState.more-labels.length > 0: ArtistCarousel { + title: @tr("More labels"); + artists: LabelState.more-labels; + // Round cards with a Follow/Following toggle, kind = label. + card-follow-mode: "toggle"; + card-follow-kind: "label"; + artist-clicked(id) => { + root.media-action("label", id, "open"); + } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + } + } + + // Shared scrollbar in the right gutter (homologated cross-view). + ListScrollbar { + x: parent.width - self.width - 4px; + width: 14px; + height: 100%; + viewport-height: page-flickable.viewport-height; + visible-height: page-flickable.height; + viewport-y <=> page-flickable.viewport-y; + } + + // --- Sticky JUMP TO bar ------------------------------------------------- + // Same sticky math as ArtistPageView: tracks the page scroll until the + // header scrolls away, then pins to y = 0. No inline search (Tauri's + // label jump-nav has none). + if LabelState.jump-tabs.length > 1: JumpNavBar { + property natural-top: + jump-anchor.absolute-position.y - page-flickable.absolute-position.y; + x: 0; + y: max(self.natural-top, 0px); + width: root.width; + show-search: false; + tabs: LabelState.jump-tabs; + active-tab-id: root.active-jump-tab; + tab-clicked(id, anchor-y) => { + root.active-jump-tab = id; + page-flickable.viewport-y = -(anchor-y - self.height); + } + } + + // --- Full-description modal --------------------------------------------- + if root.show-desc: Rectangle { + background: #000000bf; + TouchArea { + mouse-cursor: default; + clicked => { + root.show-desc = false; + } + } + Rectangle { + width: Math.min(root.width - 80px, 560px); + height: Math.min(root.height - 120px, 460px); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: Radius.md; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + TouchArea { } + VerticalLayout { + padding: 24px; + spacing: 14px; + HorizontalLayout { + Text { + text: @tr("About"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + horizontal-stretch: 1; + vertical-alignment: center; + } + close-desc := TouchArea { + width: 28px; + height: 28px; + mouse-cursor: pointer; + clicked => { + root.show-desc = false; + } + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 18px; + height: 18px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: close-desc.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: desc-content.preferred-height; + desc-content := VerticalLayout { + width: parent.width; + Text { + text: LabelState.description; + color: Theme.text-secondary; + font-size: Typography.body; + wrap: word-wrap; + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/label/LabelReleasesView.slint b/crates/qbz-slint/ui/label/LabelReleasesView.slint index f033cffb1..cf361c74f 100644 --- a/crates/qbz-slint/ui/label/LabelReleasesView.slint +++ b/crates/qbz-slint/ui/label/LabelReleasesView.slint @@ -1,22 +1,27 @@ -// LabelReleasesView — opened from the artist network sidebar's -// Labels section. Mirrors Tauri's LabelReleasesView.svelte core: -// a label header (image + name + album count) over a wrapping -// album grid with load-more pagination. The sort / filter / -// group-by-artist controls from the Tauri view are deferred. +// LabelReleasesView — the "See all releases" sub-view reached from the +// label landing's Releases carousel. A circular label header over the +// full album catalog, with a toolbar (search / group-by-artist / Hi-Res +// filter / sort) and a grid/list toggle, plus load-more pagination. +// +// The rendered set (`visible` / `grouped`) is derived in Rust from the +// loaded catalog (crate::label::derive_releases). Search is a local +// filter over the loaded catalog. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { LabelState, LabelActions, AlbumCardItem } from "../state.slint"; +import { LabelState, LabelActions, AlbumCardItem, DiscoverSection } from "../state.slint"; import { AlbumCard } from "../discover/AlbumCard.slint"; import { AlbumListRow, AlbumListHeader } from "../primitives/AlbumListRow.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QbzSelect } from "../primitives/QbzSelect.slint"; +import { ExpandableSearch } from "../primitives/ExpandableSearch.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { NavButtons } from "../shell/NavButtons.slint"; -// One half of the grid/list segmented toggle — identical to -// DiscoverBrowseView's ToggleButton so the two views read the same. +// One half of a segmented toggle (grid/list) or a standalone toolbar +// toggle (group / Hi-Res). Identical surface to DiscoverBrowseView's. component ToggleButton inherits Rectangle { in property icon; in property active: false; @@ -45,14 +50,49 @@ component ToggleButton inherits Rectangle { } } +// Wrapping album grid over a model — shared by the flat view and each +// group-by-artist section so the column math lives in one place. +component AlbumGrid inherits Rectangle { + in property <[AlbumCardItem]> albums; + in property card-width: 220px; + in property card-height: 266px; + in property card-gap: 24px; + callback open-album(string); + callback open-artist(string); + callback media-action(string, string, string); + + property columns: Math.max( + 1, + Math.floor((self.width + root.card-gap) / (root.card-width + root.card-gap))); + property rows: Math.ceil(root.albums.length / self.columns); + height: self.rows > 0 + ? self.rows * root.card-height + Math.max(0, self.rows - 1) * root.card-gap + : 0px; + + for album[i] in root.albums: AlbumCard { + x: Math.mod(i, root.columns) * (root.card-width + root.card-gap); + y: Math.floor(i / root.columns) * (root.card-height + root.card-gap); + width: root.card-width; + height: root.card-height; + album: album; + clicked(id) => { + root.open-album(id); + } + media-action(kind, id, action) => { + if (kind == "artist") { + root.open-artist(id); + } else { + root.media-action(kind, id, action); + } + } + } +} + export component LabelReleasesView inherits Rectangle { callback open-album(string); callback open-artist(string); callback media-action(string, string, string); - property card-width: 220px; - property card-height: 266px; - property card-gap: 24px; property list-row-gap: 4px; background: Theme.surface-main; @@ -77,30 +117,33 @@ export component LabelReleasesView inherits Rectangle { Rectangle { height: 22px; } - // --- Label header ------------------------------------------ + // --- Label header (circular 180px portrait) ------------------ HorizontalLayout { spacing: 24px; alignment: start; Rectangle { - width: 120px; - height: 120px; - border-radius: Radius.md; + width: 180px; + height: 180px; + border-radius: 90px; background: Theme.surface-elevated; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + drop-shadow-offset-y: 8px; clip: true; + if LabelState.image-url == "": QbzIcon { + source: @image-url("../assets/icons/disc-3.svg"); + width: 60px; + height: 60px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: Theme.text-muted; + } Image { width: 100%; height: 100%; source: LabelState.image; image-fit: cover; } - if LabelState.image-url == "": QbzIcon { - source: @image-url("../assets/icons/disc-3.svg"); - width: 44px; - height: 44px; - x: (parent.width - self.width) / 2; - y: (parent.height - self.height) / 2; - tint: Theme.text-muted; - } } VerticalLayout { alignment: center; @@ -120,35 +163,122 @@ export component LabelReleasesView inherits Rectangle { font-weight: Typography.bold; } if LabelState.total > 0: Text { - text: LabelState.total + " " + @tr("releases"); + text: LabelState.total + " " + @tr("albums"); color: Theme.text-muted; font-size: Typography.body; } } + } - // Grid/list segmented toggle — floats right of the header, - // same widget + behavior as DiscoverBrowseView. + Rectangle { height: 24px; } + + // --- Toolbar: title + count, then search / group / Hi-Res / + // sort / grid-list (floated right) --------------------------- + HorizontalLayout { + spacing: 12px; + alignment: stretch; VerticalLayout { alignment: center; - Rectangle { - width: 68px; - height: 34px; - border-radius: 6px; - background: Theme.surface-elevated; - clip: true; - HorizontalLayout { - ToggleButton { - icon: @image-url("../assets/icons/layout-grid.svg"); - active: LabelState.view-mode == "grid"; - clicked => { - LabelState.view-mode = "grid"; - } + horizontal-stretch: 1; + Text { + text: @tr("Releases"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + } + Text { + text: LabelState.shown + " " + @tr("albums") + + (LabelState.hires-count > 0 + ? " · " + LabelState.hires-count + " Hi-Res" + : ""); + color: Theme.text-muted; + font-size: Typography.legal; + } + } + HorizontalLayout { + horizontal-stretch: 0; + spacing: 8px; + VerticalLayout { + alignment: center; + ExpandableSearch { + placeholder: @tr("Search this label…"); + query <=> LabelState.search-query; + edited(q) => { + LabelActions.search(q); } - ToggleButton { - icon: @image-url("../assets/icons/list.svg"); - active: LabelState.view-mode == "list"; - clicked => { - LabelState.view-mode = "list"; + } + } + VerticalLayout { + alignment: center; + ToggleButton { + icon: @image-url("../assets/icons/user.svg"); + active: LabelState.group-by-artist; + clicked => { + LabelState.group-by-artist = !LabelState.group-by-artist; + LabelActions.set-group(LabelState.group-by-artist); + } + } + } + VerticalLayout { + alignment: center; + ToggleButton { + icon: @image-url("../assets/icons/list-filter.svg"); + active: LabelState.filter-hires; + clicked => { + LabelState.filter-hires = !LabelState.filter-hires; + LabelActions.set-hires(LabelState.filter-hires); + } + } + } + VerticalLayout { + alignment: center; + QbzSelect { + menu-width: 168px; + options: [ + @tr("Newest"), + @tr("Oldest"), + @tr("Title A-Z"), + @tr("Title Z-A"), + @tr("Artist A-Z"), + ]; + current-index: LabelState.sort-by == "newest" ? 0 + : LabelState.sort-by == "oldest" ? 1 + : LabelState.sort-by == "title-asc" ? 2 + : LabelState.sort-by == "title-desc" ? 3 + : 4; + selected(i) => { + LabelState.sort-by = i == 0 ? "newest" + : i == 1 ? "oldest" + : i == 2 ? "title-asc" + : i == 3 ? "title-desc" + : "artist-asc"; + LabelActions.set-sort(LabelState.sort-by); + } + } + } + // Grid / list segmented toggle. + VerticalLayout { + alignment: center; + Rectangle { + width: 68px; + height: 34px; + border-radius: 6px; + background: Theme.surface-elevated; + clip: true; + HorizontalLayout { + ToggleButton { + icon: @image-url("../assets/icons/layout-grid.svg"); + active: LabelState.view-mode == "grid"; + clicked => { + LabelState.view-mode = "grid"; + } + } + ToggleButton { + icon: @image-url("../assets/icons/list.svg"); + active: LabelState.view-mode == "list"; + clicked => { + LabelState.view-mode = "list"; + } } } } @@ -156,52 +286,85 @@ export component LabelReleasesView inherits Rectangle { } } - Rectangle { height: 32px; } + Rectangle { height: 24px; } + // --- States -------------------------------------------------- if LabelState.loading && LabelState.albums.length == 0: HorizontalLayout { alignment: center; LoadingSpinner { size: 36px; } } - - // --- Album grid -------------------------------------------- - if LabelState.albums.length > 0 - && LabelState.view-mode == "grid": grid := Rectangle { - property columns: Math.max( - 1, - Math.floor((self.width + root.card-gap) / (root.card-width + root.card-gap))); - property rows: Math.ceil(LabelState.albums.length / self.columns); - height: self.rows > 0 - ? self.rows * root.card-height + Math.max(0, self.rows - 1) * root.card-gap - : 0px; - for album[i] in LabelState.albums: AlbumCard { - x: Math.mod(i, grid.columns) * (root.card-width + root.card-gap); - y: Math.floor(i / grid.columns) * (root.card-height + root.card-gap); - width: root.card-width; - height: root.card-height; - album: album; - clicked(id) => { - root.open-album(id); + // No search/filter results. + if !LabelState.loading && LabelState.albums.length > 0 + && LabelState.shown == 0: VerticalLayout { + alignment: center; + spacing: 10px; + HorizontalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/search.svg"); + width: 32px; + height: 32px; + tint: Theme.text-muted; } - media-action(kind, id, action) => { - if (kind == "artist") { + } + Text { + text: @tr("No results"); + color: Theme.text-muted; + font-size: 14px; + horizontal-alignment: center; + } + } + + // --- Grouped by artist --------------------------------------- + if LabelState.group-by-artist: VerticalLayout { + spacing: 0px; + for section[s] in LabelState.grouped: VerticalLayout { + spacing: 12px; + if s > 0: Rectangle { height: 24px; } + Text { + text: section.title; + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + } + AlbumGrid { + albums: section.albums; + open-album(id) => { + root.open-album(id); + } + open-artist(id) => { root.open-artist(id); - } else { + } + media-action(kind, id, action) => { root.media-action(kind, id, action); } } } } - // --- Album list -------------------------------------------- - // Reusable AlbumListRow; SOURCE column hidden (single-source - // Qobuz catalog). Header labels the columns. - if LabelState.albums.length > 0 + // --- Flat grid ----------------------------------------------- + if !LabelState.group-by-artist && LabelState.visible.length > 0 + && LabelState.view-mode == "grid": AlbumGrid { + albums: LabelState.visible; + 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); + } + } + + // --- Flat list ----------------------------------------------- + if !LabelState.group-by-artist && LabelState.visible.length > 0 && LabelState.view-mode == "list": VerticalLayout { spacing: root.list-row-gap; AlbumListHeader { show-source: false; } - for album[i] in LabelState.albums: AlbumListRow { + for album[i] in LabelState.visible: AlbumListRow { album: album; index: i; show-source: false; @@ -218,6 +381,7 @@ export component LabelReleasesView inherits Rectangle { } } + // --- Load more ----------------------------------------------- if LabelState.albums.length > 0 && LabelState.albums.length < LabelState.total: HorizontalLayout { alignment: center; diff --git a/crates/qbz-slint/ui/primitives/CircleAction.slint b/crates/qbz-slint/ui/primitives/CircleAction.slint new file mode 100644 index 000000000..963e3ad8d --- /dev/null +++ b/crates/qbz-slint/ui/primitives/CircleAction.slint @@ -0,0 +1,41 @@ +// Ghost circular action button — translucent fill with a faint ring, +// icon tinted on hover/active (never a solid accent fill, mirroring the +// Tauri transport controls). Shared by the artist + label page headers +// and their Popular Tracks clusters (Play all / Select / More). + +import { Theme } from "../foundation/semantic-colors.slint"; +import { QbzIcon } from "QbzIcon.slint"; + +export component CircleAction inherits Rectangle { + in property icon; + in property primary: false; + // Stays "pressed" — same surface as hover, icon tinted accent. + in property active: false; + in property diameter: 40px; + callback clicked(); + + width: root.diameter; + height: root.diameter; + border-radius: root.diameter / 2; + background: (ta.has-hover || root.active) ? Theme.surface-hover : transparent; + border-width: 1px; + border-color: (ta.has-hover || root.active) ? Theme.text-disabled : Theme.border-subtle; + + QbzIcon { + source: root.icon; + width: root.primary ? 19px : 17px; + height: root.primary ? 19px : 17px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: root.active + ? Theme.accent + : (ta.has-hover ? Theme.text-primary : Theme.text-secondary); + } + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} diff --git a/crates/qbz-slint/ui/primitives/JumpNavBar.slint b/crates/qbz-slint/ui/primitives/JumpNavBar.slint index 3755c360e..bd90998cf 100644 --- a/crates/qbz-slint/ui/primitives/JumpNavBar.slint +++ b/crates/qbz-slint/ui/primitives/JumpNavBar.slint @@ -31,6 +31,10 @@ export struct JumpNavTab { export component JumpNavBar inherits Rectangle { in property <[JumpNavTab]> tabs; in property active-tab-id; + // Whether the inline page-search affordance is offered. Artist pages + // have it; the label page does not (Tauri's label jump-nav has no + // search), so the label view passes show-search: false. + in property show-search: true; in-out property search-open: false; in-out property search-text; @@ -244,7 +248,7 @@ export component JumpNavBar inherits Rectangle { // expanded input run all the way to the bar's right padding // (right where the user just clicked the lupa). The inline // input carries its own X close button. - if !root.search-open: VerticalLayout { + if root.show-search && !root.search-open: VerticalLayout { alignment: center; horizontal-stretch: 0; Rectangle { diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index c2d1621ca..67e988b25 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -25,6 +25,7 @@ import { AlbumPageView } from "../album/AlbumPageView.slint"; import { ArtistPageView } from "../artist/ArtistPageView.slint"; import { MusicianPageView } from "../musician/MusicianPageView.slint"; import { LabelReleasesView } from "../label/LabelReleasesView.slint"; +import { LabelPageView } from "../label/LabelPageView.slint"; import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint"; import { FavoritesView } from "../favorites/FavoritesView.slint"; import { MixView } from "../mix/MixView.slint"; @@ -132,7 +133,18 @@ export component AppShell inherits Rectangle { root.open-album(id); } } - if NavState.view == ContentView.label: LabelReleasesView { + if NavState.view == ContentView.label: LabelPageView { + 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.label-releases: LabelReleasesView { open-album(id) => { root.open-album(id); } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 437f3e129..b9ca07cbf 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -49,6 +49,25 @@ export struct SlimItem { following: bool, } +// A playlist result in search (and the label landing's Playlists row). +// Qobuz returns up to four cover URLs per playlist; the card renders them +// as a count-based collage. Defined here (before LabelState) so the label +// landing can reuse it — Slint structs must be declared before first use. +export struct SearchPlaylistItem { + id: string, + title: string, + subtitle: string, // owner name + track count + cover-count: int, // number of cover images, 0-4 + url1: string, + url2: string, + url3: string, + url4: string, + cover1: image, + cover2: image, + cover3: image, + cover4: image, +} + // Discover / Home screen state. export global HomeState { in property <[DiscoverSection]> sections: []; @@ -273,7 +292,7 @@ export global DragActions { } // Which content view the shell shows. -export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix, playlist, discover-browse, playlist-manager } +export enum ContentView { home, album, artist, settings, search, musician, label, label-releases, location, favorites, mix, playlist, discover-browse, playlist-manager } // In-app toast (transient notification). Mirrors Tauri's toastStore: // a single toast at a time (a new one replaces the current), auto-hidden @@ -1131,17 +1150,77 @@ export global LabelState { in property name; in property image-url; in property image; + // Releases sub-view (LabelReleasesView) — the paginated album grid. + // `albums` is the full loaded catalog + the artwork-pipeline target + // (stable indices); `visible` is the sorted/filtered/searched subset + // the view actually renders (shares `albums` when no filter is active, + // so artwork stays live — mirrors DiscoverBrowseState). in property <[AlbumCardItem]> albums: []; + in property <[AlbumCardItem]> visible: []; in property total: 0; in property loading: false; in property load-more-loading: false; // "grid" | "list" — the view toggle (mirrors DiscoverBrowseView). // UI-owned, like Discover's; no Rust round-trip needed. in-out property view-mode: "grid"; + + // --- Landing page (LabelPageView) ----------------------------------- + // Header description (HTML-stripped) + truncated form + the flag that + // gates the READ MORE affordance (same pattern as ArtistState.bio). + in property description; + in property description-short; + in property description-truncated: false; + // Header follow state — true when the label is in the user's favorites. + in property is-following: false; + // Popular tracks (album-thumbnail rows, multi-select capable). + in property <[TrackItem]> top-tracks: []; + // Releases + Critics' Picks carousels (reuse Carousel). + in property releases-section; + in property critics-section; + // Playlists / Artists / More-labels carousels. More-labels reuses + // ArtistCarousel (SlimItem already carries `following`). + in property <[SearchPlaylistItem]> playlists: []; + in property <[SlimItem]> artists: []; + in property <[SlimItem]> more-labels: []; + // Sticky JUMP TO tabs — only the sections that have content. + in property <[JumpNavTab]> jump-tabs: []; + // True once the landing page-data load resolves (gates the spinner). + in property page-loaded: false; + + // --- Releases sub-view toolbar (UI-owned, like Discover) ------------ + // "newest" | "oldest" | "title-asc" | "title-desc" | "artist-asc". + in-out property sort-by: "newest"; + in-out property filter-hires: false; + in-out property group-by-artist: false; + // Releases sub-view in-view search query (drives the count line). + in-out property search-query: ""; + // Group-by-artist rendering — one section per artist (reuses the + // DiscoverSection + grid pattern). Empty unless group-by-artist is on. + in property <[DiscoverSection]> grouped: []; + // Count of albums actually shown after filter/search (for the count line). + in property shown: 0; + // Count of Hi-Res albums in the loaded catalog (count line). + in property hires-count: 0; } export global LabelActions { + // Releases sub-view: fetch the next album page. callback load-more(); + // Landing: toggle follow on the header label or a more-labels card. + callback toggle-follow(string /* label id */); + // Landing: play all popular tracks. + callback play-top(); + // Landing: top-tracks header overflow menu (play-next / queue / shuffle). + callback top-tracks-menu(); + // Landing: enter/leave multi-select for popular tracks (select-all). + callback select-all-top-tracks(); + // Landing: open the full releases sub-view ("See all"). + callback open-releases(); + // Releases sub-view toolbar. + callback set-sort(string /* option */); + callback set-hires(bool); + callback set-group(bool); + callback search(string /* query */); } // One artist in the scene-discovery grid (artists from the same @@ -1322,23 +1401,6 @@ export global PlaylistPickerActions { callback close(); } -// A playlist result in search. Qobuz returns up to four cover URLs per -// playlist; the card renders them as a count-based collage. -export struct SearchPlaylistItem { - id: string, - title: string, - subtitle: string, // owner name + track count - cover-count: int, // number of cover images, 0-4 - url1: string, - url2: string, - url3: string, - url4: string, - cover1: image, - cover2: image, - cover3: image, - cover4: image, -} - // One artist tile in the Favorites > Artists grid. export struct FavoriteArtistItem { id: string,