push-ppmsrvolzpzk #93
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.42"
|
||||
version = "0.3.44"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.42"
|
||||
version = "0.3.45"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -13,6 +13,7 @@ const props = defineProps<{
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const uiStore = useUIStore();
|
||||
const metadata = computed(() => state.value?.current_track);
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
const isSeeking = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const dragProgress = ref(0);
|
||||
@@ -149,7 +150,7 @@ function handleProgressBarMouseDown(event: MouseEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
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
|
||||
seekTargetMs.value = null;
|
||||
@@ -188,7 +189,7 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
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
|
||||
seekTargetMs.value = null;
|
||||
@@ -251,7 +252,7 @@ function handleOverlayProgressBarTouchStart(event: TouchEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
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;
|
||||
dragProgress.value = 0;
|
||||
|
||||
@@ -20,12 +20,14 @@ const isStopped = computed(
|
||||
state.value?.transport_state === "NO_MEDIA",
|
||||
);
|
||||
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
|
||||
async function handlePlay() {
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
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);
|
||||
} catch (error) {
|
||||
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);
|
||||
} catch (error) {
|
||||
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);
|
||||
} catch (error) {
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, toRef } from "vue";
|
||||
import { ref, watch, computed, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Volume2, VolumeX } from "lucide-vue-next";
|
||||
@@ -12,6 +12,7 @@ const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { setVolume, toggleMute } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
const localVolume = ref(state.value?.volume ?? 50);
|
||||
|
||||
// Synchroniser localVolume avec le state
|
||||
@@ -41,7 +42,7 @@ function handleVolumeChange(event: Event) {
|
||||
await setVolume(props.rendererId, localVolume.value);
|
||||
} catch (error) {
|
||||
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;
|
||||
@@ -53,7 +54,7 @@ async function handleToggleMute() {
|
||||
await toggleMute(props.rendererId);
|
||||
} catch (error) {
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const { renderer, state, queue, refresh } = useRenderer(
|
||||
toRef(props, "rendererId"),
|
||||
);
|
||||
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
|
||||
const isMobilePortrait = useMediaQuery(
|
||||
@@ -59,7 +60,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
error,
|
||||
);
|
||||
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"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ const loadingIds = reactive(new Set<string>());
|
||||
const queueRefreshingIds = reactive(new Set<string>());
|
||||
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)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
@@ -205,15 +201,10 @@ function ensureSSEInitialized() {
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Annuler le timer précédent pour ce renderer
|
||||
const existingTimer = queueUpdateDebounceTimers.get(rendererId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
|
||||
// Programmer un seul fetch après stabilisation
|
||||
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
|
||||
queueUpdateDebounceTimers.delete(rendererId);
|
||||
// Fetch queue items immediately when we get queue_updated
|
||||
// The debounce was causing race conditions where queue_len was updated
|
||||
// but items weren't fetched yet when user clicked play
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
}, QUEUE_UPDATE_DEBOUNCE_MS));
|
||||
break;
|
||||
|
||||
case "binding_changed":
|
||||
@@ -342,7 +333,7 @@ async function fetchRenderers(force = false, retries = 2) {
|
||||
error.value = lastError?.message ?? "Erreur fetch renderers";
|
||||
|
||||
// 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(
|
||||
@@ -383,7 +374,8 @@ async function fetchRendererSnapshot(
|
||||
snapshots.delete(rendererId);
|
||||
|
||||
// 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 {
|
||||
// Toujours nettoyer le flag de chargement
|
||||
loadingIds.delete(rendererId);
|
||||
@@ -447,6 +439,7 @@ async function resumeOrPlayFromQueue(id: string) {
|
||||
return play(id);
|
||||
}
|
||||
|
||||
// Check if queue has content
|
||||
if (
|
||||
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
|
||||
snapshot.queue.items.length > 0
|
||||
|
||||
@@ -839,11 +839,17 @@ impl ControlPoint {
|
||||
"Playing current playback item from queue"
|
||||
);
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
// when renderer sends Stopped event during SetAVTransportURI
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: We set playback_source to FromQueue BEFORE calling play_from_queue().
|
||||
// This prevents a race condition where:
|
||||
// 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
|
||||
// Set source to FromQueue BEFORE calling to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
if let Err(err) = renderer.play_from_queue() {
|
||||
error!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
@@ -865,7 +871,7 @@ impl ControlPoint {
|
||||
let metadata = playback_item_track_metadata(&item);
|
||||
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)
|
||||
self.prefetch_next_track(&renderer, renderer_id);
|
||||
@@ -895,8 +901,9 @@ impl ControlPoint {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
|
||||
// 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
|
||||
if let Err(err) = renderer.play_next_from_queue() {
|
||||
@@ -1035,8 +1042,9 @@ impl ControlPoint {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
|
||||
// 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
|
||||
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(|| {
|
||||
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 {
|
||||
None
|
||||
@@ -1250,7 +1259,8 @@ impl ControlPoint {
|
||||
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
|
||||
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 {
|
||||
None
|
||||
|
||||
@@ -703,8 +703,9 @@ impl MusicRenderer {
|
||||
);
|
||||
// Use catch_unwind to prevent panics in play_next_from_queue
|
||||
// from poisoning the backend mutex
|
||||
// Also add retry logic for transient errors from renderer
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.play_next_from_queue()
|
||||
self.play_next_from_queue_with_retry()
|
||||
}));
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
@@ -712,7 +713,7 @@ impl MusicRenderer {
|
||||
error!(
|
||||
renderer = self.info.friendly_name(),
|
||||
error = %err,
|
||||
"Auto-advance failed; clearing queue playback state"
|
||||
"Auto-advance failed after retries; clearing queue playback state"
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
}
|
||||
@@ -993,13 +994,69 @@ impl MusicRenderer {
|
||||
self.lock_backend_for("upcoming_len").upcoming_len()
|
||||
}
|
||||
|
||||
/// Play the current item from the queue.
|
||||
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
/// Play the current item from the 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_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
|
||||
// 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);
|
||||
|
||||
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")
|
||||
.play_from_queue()
|
||||
}
|
||||
@@ -1011,11 +1068,50 @@ impl MusicRenderer {
|
||||
// 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_next_from_queue").play_next()?;
|
||||
self.emit_queue_updated();
|
||||
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.
|
||||
///
|
||||
/// Used by the WebRenderer gapless path: the browser autonomously transitions
|
||||
@@ -1033,14 +1129,21 @@ impl MusicRenderer {
|
||||
|
||||
/// Play from a specific index in the queue.
|
||||
pub fn play_from_index(&self, index: usize) -> 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.
|
||||
// ✅ CORRECTIF BUG SHUFFLE: Quand on change d'index manuellement
|
||||
// (par exemple shuffle, clique sur un titre), on réinitialise OBLIGATOIREMENT
|
||||
// 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.lock_backend_for("play_from_index")
|
||||
.play_from_index(index)?;
|
||||
self.emit_queue_updated();
|
||||
|
||||
self.set_playback_source(PlaybackSource::FromQueue);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1063,6 +1166,11 @@ impl MusicRenderer {
|
||||
let queue_not_empty = backend.len().unwrap_or(0) > 0;
|
||||
|
||||
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é)
|
||||
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
|
||||
backend.play_from_queue()
|
||||
@@ -1528,6 +1636,11 @@ impl MusicRenderer {
|
||||
// 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_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) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if matches!(state.playback_source, PlaybackSource::None) {
|
||||
state.playback_source = PlaybackSource::External;
|
||||
if matches!(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
|
||||
self.replace_queue(shuffled_items, Some(0))?;
|
||||
|
||||
// 6. Start playback from the first track
|
||||
self.play_from_index(0)?;
|
||||
// 6. Start playback from the first track with retry for JBL-like renderers
|
||||
// that fail the first Play command due to timing issues
|
||||
self.play_current_from_queue_with_retry()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -218,9 +218,22 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
// 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);
|
||||
*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!(
|
||||
"UpnpRenderer play_from_queue: URI={}, continuous_stream={}",
|
||||
item.uri,
|
||||
"UpnpRenderer play_from_queue: index={:?}/{}, uri={}, title={:?}, continuous_stream={}",
|
||||
queue_state.0,
|
||||
queue_state.1,
|
||||
queue_state.2,
|
||||
queue_state.3,
|
||||
is_stream
|
||||
);
|
||||
|
||||
@@ -243,11 +256,28 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
}
|
||||
|
||||
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();
|
||||
if !queue.advance()? {
|
||||
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()
|
||||
@@ -269,6 +299,9 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -178,7 +178,9 @@ impl MusicQueue {
|
||||
};
|
||||
// Queue lock is released here.
|
||||
// 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!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready triggered, calling callback"
|
||||
@@ -186,12 +188,16 @@ impl MusicQueue {
|
||||
if let Some(f) = real_on_ready {
|
||||
f();
|
||||
}
|
||||
} else if real_on_ready.is_some() {
|
||||
None
|
||||
} else {
|
||||
if real_on_ready.is_some() {
|
||||
tracing::debug!(
|
||||
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 {
|
||||
Err(ControlPointError::SyncCancelled) => {
|
||||
@@ -222,7 +228,7 @@ impl MusicQueue {
|
||||
match pending_items_fn() {
|
||||
Ok(new_items) => {
|
||||
current_items = new_items;
|
||||
current_on_ready = Some(None);
|
||||
current_on_ready = Some(carry_on_ready);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync pending re-fetch error: {}", e);
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.42
|
||||
0.3.45
|
||||
|
||||
Reference in New Issue
Block a user