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:
1 file changed
+156
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user