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:
6 files changed
+138
-33
No files matched your search
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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,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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user