From 78014788195d927352860918ae5232d6355f9f3c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 18 May 2026 13:20:52 -0600 Subject: [PATCH] fix(qbz-slint): match current QBZ Discover Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections from testing feedback: - Remove the greeting — the current QBZ discovery view has no greeting, only the tab selector. Drops HomeState.greeting and the chrono dep. - Quality badge is now the icon-only badge (hi-res.svg, or cd.svg in a framed square), not a numeric text pill. AlbumCardItem carries a quality tier instead of a quality string. - Tighten the album card: deterministic 274px height, title and artist in a 2px-spaced stack — removes the large gap between them. - SectionRow no longer scrolls vertically (vertical-scrollbar-policy always-off, height pinned to the card height). - Artwork is downscaled to 264px on decode instead of holding ~600px source textures. cc --- crates/Cargo.lock | 1 - crates/qbz-slint/Cargo.toml | 1 - crates/qbz-slint/src/artwork.rs | 13 +- crates/qbz-slint/src/auth.rs | 18 +- crates/qbz-slint/src/home.rs | 90 ++---- crates/qbz-slint/src/main.rs | 11 +- crates/qbz-slint/ui/assets/cd.svg | 15 + crates/qbz-slint/ui/assets/hi-res.svg | 293 ++++++++++++++++++ crates/qbz-slint/ui/discover/AlbumCard.slint | 86 ++--- crates/qbz-slint/ui/discover/HomeView.slint | 8 - crates/qbz-slint/ui/discover/SectionRow.slint | 5 +- crates/qbz-slint/ui/state.slint | 3 +- 12 files changed, 408 insertions(+), 136 deletions(-) create mode 100644 crates/qbz-slint/ui/assets/cd.svg create mode 100644 crates/qbz-slint/ui/assets/hi-res.svg diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 3c2b606a2..6245b227d 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5372,7 +5372,6 @@ name = "qbz-slint" version = "0.1.0" dependencies = [ "async-trait", - "chrono", "env_logger", "image", "log", diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index d3717c3da..350354638 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -16,7 +16,6 @@ log = { workspace = true } env_logger = "0.11" tokio = { workspace = true } async-trait = { workspace = true } -chrono = { workspace = true } reqwest = { workspace = true } image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } urlencoding = "2" diff --git a/crates/qbz-slint/src/artwork.rs b/crates/qbz-slint/src/artwork.rs index bd9bf66dd..b24b1477c 100644 --- a/crates/qbz-slint/src/artwork.rs +++ b/crates/qbz-slint/src/artwork.rs @@ -40,11 +40,18 @@ pub fn spawn_loads(jobs: Vec, window: slint::Weak) { } } -/// Download and decode one cover image into raw RGBA8 pixels. Runs on a -/// worker thread; the result tuple is `Send`. +/// Target decode size. Cards display at 220px; 264px keeps them crisp at +/// modest DPI without holding full ~600px source textures in memory. +const DECODE_SIZE: u32 = 264; + +/// Download and decode one cover image into raw RGBA8 pixels, downscaled to +/// `DECODE_SIZE`. Runs on a worker thread; the result tuple is `Send`. async fn fetch_and_decode(url: &str) -> Option<(Vec, u32, u32)> { let bytes = reqwest::get(url).await.ok()?.bytes().await.ok()?; - let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); + let rgba = image::load_from_memory(&bytes) + .ok()? + .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/auth.rs b/crates/qbz-slint/src/auth.rs index d3e6f4604..3f509f7f6 100644 --- a/crates/qbz-slint/src/auth.rs +++ b/crates/qbz-slint/src/auth.rs @@ -16,17 +16,9 @@ use tokio::net::TcpListener; const OAUTH_TIMEOUT: Duration = Duration::from_secs(180); -/// Result of a successful login. -pub struct LoginOutcome { - pub user_id: u64, - pub display_name: String, -} - /// Run the full system-browser OAuth login. Returns the authenticated -/// user on success. -pub async fn login_via_system_browser( - runtime: &Arc>, -) -> Result +/// user id on success. +pub async fn login_via_system_browser(runtime: &Arc>) -> Result where A: FrontendAdapter + Send + Sync + 'static, { @@ -76,7 +68,6 @@ where .map_err(|e| e.to_string())? }; let user_id = session.user_id; - let display_name = session.display_name.clone(); // Emit LoggedIn through the core (idempotent set_session). core.set_session(session).await.map_err(|e| e.to_string())?; @@ -85,10 +76,7 @@ where runtime.activate(user_id).await?; log::info!("[qbz-slint] login complete for user {user_id}"); - Ok(LoginOutcome { - user_id, - display_name, - }) + Ok(user_id) } /// Accept connections until one carries the OAuth code, replying with a diff --git a/crates/qbz-slint/src/home.rs b/crates/qbz-slint/src/home.rs index 6c3793ad6..a6e69a298 100644 --- a/crates/qbz-slint/src/home.rs +++ b/crates/qbz-slint/src/home.rs @@ -24,33 +24,13 @@ pub struct CardData { pub id: String, pub title: String, pub artist: String, - pub quality: String, + /// "hires" | "cd" | "" — drives the icon-only quality badge. + pub quality_tier: String, pub ribbon: String, pub ribbon_kind: String, pub artwork_url: String, } -/// Time-of-day greeting, matching the QBZ home greeting strings. -pub fn greeting(display_name: &str) -> String { - use chrono::Timelike; - let hour = chrono::Local::now().hour(); - let part = if (5..12).contains(&hour) { - "Good morning" - } else if (12..17).contains(&hour) { - "Good afternoon" - } else if (17..21).contains(&hour) { - "Good evening" - } else { - "Good night" - }; - let first = display_name.split_whitespace().next().unwrap_or(""); - if first.is_empty() { - part.to_string() - } else { - format!("{part}, {first}") - } -} - /// Fetch the discover index and map it into Home sections. pub async fn load_home(runtime: &Arc>) -> Result, String> where @@ -114,7 +94,7 @@ fn map_album(album: DiscoverAlbum) -> CardData { id: album.id, title: album.title, artist, - quality: quality_string(album.audio_info.as_ref()), + quality_tier: quality_tier(album.audio_info.as_ref()).to_string(), ribbon, ribbon_kind, artwork_url, @@ -141,22 +121,15 @@ fn pick_ribbon(awards: Option<&[AlbumAward]>) -> (String, String) { (last.name.clone(), "press".to_string()) } -/// Format the quality badge as `{depth}-bit / {rate} kHz`. -fn quality_string(audio: Option<&DiscoverAudioInfo>) -> String { +/// Classify the quality tier for the icon-only badge: 24-bit and up is +/// Hi-Res, anything else with audio info is CD-quality. +fn quality_tier(audio: Option<&DiscoverAudioInfo>) -> &'static str { let Some(audio) = audio else { - return String::new(); + return ""; }; - match (audio.maximum_bit_depth, audio.maximum_sampling_rate) { - (Some(depth), Some(rate)) => format!("{depth}-bit / {} kHz", format_rate(rate)), - _ => String::new(), - } -} - -fn format_rate(rate: f64) -> String { - if rate.fract().abs() < 0.05 { - format!("{}", rate.round() as i64) - } else { - format!("{rate:.1}") + match audio.maximum_bit_depth { + Some(depth) if depth >= 24 => "hires", + _ => "cd", } } @@ -173,7 +146,7 @@ pub fn apply_sections(window: &AppWindow, data: Vec) { id: card.id.into(), title: card.title.into(), artist: card.artist.into(), - quality: card.quality.into(), + quality_tier: card.quality_tier.into(), ribbon: card.ribbon.into(), ribbon_kind: card.ribbon_kind.into(), artwork_url: card.artwork_url.into(), @@ -195,36 +168,27 @@ pub fn apply_sections(window: &AppWindow, data: Vec) { mod tests { use super::*; - #[test] - fn greeting_uses_first_name_only() { - let g = greeting("Victor Rodriguez Hernandez"); - assert!(g.ends_with(", Victor"), "got: {g}"); - } - - #[test] - fn greeting_without_name_omits_comma() { - let g = greeting(""); - assert!(!g.contains(','), "got: {g}"); - } - - #[test] - fn quality_formats_bit_depth_and_rate() { - let audio = DiscoverAudioInfo { - maximum_bit_depth: Some(24), + fn audio(bit_depth: Option) -> DiscoverAudioInfo { + DiscoverAudioInfo { + maximum_bit_depth: bit_depth, maximum_sampling_rate: Some(96.0), maximum_channel_count: Some(2), - }; - assert_eq!(quality_string(Some(&audio)), "24-bit / 96 kHz"); + } } #[test] - fn quality_keeps_fractional_rate() { - let audio = DiscoverAudioInfo { - maximum_bit_depth: Some(16), - maximum_sampling_rate: Some(44.1), - maximum_channel_count: Some(2), - }; - assert_eq!(quality_string(Some(&audio)), "16-bit / 44.1 kHz"); + fn quality_tier_hires_for_24_bit() { + assert_eq!(quality_tier(Some(&audio(Some(24)))), "hires"); + } + + #[test] + fn quality_tier_cd_for_16_bit() { + assert_eq!(quality_tier(Some(&audio(Some(16)))), "cd"); + } + + #[test] + fn quality_tier_empty_without_audio_info() { + assert_eq!(quality_tier(None), ""); } #[test] diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index d802775e9..150d1fa61 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -63,20 +63,17 @@ fn main() -> Result<(), Box> { let runtime = runtime.clone(); let weak = weak.clone(); handle.spawn(async move { - let outcome = match auth::login_via_system_browser(&runtime).await { - Ok(outcome) => outcome, + let user_id = match auth::login_via_system_browser(&runtime).await { + Ok(user_id) => user_id, Err(e) => { log::error!("[qbz-slint] sign-in failed: {e}"); return; } }; - log::info!("[qbz-slint] authenticated as user {}", outcome.user_id); + log::info!("[qbz-slint] authenticated as user {user_id}"); - let greeting = home::greeting(&outcome.display_name); let _ = weak.upgrade_in_event_loop(move |w| { - let state = w.global::(); - state.set_greeting(greeting.into()); - state.set_loading(true); + w.global::().set_loading(true); w.set_screen(AppScreen::Shell); }); diff --git a/crates/qbz-slint/ui/assets/cd.svg b/crates/qbz-slint/ui/assets/cd.svg new file mode 100644 index 000000000..0bf0212b1 --- /dev/null +++ b/crates/qbz-slint/ui/assets/cd.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/qbz-slint/ui/assets/hi-res.svg b/crates/qbz-slint/ui/assets/hi-res.svg new file mode 100644 index 000000000..908574bb8 --- /dev/null +++ b/crates/qbz-slint/ui/assets/hi-res.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/qbz-slint/ui/discover/AlbumCard.slint b/crates/qbz-slint/ui/discover/AlbumCard.slint index c39fd06fd..abc5a8667 100644 --- a/crates/qbz-slint/ui/discover/AlbumCard.slint +++ b/crates/qbz-slint/ui/discover/AlbumCard.slint @@ -1,10 +1,9 @@ // Album card for the Discover sections. // -// Mirrors the Svelte AlbumCardLite: 220px column, square artwork with a -// bottom-left award ribbon, single-line title, artist, and a compact -// quality badge. Artwork image loading lands in the artwork-pipeline -// increment; for now the artwork area is the same elevated placeholder -// the real card shows before its image resolves. +// Mirrors the Svelte AlbumCardLite: 220px column, 220px square artwork +// with a bottom-left award ribbon, then a tight title / artist stack +// alongside an icon-only quality badge. Deterministic height (274px) so +// the section row never scrolls vertically. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; @@ -16,10 +15,11 @@ export component AlbumCard inherits Rectangle { in property album; width: 220px; - height: 282px; + height: 274px; VerticalLayout { - spacing: Spacing.xs; + alignment: start; + spacing: 0px; // --- Artwork ------------------------------------------------------ Rectangle { @@ -73,41 +73,57 @@ export component AlbumCard inherits Rectangle { } } - // --- Title -------------------------------------------------------- - Text { - text: root.album.title; - color: Theme.text-primary; - font-size: Typography.body; - font-weight: Typography.medium; - overflow: elide; - } + Rectangle { height: 8px; } - // --- Artist + quality -------------------------------------------- + // --- Title / artist + quality badge ------------------------------ HorizontalLayout { + height: 46px; spacing: Spacing.sm; - Text { - text: root.album.artist; - color: Theme.text-muted; - font-size: Typography.link; - vertical-alignment: center; - overflow: elide; + VerticalLayout { + alignment: center; + spacing: 2px; horizontal-stretch: 1; + + Text { + text: root.album.title; + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + overflow: elide; + } + Text { + text: root.album.artist; + color: Theme.text-muted; + font-size: Typography.link; + overflow: elide; + } } - if root.album.quality != "": Rectangle { - background: Theme.surface-elevated; - border-radius: 3px; - HorizontalLayout { - padding-left: 6px; - padding-right: 6px; - padding-top: 3px; - padding-bottom: 3px; - Text { - text: root.album.quality; - color: Theme.text-secondary; - font-size: 9px; - vertical-alignment: center; + // Icon-only quality badge. + VerticalLayout { + alignment: center; + + if root.album.quality-tier == "hires": Image { + source: @image-url("../assets/hi-res.svg"); + width: 42px; + height: 28px; + image-fit: contain; + } + if root.album.quality-tier == "cd": Rectangle { + width: 30px; + height: 30px; + border-radius: 3px; + background: Theme.surface-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + Image { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 16px; + height: 16px; + source: @image-url("../assets/cd.svg"); + image-fit: contain; } } } diff --git a/crates/qbz-slint/ui/discover/HomeView.slint b/crates/qbz-slint/ui/discover/HomeView.slint index 01f0e8af1..fbc25b2ed 100644 --- a/crates/qbz-slint/ui/discover/HomeView.slint +++ b/crates/qbz-slint/ui/discover/HomeView.slint @@ -49,14 +49,6 @@ export component HomeView inherits Rectangle { spacing: 28px; alignment: start; - Text { - text: HomeState.greeting; - visible: HomeState.greeting != ""; - color: Theme.text-primary; - font-size: 26px; - font-weight: Typography.semibold; - } - HorizontalLayout { alignment: start; Rectangle { diff --git a/crates/qbz-slint/ui/discover/SectionRow.slint b/crates/qbz-slint/ui/discover/SectionRow.slint index 6599fd654..1f23f8973 100644 --- a/crates/qbz-slint/ui/discover/SectionRow.slint +++ b/crates/qbz-slint/ui/discover/SectionRow.slint @@ -22,8 +22,11 @@ export component SectionRow inherits VerticalLayout { font-weight: Typography.semibold; } + // Fixed to the AlbumCard height with vertical scrolling disabled, so + // the row never grows an internal vertical scrollbar. ScrollView { - height: 290px; + height: 274px; + vertical-scrollbar-policy: ScrollBarPolicy.always-off; HorizontalLayout { spacing: 32px; for album in root.section.albums: AlbumCard { diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 1fe278475..765cfaeab 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -7,7 +7,7 @@ export struct AlbumCardItem { id: string, title: string, artist: string, - quality: string, + quality-tier: string, // "hires" | "cd" | "" — drives the badge icon ribbon: string, ribbon-kind: string, // "press" | "qobuzissime" | "albumOfTheWeek" | "" artwork-url: string, @@ -21,7 +21,6 @@ export struct DiscoverSection { // Discover / Home screen state. export global HomeState { - in property greeting: ""; in property <[DiscoverSection]> sections: []; in property loading: false; }