push-ppmsrvolzpzk #93

Merged
eric merged 5 commits from push-ppmsrvolzpzk into main 2026-04-09 22:37:49 +02:00
12 changed files with 229 additions and 61 deletions

2
Cargo.lock generated
View File

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

View File

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

View File

@@ -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;

View File

@@ -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"}`,
);
}
}

View File

@@ -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"}`,
);
}
}

View File

@@ -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"}`,
);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(())
}

View File

@@ -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()
}

View File

@@ -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);

View File

@@ -1 +1 @@
0.3.42
0.3.45