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:
1 parent
5f7c0bb4fa
commit
53e0fd0e10
3 files changed
+114
-53
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user