fix(slint): reconcile now-playing card to the live track on seamless change

The poll loop's seamless-transition branch guessed whether a track-id change
while still playing was a gapless hand-off via a peek_upcoming(1) == track_id
heuristic. When that heuristic missed (queue pointer lagging the engine), the
meta was never refreshed: the seek bar kept advancing while the title/art/album
stayed on the previous track (the reported now-playing populate bug).

Replace the heuristic with reconciliation to what is ACTUALLY playing. New
frontend-agnostic primitive QueueManager::sync_current_to_id(id) moves the queue
pointer to the track the engine advanced to (recording history + keeping the
shuffle cursor aligned) and reports whether it moved; QbzCore wraps it and emits
a queue update only on a real move. The poll loop now syncs to the live track_id
and always refreshes the card, doing the record/sidebar/prefetch bookkeeping only
on a genuine advance (a manual play already moved the pointer, so it is idempotent).

cc
This commit is contained in:
vicrodh committed 2026-06-01 13:29:21 -06:00
1 parent 63d9f6b496
commit c763d5b2fe
3 files changed
+73 -24

No files matched your search

+19
View File
@@ -606,6 +606,25 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
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
+32
View File
@@ -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
+22 -24
View File
@@ -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