feat(slint): Plex quality hydration on album-open + consistent quality badges (port slice 6)

Hydration:
- qbz-plex: additive plex_hydrate_album_quality(base, token, rating_keys) — per
  key plex_get_track_metadata (batch 5, 5s/call timeout, skip failures) then the
  COALESCE write-back plex_cache_update_track_quality; awaited before re-read.
- qbz-player/qbz-core: patch_plex_quality / patch_plex_queue_quality patch the
  in-memory queue in place by rating_key (for the now-playing stamp).
- qbz-slint: album-open trigger (gated on DB-NULL, not a value heuristic) then
  refresh of all badge surfaces — album-card grid (re-read aggregate + patch
  every model + the raw filter source), album-detail header + rows, now-playing
  stamp. Re-sync stays routed through plex_cache_save_tracks (carry-over).

Quality badge consistency (the four surfaces must agree — and pre-hydration
show the filetype, not nothing):
- One shared classifier crate::quality::badge(format, bit_depth, sample_rate) ->
  (tier, detail, tooltip); the album card, album-detail header and track rows
  all route through it (kills the per-surface duplication that produced
  "96 kHz" vs "96000 kHz"). sample_rate normalized to kHz via a >=1000 guard so
  Qobuz's already-kHz values are never re-divided (no "0.041 kHz").
- New "lossless" tier: an un-hydrated lossless file (FLAC/WAV/ALAC… with unknown
  bit depth) shows a generic filetype badge ("FLAC") instead of an empty badge,
  rendered in QualityBadge (compact chip) + QualityBadgeFull; upgrades to
  "24-bit / 96 kHz" after hydration. Local/Qobuz/mp3 unchanged.

cc
This commit is contained in:
vicrodh committed 2026-06-08 10:27:28 -06:00
1 parent a680da5ad8
commit 5939c090ca
10 files changed
+599 -38

No files matched your search

