fix(slint): album card overlay, award ribbon, and quality tooltip

Three corrections to the Discover album card:

- Overlay meta: the genre is one point larger and bold, and the year
  now sits a single line below it (conditional `if` rows with explicit
  heights, instead of `visible` toggles that left a phantom gap)
- Award ribbon: width hugs its label (capped at 200px, below the card
  width) instead of spanning the full card — it reads as a ribbon again
- Quality badge: extracted to a QualityBadge primitive that reveals
  the exact quality ("Hi-Res: 24-bit / 96 kHz") on hover, mirroring the
  Tauri QualityBadge; home.rs derives the label from the discover
  audio info

cc
This commit is contained in:
vicrodh committed 2026-05-18 17:22:43 -06:00
1 parent 54b3a7350d
commit 1fd3cfcb2b
6 files changed
+138 -33

No files matched your search

+1
View File
@@ -5454,6 +5454,7 @@ dependencies = [
"log", "log",
"open", "open",
"qbz-app", "qbz-app",
"qbz-audio",
"qbz-cache", "qbz-cache",
"qbz-core", "qbz-core",
"qbz-credentials", "qbz-credentials",
+2
View File
@@ -120,6 +120,7 @@ fn map_release(release: PageArtistRelease) -> CardData {
genre: release.genre.map(|g| g.name).unwrap_or_default(), genre: release.genre.map(|g| g.name).unwrap_or_default(),
year: String::new(), year: String::new(),
quality_tier: String::new(), quality_tier: String::new(),
quality_label: String::new(),
ribbon: String::new(), ribbon: String::new(),
ribbon_kind: String::new(), ribbon_kind: String::new(),
artwork_url, artwork_url,
@@ -182,6 +183,7 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) {
genre: card.genre.into(), genre: card.genre.into(),
year: card.year.into(), year: card.year.into(),
quality_tier: card.quality_tier.into(), quality_tier: card.quality_tier.into(),
quality_label: slint::SharedString::new(),
ribbon: card.ribbon.into(), ribbon: card.ribbon.into(),
ribbon_kind: card.ribbon_kind.into(), ribbon_kind: card.ribbon_kind.into(),
artwork_url: card.artwork_url.into(), artwork_url: card.artwork_url.into(),
+33
View File
@@ -33,6 +33,8 @@ pub struct CardData {
pub year: String, pub year: String,
/// "hires" | "cd" | "" — drives the icon-only quality badge. /// "hires" | "cd" | "" — drives the icon-only quality badge.
pub quality_tier: String, 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: String,
pub ribbon_kind: String, pub ribbon_kind: String,
pub artwork_url: String, pub artwork_url: String,
@@ -119,6 +121,7 @@ fn map_album(album: DiscoverAlbum) -> CardData {
.to_string(); .to_string();
let (ribbon, ribbon_kind) = pick_ribbon(album.awards.as_deref()); let (ribbon, ribbon_kind) = pick_ribbon(album.awards.as_deref());
let quality_tier = quality_tier(album.audio_info.as_ref()).to_string(); 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 let artwork_url = album
.image .image
.large .large
@@ -132,6 +135,7 @@ fn map_album(album: DiscoverAlbum) -> CardData {
genre, genre,
year, year,
quality_tier, quality_tier,
quality_label,
ribbon, ribbon,
ribbon_kind, ribbon_kind,
artwork_url, 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 /// Convert worker-thread home data into Slint models and push them onto
/// the `HomeState` global. Must run on the Slint event loop. /// the `HomeState` global. Must run on the Slint event loop.
pub fn apply_home(window: &AppWindow, data: HomeData) { pub fn apply_home(window: &AppWindow, data: HomeData) {
@@ -208,6 +240,7 @@ pub fn apply_home(window: &AppWindow, data: HomeData) {
genre: card.genre.into(), genre: card.genre.into(),
year: card.year.into(), year: card.year.into(),
quality_tier: card.quality_tier.into(), quality_tier: card.quality_tier.into(),
quality_label: card.quality_label.into(),
ribbon: card.ribbon.into(), ribbon: card.ribbon.into(),
ribbon_kind: card.ribbon_kind.into(), ribbon_kind: card.ribbon_kind.into(),
artwork_url: card.artwork_url.into(), artwork_url: card.artwork_url.into(),
+23 -33
View File
@@ -12,6 +12,7 @@ import { Radius } from "../foundation/radius.slint";
import { Spacing } from "../foundation/spacing.slint"; import { Spacing } from "../foundation/spacing.slint";
import { AlbumCardItem } from "../state.slint"; import { AlbumCardItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadge } from "../primitives/QualityBadge.slint";
// Circular hover-overlay action button. // Circular hover-overlay action button.
component OverlayButton inherits Rectangle { component OverlayButton inherits Rectangle {
@@ -75,26 +76,29 @@ export component AlbumCard inherits Rectangle {
animate opacity { duration: 150ms; } 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 { VerticalLayout {
x: 12px; x: 12px;
y: 12px; y: 12px;
spacing: 1px; spacing: 2px;
opacity: hover.has-hover ? 1.0 : 0.0; opacity: hover.has-hover ? 1.0 : 0.0;
animate opacity { duration: 150ms; } animate opacity { duration: 150ms; }
Text { if root.album.genre != "": Text {
text: root.album.genre; text: root.album.genre;
visible: root.album.genre != ""; height: 20px;
color: #ffffffeb; color: #ffffffeb;
font-size: 14px; font-size: 15px;
font-weight: Typography.medium; font-weight: Typography.bold;
vertical-alignment: center;
} }
Text { if root.album.year != "": Text {
text: root.album.year; text: root.album.year;
visible: root.album.year != ""; height: 17px;
color: #ffffffcc; color: #ffffffcc;
font-size: 13px; 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 { if root.album.ribbon != "": Rectangle {
x: 0px; x: 0px;
y: parent.height - self.height - 8px; y: parent.height - self.height - 8px;
height: 20px; 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" background: root.album.ribbon-kind == "press"
? #d49511 ? #d49511
: #000000e0; : #000000e0;
border-top-right-radius: 3px; border-top-right-radius: 3px;
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
clip: true;
// Left accent stripe (not on the gold press ribbon). // Left accent stripe (not on the gold press ribbon).
Rectangle { Rectangle {
@@ -144,7 +153,7 @@ export component AlbumCard inherits Rectangle {
? #8b5cf6 ? #8b5cf6
: #eab308; : #eab308;
} }
HorizontalLayout { ribbon-row := HorizontalLayout {
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
Text { 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 { VerticalLayout {
alignment: center; alignment: center;
QualityBadge {
if root.album.quality-tier == "hires": Image { tier: root.album.quality-tier;
source: @image-url("../assets/hi-res.svg"); label: root.album.quality-label;
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;
}
} }
} }
} }
@@ -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 <string> tier;
// Exact quality string for the hover tooltip.
in property <string> 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;
}
}
+1
View File
@@ -10,6 +10,7 @@ export struct AlbumCardItem {
genre: string, // shown in the hover overlay genre: string, // shown in the hover overlay
year: string, // shown in the hover overlay year: string, // shown in the hover overlay
quality-tier: string, // "hires" | "cd" | "" — drives the badge icon quality-tier: string, // "hires" | "cd" | "" — drives the badge icon
quality-label: string, // "Hi-Res: 24-bit / 96 kHz" — badge hover tooltip
ribbon: string, ribbon: string,
ribbon-kind: string, // "press" | "qobuzissime" | "albumOfTheWeek" | "" ribbon-kind: string, // "press" | "qobuzissime" | "albumOfTheWeek" | ""
artwork-url: string, artwork-url: string,