feat(playback): source-aware artwork resolution (ArtworkRef + PlaybackSource)
Add the frontend-agnostic contract for the source-aware playback context: - qbz-models: new `source` module with `PlaybackSource` (Qobuz / OfflineCache / Local / Plex; parses the existing stringly-typed `source` field, defaults to Qobuz) and `ArtworkRef` (Remote / LocalFile / PlexThumb / Embedded / None). `QueueTrack::source_kind()` + `QueueTrack::artwork_ref()` derive both without changing the wire format. `PlaybackSource::is_qobuz_streamable()` seeds the Qobuz Connect queue gate (only Qobuz-sourced tracks are castable). - qbz-slint artwork pipeline: `fetch_and_decode_ref(&ArtworkRef, ...)` resolves cover bytes per origin — HTTP/Plex through the disk cache, local files read directly, embedded bytes in place. This fixes the root cause of local/Plex artwork never reaching the UI (the loader was HTTP-only). The URL-only `fetch_and_decode` becomes a thin wrapper, so all existing card/row jobs are unchanged. The now-playing loader now takes an `ArtworkRef` derived from the queue track, so local-library/Plex covers reach the now-playing bar. Groundwork for the Local Library Slint port (source-aware playback context designed in from the start). qbz-models tests pass; qbz-slint cargo check green. cc
This commit is contained in:
4 files changed
+270
-18
No files matched your search
@@ -9,6 +9,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use qbz_cache::ImageCacheService;
|
||||
use qbz_models::ArtworkRef;
|
||||
use slint::{ComponentHandle, Model};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
@@ -225,21 +226,16 @@ pub fn spawn_loads(jobs: Vec<ArtworkJob>, window: slint::Weak<AppWindow>, cache:
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one cover image to raw RGBA8 pixels, downscaled to `decode_size`.
|
||||
/// Reads from the shared cache on a hit; on a miss downloads, stores, and
|
||||
/// uses the bytes. Runs on a worker thread; the result tuple is `Send`.
|
||||
pub async fn fetch_and_decode(
|
||||
url: &str,
|
||||
cache: &ImageCache,
|
||||
decode_size: u32,
|
||||
) -> Option<(Vec<u8>, u32, u32)> {
|
||||
/// Resolve a remote URL to raw bytes via the shared disk cache: a hit reads
|
||||
/// from disk, a miss downloads and stores. HTTP(S) only.
|
||||
async fn fetch_cached_http(url: &str, cache: &ImageCache) -> Option<Vec<u8>> {
|
||||
let cached_path = {
|
||||
let guard = cache.lock().ok()?;
|
||||
guard.as_ref().and_then(|service| service.get(url))
|
||||
};
|
||||
|
||||
let bytes: Vec<u8> = match cached_path {
|
||||
Some(path) => tokio::fs::read(&path).await.ok()?,
|
||||
match cached_path {
|
||||
Some(path) => tokio::fs::read(&path).await.ok(),
|
||||
None => {
|
||||
let downloaded = reqwest::get(url).await.ok()?.bytes().await.ok()?.to_vec();
|
||||
if let Ok(guard) = cache.lock() {
|
||||
@@ -247,11 +243,14 @@ pub async fn fetch_and_decode(
|
||||
let _ = service.store(url, &downloaded);
|
||||
}
|
||||
}
|
||||
downloaded
|
||||
Some(downloaded)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let rgba = image::load_from_memory(&bytes)
|
||||
/// Decode raw image bytes to RGBA8, downscaled to `decode_size`.
|
||||
fn decode_rgba(bytes: &[u8], decode_size: u32) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let rgba = image::load_from_memory(bytes)
|
||||
.ok()?
|
||||
.thumbnail(decode_size, decode_size)
|
||||
.to_rgba8();
|
||||
@@ -259,6 +258,49 @@ pub async fn fetch_and_decode(
|
||||
Some((rgba.into_raw(), width, height))
|
||||
}
|
||||
|
||||
/// Resolve an [`ArtworkRef`] to raw RGBA8 pixels, downscaled to
|
||||
/// `decode_size`, regardless of origin. This is the source-aware entry
|
||||
/// point that fixes local/Plex artwork never reaching the UI: HTTP and Plex
|
||||
/// thumbnails go through the disk cache, local files are read directly, and
|
||||
/// embedded bytes decode in place. Runs on a worker thread; the result tuple
|
||||
/// is `Send`.
|
||||
pub async fn fetch_and_decode_ref(
|
||||
art: &ArtworkRef,
|
||||
cache: &ImageCache,
|
||||
decode_size: u32,
|
||||
) -> Option<(Vec<u8>, u32, u32)> {
|
||||
if art.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = match art {
|
||||
ArtworkRef::None => return None,
|
||||
ArtworkRef::Embedded(b) => b.clone(),
|
||||
ArtworkRef::LocalFile(path) => tokio::fs::read(path).await.ok()?,
|
||||
ArtworkRef::Remote(url) => fetch_cached_http(url, cache).await?,
|
||||
ArtworkRef::PlexThumb {
|
||||
base_url,
|
||||
token,
|
||||
path,
|
||||
} => {
|
||||
let sep = if path.contains('?') { '&' } else { '?' };
|
||||
let url = format!("{base_url}{path}{sep}X-Plex-Token={token}");
|
||||
fetch_cached_http(&url, cache).await?
|
||||
}
|
||||
};
|
||||
decode_rgba(&bytes, decode_size)
|
||||
}
|
||||
|
||||
/// Resolve one cover image (by remote URL) to raw RGBA8 pixels. Kept for the
|
||||
/// many card/row jobs that already hold a URL; source-aware call sites
|
||||
/// (local library, Plex) use [`fetch_and_decode_ref`].
|
||||
pub async fn fetch_and_decode(
|
||||
url: &str,
|
||||
cache: &ImageCache,
|
||||
decode_size: u32,
|
||||
) -> Option<(Vec<u8>, u32, u32)> {
|
||||
fetch_and_decode_ref(&ArtworkRef::Remote(url.to_string()), cache, decode_size).await
|
||||
}
|
||||
|
||||
/// Representative color of decoded RGBA pixels for the header gradient.
|
||||
///
|
||||
/// A plain average desaturates badly (everything trends grey), so the
|
||||
|
||||
@@ -146,8 +146,11 @@ async fn play_audible(runtime: &Runtime, weak: &slint::Weak<AppWindow>, track_id
|
||||
}
|
||||
|
||||
/// Resolve the now-playing cover and apply it to `NowPlayingState`.
|
||||
fn load_now_playing_artwork(weak: slint::Weak<AppWindow>, url: String) {
|
||||
if url.is_empty() {
|
||||
///
|
||||
/// Takes a source-aware [`qbz_models::ArtworkRef`] so local-library and Plex
|
||||
/// covers reach the now-playing bar, not just remote Qobuz URLs.
|
||||
fn load_now_playing_artwork(weak: slint::Weak<AppWindow>, art: qbz_models::ArtworkRef) {
|
||||
if art.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(cache) = crate::artwork::shared_cache() else {
|
||||
@@ -155,7 +158,7 @@ fn load_now_playing_artwork(weak: slint::Weak<AppWindow>, url: String) {
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let Some((pixels, w, h)) =
|
||||
crate::artwork::fetch_and_decode(&url, &cache, 160).await
|
||||
crate::artwork::fetch_and_decode_ref(&art, &cache, 160).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -199,7 +202,7 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindo
|
||||
let artist_id = track.artist_id.map(|id| id.to_string()).unwrap_or_default();
|
||||
let track_id = track.id.to_string();
|
||||
let duration = track.duration_secs;
|
||||
let artwork_url = track.artwork_url.clone().unwrap_or_default();
|
||||
let artwork = track.artwork_ref();
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let np = w.global::<NowPlayingState>();
|
||||
@@ -222,7 +225,7 @@ async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindo
|
||||
np.set_artwork(slint::Image::default());
|
||||
});
|
||||
|
||||
load_now_playing_artwork(weak.clone(), artwork_url);
|
||||
load_now_playing_artwork(weak.clone(), artwork);
|
||||
}
|
||||
|
||||
/// Build a `QueueTrack` for the queue from the catalog `Track`, filling
|
||||
|
||||
Reference in new issue
Block a user