diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index 54490a6af..64543f1fb 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -54,6 +54,8 @@ 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]. + LabelAlbum { index: usize }, } /// An artwork download job: which card, and the image URL. @@ -349,5 +351,12 @@ fn apply_artwork( model.set_row_data(index, item); } } + ArtworkTarget::LabelAlbum { index } => { + let model = window.global::().get_albums(); + if let Some(mut item) = model.row_data(index) { + item.artwork = image; + model.set_row_data(index, item); + } + } } } diff --git a/crates/qbz-slint/src/label.rs b/crates/qbz-slint/src/label.rs new file mode 100644 index 000000000..3f24fe80f --- /dev/null +++ b/crates/qbz-slint/src/label.rs @@ -0,0 +1,222 @@ +//! LabelReleasesView controller — loads the label header (name + +//! image from /label/page) and the paginated album catalog (from +//! /label/getAlbums), pushing them into `LabelState`. +//! +//! Mirrors Tauri's LabelReleasesView.svelte data flow. The rich +//! sort / filter / group-by-artist controls there are deferred; this +//! port covers the header, the album grid, and load-more pagination. + +use std::sync::Arc; + +use qbz_app::shell::AppRuntime; +use qbz_core::FrontendAdapter; +use qbz_models::Album; +use slint::{ComponentHandle, Model, ModelRc, VecModel}; + +use crate::artwork::{ArtworkJob, ArtworkTarget}; +use crate::{AlbumCardItem, AppWindow, LabelState}; + +/// Page size for the album catalog. Tauri pulls 500 at a time; keep +/// the same so a typical label loads in one shot. +pub const PAGE_SIZE: u32 = 500; + +pub struct LabelData { + pub id: String, + pub name: String, + pub image_url: String, + pub albums: Vec, + pub total: usize, +} + +#[derive(Clone)] +pub struct AlbumCard { + pub id: String, + pub title: String, + pub artist: String, + pub year: String, + pub quality_tier: String, + pub quality_label: String, + pub artwork_url: String, +} + +/// Fetch the label page (name + image) and the first album page. +pub async fn load_label( + 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())?; + + let albums_page = runtime + .core() + .get_label_albums(label_id, PAGE_SIZE, 0, None, None, None, None, None) + .await + .map_err(|e| e.to_string())?; + + let name = if page.name.is_empty() { + fallback_name.to_string() + } else { + page.name + }; + let image_url = extract_label_image(page.image.as_ref()); + let total = albums_page.total.unwrap_or(albums_page.items.len() as u32) as usize; + let albums = albums_page.items.into_iter().map(map_album).collect(); + + Ok(LabelData { + id: label_id.to_string(), + name, + image_url, + albums, + total, + }) +} + +/// Fetch one more album page for the load-more affordance. +pub async fn load_more_albums( + runtime: &Arc>, + label_id: u64, + offset: u32, +) -> Result<(Vec, usize), String> +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let page = runtime + .core() + .get_label_albums(label_id, PAGE_SIZE, offset, None, None, None, None, None) + .await + .map_err(|e| e.to_string())?; + let total = page.total.unwrap_or(0) as usize; + let albums = page.items.into_iter().map(map_album).collect(); + Ok((albums, total)) +} + +fn map_album(album: Album) -> AlbumCard { + let year = album + .release_date_original + .as_deref() + .and_then(|s| s.get(..4).map(|y| y.to_string())) + .unwrap_or_default(); + let quality_tier = tier(album.maximum_bit_depth).to_string(); + let quality_label = match (album.maximum_bit_depth, album.maximum_sampling_rate) { + (Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr), + _ => String::new(), + }; + AlbumCard { + id: album.id, + title: album.title, + artist: album.artist.name, + year, + quality_tier, + quality_label, + artwork_url: album.image.best().cloned().unwrap_or_default(), + } +} + +fn tier(bit_depth: Option) -> &'static str { + match bit_depth { + Some(b) if b > 16 => "hires", + Some(_) => "cd", + None => "", + } +} + +/// 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). +fn extract_label_image(image: Option<&serde_json::Value>) -> String { + let Some(image) = image else { + return String::new(); + }; + if let Some(s) = image.as_str() { + return s.to_string(); + } + for key in ["mega", "extralarge", "large", "thumbnail", "small"] { + if let Some(s) = image.get(key).and_then(|v| v.as_str()) { + return s.to_string(); + } + } + String::new() +} + +fn to_item(card: AlbumCard) -> AlbumCardItem { + AlbumCardItem { + id: card.id.into(), + title: card.title.into(), + artist: card.artist.into(), + genre: "".into(), + year: card.year.into(), + quality_tier: card.quality_tier.into(), + quality_label: card.quality_label.into(), + ribbon: "".into(), + ribbon_kind: "".into(), + artwork_url: card.artwork_url.into(), + artwork: slint::Image::default(), + } +} + +pub fn apply_label(window: &AppWindow, data: LabelData) { + let items: Vec = data.albums.into_iter().map(to_item).collect(); + let state = window.global::(); + state.set_id(data.id.into()); + state.set_name(data.name.into()); + state.set_image_url(data.image_url.into()); + state.set_albums(ModelRc::new(VecModel::from(items))); + state.set_total(data.total as i32); + state.set_loading(false); +} + +pub fn append_albums(window: &AppWindow, albums: Vec, total: usize) { + let state = window.global::(); + let model = state.get_albums(); + let mut combined: Vec = (0..model.row_count()) + .filter_map(|i| model.row_data(i)) + .collect(); + combined.extend(albums.into_iter().map(to_item)); + state.set_albums(ModelRc::new(VecModel::from(combined))); + state.set_total(total as i32); + state.set_load_more_loading(false); +} + +/// Apply the decoded label header image. Runs on the Slint event loop. +pub fn apply_image(window: &AppWindow, pixels: &[u8], width: u32, height: u32) { + let mut buffer = slint::SharedPixelBuffer::::new(width, height); + let dst = buffer.make_mut_bytes(); + if dst.len() != pixels.len() { + return; + } + dst.copy_from_slice(pixels); + window + .global::() + .set_image(slint::Image::from_rgba8(buffer)); +} + +pub fn reset_label(window: &AppWindow) { + let state = window.global::(); + state.set_name("".into()); + state.set_image_url("".into()); + state.set_albums(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_total(0); + state.set_loading(true); + state.set_load_more_loading(false); +} + +/// Artwork jobs for the label album grid — same pipeline the +/// Discover cards use. +pub fn artwork_jobs(data: &LabelData) -> Vec { + data.albums + .iter() + .enumerate() + .filter(|(_, a)| !a.artwork_url.is_empty()) + .map(|(i, a)| ArtworkJob { + url: a.artwork_url.clone(), + target: ArtworkTarget::LabelAlbum { index: i }, + }) + .collect() +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 8d6527149..5e34144eb 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -23,6 +23,7 @@ mod commands; mod custom_artwork; mod discovery_dismiss; mod home; +mod label; mod musician; mod nav; mod play_history; @@ -424,9 +425,55 @@ fn apply_entry( role, ); } + nav::NavEntry::Label { id, name } => { + navigate_label(runtime.clone(), weak.clone(), handle, image_cache.clone(), id, name); + } } } +/// Open a LabelReleasesView for `label_id`. Fetches the label header +/// + first album page, then the header image. +fn navigate_label( + 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::Label); + }); + match label::load_label(&runtime, label_id, &name).await { + Ok(data) => { + let jobs = label::artwork_jobs(&data); + let image_url = data.image_url.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + label::apply_label(&w, data); + }); + 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 load failed: {e}"); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_loading(false); + }); + } + } + }); +} + /// Open a MusicianPageView for `name + role`. Routes to the artist /// page instead when the resolved musician has a Confirmed Qobuz /// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches @@ -1278,11 +1325,37 @@ fn main() -> Result<(), Box> { .on_location_clicked(|mbid| { log::info!("[qbz-slint] network sidebar: location clicked for mbid={mbid}"); }); - window - .global::() - .on_label_clicked(|id, name| { - log::info!("[qbz-slint] network sidebar: label clicked id={id} name={name}"); - }); + // Label click — open LabelReleasesView. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_label_clicked(move |id, name| { + let Ok(label_id) = id.parse::() else { + log::warn!("[qbz-slint] label clicked: invalid id {id}"); + return; + }; + let name = name.to_string(); + 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); + } + }); + } // artist-clicked actually navigates — the target view (artist page) // already exists in Slint, unlike LabelReleases / ArtistsByLocation / // MusicianPage. Same flow as the top-level on_open_artist handler. @@ -1448,6 +1521,58 @@ fn main() -> Result<(), Box> { }); } + // Label album pagination — Load more in LabelReleasesView + // appends the next page onto the grid. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_load_more(move || { + let Some(w) = weak.upgrade() else { + return; + }; + let state = w.global::(); + let Ok(label_id) = state.get_id().to_string().parse::() else { + return; + }; + let offset = state.get_albums().row_count() as u32; + state.set_load_more_loading(true); + let runtime = runtime.clone(); + let weak = weak.clone(); + let image_cache = image_cache.clone(); + handle.spawn(async move { + match label::load_more_albums(&runtime, label_id, offset).await { + Ok((data, total)) => { + let jobs: Vec = data + .iter() + .enumerate() + .filter(|(_, a)| !a.artwork_url.is_empty()) + .map(|(i, a)| artwork::ArtworkJob { + url: a.artwork_url.clone(), + target: artwork::ArtworkTarget::LabelAlbum { + index: offset as usize + i, + }, + }) + .collect(); + let _ = weak.upgrade_in_event_loop(move |w| { + label::append_albums(&w, data, total); + }); + artwork::spawn_loads(jobs, weak, image_cache); + } + Err(e) => { + log::error!("[qbz-slint] label load-more failed: {e}"); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_load_more_loading(false); + }); + } + } + }); + }); + } + // Artwork right-click menu wiring — Open in browser / Save as / // Add custom / Remove custom. Mirrors the v2_library_* + native // dialog flow Tauri uses on artist portraits + album covers. diff --git a/crates/qbz-slint/src/nav.rs b/crates/qbz-slint/src/nav.rs index c73b82d82..eb0ede457 100644 --- a/crates/qbz-slint/src/nav.rs +++ b/crates/qbz-slint/src/nav.rs @@ -26,6 +26,12 @@ pub enum NavEntry { name: String, role: String, }, + /// LabelReleasesView — opened by the artist network sidebar's + /// Labels section. Carries the label id + name fallback. + Label { + id: u64, + name: String, + }, } struct History { diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 843bd9ffc..dd1e5cf9a 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint"; import { AppShell } from "shell/AppShell.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions } from "state.slint"; +export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/label/LabelReleasesView.slint b/crates/qbz-slint/ui/label/LabelReleasesView.slint new file mode 100644 index 000000000..367c92c87 --- /dev/null +++ b/crates/qbz-slint/ui/label/LabelReleasesView.slint @@ -0,0 +1,154 @@ +// 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. + +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 { AlbumCard } from "../discover/AlbumCard.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; +import { NavButtons } from "../shell/NavButtons.slint"; + +export component LabelReleasesView inherits Rectangle { + callback open-album(string); + callback media-action(string, string, string); + + property card-width: 220px; + property card-height: 266px; + property card-gap: 24px; + + background: Theme.surface-main; + + 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; + + HorizontalLayout { + alignment: start; + NavButtons { } + } + + Rectangle { height: 22px; } + + // --- Label header ------------------------------------------ + HorizontalLayout { + spacing: 24px; + alignment: start; + Rectangle { + width: 120px; + height: 120px; + border-radius: Radius.md; + background: Theme.surface-elevated; + clip: true; + 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; + spacing: 6px; + Text { + text: @tr("LABEL"); + color: Theme.text-muted; + font-size: 10px; + font-weight: Typography.semibold; + letter-spacing: 1.5px; + } + Text { + text: LabelState.name; + color: Theme.text-primary; + font-size: Typography.title; + font-weight: Typography.bold; + } + if LabelState.total > 0: Text { + text: LabelState.total + " " + @tr("releases"); + color: Theme.text-muted; + font-size: Typography.body; + } + } + } + + Rectangle { height: 32px; } + + if LabelState.loading && LabelState.albums.length == 0: HorizontalLayout { + alignment: center; + LoadingSpinner { size: 36px; } + } + + // --- Album grid -------------------------------------------- + if LabelState.albums.length > 0: 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); + } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + } + + if LabelState.albums.length > 0 + && LabelState.albums.length < LabelState.total: HorizontalLayout { + alignment: center; + Rectangle { + width: load-more-text.preferred-width + 24px; + height: 32px; + load-more-text := Text { + text: LabelState.load-more-loading + ? @tr("Loading…") + : @tr("Load more"); + color: load-more-ta.has-hover + ? Theme.text-primary + : Theme.text-secondary; + font-size: 13px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + load-more-ta := TouchArea { + mouse-cursor: pointer; + enabled: !LabelState.load-more-loading; + clicked => { + LabelActions.load-more(); + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 16107eb25..36e1781e5 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -17,6 +17,7 @@ import { HomeView } from "../discover/HomeView.slint"; 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 { SettingsView } from "../settings/SettingsView.slint"; import { SearchResultsView } from "../search/SearchResultsView.slint"; @@ -100,6 +101,14 @@ export component AppShell inherits Rectangle { root.open-album(id); } } + if NavState.view == ContentView.label: LabelReleasesView { + open-album(id) => { + root.open-album(id); + } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } if NavState.view == ContentView.settings: SettingsView { settings-bool(k, v) => { root.settings-bool(k, v); diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 713d22b60..289fec266 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -94,7 +94,7 @@ export global AlbumActions { } // Which content view the shell shows. -export enum ContentView { home, album, artist, settings, search, musician } +export enum ContentView { home, album, artist, settings, search, musician, label } // Settings screen state. Audio and Playback preferences are loaded from // the persistence stores by the Rust settings controller and pushed here; @@ -433,6 +433,24 @@ export global MusicianActions { callback load-more(); } +// LabelReleasesView state — opened by the artist network sidebar's +// Labels section. Header (name + image) from /label/page, the +// album grid from /label/getAlbums. Populated by `crate::label`. +export global LabelState { + in property id; + in property name; + in property image-url; + in property image; + in property <[AlbumCardItem]> albums: []; + in property total: 0; + in property loading: false; + in property load-more-loading: false; +} + +export global LabelActions { + callback load-more(); +} + // Shell chrome state — collapsible left sidebar and right-side panels. // Globals are imported directly by the shell components, so toggles // need no callback plumbing.