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
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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.42"
|
version = "0.3.45"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
void fetchRendererSnapshot(rendererId, { force: true });
|
||||||
// Programmer un seul fetch après stabilisation
|
|
||||||
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
|
|
||||||
queueUpdateDebounceTimers.delete(rendererId);
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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.
|
/// 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> {
|
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
// 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);
|
||||||
|
|
||||||
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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.42
|
0.3.45
|
||||||
|
|||||||
Reference in New Issue
Block a user