diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 5e195ce83..87df7858a 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -606,6 +606,25 @@ impl QbzCore { queue.current() } + /// Reconcile the queue pointer to the track the audio engine is actually + /// playing. A gapless hand-off advances inside the player without going + /// through `next_track`, so the core pointer can lag the live track and + /// the now-playing card goes stale. This moves the pointer to the track + /// with `id` and returns it plus whether the pointer moved; a queue + /// update is emitted only when it did. Frontend-agnostic — the playback + /// poll loop calls this to keep now-playing in sync (ADR-006). + pub async fn sync_current_to_id(&self, id: u64) -> Option<(QueueTrack, bool)> { + let queue = self.queue.write().await; + let result = queue.sync_current_to_id(id); + if matches!(result, Some((_, true))) { + self.emit(CoreEvent::QueueUpdated { + state: queue.get_state(), + }) + .await; + } + result + } + // ==================== Search & Catalog ==================== /// Search for albums diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs index 4ec1d53a0..0f20e1423 100644 --- a/crates/qbz-player/src/queue.rs +++ b/crates/qbz-player/src/queue.rs @@ -659,6 +659,38 @@ impl QueueManager { prev_idx.and_then(|idx| state.tracks.get(idx).cloned()) } + /// Move the current pointer to the track whose id matches `id`, WITHOUT + /// starting playback. Used to reconcile the queue pointer to a track the + /// audio engine already advanced to on its own (a gapless hand-off + /// happens inside the player, not through `next`), so the now-playing + /// card never goes stale while the seek bar keeps moving. + /// + /// Returns the matched track plus whether the pointer actually moved + /// (`false` = it was already current). Returns `None` when no queue track + /// has that id, leaving the pointer untouched. + pub fn sync_current_to_id(&self, id: u64) -> Option<(QueueTrack, bool)> { + let mut state = self.state.lock().unwrap(); + let target = state.tracks.iter().position(|t| t.id == id)?; + let moved = state.current_index != Some(target); + if moved { + // Record the outgoing track so `previous` still walks back. + if let Some(curr_idx) = state.current_index { + state.history.push_back(curr_idx); + while state.history.len() > 50 { + state.history.pop_front(); + } + } + state.current_index = Some(target); + // Keep the shuffle cursor aligned with the new position. + if state.shuffle { + if let Some(pos) = state.shuffle_order.iter().position(|&x| x == target) { + state.shuffle_position = pos; + } + } + } + state.tracks.get(target).cloned().map(|t| (t, moved)) + } + /// Jump to a track by its position in the `upcoming` list as returned by /// `get_state`. This is the position the user sees in the Queue sidebar; /// the method resolves it to the correct canonical index even when diff --git a/crates/qbz-slint/src/playback.rs b/crates/qbz-slint/src/playback.rs index 924b33f0b..2073775a0 100644 --- a/crates/qbz-slint/src/playback.rs +++ b/crates/qbz-slint/src/playback.rs @@ -1932,32 +1932,30 @@ pub fn start_poll_loop( && is_playing && was_playing; if seamless_change { - // A track-id change while still playing is EITHER a real - // gapless hand-off (the engine started the prefetched next + // The audio engine advanced to `track_id` on its own — EITHER + // a real gapless hand-off (it started the prefetched next // track) OR a manual new-track play that just replaced the - // queue. Only the former should advance the core queue - // pointer: a real gapless next IS the current upcoming track. - // For a manual play the queue is already correct, and calling - // next_track() would push the pointer one past what's actually - // playing — desyncing now-playing from the queue (the reported - // erratic mismatch). - let is_gapless_advance = runtime - .core() - .peek_upcoming(1) - .await - .first() - .map(|t| t.id) - == Some(track_id); - if is_gapless_advance { - log::info!( - "[qbz-slint] [GAPLESS] seamless transition {last_track_id} -> {track_id}" - ); - let _ = runtime.core().next_track().await; + // queue. Rather than guess which (the old peek-based heuristic + // missed cases and left the card stale while the seek bar kept + // moving — the reported populate bug), reconcile the queue + // pointer + the now-playing card to whatever is ACTUALLY + // playing. `sync_current_to_id` moves the pointer only when it + // lags (a real advance); a manual play already moved it, so it + // reports `moved == false` and we skip the double bookkeeping. + if let Some((_, moved)) = + runtime.core().sync_current_to_id(track_id).await + { + // Always refresh so title/art/meta match the live track. refresh_now_playing_meta(&runtime, &weak).await; - record_recent(&runtime).await; - refresh_sidebar(true); - // Prefetch the successors of the now-current track. - kick_prefetch(&runtime).await; + if moved { + log::info!( + "[qbz-slint] [GAPLESS] seamless transition {last_track_id} -> {track_id}" + ); + record_recent(&runtime).await; + refresh_sidebar(true); + // Prefetch the successors of the now-current track. + kick_prefetch(&runtime).await; + } gapless_requested_for = 0; } // Resync the edge trackers either way so this change is not