Merge pull request 'push-ppmsrvolzpzk' (#93) from push-ppmsrvolzpzk into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m10s

Reviewed-on: #93
This commit was merged in pull request #93.
This commit is contained in:
2026-04-09 22:37:48 +02:00
12 changed files with 229 additions and 61 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "PMOMusic" name = "PMOMusic"
version = "0.3.42" version = "0.3.44"
dependencies = [ dependencies = [
"axum 0.8.7", "axum 0.8.7",
"console-subscriber", "console-subscriber",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "PMOMusic" name = "PMOMusic"
version = "0.3.42" version = "0.3.45"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -13,6 +13,7 @@ const props = defineProps<{
const { state } = useRenderer(toRef(props, "rendererId")); const { state } = useRenderer(toRef(props, "rendererId"));
const uiStore = useUIStore(); const uiStore = useUIStore();
const metadata = computed(() => state.value?.current_track); const metadata = computed(() => state.value?.current_track);
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
const isSeeking = ref(false); const isSeeking = ref(false);
const isDragging = ref(false); const isDragging = ref(false);
const dragProgress = ref(0); const dragProgress = ref(0);
@@ -149,7 +150,7 @@ function handleProgressBarMouseDown(event: MouseEvent) {
await api.seekTo(props.rendererId, newPositionSeconds); await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
// En cas d'erreur, réinitialiser // En cas d'erreur, réinitialiser
seekTargetMs.value = null; seekTargetMs.value = null;
@@ -188,7 +189,7 @@ async function handleProgressBarClick(event: MouseEvent) {
await api.seekTo(props.rendererId, newPositionSeconds); await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
// En cas d'erreur, réinitialiser // En cas d'erreur, réinitialiser
seekTargetMs.value = null; seekTargetMs.value = null;
@@ -251,7 +252,7 @@ function handleOverlayProgressBarTouchStart(event: TouchEvent) {
await api.seekTo(props.rendererId, newPositionSeconds); await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
seekTargetMs.value = null; seekTargetMs.value = null;
dragProgress.value = 0; dragProgress.value = 0;

View File

@@ -20,12 +20,14 @@ const isStopped = computed(
state.value?.transport_state === "NO_MEDIA", state.value?.transport_state === "NO_MEDIA",
); );
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
async function handlePlay() { async function handlePlay() {
try { try {
await resumeOrPlayFromQueue(props.rendererId); await resumeOrPlayFromQueue(props.rendererId);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
} }
} }
@@ -35,7 +37,7 @@ async function handlePause() {
await pause(props.rendererId); await pause(props.rendererId);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
} }
} }
@@ -45,7 +47,7 @@ async function handleStop() {
await stop(props.rendererId); await stop(props.rendererId);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
} }
} }
@@ -55,7 +57,7 @@ async function handleNext() {
await next(props.rendererId); await next(props.rendererId);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
} }
} }

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, toRef } from "vue"; import { ref, watch, computed, toRef } from "vue";
import { useRenderer, useRenderers } from "@/composables/useRenderers"; import { useRenderer, useRenderers } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui"; import { useUIStore } from "@/stores/ui";
import { Volume2, VolumeX } from "lucide-vue-next"; import { Volume2, VolumeX } from "lucide-vue-next";
@@ -12,6 +12,7 @@ const { state } = useRenderer(toRef(props, "rendererId"));
const { setVolume, toggleMute } = useRenderers(); const { setVolume, toggleMute } = useRenderers();
const uiStore = useUIStore(); const uiStore = useUIStore();
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
const localVolume = ref(state.value?.volume ?? 50); const localVolume = ref(state.value?.volume ?? 50);
// Synchroniser localVolume avec le state // Synchroniser localVolume avec le state
@@ -41,7 +42,7 @@ function handleVolumeChange(event: Event) {
await setVolume(props.rendererId, localVolume.value); await setVolume(props.rendererId, localVolume.value);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
} }
debounceTimer = null; debounceTimer = null;
@@ -53,7 +54,7 @@ async function handleToggleMute() {
await toggleMute(props.rendererId); await toggleMute(props.rendererId);
} catch (error) { } catch (error) {
uiStore.notifyError( uiStore.notifyError(
`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : "Erreur inconnue"}`, `« ${rendererName.value} » — impossible de couper/rétablir le son: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
} }
} }

View File

@@ -19,6 +19,7 @@ const { renderer, state, queue, refresh } = useRenderer(
toRef(props, "rendererId"), toRef(props, "rendererId"),
); );
const uiStore = useUIStore(); const uiStore = useUIStore();
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
// Détection mobile portrait pour afficher le drawer au lieu de la colonne // Détection mobile portrait pour afficher le drawer au lieu de la colonne
const isMobilePortrait = useMediaQuery( const isMobilePortrait = useMediaQuery(
@@ -59,7 +60,7 @@ async function handleQueueItemClick(item: QueueItem) {
error, error,
); );
uiStore.notifyError( uiStore.notifyError(
`Erreur: ${error instanceof Error ? error.message : "Impossible de sauter à cet item"}`, `« ${rendererName.value} » — impossible de lire ce morceau: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
); );
} }
} }

