🎨 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
This commit is contained in:
2026-04-13 04:04:24 +02:00
parent 9ad7d7454d
commit 9b5a028f3f
17 changed files with 241 additions and 409 deletions

1
.gitignore vendored
View File

@@ -50,3 +50,4 @@ RF_old.json
.claude/
.claude.old
Kilo-session.md
pmomusic_logs.txt

2
Cargo.lock generated
View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>webapp</title>
</head>
<body>

View File

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

View File

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

View File

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

View File

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

View File

@@ -243,61 +243,24 @@ impl PlaybackStatus for ArylicTcpRenderer {
impl PlaybackPosition for ArylicTcpRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
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,
&current_item.uri,
&current_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)
}
}

View File

@@ -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<CastDevice<'_>, 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<PlaybackState, ControlPointError> {
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<PlaybackPositionInfo, ControlPointError> {
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)
}
}

View File

@@ -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<PlaybackPositionInfo, ControlPointError> {
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,
&current_item.uri,
&current_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)
}
}

View File

@@ -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<B: HasQueue>(
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,
&current_item.uri,
&current_item.protocol_info,
));
}
position.track_uri = Some(current_item.uri.clone());
}
}
impl DeviceIdentity for MusicRenderer {
fn id(&self) -> DeviceId {
self.info.id()

View File

@@ -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,
&current_item.uri,
&current_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)
}
}

View File

@@ -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<Arc<Agent>> = 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<Agent> {
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<SoapCallResult, ControlPointError> {
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(

View File

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

View File

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

View File

@@ -1 +1 @@
0.3.48
0.3.49