diff --git a/crates/Cargo.lock b/crates/Cargo.lock index bf4a2d1f2..9080cbe2e 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -5454,6 +5454,7 @@ dependencies = [ "log", "open", "qbz-app", + "qbz-audio", "qbz-cache", "qbz-core", "qbz-credentials", diff --git a/crates/qbz-slint/src/artist.rs b/crates/qbz-slint/src/artist.rs index 8bed0aa82..e94998465 100644 --- a/crates/qbz-slint/src/artist.rs +++ b/crates/qbz-slint/src/artist.rs @@ -120,6 +120,7 @@ fn map_release(release: PageArtistRelease) -> CardData { genre: release.genre.map(|g| g.name).unwrap_or_default(), year: String::new(), quality_tier: String::new(), + quality_label: String::new(), ribbon: String::new(), ribbon_kind: String::new(), artwork_url, @@ -182,6 +183,7 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { genre: card.genre.into(), year: card.year.into(), quality_tier: card.quality_tier.into(), + quality_label: slint::SharedString::new(), ribbon: card.ribbon.into(), ribbon_kind: card.ribbon_kind.into(), artwork_url: card.artwork_url.into(), diff --git a/crates/qbz-slint/src/home.rs b/crates/qbz-slint/src/home.rs index f85fabcfc..059a5dc86 100644 --- a/crates/qbz-slint/src/home.rs +++ b/crates/qbz-slint/src/home.rs @@ -33,6 +33,8 @@ pub struct CardData { pub year: String, /// "hires" | "cd" | "" — drives the icon-only quality badge. pub quality_tier: String, + /// "Hi-Res: 24-bit / 96 kHz" — shown when hovering the quality badge. + pub quality_label: String, pub ribbon: String, pub ribbon_kind: String, pub artwork_url: String, @@ -119,6 +121,7 @@ fn map_album(album: DiscoverAlbum) -> CardData { .to_string(); let (ribbon, ribbon_kind) = pick_ribbon(album.awards.as_deref()); let quality_tier = quality_tier(album.audio_info.as_ref()).to_string(); + let quality_label = quality_label(album.audio_info.as_ref()); let artwork_url = album .image .large @@ -132,6 +135,7 @@ fn map_album(album: DiscoverAlbum) -> CardData { genre, year, quality_tier, + quality_label, ribbon, ribbon_kind, artwork_url, @@ -191,6 +195,34 @@ fn quality_tier(audio: Option<&DiscoverAudioInfo>) -> &'static str { } } +/// Exact-quality label for the badge hover tooltip, mirroring the Tauri +/// `QualityBadge` (`{tier}: {depth}-bit / {rate} kHz`). Empty when the +/// discover entry carries no audio info, matching `quality_tier`. +fn quality_label(audio: Option<&DiscoverAudioInfo>) -> String { + let Some(audio) = audio else { + return String::new(); + }; + let hi_res = matches!(audio.maximum_bit_depth, Some(depth) if depth >= 24); + let tier = if hi_res { "Hi-Res" } else { "CD" }; + let depth = audio + .maximum_bit_depth + .unwrap_or(if hi_res { 24 } else { 16 }); + let rate = audio + .maximum_sampling_rate + .unwrap_or(if hi_res { 96.0 } else { 44.1 }); + format!("{tier}: {depth}-bit / {} kHz", format_rate(rate)) +} + +/// Format a kHz sample rate without a trailing `.0` (96.0 -> "96", +/// 44.1 -> "44.1"). +fn format_rate(rate: f64) -> String { + if (rate.fract()).abs() < f64::EPSILON { + format!("{}", rate as i64) + } else { + format!("{rate}") + } +} + /// Convert worker-thread home data into Slint models and push them onto /// the `HomeState` global. Must run on the Slint event loop. pub fn apply_home(window: &AppWindow, data: HomeData) { @@ -208,6 +240,7 @@ pub fn apply_home(window: &AppWindow, data: HomeData) { genre: card.genre.into(), year: card.year.into(), quality_tier: card.quality_tier.into(), + quality_label: card.quality_label.into(), ribbon: card.ribbon.into(), ribbon_kind: card.ribbon_kind.into(), artwork_url: card.artwork_url.into(), diff --git a/crates/qbz-slint/ui/discover/AlbumCard.slint b/crates/qbz-slint/ui/discover/AlbumCard.slint index f506a3863..5a14cafe3 100644 --- a/crates/qbz-slint/ui/discover/AlbumCard.slint +++ b/crates/qbz-slint/ui/discover/AlbumCard.slint @@ -12,6 +12,7 @@ import { Radius } from "../foundation/radius.slint"; import { Spacing } from "../foundation/spacing.slint"; import { AlbumCardItem } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QualityBadge } from "../primitives/QualityBadge.slint"; // Circular hover-overlay action button. component OverlayButton inherits Rectangle { @@ -75,26 +76,29 @@ export component AlbumCard inherits Rectangle { animate opacity { duration: 150ms; } } - // Hover meta — genre + year, top-left. + // Hover meta — genre + year, top-left. The year sits one + // single line below the genre. VerticalLayout { x: 12px; y: 12px; - spacing: 1px; + spacing: 2px; opacity: hover.has-hover ? 1.0 : 0.0; animate opacity { duration: 150ms; } - Text { + if root.album.genre != "": Text { text: root.album.genre; - visible: root.album.genre != ""; + height: 20px; color: #ffffffeb; - font-size: 14px; - font-weight: Typography.medium; + font-size: 15px; + font-weight: Typography.bold; + vertical-alignment: center; } - Text { + if root.album.year != "": Text { text: root.album.year; - visible: root.album.year != ""; + height: 17px; color: #ffffffcc; font-size: 13px; + vertical-alignment: center; } } @@ -125,16 +129,21 @@ export component AlbumCard inherits Rectangle { } } - // Award ribbon — always visible, above the overlay. + // Award ribbon — content-width only (it is a ribbon, not a + // full-width band), capped so long labels still elide. if root.album.ribbon != "": Rectangle { x: 0px; y: parent.height - self.height - 8px; height: 20px; + // Hug the label; cap below the card width so a runaway + // label elides instead of spanning the whole card. + width: Math.min(ribbon-row.preferred-width, 200px); background: root.album.ribbon-kind == "press" ? #d49511 : #000000e0; border-top-right-radius: 3px; border-bottom-right-radius: 3px; + clip: true; // Left accent stripe (not on the gold press ribbon). Rectangle { @@ -144,7 +153,7 @@ export component AlbumCard inherits Rectangle { ? #8b5cf6 : #eab308; } - HorizontalLayout { + ribbon-row := HorizontalLayout { padding-left: 10px; padding-right: 10px; Text { @@ -202,31 +211,12 @@ export component AlbumCard inherits Rectangle { } } - // Icon-only quality badge. + // Icon-only quality badge with an exact-quality hover tooltip. 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; - } + QualityBadge { + tier: root.album.quality-tier; + label: root.album.quality-label; } } } diff --git a/crates/qbz-slint/ui/primitives/QualityBadge.slint b/crates/qbz-slint/ui/primitives/QualityBadge.slint new file mode 100644 index 000000000..b8e9a169f --- /dev/null +++ b/crates/qbz-slint/ui/primitives/QualityBadge.slint @@ -0,0 +1,78 @@ +// Icon-only quality badge with a hover tooltip. +// +// The square badge is intentionally compact (it buys layout space); the +// exact quality — "Hi-Res: 24-bit / 96 kHz" — is revealed on hover, the +// same string the Tauri QualityBadge surfaces. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; + +export component QualityBadge inherits Rectangle { + // "hires" | "cd" | "" — empty hides the badge entirely. + in property tier; + // Exact quality string for the hover tooltip. + in property label; + + visible: root.tier != ""; + width: root.tier == "hires" ? 42px : 30px; + height: 30px; + + if root.tier == "hires": Image { + source: @image-url("../assets/hi-res.svg"); + width: 42px; + height: 28px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + image-fit: contain; + } + if root.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; + } + } + + // Hover tooltip — floats just above the badge. + tip := Rectangle { + x: (parent.width - self.width) / 2; + y: -self.height - 6px; + width: tip-row.preferred-width; + height: 24px; + background: Theme.surface-elevated; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 14px; + drop-shadow-color: Theme.card-shadow; + drop-shadow-offset-y: 3px; + opacity: ta.has-hover && root.label != "" ? 1.0 : 0.0; + animate opacity { duration: 120ms; } + + tip-row := HorizontalLayout { + padding-left: 9px; + padding-right: 9px; + Text { + text: root.label; + color: Theme.text-secondary; + font-size: 11px; + font-weight: Typography.medium; + vertical-alignment: center; + } + } + } + + ta := TouchArea { + mouse-cursor: default; + } +} diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 30f528333..d2aaac2ca 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -10,6 +10,7 @@ export struct AlbumCardItem { genre: string, // shown in the hover overlay year: string, // shown in the hover overlay quality-tier: string, // "hires" | "cd" | "" — drives the badge icon + quality-label: string, // "Hi-Res: 24-bit / 96 kHz" — badge hover tooltip ribbon: string, ribbon-kind: string, // "press" | "qobuzissime" | "albumOfTheWeek" | "" artwork-url: string,