+327 -32
View File
@@ -113,16 +113,12 @@ pub fn albums_gen_current(gen: u64) -> bool {
pub fn map_local_album(a: qbz_library::LocalAlbum) -> crate::album_map::AlbumCard {
// Format-first classification (mirrors Tauri): a lossy format (MP3) gets
// the dedicated MP3 badge tier, never CD.
let tier = if a.format.to_string().eq_ignore_ascii_case("mp3") {
"mp3"
} else {
match a.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
}
};
let (quality_detail, quality_label) = local_quality(a.bit_depth, a.sample_rate);
// One shared classifier (see crate::quality::badge) so the card, the
// album-detail header and the track rows can never disagree — and so an
// un-hydrated lossless Plex album shows a generic "FLAC" badge instead of
// nothing. `a.sample_rate` is Hz; `badge` normalizes it to kHz (guarded).
let (tier, quality_detail, quality_label) =
crate::quality::badge(&a.format.to_string(), a.bit_depth, Some(a.sample_rate));
let year = a.year.map(|y| y.to_string()).unwrap_or_default();
let track_count = if a.track_count > 0 {
a.track_count.to_string()
@@ -708,16 +704,11 @@ fn fmt_duration(secs: u64) -> String {
/// non-Send `slint::Image`). Local tracks aren't Qobuz-linkable, so the
/// artist/album link ids are empty (the row renders them as plain text).
fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem {
// Format-first classification (mirrors Tauri): MP3/lossy → MP3 tier.
let tier = if t.format.to_string().eq_ignore_ascii_case("mp3") {
"mp3"
} else {
match t.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
}
};
// One shared classifier (crate::quality::badge) — same source the album
// card + header use, so all surfaces agree; an un-hydrated lossless track
// shows a generic "FLAC" detail. `t.sample_rate` is Hz; badge normalizes.
let (tier, quality_detail, _) =
crate::quality::badge(&t.format.to_string(), t.bit_depth, Some(t.sample_rate));
TrackItem {
id: t.id.to_string().into(),
number: t.track_number.map(|n| n.to_string()).unwrap_or_default().into(),
@@ -726,12 +717,7 @@ fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem {
album: t.album.into(),
duration: fmt_duration(t.duration_secs).into(),
quality_tier: tier.into(),
quality_detail: if tier == "mp3" {
String::new()
} else {
crate::quality::detail(t.bit_depth, Some(t.sample_rate))
}
.into(),
quality_detail: quality_detail.into(),
explicit: false,
selected: false,
artwork_url: t.artwork_path.unwrap_or_default().into(),
@@ -1144,6 +1130,7 @@ pub fn open_local_album(
s.set_cover(slint::Image::default());
});
let gk = group_key.clone();
let hydrate_handle = handle.clone();
handle.spawn(async move {
let tracks = tokio::task::spawn_blocking(move || {
let mut t = fetch_album_tracks_blocking(&gk);
@@ -1210,6 +1197,16 @@ pub fn open_local_album(
s.set_loading(false);
s.set_cover_url(album_cover.clone().into());
apply_album_version(&w, 0);
// Plex quality hydration (slice 6): if this is a Plex album, the
// cached rows may carry NULL/incomplete quality (the bulk `/all`
// list omits bitDepth/samplingRate). The cached badge is already
// painted above (KEEP it — a partial badge beats nothing); now
// hydrate the real per-track quality in the background and fan the
// result out to every badge surface so they agree.
let gk_for_hydrate = w.global::<crate::LocalAlbumState>().get_id().to_string();
if gk_for_hydrate.starts_with("plex:") {
spawn_plex_quality_hydration(w.as_weak(), hydrate_handle.clone(), gk_for_hydrate);
}
// Decode the album cover once (stable across version switches).
// Plex-aware: a Plex album's cover is a raw `/library/...` thumb
// path that needs the token (PlexThumb), not a local file read.
@@ -1261,12 +1258,11 @@ pub fn apply_album_version(window: &AppWindow, index: i32) {
let info_line = format!("{} tracks · {}", tracks.len(), fmt_album_duration(total_secs));
let (tier, detail) = match tracks.iter().max_by_key(|t| t.bit_depth.unwrap_or(0)) {
Some(t) => {
let tier = match t.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
};
(tier.to_string(), local_quality(t.bit_depth, t.sample_rate).0)
// Same shared classifier as the card + rows (badge), so the header
// matches them; un-hydrated lossless → generic "FLAC".
let (tier, detail, _) =
crate::quality::badge(&t.format.to_string(), t.bit_depth, Some(t.sample_rate));
(tier.to_string(), detail)
}
None => (String::new(), String::new()),
};
@@ -1288,6 +1284,305 @@ pub fn apply_album_version(window: &AppWindow, index: i32) {
s.set_version_index(index);
}
// ======================= Plex quality hydration (slice 6) =================
// Album-open trigger: a Plex album's cached rows often carry NULL/incomplete
// quality (the bulk `/library/sections/.../all` list omits bitDepth and
// samplingRate). We fetch the real per-track quality on open, AWAIT the SQLite
// write-back (qbz_plex persists via COALESCE so a NULL incoming value never
// erases an existing one), then fan the result out to every badge surface so
// they agree:
// (1) album-CARD grid badge — re-read the album aggregate, patch the 3 album
// models + the raw LOCAL_ALBUMS filter source.
// (2) album-DETAIL header + per-row + audio-specs — rebuild album_versions()
// from the hydrated cache, re-run apply_album_version.
// (3) flat Tracks-tab rows — patch matching rows in `tracks`/`tracks-visible`
// + the tracks_current() selection cache by id.
// (4) now-playing STAMP — if a hydrated track is the current queue track,
// patch its frozen snapshot and re-push.
//
// "Needs hydration" is the DB-NULL definition (bit_depth IS NULL OR sample_rate
// is 0) — NOT a value heuristic; a genuine 16/44.1 FLAC is written once and
// never re-probed (fixes the Tauri isLikelyFallbackPlexQuality bug).
/// Spawn the album-open Plex quality hydration. Reads the just-loaded version
/// tracks to find which Plex rating_keys still need hydration, hydrates them
/// (awaiting the persist), then refreshes all four badge surfaces.
fn spawn_plex_quality_hydration(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
group_key: String,
) {
// Collect rating_keys whose cached quality is still incomplete. The Plex
// LocalTrack carries the rating_key in `file_path` and the cached quality in
// `bit_depth` / `sample_rate` (0.0 == unknown).
let needing: Vec<String> = {
let versions = album_versions();
let mut keys: Vec<String> = versions
.iter()
.flat_map(|(_, tracks)| tracks.iter())
.filter(|t| t.source.as_deref() == Some("plex"))
.filter(|t| t.bit_depth.is_none() || t.sample_rate <= 0.0)
.map(|t| t.file_path.clone())
.collect();
keys.sort();
keys.dedup();
keys
};
if needing.is_empty() {
return;
}
let plex = crate::plex_settings::get();
if plex.base_url.is_empty() || plex.token.is_empty() {
return;
}
handle.spawn(async move {
let updates = match qbz_plex::plex_hydrate_album_quality(
plex.base_url.clone(),
plex.token.clone(),
needing,
)
.await
{
Ok(u) if !u.is_empty() => u,
_ => return, // nothing fetched/persisted → leave the cached badge as-is
};
// Surface 4 (now-playing): if any hydrated track is the current queue
// track, patch its frozen snapshot and re-push the stamp. Fire-and-forget
// through the global queue controller.
let queue_updates: Vec<(String, Option<u32>, Option<f64>)> = updates
.iter()
.map(|u| {
let khz = u.sampling_rate_hz.map(|hz| hz as f64 / 1000.0);
(u.rating_key.clone(), u.bit_depth, khz)
})
.collect();
crate::playback::apply_plex_quality_to_queue(queue_updates);
// Re-read the now-hydrated album tracks + the album aggregate off the UI
// thread (both are blocking SQLite reads).
let gk = group_key.clone();
let plex_path = plex_cache_db_path();
let reread = tokio::task::spawn_blocking(move || {
let tracks = qbz_plex::plex_cache_get_album_tracks(gk.clone())
.unwrap_or_default()
.into_iter()
.map(map_plex_cached_to_local_track)
.collect::<Vec<_>>();
// The album aggregate (MAX over tracks) is a pure runtime query; the
// refreshed Plex track quality flows into it automatically.
let album = plex_path.as_deref().and_then(|p| {
crate::library_db::with_db(|db| {
let page = db.get_albums_metadata_page(
0,
ALBUMS_FULL_LOAD_LIMIT,
None,
"artist",
"asc",
false,
true,
Some(p),
)?;
Ok(page.albums.into_iter().find(|a| a.id == gk))
})
.flatten()
});
(tracks, album)
})
.await
.unwrap_or_else(|_| (Vec::new(), None));
let (hydrated_tracks, hydrated_album) = reread;
if hydrated_tracks.is_empty() {
return;
}
let updated_rows: Vec<(String, Option<u32>, u32)> = updates
.iter()
.map(|u| (u.rating_key.clone(), u.bit_depth, u.sampling_rate_hz.unwrap_or(0)))
.collect();
let _ = weak.upgrade_in_event_loop(move |w| {
refresh_open_album_after_hydration(&w, &group_key, hydrated_tracks);
if let Some(album) = hydrated_album {
refresh_album_card_quality(&w, &album);
}
refresh_track_rows_quality(&w, &updated_rows);
});
});
}
/// Surface 2 — rebuild the open album's version tracks from the hydrated cache
/// rows and re-run `apply_album_version` so the header MAX badge, the audio
/// specs, and the per-row badges all recompute from the real quality. No-op if
/// the open album changed while we were hydrating.
fn refresh_open_album_after_hydration(
window: &AppWindow,
group_key: &str,
hydrated_tracks: Vec<qbz_library::LocalTrack>,
) {
let s = window.global::<crate::LocalAlbumState>();
if s.get_id().to_string() != group_key {
return; // user navigated away; the album-detail surface is no longer this album
}
// Re-split into versions by source dir, mirroring open_local_album.
let mut groups: std::collections::HashMap<String, Vec<qbz_library::LocalTrack>> =
std::collections::HashMap::new();
let mut order: Vec<String> = Vec::new();
for t in hydrated_tracks {
let key = t.album_group_key.clone();
if !groups.contains_key(&key) {
order.push(key.clone());
}
groups.entry(key).or_default().push(t);
}
let mut versions: Vec<(String, Vec<qbz_library::LocalTrack>)> = order
.into_iter()
.filter_map(|k| {
groups.remove(&k).map(|mut v| {
v.sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(0)));
(k, v)
})
})
.collect();
versions.sort_by(|a, b| {
let qa = a.1.iter().map(version_rank).max().unwrap_or((0, 0));
let qb = b.1.iter().map(version_rank).max().unwrap_or((0, 0));
qb.cmp(&qa)
});
let index = s.get_version_index().max(0);
*album_versions() = versions;
apply_album_version(window, index);
}
/// Surface 1 — patch the album-CARD grid badge for one album across all three
/// rendered album models (`albums`, `albums-visible`, each `albums-grouped`
/// section) AND the raw `LOCAL_ALBUMS` filter source, recomputing tier/label/
/// detail from the freshly aggregated quality. Mirrors `set_local_album_artwork`
/// (refresh-one-row across every collection — the Tauri bug was patching only
/// one collection, leaving the grid badge stale).
fn refresh_album_card_quality(window: &AppWindow, album: &qbz_library::LocalAlbum) {
let card = map_local_album(album.clone());
let tier: slint::SharedString = card.quality_tier.clone().into();
let label: slint::SharedString = card.quality_label.clone().into();
let detail: slint::SharedString = card.quality_detail.clone().into();
let id = album.id.clone();
let s = window.global::<LocalLibraryState>();
let patch_in = |m: &ModelRc<AlbumCardItem>| {
for i in 0..m.row_count() {
if let Some(mut it) = m.row_data(i) {
if it.id.as_str() == id {
it.quality_tier = tier.clone();
it.quality_label = label.clone();
it.quality_detail = detail.clone();
m.set_row_data(i, it);
break;
}
}
}
};
patch_in(&s.get_albums());
patch_in(&s.get_albums_visible());
let grouped = s.get_albums_grouped();
for gi in 0..grouped.row_count() {
if let Some(sec) = grouped.row_data(gi) {
patch_in(&sec.albums);
}
}
// Keep the raw filter source consistent so a re-derive (search/filter/sort)
// doesn't snap the badge back to the stale MAX.
{
let mut cache = local_albums();
if let Some(a) = cache.iter_mut().find(|a| a.id == id) {
a.bit_depth = album.bit_depth;
a.sample_rate = album.sample_rate;
a.format = album.format.clone();
}
}
}
/// Surface 3 — patch the flat Tracks-tab row badges (and the `tracks_current()`
/// selection cache) for the hydrated Plex tracks, matched by their namespaced
/// row id. Patches in place (no model rebuild) to avoid re-grouping the 16K-row
/// set — the same reason Tauri used an override map instead of a `tracks`
/// reassignment.
fn refresh_track_rows_quality(window: &AppWindow, updates: &[(String, Option<u32>, u32)]) {
if updates.is_empty() {
return;
}
// Map rating_key -> namespaced TrackItem id string (matches map_plex_cached_to_local_track).
let by_id: std::collections::HashMap<String, (Option<u32>, u32)> = {
let cache = tracks_current();
let mut m = std::collections::HashMap::new();
for t in cache.iter() {
if t.source.as_deref() != Some("plex") {
continue;
}
if let Some((_, bd, sr)) = updates.iter().find(|(rk, _, _)| *rk == t.file_path) {
m.insert(t.id.to_string(), (*bd, *sr));
}
}
m
};
if by_id.is_empty() {
return;
}
// Patch the in-memory selection cache so a later derive_tracks keeps quality.
{
let mut cache = tracks_current();
for t in cache.iter_mut() {
if t.source.as_deref() != Some("plex") {
continue;
}
if let Some((_, bd, sr)) = updates.iter().find(|(rk, _, _)| *rk == t.file_path) {
if bd.is_some() {
t.bit_depth = *bd;
}
if *sr > 0 {
t.sample_rate = *sr as f64;
}
}
}
}
let recompute = |bd: Option<u32>, sr: u32| -> (slint::SharedString, slint::SharedString) {
let tier = match bd {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
};
let detail = if tier.is_empty() {
String::new()
} else {
crate::quality::detail(bd, Some(sr as f64))
};
(tier.into(), detail.into())
};
let s = window.global::<LocalLibraryState>();
let patch_in = |m: &ModelRc<TrackItem>| {
for i in 0..m.row_count() {
if let Some(mut it) = m.row_data(i) {
if it.source.as_str() != "plex" {
continue;
}
if let Some((bd, sr)) = by_id.get(it.id.as_str()) {
let (tier, detail) = recompute(*bd, *sr);
it.quality_tier = tier;
it.quality_detail = detail;
m.set_row_data(i, it);
}
}
}
};
patch_in(&s.get_tracks());
patch_in(&s.get_tracks_visible());
}
/// The source directory of version `index` (for the tag editor — a real dir).
pub fn album_version_dir(index: i32) -> Option<String> {
album_versions().get(index as usize).map(|(dir, _)| dir.clone())
+24
View File
@@ -48,6 +48,30 @@ pub(crate) fn refresh_sidebar(with_favorites: bool) {
}
}
/// Apply Plex quality updates to any queued track (by `rating_key`) and, if
/// the CURRENTLY-playing track was among them, re-push the now-playing stamp so
/// the player-bar quality badge agrees with the freshly-hydrated value. Reaches
/// the runtime through the global queue controller (the hydration path runs in
/// a detail-view context that does not carry the runtime). No-op before the
/// controller is registered or when nothing matches. `updates` is
/// `(rating_key, bit_depth, sample_rate_khz)`.
pub fn apply_plex_quality_to_queue(updates: Vec<(String, Option<u32>, Option<f64>)>) {
if updates.is_empty() {
return;
}
let Some(controller) = QUEUE_CONTROLLER.get() else {
return;
};
let runtime = controller.runtime().clone();
let weak = controller.weak().clone();
controller.handle().spawn(async move {
let current_patched = runtime.core().patch_plex_queue_quality(&updates).await;
if current_patched {
refresh_now_playing_meta(&runtime, &weak).await;
}
});
}
/// Shared post-track-change step: update the now-playing card, record the
/// play in the recently-played store, and start audio for `track_id`.
/// Used by the queue controller's play paths.
+62
View File
@@ -14,6 +14,11 @@ pub fn detail(bit_depth: Option<u32>, sample_rate: Option<f64>) -> String {
let hi_res = matches!(bit_depth, Some(depth) if depth >= 24);
let depth = bit_depth.unwrap_or(if hi_res { 24 } else { 16 });
let rate = sample_rate.unwrap_or(if hi_res { 96.0 } else { 44.1 });
// Accept Hz or kHz: Qobuz passes kHz (e.g. 96.0), while local/Plex track
// rows pass raw Hz (e.g. 96000). Normalize so every surface — album-card,
// album-detail header, track row, now-playing stamp — shows "96 kHz", not
// a mix of "96 kHz" and "96000 kHz".
let rate = if rate >= 1000.0 { rate / 1000.0 } else { rate };
let rate = if rate.fract().abs() < f64::EPSILON {
format!("{}", rate as i64)
} else {
@@ -21,3 +26,60 @@ pub fn detail(bit_depth: Option<u32>, sample_rate: Option<f64>) -> String {
};
format!("{depth}-bit / {rate} kHz")
}
/// Lossless container/codec formats — the file IS lossless even when its exact
/// bit depth / sample rate isn't known yet (e.g. an un-hydrated Plex track).
pub fn is_lossless_format(format: &str) -> bool {
matches!(
format.trim().to_ascii_lowercase().as_str(),
"flac" | "wav" | "wave" | "aiff" | "aif" | "alac" | "ape" | "dsd" | "dsf" | "dff"
)
}
/// Badge tier from a container/codec + (possibly-unknown) max bit depth:
/// - `"mp3"` lossy
/// - `"hires"` >= 24-bit
/// - `"cd"` known bit depth < 24
/// - `"lossless"` bit depth UNKNOWN but the container IS lossless (un-hydrated
/// Plex FLAC etc.) — show the filetype; better than no badge at all
/// - `""` unknown format AND unknown bit depth → badge hidden
pub fn tier(format: &str, bit_depth: Option<u32>) -> &'static str {
if format.trim().eq_ignore_ascii_case("mp3") {
return "mp3";
}
match bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None if is_lossless_format(format) => "lossless",
None => "",
}
}
/// The ONE source of truth for a quality badge: `(tier, detail, tooltip)`.
/// Every local/Plex surface — album card, album-detail header, track row,
/// now-playing stamp — goes through this so they can never disagree (the
/// earlier per-surface duplication is exactly what produced "96 kHz" on one
/// surface and "96000 kHz" on another). `sample_rate` accepts Hz OR kHz: the
/// shared [`detail`] normalizes via a `>= 1000` guard, so Qobuz's already-kHz
/// values are never re-divided (no "0.041 kHz") and Hz values become kHz once.
/// For an un-hydrated lossless track the detail is the bare filetype (`"FLAC"`);
/// after hydration it becomes the usual `"24-bit / 96 kHz"`, matching Qobuz.
pub fn badge(
format: &str,
bit_depth: Option<u32>,
sample_rate: Option<f64>,
) -> (&'static str, String, String) {
let t = tier(format, bit_depth);
match t {
"" | "mp3" => (t, String::new(), String::new()),
"lossless" => {
let f = format.trim().to_uppercase();
(t, f.clone(), format!("Lossless: {f}"))
}
_ => {
let d = detail(bit_depth, sample_rate);
let prefix = if t == "hires" { "Hi-Res" } else { "CD" };
(t, d.clone(), format!("{prefix}: {d}"))
}
}
}
+13
View File
@@ -154,6 +154,19 @@ impl QueueController {
}
}
/// Accessors so background flows (e.g. Plex quality hydration) reachable
/// only through the global controller can re-push now-playing without
/// threading the runtime through every detail-view entry point.
pub fn runtime(&self) -> &Runtime {
&self.runtime
}
pub fn weak(&self) -> &slint::Weak<AppWindow> {
&self.weak
}
pub fn handle(&self) -> &tokio::runtime::Handle {
&self.handle
}
/// Pull a fresh full queue snapshot, re-apply the search filter and
/// current page, and push the result onto `QueueState`. Spawns on the
/// tokio runtime; safe to call from any thread.
@@ -373,6 +373,7 @@ export component AlbumCard inherits Rectangle {
QualityBadge {
tier: root.album.quality-tier;
label: root.album.quality-label;
detail: root.album.quality-detail;
}
}
}
@@ -10,13 +10,16 @@ import { Radius } from "../foundation/radius.slint";
import { QbzIcon } from "QbzIcon.slint";
export component QualityBadge inherits Rectangle {
// "hires" | "cd" | "mp3" | "" — empty hides the badge entirely.
// "hires" | "cd" | "mp3" | "lossless" | "" — empty hides the badge entirely.
in property <string> tier;
// Exact quality string for the hover tooltip.
in property <string> label;
// For the "lossless" tier (un-hydrated filetype, e.g. an un-hydrated Plex
// FLAC): the bare filetype to print, e.g. "FLAC".
in property <string> detail;
visible: root.tier != "";
width: root.tier == "hires" ? 42px : 30px;
width: root.tier == "hires" ? 42px : (root.tier == "lossless" ? 36px : 30px);
height: 30px;
if root.tier == "hires": Image {
@@ -62,6 +65,25 @@ export component QualityBadge inherits Rectangle {
tint: #b0b0b0;
}
}
// Un-hydrated lossless: the bit-depth/rate isn't known yet, but the
// filetype is — show it as a neutral text chip (e.g. "FLAC") rather than
// disappearing. Upgrades to the cd/hires badge once hydrated.
if root.tier == "lossless": Rectangle {
width: 36px;
height: 30px;
border-radius: 3px;
background: Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
Text {
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
text: root.detail;
color: #b0b0b0;
font-size: 9px;
font-weight: Typography.medium;
}
}
// Hover tooltip — floats just above the badge, right-anchored so it
// extends leftward into the card and is never clipped by the next card.
@@ -30,7 +30,9 @@ export component QualityBadgeFull inherits Rectangle {
property <string> tier-label: root.tier == "hires"
? "HI-RES"
: (root.tier == "mp3" ? "MP3" : "CD");
: (root.tier == "mp3"
? "MP3"
: (root.tier == "lossless" ? "LOSSLESS" : "CD"));
visible: root.tier != "";
height: (root.compact ? 29px : 36px) * root.scale-factor;
@@ -70,6 +72,14 @@ export component QualityBadgeFull inherits Rectangle {
height: (root.compact ? 13px : 16px) * root.scale-factor;
tint: #b0b0b0;
}
// Un-hydrated lossless (filetype known, bit-depth/rate not yet):
// reuse the neutral disc mark; the detail line carries "FLAC".
if root.tier == "lossless": QbzIcon {
source: @image-url("../assets/cd.svg");
width: (root.compact ? 13px : 16px) * root.scale-factor;
height: (root.compact ? 13px : 16px) * root.scale-factor;
tint: #b0b0b0;
}
}
VerticalLayout {