From 9b5a028f3f6f91a7ecfe6808b5c3668a682aee15 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 13 Apr 2026 04:04:24 +0200 Subject: [PATCH] :art: Improve mobile safe-area support and refine UPnP/Chromecast rendering - Add viewport-fit=cover to HTML meta for iOS safe-area support - Extend bottom drawers and tab bar into system navigation area using env(safe_area_inset_bottom) - Adjust queue drawer transforms to account for safe-area padding + Add enrich_position_from_queue() helper and integrate across UPnP, Arylic TCP, LinkPlay & Chromecast backends to ensure queue-authoritative metadata + Add transient error retry logic for UPnP control actions (2 retries, 300ms delay) + Separate timeouts: short poll timeout for GetTransportInfo/GetPosition (3s), longer actiontimeout SetAVTURI/SetNext... for slow devices + Remove duplicate continuous-stream detection from play_uri() methods (now handled centrally) - Bump version to 0.3.49 --- .gitignore | 1 + Cargo.lock | 2 +- PMOMusic/Cargo.toml | 2 +- pmoapp/webapp/index.html | 2 +- pmoapp/webapp/src/assets/styles/drawers.css | 3 + .../src/components/unified/BottomTabBar.vue | 7 +- .../components/unified/RendererTabContent.vue | 13 +- pmocontrol/src/errors.rs | 9 + pmocontrol/src/music_renderer/arylic_tcp.rs | 63 +--- .../src/music_renderer/chromecast_renderer.rs | 332 +++++------------- .../src/music_renderer/linkplay_renderer.rs | 34 +- .../src/music_renderer/musicrenderer.rs | 27 ++ .../src/music_renderer/upnp_renderer.rs | 74 +--- pmocontrol/src/soap_client.rs | 44 ++- .../src/upnp_clients/avtransport_client.rs | 10 +- .../upnp_clients/rendering_control_client.rs | 25 +- version.txt | 2 +- 17 files changed, 241 insertions(+), 409 deletions(-) diff --git a/.gitignore b/.gitignore index 9469b02d..679494fd 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ RF_old.json .claude/ .claude.old Kilo-session.md +pmomusic_logs.txt diff --git a/Cargo.lock b/Cargo.lock index fd6c093e..450d1a02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "PMOMusic" -version = "0.3.48" +version = "0.3.49" dependencies = [ "axum 0.8.7", "console-subscriber", diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index 32f6d26a..18df9b9c 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.48" +version = "0.3.49" edition = "2024" [dependencies] diff --git a/pmoapp/webapp/index.html b/pmoapp/webapp/index.html index 3c01cf91..1802a0d7 100644 --- a/pmoapp/webapp/index.html +++ b/pmoapp/webapp/index.html @@ -3,7 +3,7 @@ - + webapp diff --git a/pmoapp/webapp/src/assets/styles/drawers.css b/pmoapp/webapp/src/assets/styles/drawers.css index 1d7adc95..bc87c1af 100644 --- a/pmoapp/webapp/src/assets/styles/drawers.css +++ b/pmoapp/webapp/src/assets/styles/drawers.css @@ -257,6 +257,9 @@ .drawer-footer { padding: var(--spacing-md); + /* On mobile, extend into the safe-area so the footer doesn't sit under + the system navigation bar / home indicator. */ + padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom, 0px)); border-top: 1px solid rgba(255, 255, 255, 0.1); flex-shrink: 0; } diff --git a/pmoapp/webapp/src/components/unified/BottomTabBar.vue b/pmoapp/webapp/src/components/unified/BottomTabBar.vue index 48d6383e..37721e90 100644 --- a/pmoapp/webapp/src/components/unified/BottomTabBar.vue +++ b/pmoapp/webapp/src/components/unified/BottomTabBar.vue @@ -147,7 +147,10 @@ function handleRendererDrawerClick() { align-items: center; gap: var(--spacing-md); height: 72px; - padding: 0 var(--spacing-md); + /* Extend background into the system navigation bar area (iOS home indicator, + Android gesture bar). Content stays in the 72px zone; only the visual + background bleeds into the safe-area below. */ + padding: 0 var(--spacing-md) env(safe-area-inset-bottom, 0px); background: rgba(22, 22, 32, 0.96); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); @@ -323,7 +326,7 @@ function handleRendererDrawerClick() { @media (max-width: 768px) { .bottom-bar { height: 64px; - padding: 0 var(--spacing-sm); + padding: 0 var(--spacing-sm) env(safe-area-inset-bottom, 0px); gap: var(--spacing-sm); } diff --git a/pmoapp/webapp/src/components/unified/RendererTabContent.vue b/pmoapp/webapp/src/components/unified/RendererTabContent.vue index 769c2b91..9dd5c932 100644 --- a/pmoapp/webapp/src/components/unified/RendererTabContent.vue +++ b/pmoapp/webapp/src/components/unified/RendererTabContent.vue @@ -239,10 +239,11 @@ async function handleQueueItemClick(item: QueueItem) { -webkit-backdrop-filter: blur(8px); border-top: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4); - /* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar (64px) */ - transform: translateY(calc(100% - 56px - 64px)); + /* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar. + La BottomTabBar fait 64px + env(safe-area-inset-bottom) de padding. */ + transform: translateY(calc(100% - 56px - 64px - env(safe-area-inset-bottom, 0px))); transition: transform 0.3s ease; - z-index: 95; /* Au-dessus de la BottomTabBar (z-index: 100) */ + z-index: 95; /* En dessous de la BottomTabBar (z-index: 100) */ max-height: 70vh; display: flex; flex-direction: column; @@ -251,8 +252,8 @@ async function handleQueueItemClick(item: QueueItem) { } .queue-drawer.open { - /* Ouvert: remonte mais s'arrête à 64px du bas pour laisser la BottomTabBar accessible */ - transform: translateY(64px); + /* Ouvert: remonte juste au-dessus de la BottomTabBar (64px + safe area) */ + transform: translateY(calc(64px + env(safe-area-inset-bottom, 0px))); pointer-events: auto; /* Ouvert: capture les clics */ } @@ -284,6 +285,8 @@ async function handleQueueItemClick(item: QueueItem) { max-height: calc(70vh - 56px); overflow-y: auto; padding: var(--spacing-md); + /* Ensure the last item clears the safe-area / system nav bar */ + padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom, 0px)); } .queue-drawer-backdrop { diff --git a/pmocontrol/src/errors.rs b/pmocontrol/src/errors.rs index 2d0ef137..8607f034 100644 --- a/pmocontrol/src/errors.rs +++ b/pmocontrol/src/errors.rs @@ -58,6 +58,15 @@ impl ControlPointError { ControlPointError::UpnpOperationNotSupported(operation.to_string(), service.to_string()) } + /// Returns true if the error is a transient transport-level failure that + /// may succeed on retry (TCP refused, connection reset, timeout). + /// + /// Protocol-level errors (UPnP fault, HTTP 4xx/5xx with a valid body) + /// are **not** transient: the device understood the request and rejected it. + pub fn is_transient_soap_error(&self) -> bool { + matches!(self, ControlPointError::SoapAction(_)) + } + pub fn upnp_missing_return_value(value: &str) -> Self { ControlPointError::UpnpMissingReturnValue(value.to_string()) } diff --git a/pmocontrol/src/music_renderer/arylic_tcp.rs b/pmocontrol/src/music_renderer/arylic_tcp.rs index 9b900d29..de91e3d1 100644 --- a/pmocontrol/src/music_renderer/arylic_tcp.rs +++ b/pmocontrol/src/music_renderer/arylic_tcp.rs @@ -243,61 +243,24 @@ impl PlaybackStatus for ArylicTcpRenderer { impl PlaybackPosition for ArylicTcpRenderer { fn playback_position(&self) -> Result { - let info = match self.fetch_playback_info() { - Ok(info) => { - tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info); - info - } - Err(e) => { - tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e); - return Err(e); - } - }; + let info = self.fetch_playback_info().map_err(|e| { + tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e); + e + })?; - let mut position_info = info.position_info(); + tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info); + + let mut position = info.position_info(); tracing::debug!( - "ArylicTcp position_info: track_duration={:?}, rel_time={:?}, track_metadata={:?}, track_uri={:?}", - position_info.track_duration, - position_info.rel_time, - position_info - .track_metadata - .as_ref() - .map(|s| &s[..s.len().min(100)]), - position_info.track_uri + "ArylicTcp position_info: track_duration={:?}, rel_time={:?}", + position.track_duration, + position.rel_time, ); - // Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée) - // Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier) - let mut queue_guard = self.queue.lock().expect("queue mutex poisoned"); - let queue_item = queue_guard.peek_current().ok().flatten(); + // Replace device metadata with queue metadata (queue is authoritative). + crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position); - if let Some((current_item, _)) = queue_item { - // Build DIDL metadata XML from cached/protected TrackMetadata - if let Some(ref metadata) = current_item.metadata { - tracing::debug!( - "ArylicTcp playback_position: using queue metadata - title={:?}, artist={:?}, duration={:?}, is_stream={}", - metadata.title, - metadata.artist, - metadata.duration, - metadata.is_continuous_stream - ); - position_info.track_metadata = Some( - crate::music_renderer::musicrenderer::build_didl_lite_metadata( - metadata, - ¤t_item.uri, - ¤t_item.protocol_info, - ), - ); - } else { - tracing::warn!("ArylicTcp playback_position: queue item has no metadata"); - } - position_info.track_uri = Some(current_item.uri.clone()); - } else { - tracing::warn!("ArylicTcp playback_position: no current queue item"); - } - drop(queue_guard); - - Ok(position_info) + Ok(position) } } diff --git a/pmocontrol/src/music_renderer/chromecast_renderer.rs b/pmocontrol/src/music_renderer/chromecast_renderer.rs index 28d79723..4a3934b5 100644 --- a/pmocontrol/src/music_renderer/chromecast_renderer.rs +++ b/pmocontrol/src/music_renderer/chromecast_renderer.rs @@ -36,7 +36,7 @@ use crate::DeviceIdentity; use rust_cast::{ channels::{ heartbeat::HeartbeatResponse, - media::{Media, PlayerState as CastPlayerState, StreamType}, + media::{Media, PlayerState as CastPlayerState, StatusEntry, StreamType}, receiver::CastDeviceApp, }, CastDevice, ChannelMessage, @@ -175,6 +175,50 @@ impl ChromecastRenderer { *self.continuous_stream.lock().expect("continuous_stream mutex poisoned") } + /// Returns `(transport_id, media_entry)` for the currently active Cast session. + /// + /// This encapsulates the repeated sequence: + /// connect device → get receiver status → get active app → connect to app → get media status + /// + /// Used by all transport operations (play/pause/stop/seek) and by + /// playback_state/playback_position to avoid duplicating this boilerplate. + fn get_active_media_entry<'d>( + &self, + device: &'d CastDevice<'d>, + ) -> Result<(String, StatusEntry), ControlPointError> { + let status = device.receiver.get_status().map_err(|e| { + ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) + })?; + + let app = status + .applications + .into_iter() + .next() + .ok_or_else(|| ControlPointError::ChromecastError("No active app found".into()))?; + + device + .connection + .connect(app.transport_id.as_str()) + .map_err(|e| { + ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) + })?; + + let media_status = device + .media + .get_status(app.transport_id.as_str(), None) + .map_err(|e| { + ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) + })?; + + let entry = media_status + .entries + .into_iter() + .next() + .ok_or_else(|| ControlPointError::ChromecastError("No media session found".into()))?; + + Ok((app.transport_id, entry)) + } + /// Connect to the device with retry on connection failures. /// Uses exponential backoff: 200ms, 400ms, 800ms fn connect_with_retry(&self) -> Result, ControlPointError> { @@ -203,15 +247,6 @@ impl TransportControl for ChromecastRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: play_uri({})", uri); - // Détecte si l'URL est un flux continu - let is_stream = crate::music_renderer::is_continuous_stream_url(uri); - *self.continuous_stream.lock().expect("continuous_stream mutex poisoned") = is_stream; - tracing::debug!( - "ChromecastRenderer play_uri: URI={}, continuous_stream={}", - uri, - is_stream - ); - // Signal any existing play thread to stop if let Ok(mut stop) = self.stop_signal.lock() { *stop = true; @@ -378,186 +413,53 @@ impl TransportControl for ChromecastRenderer { fn play(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: play()"); - let device = self.connect_with_retry()?; - - // Get receiver status to find the active app - let status = device.receiver.get_status().map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) - })?; - - let app = status - .applications - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; - - // Connect to the app - device - .connection - .connect(app.transport_id.as_str()) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) - })?; - - // Get media status - let media_status = device - .media - .get_status(app.transport_id.as_str(), None) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) - })?; - - let media_entry = media_status - .entries - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; - - // Send play command + let (transport_id, entry) = self.get_active_media_entry(&device)?; device .media - .play(app.transport_id.as_str(), media_entry.media_session_id) + .play(transport_id.as_str(), entry.media_session_id) .map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?; - Ok(()) } fn pause(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: pause()"); - let device = self.connect_with_retry()?; - - let status = device.receiver.get_status().map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) - })?; - - let app = status - .applications - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; - - device - .connection - .connect(app.transport_id.as_str()) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) - })?; - - let media_status = device - .media - .get_status(app.transport_id.as_str(), None) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) - })?; - - let media_entry = media_status - .entries - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; - + let (transport_id, entry) = self.get_active_media_entry(&device)?; device .media - .pause(app.transport_id.as_str(), media_entry.media_session_id) + .pause(transport_id.as_str(), entry.media_session_id) .map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?; - Ok(()) } fn stop(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: stop()"); - // Signal the play thread to stop + // Signal the play thread to stop first. + // The thread terminates on its own; play_uri() will wait for it if needed. if let Ok(mut stop) = self.stop_signal.lock() { *stop = true; } - // Note: We don't wait for the thread here as stop() should be quick. - // The thread will terminate on its own when it checks stop_signal. - // If a new play_uri() is called, it will properly wait for this thread. - - // Also send stop command to the device let device = self.connect_with_retry()?; - - let status = device.receiver.get_status().map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) - })?; - - let app = status - .applications - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; - - device - .connection - .connect(app.transport_id.as_str()) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) - })?; - - let media_status = device - .media - .get_status(app.transport_id.as_str(), None) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) - })?; - - let media_entry = media_status - .entries - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; - + let (transport_id, entry) = self.get_active_media_entry(&device)?; device .media - .stop(app.transport_id.as_str(), media_entry.media_session_id) + .stop(transport_id.as_str(), entry.media_session_id) .map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?; - Ok(()) } fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: seek_rel_time({})", hhmmss); - let total_seconds = parse_hhmmss_strict(hhmmss)? as f32; - let device = self.connect_with_retry()?; - - let status = device.receiver.get_status().map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) - })?; - - let app = status - .applications - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; - - device - .connection - .connect(app.transport_id.as_str()) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) - })?; - - let media_status = device - .media - .get_status(app.transport_id.as_str(), None) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) - })?; - - let media_entry = media_status - .entries - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; - + let (transport_id, entry) = self.get_active_media_entry(&device)?; device .media - .seek( - app.transport_id.as_str(), - media_entry.media_session_id, - Some(total_seconds), - None, - ) + .seek(transport_id.as_str(), entry.media_session_id, Some(total_seconds), None) .map_err(|e| ControlPointError::ChromecastError(format!("Failed to seek: {}", e)))?; - Ok(()) } } @@ -566,125 +468,57 @@ impl PlaybackStatus for ChromecastRenderer { fn playback_state(&self) -> Result { let device = self.connect_with_retry()?; - // Get receiver status to find the active app + // If no app is running there is no media — return NoMedia without error. let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; + tracing::debug!("Chromecast playback_state: {} apps running", status.applications.len()); - tracing::debug!( - "Chromecast playback_state: {} apps running", - status.applications.len() - ); + if status.applications.is_empty() { + tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia"); + return Ok(PlaybackState::NoMedia); + } - // If no app is running, return NoMedia - let app = match status.applications.first() { - Some(app) => { - tracing::debug!("Chromecast playback_state: app={}", app.display_name); - app - } - None => { - tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia"); - return Ok(PlaybackState::NoMedia); - } - }; - - // Connect to the app - device - .connection - .connect(app.transport_id.as_str()) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) - })?; - - // Get media status - let media_status = device - .media - .get_status(app.transport_id.as_str(), None) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) - })?; - - tracing::debug!( - "Chromecast playback_state: {} media entries", - media_status.entries.len() - ); - - // If no media entry, return NoMedia - let media_entry = match media_status.entries.first() { - Some(entry) => { + match self.get_active_media_entry(&device) { + Ok((_, entry)) => { tracing::debug!( "Chromecast playback_state: player_state={:?}, current_time={:?}", - entry.player_state, - entry.current_time + entry.player_state, entry.current_time ); - entry + Ok(map_player_state(&entry.player_state)) } - None => { - tracing::debug!("Chromecast playback_state: no media entries, returning NoMedia"); - return Ok(PlaybackState::NoMedia); + // No media session → device is idle + Err(_) => { + tracing::debug!("Chromecast playback_state: no media session, returning NoMedia"); + Ok(PlaybackState::NoMedia) } - }; - - Ok(map_player_state(&media_entry.player_state)) + } } } impl PlaybackPosition for ChromecastRenderer { fn playback_position(&self) -> Result { let device = self.connect_with_retry()?; + let (_, entry) = self.get_active_media_entry(&device)?; - // Get receiver status to find the active app - let status = device.receiver.get_status().map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) - })?; + let rel_time = entry.current_time.map(|t| format_hhmmss_f64(t as f64)); + let track_duration = entry.media.as_ref().and_then(|m| m.duration).map(|d| format_hhmmss_f64(d as f64)); + let track_uri = entry.media.as_ref().map(|m| m.content_id.clone()); - let app = status - .applications - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; - - // Connect to the app - device - .connection - .connect(app.transport_id.as_str()) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) - })?; - - // Get media status - let media_status = device - .media - .get_status(app.transport_id.as_str(), None) - .map_err(|e| { - ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) - })?; - - let media_entry = media_status - .entries - .first() - .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; - - // Extract position information - let rel_time = media_entry - .current_time - .map(|time| format_hhmmss_f64(time as f64)); - - let track_duration = media_entry - .media - .as_ref() - .and_then(|m| m.duration) - .map(|dur| format_hhmmss_f64(dur as f64)); - - let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone()); - - Ok(PlaybackPositionInfo { + let mut position = PlaybackPositionInfo { track: Some(1), rel_time, abs_time: None, track_duration, - track_metadata: None, // Chromecast doesn't use DIDL-Lite + track_metadata: None, track_uri, - }) + }; + + // Replace device metadata with queue metadata (queue is authoritative; + // Chromecast does not return DIDL-Lite natively so the queue is the only source). + crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position); + + Ok(position) } } diff --git a/pmocontrol/src/music_renderer/linkplay_renderer.rs b/pmocontrol/src/music_renderer/linkplay_renderer.rs index 626f0962..3ebc193d 100644 --- a/pmocontrol/src/music_renderer/linkplay_renderer.rs +++ b/pmocontrol/src/music_renderer/linkplay_renderer.rs @@ -97,15 +97,6 @@ impl LinkPlayRenderer { impl TransportControl for LinkPlayRenderer { fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> { - // Détecte si l'URL est un flux continu - let is_stream = crate::music_renderer::is_continuous_stream_url(uri); - *self.continuous_stream.lock().expect("continuous_stream mutex poisoned") = is_stream; - tracing::debug!( - "LinkPlayRenderer play_uri: URI={}, continuous_stream={}", - uri, - is_stream - ); - let encoded = percent_encode(uri); self.send_player_command(&format!("play:{}", encoded)) } @@ -155,27 +146,10 @@ impl PlaybackStatus for LinkPlayRenderer { impl PlaybackPosition for LinkPlayRenderer { fn playback_position(&self) -> Result { - let mut position_info = self.fetch_status()?.position_info(); - - // Use queue metadata instead of direct status metadata to benefit from duration protection - let mut queue_guard = self.queue.lock().expect("queue mutex poisoned"); - let queue_item = queue_guard.peek_current().ok().flatten(); - - if let Some((current_item, _)) = queue_item { - if let Some(ref metadata) = current_item.metadata { - position_info.track_metadata = Some( - crate::music_renderer::musicrenderer::build_didl_lite_metadata( - metadata, - ¤t_item.uri, - ¤t_item.protocol_info, - ), - ); - } - position_info.track_uri = Some(current_item.uri.clone()); - } - drop(queue_guard); - - Ok(position_info) + let mut position = self.fetch_status()?.position_info(); + // Replace device metadata with queue metadata (queue is authoritative). + crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position); + Ok(position) } } diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 99c03f09..5d347e8d 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -1948,6 +1948,33 @@ pub(crate) fn build_didl_lite_metadata( didl.to_xml() } +/// Enriches a [`PlaybackPositionInfo`] with metadata from the backend's queue. +/// +/// Overwrites `track_metadata` and `track_uri` with the values stored for the +/// current queue item. This is a no-op when the queue is empty or has no +/// current item. +/// +/// All backends that hold a local queue (UPnP, LinkPlay, Arylic, Chromecast) +/// should call this after fetching the raw device position, so that callers +/// always receive consistent, queue-authoritative metadata rather than +/// potentially stale or absent metadata coming from the device itself. +pub(crate) fn enrich_position_from_queue( + backend: &B, + position: &mut PlaybackPositionInfo, +) { + let mut queue = backend.queue().lock().expect("queue mutex poisoned"); + if let Ok(Some((current_item, _))) = queue.peek_current() { + if let Some(ref metadata) = current_item.metadata { + position.track_metadata = Some(build_didl_lite_metadata( + metadata, + ¤t_item.uri, + ¤t_item.protocol_info, + )); + } + position.track_uri = Some(current_item.uri.clone()); + } +} + impl DeviceIdentity for MusicRenderer { fn id(&self) -> DeviceId { self.info.id() diff --git a/pmocontrol/src/music_renderer/upnp_renderer.rs b/pmocontrol/src/music_renderer/upnp_renderer.rs index 78b364c0..01c1e57c 100644 --- a/pmocontrol/src/music_renderer/upnp_renderer.rs +++ b/pmocontrol/src/music_renderer/upnp_renderer.rs @@ -231,15 +231,6 @@ impl TransportControl 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(uri); - *self.continuous_stream.lock().expect("continuous_stream mutex poisoned") = is_stream; - tracing::debug!( - "UpnpRenderer play_uri: URI={}, continuous_stream={}", - uri, - is_stream - ); - // Parse le DIDL pour extraire la durée (fallback pour certains amplis) let duration = parse_didl_duration(meta); if let Some(ref dur) = duration { @@ -326,57 +317,30 @@ impl PlaybackPosition for UpnpRenderer { ); // Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration) - let normalized_duration = raw.track_duration.as_ref().and_then(|d| { - if d == "00:00:00" || d == "0:00:00" { - None - } else { - Some(d.clone()) - } + let track_duration = raw.track_duration.as_ref().and_then(|d| { + if d == "00:00:00" || d == "0:00:00" { None } else { Some(d.clone()) } }); - let track_duration = normalized_duration; - - // Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée) - // plutôt que depuis GetPositionInfo qui peut retourner des métadonnées obsolètes - let mut track_metadata_xml = None; - let mut track_uri = raw.track_uri.clone(); - - let mut queue_guard = self.queue.lock().expect("queue mutex poisoned"); - - // Récupérer l'item courant de la queue - // Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier) - let queue_item = queue_guard.peek_current().ok().flatten(); - - if let Some((current_item, _)) = queue_item { - track_uri = Some(current_item.uri.clone()); - - // Build DIDL metadata XML from cached/protected TrackMetadata - if let Some(ref metadata) = current_item.metadata { - track_metadata_xml = Some( - crate::music_renderer::musicrenderer::build_didl_lite_metadata( - metadata, - ¤t_item.uri, - ¤t_item.protocol_info, - ), - ); - } - } - drop(queue_guard); - - tracing::trace!( - "UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}", - track_duration, - raw.rel_time, - track_metadata_xml.is_some() - ); - - Ok(PlaybackPositionInfo { + let mut position = PlaybackPositionInfo { track: Some(raw.track), rel_time: raw.rel_time, abs_time: raw.abs_time, track_duration, - track_metadata: track_metadata_xml, - track_uri, - }) + track_metadata: None, + track_uri: raw.track_uri, + }; + + // Replace device metadata with queue metadata (queue is authoritative and protected + // against stale / decreasing durations for streams). + crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position); + + tracing::trace!( + "UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}", + position.track_duration, + position.rel_time, + position.track_metadata.is_some() + ); + + Ok(position) } } diff --git a/pmocontrol/src/soap_client.rs b/pmocontrol/src/soap_client.rs index 4b91d4f6..3c54ef35 100644 --- a/pmocontrol/src/soap_client.rs +++ b/pmocontrol/src/soap_client.rs @@ -4,13 +4,18 @@ use std::time::Duration; use anyhow::{Context, Result}; use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope}; -use tracing::{debug, trace, warn}; +use tracing::{debug, trace, warn, info}; use ureq::Agent; use crate::errors::ControlPointError; static SOAP_AGENT: OnceLock> = OnceLock::new(); +/// Number of retries for transient transport failures on control actions. +const SOAP_CONTROL_MAX_RETRIES: usize = 2; +/// Delay between retries (ms). Kept short: devices usually recover in <200 ms. +const SOAP_CONTROL_RETRY_DELAY_MS: u64 = 300; + fn get_soap_agent() -> Arc { SOAP_AGENT .get_or_init(|| { @@ -43,19 +48,44 @@ pub fn build_soap_body( build_soap_request(service_type, action, args) } -/// Invoke a UPnP SOAP action on a control URL. +/// Invoke a UPnP SOAP control action with automatic retry on transient failures. /// -/// - `control_url`: full HTTP URL of the service control endpoint -/// - `service_type`: service URN -/// - `action`: action name -/// - `args`: list of (name, value) +/// Use this for **control operations** (Play, Pause, Stop, SetAVTransportURI, …) +/// where a transient TCP error should be absorbed silently. +/// +/// Retries are attempted only on [`ControlPointError::is_transient_soap_error`] +/// (i.e. transport-level failures). Protocol-level errors (UPnP faults, +/// HTTP 4xx/5xx with a valid body) propagate immediately without retry. +/// +/// For **polling reads** (GetTransportInfo, GetPositionInfo, …) prefer +/// [`invoke_upnp_action_with_timeout`] directly so that a slow device does +/// not hold the watcher thread for multiple retry cycles. pub fn invoke_upnp_action( control_url: &str, service_type: &str, action: &str, args: &[(&str, &str)], ) -> Result { - invoke_upnp_action_with_timeout(control_url, service_type, action, args, None) + let retry_delay = Duration::from_millis(SOAP_CONTROL_RETRY_DELAY_MS); + + for attempt in 0..=SOAP_CONTROL_MAX_RETRIES { + match invoke_upnp_action_with_timeout(control_url, service_type, action, args, None) { + Ok(result) => return Ok(result), + Err(e) if e.is_transient_soap_error() && attempt < SOAP_CONTROL_MAX_RETRIES => { + info!( + url = control_url, + action = action, + attempt = attempt + 1, + error = %e, + "Transient SOAP error, retrying" + ); + std::thread::sleep(retry_delay); + } + Err(e) => return Err(e), + } + } + // Unreachable: the loop either returns Ok or propagates Err above. + unreachable!() } pub fn invoke_upnp_action_with_timeout( diff --git a/pmocontrol/src/upnp_clients/avtransport_client.rs b/pmocontrol/src/upnp_clients/avtransport_client.rs index 7bd16166..b824a95e 100644 --- a/pmocontrol/src/upnp_clients/avtransport_client.rs +++ b/pmocontrol/src/upnp_clients/avtransport_client.rs @@ -12,7 +12,11 @@ use crate::{ use pmoupnp::soap::SoapEnvelope; use xmltree::{Element, XMLNode}; +/// Timeout for slow/long control actions (SetAVTransportURI, SetNextAVTransportURI). const AVTRANSPORT_ACTION_TIMEOUT: Duration = Duration::from_secs(5); +/// Timeout for fast polling-read actions (GetTransportInfo, GetPositionInfo). +/// Must be well below the watcher short-interval (500 ms) to avoid cascading lateness. +const AVTRANSPORT_POLL_TIMEOUT: Duration = Duration::from_secs(3); #[derive(Debug, Clone)] pub struct AvTransportClient { @@ -40,11 +44,12 @@ impl AvTransportClient { let instance_id_str = instance_id.to_string(); let args = [("InstanceID", instance_id_str.as_str())]; - let call_result = invoke_upnp_action( + let call_result = invoke_upnp_action_with_timeout( &self.control_url, &self.service_type, "GetTransportInfo", &args, + Some(AVTRANSPORT_POLL_TIMEOUT), )?; if !call_result.status.is_success() { @@ -381,11 +386,12 @@ impl AvTransportClient { let instance_id_str = instance_id.to_string(); let args = [("InstanceID", instance_id_str.as_str())]; - let call_result = invoke_upnp_action( + let call_result = invoke_upnp_action_with_timeout( &self.control_url, &self.service_type, "GetPositionInfo", &args, + Some(AVTRANSPORT_POLL_TIMEOUT), )?; if !call_result.status.is_success() { diff --git a/pmocontrol/src/upnp_clients/rendering_control_client.rs b/pmocontrol/src/upnp_clients/rendering_control_client.rs index 3e7108d1..900cdc62 100644 --- a/pmocontrol/src/upnp_clients/rendering_control_client.rs +++ b/pmocontrol/src/upnp_clients/rendering_control_client.rs @@ -1,12 +1,17 @@ +use std::time::Duration; + use crate::{ errors::ControlPointError, soap_client::{ ensure_success, extract_child_text, find_child_with_suffix, handle_action_response, - invoke_upnp_action, parse_upnp_error, + invoke_upnp_action, invoke_upnp_action_with_timeout, parse_upnp_error, }, }; use tracing::debug; +/// Timeout for fast polling-read actions (GetVolume, GetMute). +const RENDERING_CONTROL_POLL_TIMEOUT: Duration = Duration::from_secs(3); + #[derive(Debug, Clone)] pub struct RenderingControlClient { pub control_url: String, @@ -30,8 +35,13 @@ impl RenderingControlClient { ("Channel", channel), ]; - let call_result = - invoke_upnp_action(&self.control_url, &self.service_type, "GetVolume", &args)?; + let call_result = invoke_upnp_action_with_timeout( + &self.control_url, + &self.service_type, + "GetVolume", + &args, + Some(RENDERING_CONTROL_POLL_TIMEOUT), + )?; ensure_success("GetVolume", &call_result)?; @@ -90,8 +100,13 @@ impl RenderingControlClient { ("Channel", channel), ]; - let call_result = - invoke_upnp_action(&self.control_url, &self.service_type, "GetMute", &args)?; + let call_result = invoke_upnp_action_with_timeout( + &self.control_url, + &self.service_type, + "GetMute", + &args, + Some(RENDERING_CONTROL_POLL_TIMEOUT), + )?; ensure_success("GetMute", &call_result)?; diff --git a/version.txt b/version.txt index 2fb885a2..b3682c13 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.48 +0.3.49