feat(player): add prefetch and gapless cache helpers

Add three public Player methods that drive gapless playback without
touching the audio thread:

- prefetch_into_cache: download an upcoming track fully into L1/L2
  cache so it later plays via play_data (cache hit) rather than the
  streaming path. CMAF download_full first, legacy full download as
  fallback. No-ops on streaming_only / already-cached / in-flight.
- is_track_cached: cheap L1/L2 membership check for the controller.
- fetch_for_gapless: resolve a track's bytes for a gapless handoff
  via L1 memory then L2 disk then CMAF download_full. Ports the
  L1/L2/CMAF tiers of Tauri's v2_play_next_gapless.

All three reuse existing primitives (audio_cache, download_audio);
the protected audio-thread command handlers are untouched.

cc
This commit is contained in:
vicrodh committed 2026-05-19 03:01:22 -06:00
1 parent ff1ad3c619
commit 3a6aae19a3
1 file changed
+156
+156
View File
@@ -3504,6 +3504,162 @@ impl Player {
self.play_data(audio_data, track_id)
}
/// Download a track fully into the L1/L2 cache **without** starting
/// playback.
///
/// Gapless playback requires upcoming tracks to be cache hits so they
/// play via `play_data` (fully in-memory) rather than the streaming
/// path — the audio engine's `PlayNext` handler ignores gapless
/// requests while a streaming source is active. This method is the
/// prefetch primitive the controller drives for the next 1-2 queue
/// tracks.
///
/// Mirrors the Tauri V2 prefetch download: CMAF `download_full` first
/// (Akamai CDN), legacy `/track/getFileUrl` full download as fallback.
/// No-ops when `streaming_only` is set, when the track is already
/// cached, or when another fetch for the same id is in flight.
pub async fn prefetch_into_cache(
&self,
client: &QobuzClient,
track_id: u64,
quality: Quality,
) -> Result<(), String> {
// Honor streaming_only — never warm the cache when the user has
// opted out of caching.
let skip_cache = self
.audio_settings
.lock()
.map(|s| s.streaming_only)
.unwrap_or(false);
if skip_cache {
log::debug!("[PREFETCH] Skipped track {track_id} — streaming_only mode active");
return Ok(());
}
// Already cached, or another prefetch for this id is already
// running — nothing to do.
if self.audio_cache.contains(track_id) {
log::debug!("[PREFETCH] Track {track_id} already cached");
return Ok(());
}
if self.audio_cache.is_fetching(track_id) {
log::debug!("[PREFETCH] Track {track_id} already being fetched");
return Ok(());
}
self.audio_cache.mark_fetching(track_id);
log::info!("[PREFETCH] Prefetching track {track_id} at {quality:?}");
// Try CMAF full download first (Akamai CDN), legacy full download
// as fallback (nginx CDN).
let result = match qbz_qobuz::cmaf::download_full(client, track_id, quality).await {
Ok(data) => Ok(data),
Err(e) => {
log::warn!(
"[PREFETCH] CMAF failed for track {track_id}: {e}, trying legacy"
);
match client.get_stream_url_with_fallback(track_id, quality).await {
Ok(stream_url) => self.download_audio(&stream_url.url).await,
Err(e) => Err(format!("Failed to get stream URL: {e}")),
}
}
};
match result {
Ok(data) => {
// Brief delay before the cache write to avoid racing the
// audio thread, matching the Tauri prefetch path.
tokio::time::sleep(Duration::from_millis(50)).await;
let len = data.len();
self.audio_cache.insert(track_id, data);
self.audio_cache.unmark_fetching(track_id);
log::info!("[PREFETCH] Complete for track {track_id} ({len} bytes)");
Ok(())
}
Err(e) => {
self.audio_cache.unmark_fetching(track_id);
log::warn!("[PREFETCH] Failed for track {track_id}: {e}");
Err(e)
}
}
}
/// True if `track_id` is present in the L1/L2 playback cache. Used by
/// the gapless controller to decide whether a track can be queued for
/// a seamless handoff.
pub fn is_track_cached(&self, track_id: u64) -> bool {
self.audio_cache.contains(track_id)
}
/// Fetch a track's audio bytes for a gapless handoff: L1 memory →
/// L2 disk → CMAF `download_full` (legacy full download as fallback).
/// Does not start playback — the caller passes the bytes to
/// `play_next`. Returns `None` only when every tier fails.
///
/// Ports the L1/L2/CMAF tiers of Tauri's `v2_play_next_gapless`; the
/// ephemeral / offline-cache / local-library tiers are intentionally
/// omitted as they do not exist in the Slint MVP.
pub async fn fetch_for_gapless(
&self,
client: &QobuzClient,
track_id: u64,
quality: Quality,
) -> Option<Vec<u8>> {
// L1: in-memory cache.
if let Some(cached) = self.audio_cache.get(track_id) {
log::info!(
"[GAPLESS] Track {track_id} from MEMORY cache ({} bytes)",
cached.size_bytes
);
return Some(cached.data);
}
// L2: on-disk plain-FLAC playback cache. Warm L1 on the way out.
if let Some(playback_cache) = self.audio_cache.get_playback_cache() {
if let Some(audio_data) = playback_cache.get(track_id) {
log::info!(
"[GAPLESS] Track {track_id} from DISK cache ({} bytes)",
audio_data.len()
);
self.audio_cache.insert(track_id, audio_data.clone());
return Some(audio_data);
}
}
// CMAF full download (Akamai CDN), legacy full download as
// fallback. Warm L1 so a re-gapless / replay skips the network.
let downloaded = match qbz_qobuz::cmaf::download_full(client, track_id, quality).await {
Ok(data) => Some(data),
Err(e) => {
log::warn!("[GAPLESS] CMAF failed for track {track_id}: {e}, trying legacy");
match client.get_stream_url_with_fallback(track_id, quality).await {
Ok(stream_url) => match self.download_audio(&stream_url.url).await {
Ok(data) => Some(data),
Err(e) => {
log::warn!("[GAPLESS] Legacy download failed for {track_id}: {e}");
None
}
},
Err(e) => {
log::warn!("[GAPLESS] No stream URL for {track_id}: {e}");
None
}
}
}
};
if let Some(ref data) = downloaded {
log::info!(
"[GAPLESS] Track {track_id} downloaded for gapless ({} bytes)",
data.len()
);
self.audio_cache.insert(track_id, data.clone());
} else {
log::info!("[GAPLESS] Track {track_id} not available, gapless not possible");
}
downloaded
}
/// Stream CMAF segments to the player's buffer, decrypting on the fly.
///
/// Writes the FLAC header first so the decoder can identify the format,