diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 87df7858a..4edca5036 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -625,6 +625,27 @@ impl QbzCore { result } + /// Patch the cached quality of any queued Plex track whose `rating_key` + /// matches one of `updates` (`(rating_key, bit_depth, sample_rate_khz)`). + /// Frontend-agnostic hook for the Plex quality-hydration path: a track + /// hydrated while it is already enqueued/playing carries a frozen quality + /// snapshot, so this upgrades it in place. Returns true if the CURRENT + /// track was patched — the caller then re-pushes the now-playing stamp. + pub async fn patch_plex_queue_quality( + &self, + updates: &[(String, Option, Option)], + ) -> bool { + let queue = self.queue.write().await; + let current_patched = queue.patch_plex_quality(updates); + if current_patched { + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + } + current_patched + } + // ==================== Search & Catalog ==================== /// Search for albums diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs index ca7ac0395..15c34d9aa 100644 --- a/crates/qbz-player/src/queue.rs +++ b/crates/qbz-player/src/queue.rs @@ -73,6 +73,46 @@ impl QueueManager { .and_then(|idx| state.tracks.get(idx).cloned()) } + /// Patch the cached quality (bit depth + sample rate) of every queued track + /// whose Plex `rating_key` matches one of `updates`. Used by the Plex + /// quality-hydration path so a track that gets hydrated while it is already + /// enqueued/playing has its frozen quality snapshot upgraded in place. Plex + /// rows carry their `rating_key` in `source_item_id_hint`. `sample_rate` is + /// in kHz to match the enqueue-time snapshot (`local_queue_track`). Returns + /// true if the CURRENT track was among those patched (the caller then + /// re-pushes the now-playing stamp). + pub fn patch_plex_quality(&self, updates: &[(String, Option, Option)]) -> bool { + if updates.is_empty() { + return false; + } + let mut state = self.state.lock().unwrap(); + let current_idx = state.current_index; + let mut current_patched = false; + for (idx, track) in state.tracks.iter_mut().enumerate() { + if track.source.as_deref() != Some("plex") { + continue; + } + let Some(key) = track.source_item_id_hint.as_deref() else { + continue; + }; + if let Some((_, bit_depth, sample_rate)) = + updates.iter().find(|(k, _, _)| k == key) + { + if let Some(bd) = *bit_depth { + track.bit_depth = Some(bd); + track.hires = bd > 16; + } + if let Some(sr) = *sample_rate { + track.sample_rate = Some(sr); + } + if current_idx == Some(idx) { + current_patched = true; + } + } + } + current_patched + } + /// Add a track to the end of the queue pub fn add_track(&self, track: QueueTrack) { let mut state = self.state.lock().unwrap(); diff --git a/crates/qbz-plex/src/lib.rs b/crates/qbz-plex/src/lib.rs index e1da7ddb1..ef017fb8e 100644 --- a/crates/qbz-plex/src/lib.rs +++ b/crates/qbz-plex/src/lib.rs @@ -312,6 +312,14 @@ fn open_plex_cache_db() -> Result { } fn build_plex_client() -> Result { + build_plex_client_with_timeout(Duration::from_secs(120)) +} + +/// Build the LAN Plex client with a caller-chosen request timeout. Quality +/// hydration uses a short (~5s) bound per metadata call so a single dead/slow +/// `rating_key` cannot stall the batch (matches the Svelte 5000 ms per-call +/// timeout); normal browsing keeps the generous 120s default. +fn build_plex_client_with_timeout(timeout: Duration) -> Result { let mut headers = HeaderMap::new(); headers.insert("X-Plex-Product", HeaderValue::from_static("QBZ")); headers.insert("X-Plex-Version", HeaderValue::from_static("0.1-poc")); @@ -327,7 +335,7 @@ fn build_plex_client() -> Result { reqwest::Client::builder() .default_headers(headers) - .timeout(Duration::from_secs(120)) + .timeout(timeout) .connect_timeout(Duration::from_secs(8)) .build() .map_err(|e| format!("Failed to create Plex HTTP client: {}", e)) @@ -803,9 +811,21 @@ pub async fn plex_get_track_metadata( rating_key: String, ) -> Result { let client = build_plex_client()?; - let base = normalize_base_url(&base_url); + plex_get_track_metadata_with_client(&client, &base_url, &token, &rating_key).await +} + +/// Shared metadata fetch that reuses an existing client. The hydration path +/// passes a short-timeout client so it bounds each per-track call without +/// rebuilding a client per `rating_key`. +async fn plex_get_track_metadata_with_client( + client: &reqwest::Client, + base_url: &str, + token: &str, + rating_key: &str, +) -> Result { + let base = normalize_base_url(base_url); let detail_url = format!("{base}/library/metadata/{rating_key}"); - let url = with_token(&detail_url, &token); + let url = with_token(&detail_url, token); let xml = client .get(url) @@ -824,6 +844,59 @@ pub async fn plex_get_track_metadata( .ok_or_else(|| "Plex track metadata not found".to_string()) } +/// Hydrate real per-track quality for a set of Plex `rating_keys`. +/// +/// Frontend-agnostic orchestration over the two existing primitives: +/// `plex_get_track_metadata` (the real per-track `container` / `samplingRate` / +/// `bitDepth` the bulk `/all` list omits) is fetched per key, then +/// `plex_cache_update_track_quality` persists the result (COALESCE write-back, +/// so a NULL field never erases an existing value). The keys come from the +/// DB-NULL queue (`plex_cache_get_tracks_needing_hydration`) — NOT a value +/// heuristic — so a genuine 16/44.1 FLAC is written once and never re-probed. +/// +/// Calls run in batches of `BATCH`, sequentially within a batch (matching the +/// Svelte reference), each bounded to ~5s by the client timeout. Failures and +/// timeouts are skipped so one dead key cannot abort the batch. Returns the +/// successfully-fetched updates so the caller can refresh in-memory state +/// without re-reading the cache. +pub async fn plex_hydrate_album_quality( + base_url: String, + token: String, + rating_keys: Vec, +) -> Result, String> { + const BATCH: usize = 5; + if rating_keys.is_empty() { + return Ok(Vec::new()); + } + + // Short per-call bound (5s) so a slow/dead rating_key can't stall the batch. + let client = build_plex_client_with_timeout(Duration::from_secs(5))?; + let mut updates: Vec = Vec::with_capacity(rating_keys.len()); + + for chunk in rating_keys.chunks(BATCH) { + for key in chunk { + match plex_get_track_metadata_with_client(&client, &base_url, &token, key).await { + Ok(track) => updates.push(PlexTrackQualityUpdate { + rating_key: track.rating_key, + container: track.container, + sampling_rate_hz: track.sampling_rate_hz, + bit_depth: track.bit_depth, + }), + Err(_) => continue, // skip dead/410/timeout keys, keep the batch alive + } + } + } + + // Persist (COALESCE write-back). Fire the DB write here so the caller's + // single await gives both fetched + persisted; persistence MUST complete + // before the UI refresh (fixes the Svelte fire-and-forget race). + if !updates.is_empty() { + plex_cache_update_track_quality(updates.clone())?; + } + + Ok(updates) +} + pub async fn plex_auth_pin_start(client_identifier: String) -> Result { let client = build_plex_auth_client(&client_identifier)?; let pin = client diff --git a/crates/qbz-slint/src/local_library.rs b/crates/qbz-slint/src/local_library.rs index 38227359e..13a3b726d 100644 --- a/crates/qbz-slint/src/local_library.rs +++ b/crates/qbz-slint/src/local_library.rs @@ -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::().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, + 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 = { + let versions = album_versions(); + let mut keys: Vec = 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, Option)> = 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::>(); + // 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)> = 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, +) { + let s = window.global::(); + 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> = + std::collections::HashMap::new(); + let mut order: Vec = 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)> = 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::(); + let patch_in = |m: &ModelRc| { + 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)]) { + 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, 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, 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::(); + let patch_in = |m: &ModelRc| { + 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 { album_versions().get(index as usize).map(|(dir, _)| dir.clone()) diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index ac4a1e5fc..737a3cbeb 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -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, Option)>) { + 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. diff --git a/crates/qbz-slint/src/quality.rs b/crates/qbz-slint/src/quality.rs index 735837cbf..34483b7b2 100644 --- a/crates/qbz-slint/src/quality.rs +++ b/crates/qbz-slint/src/quality.rs @@ -14,6 +14,11 @@ pub fn detail(bit_depth: Option, sample_rate: Option) -> 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, sample_rate: Option) -> 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) -> &'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, + sample_rate: Option, +) -> (&'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}")) + } + } +} diff --git a/crates/qbz-slint/src/queue.rs b/crates/qbz-slint/src/queue.rs index f51177719..0fb87e557 100644 --- a/crates/qbz-slint/src/queue.rs +++ b/crates/qbz-slint/src/queue.rs @@ -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 { + &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. diff --git a/crates/qbz-slint/ui/discover/AlbumCard.slint b/crates/qbz-slint/ui/discover/AlbumCard.slint index d7da073b2..76326602b 100644 --- a/crates/qbz-slint/ui/discover/AlbumCard.slint +++ b/crates/qbz-slint/ui/discover/AlbumCard.slint @@ -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; } } } diff --git a/crates/qbz-slint/ui/primitives/QualityBadge.slint b/crates/qbz-slint/ui/primitives/QualityBadge.slint index ec7ddf728..8b324b158 100644 --- a/crates/qbz-slint/ui/primitives/QualityBadge.slint +++ b/crates/qbz-slint/ui/primitives/QualityBadge.slint @@ -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 tier; // Exact quality string for the hover tooltip. in property 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 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. diff --git a/crates/qbz-slint/ui/primitives/QualityBadgeFull.slint b/crates/qbz-slint/ui/primitives/QualityBadgeFull.slint index bbbab9054..4a03643d0 100644 --- a/crates/qbz-slint/ui/primitives/QualityBadgeFull.slint +++ b/crates/qbz-slint/ui/primitives/QualityBadgeFull.slint @@ -30,7 +30,9 @@ export component QualityBadgeFull inherits Rectangle { property 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 {