Improve caching and UI sync

- Add skip operation guard to prevent concurrent track changes
- Implement aggressive prefetch (3 tracks ahead instead of 1)
- Add peek_upcoming method to queue for multi-track prefetch
This commit is contained in:
vicrodh committed 2026-01-09 10:20:13 -06:00
1 parent 5f7c0bb4fa
commit 53e0fd0e10
3 files changed
+114 -53

No files matched your search

+58 -51
View File
@@ -103,67 +103,74 @@ async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
Ok(bytes.to_vec()) Ok(bytes.to_vec())
} }
/// Spawn background task to prefetch next track /// Number of tracks to prefetch ahead
const PREFETCH_COUNT: usize = 3;
/// Spawn background tasks to prefetch upcoming tracks
fn spawn_prefetch( fn spawn_prefetch(
client: Arc<Mutex<QobuzClient>>, client: Arc<Mutex<QobuzClient>>,
cache: Arc<AudioCache>, cache: Arc<AudioCache>,
queue: &QueueManager, queue: &QueueManager,
) { ) {
// Get next track from queue // Get upcoming tracks from queue
let next_track = match queue.peek_next() { let upcoming_tracks = queue.peek_upcoming(PREFETCH_COUNT);
Some(t) => t,
None => {
log::debug!("No next track to prefetch");
return;
}
};
let track_id = next_track.id; if upcoming_tracks.is_empty() {
let track_title = next_track.title.clone(); log::debug!("No upcoming tracks to prefetch");
// Check if already cached or being fetched
if cache.contains(track_id) {
log::debug!("Next track {} already cached", track_id);
return; return;
} }
if cache.is_fetching(track_id) { for track in upcoming_tracks {
log::debug!("Next track {} already being fetched", track_id); let track_id = track.id;
return; let track_title = track.title.clone();
// Check if already cached or being fetched
if cache.contains(track_id) {
log::debug!("Track {} already cached", track_id);
continue;
}
if cache.is_fetching(track_id) {
log::debug!("Track {} already being fetched", track_id);
continue;
}
// Mark as fetching
cache.mark_fetching(track_id);
let client_clone = client.clone();
let cache_clone = cache.clone();
log::info!("Prefetching track: {} - {}", track_id, track_title);
// Spawn background task for each track
tokio::spawn(async move {
let result = async {
let client_guard = client_clone.lock().await;
let stream_url = client_guard
.get_stream_url_with_fallback(track_id, Quality::HiRes)
.await
.map_err(|e| format!("Failed to get stream URL: {}", e))?;
drop(client_guard);
let data = download_audio(&stream_url.url).await?;
Ok::<Vec<u8>, String>(data)
}
.await;
match result {
Ok(data) => {
cache_clone.insert(track_id, data);
log::info!("Prefetch complete for track {}", track_id);
}
Err(e) => {
log::warn!("Prefetch failed for track {}: {}", track_id, e);
}
}
cache_clone.unmark_fetching(track_id);
});
} }
// Mark as fetching
cache.mark_fetching(track_id);
log::info!("Prefetching next track: {} - {}", track_id, track_title);
// Spawn background task
tokio::spawn(async move {
let result = async {
let client_guard = client.lock().await;
let stream_url = client_guard
.get_stream_url_with_fallback(track_id, Quality::HiRes)
.await
.map_err(|e| format!("Failed to get stream URL: {}", e))?;
drop(client_guard);
let data = download_audio(&stream_url.url).await?;
Ok::<Vec<u8>, String>(data)
}
.await;
match result {
Ok(data) => {
cache.insert(track_id, data);
log::info!("Prefetch complete for track {}", track_id);
}
Err(e) => {
log::warn!("Prefetch failed for track {}: {}", track_id, e);
}
}
cache.unmark_fetching(track_id);
});
} }
/// Pause playback /// Pause playback
+47
View File
@@ -201,6 +201,53 @@ impl QueueManager {
next_idx.and_then(|idx| state.tracks.get(idx).cloned()) next_idx.and_then(|idx| state.tracks.get(idx).cloned())
} }
/// Get multiple upcoming tracks without advancing (for prefetching)
pub fn peek_upcoming(&self, count: usize) -> Vec<QueueTrack> {
let state = self.state.lock().unwrap();
if state.tracks.is_empty() || count == 0 {
return Vec::new();
}
// Don't return upcoming if repeat one (same track always)
if state.repeat == RepeatMode::One {
return Vec::new();
}
let mut result = Vec::with_capacity(count);
if state.shuffle {
let start_pos = state.shuffle_position + 1;
for i in 0..count {
let pos = start_pos + i;
if pos < state.shuffle_order.len() {
if let Some(track) = state.tracks.get(state.shuffle_order[pos]) {
result.push(track.clone());
}
} else if state.repeat == RepeatMode::All {
// Wrap around
let wrapped_pos = pos % state.shuffle_order.len();
if let Some(track) = state.tracks.get(state.shuffle_order[wrapped_pos]) {
result.push(track.clone());
}
}
}
} else {
let start_idx = state.current_index.map(|i| i + 1).unwrap_or(0);
for i in 0..count {
let idx = start_idx + i;
if idx < state.tracks.len() {
result.push(state.tracks[idx].clone());
} else if state.repeat == RepeatMode::All {
// Wrap around
let wrapped_idx = idx % state.tracks.len();
result.push(state.tracks[wrapped_idx].clone());
}
}
}
result
}
/// Advance to next track and return it /// Advance to next track and return it
pub fn next(&self) -> Option<QueueTrack> { pub fn next(&self) -> Option<QueueTrack> {
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
+9 -2
View File
@@ -398,13 +398,14 @@
// Skip track handlers - wired to backend queue // Skip track handlers - wired to backend queue
async function handleSkipBack() { async function handleSkipBack() {
if (!currentTrack) return; if (!currentTrack || isSkipping) return;
// If more than 3 seconds in, restart track; otherwise go to previous // If more than 3 seconds in, restart track; otherwise go to previous
if (currentTime > 3) { if (currentTime > 3) {
handleSeek(0); handleSeek(0);
return; return;
} }
isSkipping = true;
try { try {
const prevTrack = await invoke<BackendQueueTrack | null>('previous_track'); const prevTrack = await invoke<BackendQueueTrack | null>('previous_track');
if (prevTrack) { if (prevTrack) {
@@ -416,12 +417,15 @@
} catch (err) { } catch (err) {
console.error('Failed to go to previous track:', err); console.error('Failed to go to previous track:', err);
showToast('Failed to go to previous track', 'error'); showToast('Failed to go to previous track', 'error');
} finally {
isSkipping = false;
} }
} }
async function handleSkipForward() { async function handleSkipForward() {
if (!currentTrack) return; if (!currentTrack || isSkipping) return;
isSkipping = true;
try { try {
const nextTrackResult = await invoke<BackendQueueTrack | null>('next_track'); const nextTrackResult = await invoke<BackendQueueTrack | null>('next_track');
if (nextTrackResult) { if (nextTrackResult) {
@@ -435,6 +439,8 @@
} catch (err) { } catch (err) {
console.error('Failed to go to next track:', err); console.error('Failed to go to next track:', err);
showToast('Failed to go to next track', 'error'); showToast('Failed to go to next track', 'error');
} finally {
isSkipping = false;
} }
} }
@@ -671,6 +677,7 @@
let pollInterval: ReturnType<typeof setInterval> | null = null; let pollInterval: ReturnType<typeof setInterval> | null = null;
let isAdvancingTrack = false; // Prevent multiple advances let isAdvancingTrack = false; // Prevent multiple advances
let isSkipping = false; // Prevent concurrent skip operations
async function pollPlaybackState() { async function pollPlaybackState() {
if (!currentTrack) return; if (!currentTrack) return;