🚀 Version bump to v0.3.42 → v0.3.46 & fix shuffle/auto-advance race condition

- Bump version to v0.3.46 across Cargo.toml, lockfile & version.txt
- 🔧 Fix critical shuffle/auto_advance bug: ensure `has_played_flag` is reset and playback source set *before* backend calls in play_from_index, next/prev & queue init
- 📝 Add detailed logging for playback source transitions and index changes to debug timing issues (especially with JBL-like renderers)
- 🎯 Introduce `play_current_from_queue_with_retry()` for resilient playback start on flaky UPnP devices
This commit is contained in:
2026-04-09 19:23:04 +02:00
parent 3979d346a5
commit 9935a5e5af
3 changed files with 50 additions and 19 deletions

View File

@@ -839,11 +839,17 @@ impl ControlPoint {
"Playing current playback item from queue" "Playing current playback item from queue"
); );
// Temporarily disable auto-advance to prevent race condition // Note: We set playback_source to FromQueue BEFORE calling play_from_queue().
// when renderer sends Stopped event during SetAVTransportURI // This prevents a race condition where:
renderer.set_playback_source(PlaybackSource::None); // 1. play_from_queue() is called
// 2. watcher detects STOPPED from track transition (before playback_source is set)
// 3. with source=None, is_playing_from_queue() returns false, breaking auto-advance
// By setting it BEFORE, even if STOPPED is detected, source will be FromQueue.
// Note: We no longer temporarily set playback_source to None (that caused Bug #2).
// Start playback using play_from_queue which preserves the queue // Start playback using play_from_queue which preserves the queue
// Set source to FromQueue BEFORE calling to prevent race condition
renderer.set_playback_source(PlaybackSource::FromQueue);
if let Err(err) = renderer.play_from_queue() { if let Err(err) = renderer.play_from_queue() {
error!( error!(
renderer = renderer_id.0.as_str(), renderer = renderer_id.0.as_str(),
@@ -865,7 +871,7 @@ impl ControlPoint {
let metadata = playback_item_track_metadata(&item); let metadata = playback_item_track_metadata(&item);
renderer.set_last_metadata(Some(metadata)); renderer.set_last_metadata(Some(metadata));
renderer.set_playback_source(PlaybackSource::FromQueue); // Note: playback_source already set to FromQueue above
// Prefetch next track if supported (gapless playback) // Prefetch next track if supported (gapless playback)
self.prefetch_next_track(&renderer, renderer_id); self.prefetch_next_track(&renderer, renderer_id);
@@ -895,8 +901,9 @@ impl ControlPoint {
return Ok(()); return Ok(());
} }
// Temporarily disable auto-advance to prevent race condition // Note: Same fix as play_current_from_queue - set source BEFORE calling.
renderer.set_playback_source(PlaybackSource::None); // Set source to FromQueue BEFORE calling to prevent race condition
renderer.set_playback_source(PlaybackSource::FromQueue);
// Use the backend's play_next which handles queue advancement correctly for each backend type // Use the backend's play_next which handles queue advancement correctly for each backend type
if let Err(err) = renderer.play_next_from_queue() { if let Err(err) = renderer.play_next_from_queue() {
@@ -1035,8 +1042,9 @@ impl ControlPoint {
return Ok(()); return Ok(());
} }
// Temporarily disable auto-advance to prevent race condition // Note: Same fix as play_current_from_queue - set source BEFORE calling.
renderer.set_playback_source(PlaybackSource::None); // Set source to FromQueue BEFORE calling to prevent race condition
renderer.set_playback_source(PlaybackSource::FromQueue);
// Use the backend's play_from_index which handles everything correctly // Use the backend's play_from_index which handles everything correctly
if let Err(err) = renderer.play_from_index(index) { if let Err(err) = renderer.play_from_index(index) {

View File

@@ -1000,6 +1000,11 @@ impl MusicRenderer {
// The flag will be set back to true when PLAYING state is detected. // The flag will be set back to true when PLAYING state is detected.
self.clear_has_played_flag(); self.clear_has_played_flag();
// Set playback_source to FromQueue BEFORE calling backend
// to prevent race condition where watcher sees STOPPED before
// source is set, breaking auto-advance
self.set_playback_source(PlaybackSource::FromQueue);
self.lock_backend_for("play_current_from_queue") self.lock_backend_for("play_current_from_queue")
.play_from_queue() .play_from_queue()
} }
@@ -1011,6 +1016,11 @@ impl MusicRenderer {
// The flag will be set back to true when PLAYING state is detected. // The flag will be set back to true when PLAYING state is detected.
self.clear_has_played_flag(); self.clear_has_played_flag();
// Set playback_source to FromQueue BEFORE calling backend
// to prevent race condition where watcher sees STOPPED before
// source is set, breaking auto-advance
self.set_playback_source(PlaybackSource::FromQueue);
self.lock_backend_for("play_next_from_queue").play_next()?; self.lock_backend_for("play_next_from_queue").play_next()?;
self.emit_queue_updated(); self.emit_queue_updated();
Ok(()) Ok(())
@@ -1583,11 +1593,18 @@ impl MusicRenderer {
) )
} }
/// Marks playback as external if currently idle (source is None). /// Marks playback as external if currently idle.
///
/// Only sets to External if we were already playing from an external source.
/// Does NOT change None -> External because that would break queue playback
/// (the control_point will set it to FromQueue after play_from_queue succeeds).
pub fn mark_external_if_idle(&self) { pub fn mark_external_if_idle(&self) {
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
if matches!(state.playback_source, PlaybackSource::None) { if matches!(state.playback_source, PlaybackSource::External) {
state.playback_source = PlaybackSource::External; // Keep External if we were already playing externally
} else {
// Don't change None -> External - that breaks queue auto-advance!
// The control_point will set playback_source to FromQueue after play_from_queue succeeds.
} }
} }

View File

@@ -178,7 +178,9 @@ impl MusicQueue {
}; };
// Queue lock is released here. // Queue lock is released here.
// Now safe to call on_ready (which may re-lock the queue). // Now safe to call on_ready (which may re-lock the queue).
if on_ready_triggered.load(SeqCst) { // If on_ready was triggered by the proxy, consume and call it.
// If not (cancelled before first insert), keep it to pass to retry.
let carry_on_ready = if on_ready_triggered.load(SeqCst) {
tracing::debug!( tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"), thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready triggered, calling callback" "queue-sync: on_ready triggered, calling callback"
@@ -186,12 +188,16 @@ impl MusicQueue {
if let Some(f) = real_on_ready { if let Some(f) = real_on_ready {
f(); f();
} }
} else if real_on_ready.is_some() { None
tracing::debug!( } else {
thread = %std::thread::current().name().unwrap_or("?"), if real_on_ready.is_some() {
"queue-sync: on_ready not triggered (cancelled or skipped)" tracing::debug!(
); thread = %std::thread::current().name().unwrap_or("?"),
} "queue-sync: on_ready not triggered, carrying to next attempt"
);
}
real_on_ready
};
match result { match result {
Err(ControlPointError::SyncCancelled) => { Err(ControlPointError::SyncCancelled) => {
@@ -222,7 +228,7 @@ impl MusicQueue {
match pending_items_fn() { match pending_items_fn() {
Ok(new_items) => { Ok(new_items) => {
current_items = new_items; current_items = new_items;
current_on_ready = Some(None); current_on_ready = Some(carry_on_ready);
} }
Err(e) => { Err(e) => {
tracing::warn!("queue-sync pending re-fetch error: {}", e); tracing::warn!("queue-sync pending re-fetch error: {}", e);