View File

@@ -27,10 +27,6 @@ const loadingIds = reactive(new Set<string>());
const queueRefreshingIds = reactive(new Set<string>()); const queueRefreshingIds = reactive(new Set<string>());
const selectedRendererId = ref<string | null>(null); const selectedRendererId = ref<string | null>(null);
// Debounce pour les refetches queue_updated
const queueUpdateDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const QUEUE_UPDATE_DEBOUNCE_MS = 300;
// Cache des renderers (summary) // Cache des renderers (summary)
const renderersCache = ref<Map<string, RendererSummary>>(new Map()); const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000; const RENDERERS_CACHE_MS = 2000;
@@ -205,15 +201,10 @@ function ensureSSEInitialized() {
snapshot.state.queue_len = event.queue_length; snapshot.state.queue_len = event.queue_length;
queueRefreshingIds.delete(rendererId); queueRefreshingIds.delete(rendererId);
// Annuler le timer précédent pour ce renderer // Fetch queue items immediately when we get queue_updated
const existingTimer = queueUpdateDebounceTimers.get(rendererId); // The debounce was causing race conditions where queue_len was updated
if (existingTimer) clearTimeout(existingTimer); // but items weren't fetched yet when user clicked play
// Programmer un seul fetch après stabilisation
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
queueUpdateDebounceTimers.delete(rendererId);
void fetchRendererSnapshot(rendererId, { force: true }); void fetchRendererSnapshot(rendererId, { force: true });
}, QUEUE_UPDATE_DEBOUNCE_MS));
break; break;
case "binding_changed": case "binding_changed":
@@ -342,7 +333,7 @@ async function fetchRenderers(force = false, retries = 2) {
error.value = lastError?.message ?? "Erreur fetch renderers"; error.value = lastError?.message ?? "Erreur fetch renderers";
// Notifier l'utilisateur en cas d'erreur finale // Notifier l'utilisateur en cas d'erreur finale
uiStore.notifyError("Impossible de rafraîchir la liste des renderers"); uiStore.notifyError("Impossible de rafraîchir la liste des lecteurs audio");
} }
async function fetchRendererSnapshot( async function fetchRendererSnapshot(
@@ -383,7 +374,8 @@ async function fetchRendererSnapshot(
snapshots.delete(rendererId); snapshots.delete(rendererId);
// Notifier l'utilisateur // Notifier l'utilisateur
uiStore.notifyError(`Impossible de récupérer l'état du renderer`); const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
} finally { } finally {
// Toujours nettoyer le flag de chargement // Toujours nettoyer le flag de chargement
loadingIds.delete(rendererId); loadingIds.delete(rendererId);
@@ -447,6 +439,7 @@ async function resumeOrPlayFromQueue(id: string) {
return play(id); return play(id);
} }
// Check if queue has content
if ( if (
["STOPPED", "NO_MEDIA"].includes(state.transport_state) && ["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
snapshot.queue.items.length > 0 snapshot.queue.items.length > 0

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) {
@@ -1178,7 +1186,8 @@ impl ControlPoint {
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| { let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0)) ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
})?; })?;
renderer.play_current_from_queue() // Use retry version to handle transient renderer errors (like JBL Authentics 300)
renderer.play_current_from_queue_with_retry()
})) }))
} else { } else {
None None
@@ -1250,7 +1259,8 @@ impl ControlPoint {
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| { let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0)) ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
})?; })?;
renderer.play_current_from_queue() // Use retry version to handle transient renderer errors (like JBL Authentics 300)
renderer.play_current_from_queue_with_retry()
})) }))
} else { } else {
None None

View File

@@ -703,8 +703,9 @@ impl MusicRenderer {
); );
// Use catch_unwind to prevent panics in play_next_from_queue // Use catch_unwind to prevent panics in play_next_from_queue
// from poisoning the backend mutex // from poisoning the backend mutex
// Also add retry logic for transient errors from renderer
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.play_next_from_queue() self.play_next_from_queue_with_retry()
})); }));
match result { match result {
Ok(Ok(())) => {} Ok(Ok(())) => {}
@@ -712,7 +713,7 @@ impl MusicRenderer {
error!( error!(
renderer = self.info.friendly_name(), renderer = self.info.friendly_name(),
error = %err, error = %err,
"Auto-advance failed; clearing queue playback state" "Auto-advance failed after retries; clearing queue playback state"
); );
self.set_playback_source(PlaybackSource::None); self.set_playback_source(PlaybackSource::None);
} }
@@ -993,13 +994,69 @@ impl MusicRenderer {
self.lock_backend_for("upcoming_len").upcoming_len() self.lock_backend_for("upcoming_len").upcoming_len()
} }
/// Play the current item from the queue. /// Play the current item from the queue with retry logic for transient renderer errors.
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> { ///
/// Some renderers (like JBL Authentics 300) may fail the first Play command
/// due to timing issues. This method retries with a small delay.
pub fn play_current_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
const MAX_RETRIES: usize = 3;
const RETRY_DELAY_MS: u64 = 200;
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
// Reset the has_played flag before starting playback to prevent // Reset the has_played flag before starting playback to prevent
// auto-advance on transient STOPPED states during track initialization. // auto-advance on transient STOPPED states during track initialization.
// 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);
match self
.lock_backend_for("play_current_from_queue_with_retry")
.play_from_queue()
{
Ok(()) => return Ok(()),
Err(e) => {
last_error = Some(e);
if attempt < MAX_RETRIES - 1 {
tracing::warn!(
renderer = self.info.friendly_name(),
attempt = attempt + 1,
error = %last_error.as_ref().unwrap(),
"Initial play attempt failed, retrying..."
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
}
}
}
}
// All retries failed
Err(last_error.unwrap_or_else(|| {
ControlPointError::ControlPoint("Unknown error in retry logic".into())
}))
}
/// Play the current item from the queue.
#[deprecated(
since = "0.1.0",
note = "Use play_current_from_queue_with_retry instead"
)]
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
// Reset the has_played_flag before starting playback to prevent
// auto-advance on transient STOPPED states during track initialization.
// The flag will be set back to true when PLAYING state is detected.
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,11 +1068,50 @@ 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(())
} }
/// Play next from queue with retry logic for transient renderer errors.
///
/// Some renderers (like JBL Authentics 300) may fail the first Play command
/// due to timing issues. This method retries with a small delay.
pub fn play_next_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
const MAX_RETRIES: usize = 3;
const RETRY_DELAY_MS: u64 = 200;
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
match self.play_next_from_queue() {
Ok(()) => return Ok(()),
Err(e) => {
last_error = Some(e);
if attempt < MAX_RETRIES - 1 {
tracing::warn!(
renderer = self.info.friendly_name(),
attempt = attempt + 1,
error = %last_error.as_ref().unwrap(),
"Auto-advance attempt failed, retrying..."
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
}
}
}
}
// All retries failed
Err(last_error.unwrap_or_else(|| {
ControlPointError::ControlPoint("Unknown error in retry logic".into())
}))
}
/// Advance the queue index by one without starting playback. /// Advance the queue index by one without starting playback.
/// ///
/// Used by the WebRenderer gapless path: the browser autonomously transitions /// Used by the WebRenderer gapless path: the browser autonomously transitions
@@ -1033,14 +1129,21 @@ impl MusicRenderer {
/// Play from a specific index in the queue. /// Play from a specific index in the queue.
pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> { pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
// Reset the has_played flag before starting playback to prevent // ✅ CORRECTIF BUG SHUFFLE: Quand on change d'index manuellement
// auto-advance on transient STOPPED states during track initialization. // (par exemple shuffle, clique sur un titre), on réinitialise OBLIGATOIREMENT
// The flag will be set back to true when PLAYING state is detected. // le flag has_played. Sinon quand le titre se termine l'auto-avance
// pense qu'il n'a jamais démarré et s'arrête.
tracing::debug!(
index = index,
renderer = self.info.friendly_name(),
"🎯 play_from_index appelé, réinitialisation has_played_flag"
);
self.clear_has_played_flag(); self.clear_has_played_flag();
self.lock_backend_for("play_from_index") self.lock_backend_for("play_from_index")
.play_from_index(index)?; .play_from_index(index)?;
self.emit_queue_updated();
self.set_playback_source(PlaybackSource::FromQueue);
Ok(()) Ok(())
} }
@@ -1063,6 +1166,11 @@ impl MusicRenderer {
let queue_not_empty = backend.len().unwrap_or(0) > 0; let queue_not_empty = backend.len().unwrap_or(0) > 0;
if queue_not_empty { if queue_not_empty {
// 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);
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné) // Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.) // Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
backend.play_from_queue() backend.play_from_queue()
@@ -1528,6 +1636,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_from_queue").play_from_queue() self.lock_backend_for("play_from_queue").play_from_queue()
} }
@@ -1583,11 +1696,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.
} }
} }
@@ -1740,8 +1860,9 @@ impl MusicRenderer {
// 5. Replace the queue with shuffled items, starting at index 0 // 5. Replace the queue with shuffled items, starting at index 0
self.replace_queue(shuffled_items, Some(0))?; self.replace_queue(shuffled_items, Some(0))?;
// 6. Start playback from the first track // 6. Start playback from the first track with retry for JBL-like renderers
self.play_from_index(0)?; // that fail the first Play command due to timing issues
self.play_current_from_queue_with_retry()?;
Ok(()) Ok(())
} }

