diff --git a/crates/qbz-slint/src/album.rs b/crates/qbz-slint/src/album.rs new file mode 100644 index 000000000..61f60fcfa --- /dev/null +++ b/crates/qbz-slint/src/album.rs @@ -0,0 +1,205 @@ +//! Album detail controller. +//! +//! Fetches a full album through `QbzCore`, maps it to plain (Send) data +//! on the worker thread, and applies it to the `AlbumState` global on the +//! Slint event loop. + +use std::sync::Arc; + +use qbz_app::shell::AppRuntime; +use qbz_core::FrontendAdapter; +use qbz_models::{Album, Track}; +use slint::{ComponentHandle, ModelRc, VecModel}; + +use crate::{AlbumState, AlbumTrackItem, AppWindow}; + +/// Plain, `Send` album data produced on the worker thread. +pub struct AlbumData { + pub title: String, + pub artist: String, + /// Pre-formatted "year • label • genre • N tracks • duration". + pub info_line: String, + pub quality_tier: String, + pub artwork_url: String, + pub tracks: Vec, +} + +pub struct TrackData { + pub number: String, + pub title: String, + pub artist: String, + pub duration: String, + pub quality_tier: String, + pub explicit: bool, +} + +/// Fetch and map a full album by id. +pub async fn load_album(runtime: &Arc>, album_id: &str) -> Result +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let album = runtime + .core() + .get_album(album_id) + .await + .map_err(|e| e.to_string())?; + Ok(map_album(album)) +} + +fn map_album(album: Album) -> AlbumData { + let artist = album.artist.name.clone(); + + let year = album + .release_date_original + .as_deref() + .and_then(|date| date.get(0..4)) + .unwrap_or("") + .to_string(); + + let mut parts: Vec = Vec::new(); + if !year.is_empty() { + parts.push(year); + } + if let Some(label) = album.label.as_ref().filter(|l| !l.name.is_empty()) { + parts.push(label.name.clone()); + } + if let Some(genre) = album.genre.as_ref().filter(|g| !g.name.is_empty()) { + parts.push(genre.name.clone()); + } + if let Some(count) = album.tracks_count { + parts.push(format!("{count} tracks")); + } + if let Some(duration) = album.duration { + parts.push(format_duration(duration)); + } + let info_line = parts.join(" • "); + + let quality_tier = tier(album.maximum_bit_depth).to_string(); + let artwork_url = album.image.best().cloned().unwrap_or_default(); + let tracks = album + .tracks + .map(|container| container.items) + .unwrap_or_default() + .into_iter() + .map(map_track) + .collect(); + + AlbumData { + title: album.title, + artist, + info_line, + quality_tier, + artwork_url, + tracks, + } +} + +fn map_track(track: Track) -> TrackData { + let mut title = track.title; + if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) { + title = format!("{title} ({version})"); + } + TrackData { + number: track.track_number.to_string(), + title, + artist: track.performer.map(|p| p.name).unwrap_or_default(), + duration: mmss(track.duration), + quality_tier: tier(track.maximum_bit_depth).to_string(), + explicit: track.parental_warning, + } +} + +/// 24-bit and up is Hi-Res, anything else with depth info is CD-quality. +fn tier(bit_depth: Option) -> &'static str { + match bit_depth { + Some(depth) if depth >= 24 => "hires", + Some(_) => "cd", + None => "", + } +} + +/// `m:ss` track duration. +fn mmss(secs: u32) -> String { + format!("{}:{:02}", secs / 60, secs % 60) +} + +/// `Xh Ym` / `Ym` album duration. +fn format_duration(secs: u32) -> String { + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + if hours > 0 { + format!("{hours}h {minutes}m") + } else { + format!("{minutes}m") + } +} + +/// Apply album data to the `AlbumState` global. Runs on the Slint event loop. +pub fn apply_album(window: &AppWindow, data: AlbumData) { + let tracks: Vec = data + .tracks + .into_iter() + .map(|track| AlbumTrackItem { + number: track.number.into(), + title: track.title.into(), + artist: track.artist.into(), + duration: track.duration.into(), + quality_tier: track.quality_tier.into(), + explicit: track.explicit, + }) + .collect(); + + let state = window.global::(); + state.set_title(data.title.into()); + state.set_artist(data.artist.into()); + state.set_info_line(data.info_line.into()); + state.set_quality_tier(data.quality_tier.into()); + state.set_tracks(ModelRc::new(VecModel::from(tracks))); +} + +/// Clear album state and show an empty track list (used when opening a new +/// album so the previous one does not flash). +pub fn reset_album(window: &AppWindow) { + let state = window.global::(); + state.set_tracks(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_artwork(slint::Image::default()); + state.set_loading(true); +} + +/// Apply decoded header artwork pixels. Runs on the Slint event loop. +pub fn apply_artwork(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_artwork(slint::Image::from_rgba8(buffer)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mmss_pads_seconds() { + assert_eq!(mmss(5), "0:05"); + assert_eq!(mmss(65), "1:05"); + assert_eq!(mmss(225), "3:45"); + } + + #[test] + fn duration_drops_zero_hours() { + assert_eq!(format_duration(2700), "45m"); + assert_eq!(format_duration(3720), "1h 2m"); + } + + #[test] + fn tier_classifies_bit_depth() { + assert_eq!(tier(Some(24)), "hires"); + assert_eq!(tier(Some(16)), "cd"); + assert_eq!(tier(None), ""); + } +} diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index 1e4b6b5af..cef3df025 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -73,7 +73,8 @@ pub fn spawn_loads(jobs: Vec, window: slint::Weak, cache: let cache = cache.clone(); tokio::spawn(async move { let _permit = semaphore.acquire().await.ok()?; - let (pixels, width, height) = fetch_and_decode(&job.url, &cache).await?; + let (pixels, width, height) = + fetch_and_decode(&job.url, &cache, DECODE_SIZE).await?; let _ = window.upgrade_in_event_loop(move |w| { apply_artwork(&w, job.section_idx, job.album_idx, &pixels, width, height); }); @@ -82,10 +83,14 @@ pub fn spawn_loads(jobs: Vec, window: slint::Weak, cache: } } -/// Resolve one cover image to raw RGBA8 pixels, downscaled to `DECODE_SIZE`. +/// Resolve one cover image to raw RGBA8 pixels, downscaled to `decode_size`. /// Reads from the shared cache on a hit; on a miss downloads, stores, and /// uses the bytes. Runs on a worker thread; the result tuple is `Send`. -async fn fetch_and_decode(url: &str, cache: &ImageCache) -> Option<(Vec, u32, u32)> { +pub async fn fetch_and_decode( + url: &str, + cache: &ImageCache, + decode_size: u32, +) -> Option<(Vec, u32, u32)> { let cached_path = { let guard = cache.lock().ok()?; guard.as_ref().and_then(|service| service.get(url)) @@ -106,7 +111,7 @@ async fn fetch_and_decode(url: &str, cache: &ImageCache) -> Option<(Vec, u32 let rgba = image::load_from_memory(&bytes) .ok()? - .thumbnail(DECODE_SIZE, DECODE_SIZE) + .thumbnail(decode_size, decode_size) .to_rgba8(); let (width, height) = rgba.dimensions(); Some((rgba.into_raw(), width, height)) diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index a65e908de..ca4cf7584 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -15,6 +15,7 @@ slint::include_modules!(); mod adapter; +mod album; mod artwork; mod auth; mod commands; @@ -186,6 +187,50 @@ fn main() -> Result<(), Box> { }); } + // Open an album: load it, show the album view, then fetch its artwork. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.on_open_album(move |album_id| { + let runtime = runtime.clone(); + let weak = weak.clone(); + let image_cache = image_cache.clone(); + let album_id = album_id.to_string(); + handle.spawn(async move { + let _ = weak.upgrade_in_event_loop(|w| { + album::reset_album(&w); + w.global::().set_showing_album(true); + }); + match album::load_album(&runtime, &album_id).await { + Ok(data) => { + let artwork_url = data.artwork_url.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + album::apply_album(&w, data); + w.global::().set_loading(false); + }); + if !artwork_url.is_empty() { + if let Some((pixels, width, height)) = + artwork::fetch_and_decode(&artwork_url, &image_cache, 448).await + { + let _ = weak.upgrade_in_event_loop(move |w| { + album::apply_artwork(&w, &pixels, width, height); + }); + } + } + } + Err(e) => { + log::error!("[qbz-slint] album load failed: {e}"); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_loading(false); + }); + } + } + }); + }); + } + window.on_open_tos(|| { dispatch(AppCommand::OpenTermsOfService); if let Err(e) = open::that(QOBUZ_TOS_URL) { diff --git a/crates/qbz-slint/ui/album/AlbumPageView.slint b/crates/qbz-slint/ui/album/AlbumPageView.slint new file mode 100644 index 000000000..37ebb1fea --- /dev/null +++ b/crates/qbz-slint/ui/album/AlbumPageView.slint @@ -0,0 +1,304 @@ +// Album detail page. +// +// Mirrors the Svelte AlbumDetailView: back button, an album header +// (artwork + title / artist / info line / action buttons), a divider, +// and the track list with a column header. Data comes from the +// AlbumState global; the action buttons are visual for now. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { Spacing } from "../foundation/spacing.slint"; +import { AlbumState, AlbumTrackItem, NavState } from "../state.slint"; +import { ScrollView } from "std-widgets.slint"; + +component CircleAction inherits Rectangle { + in property glyph; + in property primary: false; + in property diameter: 40px; + callback clicked(); + + width: root.diameter; + height: root.diameter; + border-radius: root.diameter / 2; + background: root.primary + ? (ta.has-hover ? Theme.accent-hover : Theme.accent) + : (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); + + Text { + text: root.glyph; + color: root.primary ? Theme.accent-text : Theme.text-secondary; + font-size: 16px; + horizontal-alignment: center; + vertical-alignment: center; + } + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } +} + +component TrackRow inherits Rectangle { + in property track; + in property index; + + height: 56px; + border-radius: 8px; + background: ta.has-hover + ? Theme.surface-hover + : (Math.mod(root.index, 2) == 1 ? Theme.surface-card : transparent); + + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 16px; + + Text { + text: root.track.number; + width: 32px; + color: Theme.text-muted; + font-size: Typography.body; + vertical-alignment: center; + } + + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + spacing: 2px; + + HorizontalLayout { + spacing: 6px; + Text { + text: root.track.title; + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + vertical-alignment: center; + overflow: elide; + } + if root.track.explicit: Rectangle { + width: 16px; + height: 16px; + y: (parent.height - self.height) / 2; + border-radius: 3px; + background: Theme.surface-elevated; + Text { + text: "E"; + color: Theme.text-muted; + font-size: 9px; + font-weight: Typography.semibold; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + Text { + text: root.track.artist; + visible: root.track.artist != ""; + color: Theme.text-muted; + font-size: Typography.link; + overflow: elide; + } + } + + Text { + text: root.track.duration; + width: 80px; + color: Theme.text-muted; + font-size: Typography.body; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + text: root.track.quality-tier == "hires" + ? "Hi-Res" + : (root.track.quality-tier == "cd" ? "CD" : ""); + width: 80px; + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + ta := TouchArea { + mouse-cursor: pointer; + } +} + +export component AlbumPageView inherits Rectangle { + background: Theme.surface-main; + + ScrollView { + VerticalLayout { + padding-left: 24px; + padding-right: 24px; + padding-top: 14px; + padding-bottom: 100px; + spacing: 0px; + alignment: start; + + // --- Back button ------------------------------------------------- + back := TouchArea { + width: back-row.preferred-width; + height: 28px; + mouse-cursor: pointer; + clicked => { + NavState.showing-album = false; + } + back-row := HorizontalLayout { + spacing: 6px; + Text { + text: "‹"; + color: back.has-hover ? Theme.text-secondary : Theme.text-muted; + font-size: 18px; + vertical-alignment: center; + } + Text { + text: @tr("Back"); + color: back.has-hover ? Theme.text-secondary : Theme.text-muted; + font-size: Typography.body; + vertical-alignment: center; + } + } + } + + Rectangle { height: 22px; } + + // --- Album header ------------------------------------------------ + HorizontalLayout { + spacing: 32px; + alignment: start; + + Rectangle { + width: 224px; + height: 224px; + border-radius: 12px; + background: Theme.surface-elevated; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + drop-shadow-offset-y: 8px; + clip: true; + Image { + width: 100%; + height: 100%; + source: AlbumState.artwork; + image-fit: cover; + } + } + + VerticalLayout { + alignment: start; + padding-top: 4px; + spacing: 0px; + horizontal-stretch: 1; + + Text { + text: AlbumState.title; + color: Theme.text-primary; + font-size: Typography.title; + font-weight: Typography.bold; + overflow: elide; + } + Rectangle { height: 4px; } + Text { + text: AlbumState.artist; + color: Theme.accent; + font-size: Typography.heading; + font-weight: Typography.medium; + overflow: elide; + } + Rectangle { height: 10px; } + Text { + text: AlbumState.info-line; + color: Theme.text-muted; + font-size: Typography.body; + overflow: elide; + } + Rectangle { height: 20px; } + HorizontalLayout { + alignment: start; + spacing: 12px; + CircleAction { glyph: "▶"; primary: true; diameter: 44px; } + VerticalLayout { + alignment: center; + CircleAction { glyph: "⇄"; } + } + VerticalLayout { + alignment: center; + CircleAction { glyph: "♡"; } + } + VerticalLayout { + alignment: center; + CircleAction { glyph: "⋯"; } + } + } + } + } + + Rectangle { height: 20px; } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + Rectangle { height: 8px; } + + // --- Track list -------------------------------------------------- + if AlbumState.loading && AlbumState.tracks.length == 0: Text { + text: @tr("Loading album..."); + color: Theme.text-muted; + font-size: Typography.body; + } + + // Column header. + HorizontalLayout { + height: 40px; + padding-left: 12px; + padding-right: 12px; + spacing: 16px; + Text { + text: "#"; + width: 32px; + color: Theme.text-muted; + font-size: Typography.legal; + letter-spacing: 0.5px; + vertical-alignment: center; + } + Text { + text: @tr("TITLE"); + horizontal-stretch: 1; + color: Theme.text-muted; + font-size: Typography.legal; + letter-spacing: 0.5px; + vertical-alignment: center; + } + Text { + text: @tr("DURATION"); + width: 80px; + color: Theme.text-muted; + font-size: Typography.legal; + letter-spacing: 0.5px; + horizontal-alignment: center; + vertical-alignment: center; + } + Text { + text: @tr("QUALITY"); + width: 80px; + color: Theme.text-muted; + font-size: Typography.legal; + letter-spacing: 0.5px; + horizontal-alignment: center; + vertical-alignment: center; + } + } + + for track[index] in AlbumState.tracks: TrackRow { + track: track; + index: index; + } + } + } +} diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 28436ba88..83c45171f 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -1,16 +1,16 @@ -// QBZ Slint POC — UI entry point. +// QBZ Slint MVP — UI entry point. // // Stays thin: imports the feature screens and exports the single // top-level window. No raw visual constants or domain logic here -// (POC ADR, sections 13 and 21). +// (MVP ADR, sections 13 and 21). import { Theme } from "foundation/semantic-colors.slint"; import { SplashScreen } from "splash/SplashScreen.slint"; import { LoginScreen } from "login/LoginScreen.slint"; import { AppShell } from "shell/AppShell.slint"; -// Re-export the HomeState global so the Rust layer can populate it. -export { HomeState } from "state.slint"; +// Re-export the state globals so the Rust layer can populate them. +export { HomeState, AlbumState, NavState } 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`. @@ -26,11 +26,12 @@ export component AppWindow inherits Window { in-out property screen: AppScreen.splash; - // Forwarded to the Rust layer, which maps them to typed AppCommands. + // Forwarded to the Rust layer. callback sign-in-via-browser(); callback use-system-browser(); callback start-offline(); callback open-tos(); + callback open-album(string); if root.screen == AppScreen.splash: SplashScreen { } @@ -49,5 +50,9 @@ export component AppWindow inherits Window { } } - if root.screen == AppScreen.shell: AppShell { } + if root.screen == AppScreen.shell: AppShell { + open-album(id) => { + root.open-album(id); + } + } } diff --git a/crates/qbz-slint/ui/discover/Carousel.slint b/crates/qbz-slint/ui/discover/Carousel.slint index b4ffa8372..3d2b30e18 100644 --- a/crates/qbz-slint/ui/discover/Carousel.slint +++ b/crates/qbz-slint/ui/discover/Carousel.slint @@ -42,6 +42,7 @@ component NavButton inherits Rectangle { export component Carousel inherits VerticalLayout { in property section; + callback album-clicked(string); property card-width: 220px; property card-height: 266px; @@ -112,6 +113,9 @@ export component Carousel inherits VerticalLayout { spacing: root.gap; for album in root.section.albums: AlbumCard { album: album; + clicked(id) => { + root.album-clicked(id); + } } } } diff --git a/crates/qbz-slint/ui/discover/HomeView.slint b/crates/qbz-slint/ui/discover/HomeView.slint index 0015044ee..14379ff75 100644 --- a/crates/qbz-slint/ui/discover/HomeView.slint +++ b/crates/qbz-slint/ui/discover/HomeView.slint @@ -93,6 +93,8 @@ component GearButton inherits Rectangle { } export component HomeView inherits Rectangle { + callback album-clicked(string); + background: Theme.surface-main; VerticalLayout { @@ -155,6 +157,9 @@ export component HomeView inherits Rectangle { for section in HomeState.sections: Carousel { section: section; + album-clicked(id) => { + root.album-clicked(id); + } } } } diff --git a/crates/qbz-slint/ui/foundation/typography.slint b/crates/qbz-slint/ui/foundation/typography.slint index 5b084e610..644bb8e3a 100644 --- a/crates/qbz-slint/ui/foundation/typography.slint +++ b/crates/qbz-slint/ui/foundation/typography.slint @@ -6,9 +6,12 @@ export global Typography { out property body: 14px; out property subtitle: 14px; out property button: 16px; + out property heading: 18px; + out property title: 24px; out property wordmark: 28px; out property regular: 400; out property medium: 500; out property semibold: 600; + out property bold: 700; } diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 7d43a6907..6e7722779 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -1,17 +1,19 @@ // App shell — the post-login frame: header bar, sidebar, content -// region, and player bar. -// -// The content region is a placeholder; the Discover / Home view lands -// in the next increment. Hairlines separate the regions. +// region, and player bar. The content region shows either the Discover +// Home view or the Album detail view, driven by NavState. import { Theme } from "../foundation/semantic-colors.slint"; import { Layout } from "../foundation/layout.slint"; +import { NavState } from "../state.slint"; import { Sidebar } from "Sidebar.slint"; import { HeaderBar } from "HeaderBar.slint"; import { PlayerBar } from "PlayerBar.slint"; import { HomeView } from "../discover/HomeView.slint"; +import { AlbumPageView } from "../album/AlbumPageView.slint"; export component AppShell inherits Rectangle { + callback open-album(string); + background: Theme.surface-main; VerticalLayout { @@ -32,8 +34,18 @@ export component AppShell inherits Rectangle { width: Layout.hairline; background: Theme.border-subtle; } - HomeView { + + // Content region — Home or Album detail. + Rectangle { horizontal-stretch: 1; + background: Theme.surface-main; + + if !NavState.showing-album: HomeView { + album-clicked(id) => { + root.open-album(id); + } + } + if NavState.showing-album: AlbumPageView { } } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 6afaf72aa..cae14fa4f 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -26,3 +26,28 @@ export global HomeState { in property <[DiscoverSection]> sections: []; in property loading: false; } + +export struct AlbumTrackItem { + number: string, + title: string, + artist: string, + duration: string, + quality-tier: string, + explicit: bool, +} + +// Album detail screen state. +export global AlbumState { + in property title; + in property artist; + in property info-line; + in property quality-tier; + in property artwork; + in property <[AlbumTrackItem]> tracks: []; + in property loading: false; +} + +// Which content view the shell shows. +export global NavState { + in-out property showing-album: false; +}