View File

@@ -218,9 +218,22 @@ impl QueueTransportControl for UpnpRenderer {
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP // Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri); let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri);
*self.continuous_stream.lock().unwrap() = is_stream; *self.continuous_stream.lock().unwrap() = is_stream;
// Log current queue state for debugging
let queue_state = {
let queue = self.queue.lock().unwrap();
let idx = queue.current_index().unwrap_or(None);
let len = queue.len().unwrap_or(0);
let uri = item.uri.clone();
let title = item.metadata.as_ref().and_then(|m| m.title.clone());
(idx, len, uri, title)
};
tracing::debug!( tracing::debug!(
"UpnpRenderer play_from_queue: URI={}, continuous_stream={}", "UpnpRenderer play_from_queue: index={:?}/{}, uri={}, title={:?}, continuous_stream={}",
item.uri, queue_state.0,
queue_state.1,
queue_state.2,
queue_state.3,
is_stream is_stream
); );
@@ -243,11 +256,28 @@ impl QueueTransportControl for UpnpRenderer {
} }
fn play_next(&self) -> Result<(), ControlPointError> { fn play_next(&self) -> Result<(), ControlPointError> {
let current_idx = {
let queue = self.queue.lock().unwrap();
let idx = queue.current_index().unwrap_or(None);
let len = queue.len().unwrap_or(0);
tracing::debug!(
current_index = ?idx,
queue_len = len,
"play_next: attempting to advance"
);
idx
};
{ {
let mut queue = self.queue.lock().unwrap(); let mut queue = self.queue.lock().unwrap();
if !queue.advance()? { if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into())); return Err(ControlPointError::QueueError("No next track".into()));
} }
let new_idx = queue.current_index().unwrap_or(None);
tracing::debug!(
previous_index = ?current_idx,
new_index = ?new_idx,
"play_next: advanced"
);
} }
self.play_from_queue() self.play_from_queue()
@@ -269,6 +299,9 @@ impl QueueTransportControl for UpnpRenderer {
let mut queue = self.queue.lock().unwrap(); let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?; queue.set_index(Some(index))?;
} }
// CORRECTIF: Quand on change l'index manuellement (shuffle, sélection d'un titre)
// on logue pour être sûr que c'est bien appelé
tracing::debug!(index = index, "✅ SHUFFLE / SEEK: play_from_index appelé");
self.play_from_queue() self.play_from_queue()
} }

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
} else {
if real_on_ready.is_some() {
tracing::debug!( tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"), thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready not triggered (cancelled or skipped)" "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);

View File

@@ -1 +1 @@
0.3.42 0.3.45