🎨 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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,3 +50,4 @@ RF_old.json
|
||||
.claude/
|
||||
.claude.old
|
||||
Kilo-session.md
|
||||
pmomusic_logs.txt
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.48"
|
||||
version = "0.3.49"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.48"
|
||||
version = "0.3.49"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
let info = self.fetch_playback_info().map_err(|e| {
|
||||
tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e);
|
||||
return Err(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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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 => {
|
||||
if status.applications.is_empty() {
|
||||
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))
|
||||
}
|
||||
// No media session → device is idle
|
||||
Err(_) => {
|
||||
tracing::debug!("Chromecast playback_state: no media session, returning NoMedia");
|
||||
Ok(PlaybackState::NoMedia)
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Chromecast playback_state: no media entries, returning NoMedia");
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
¤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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
¤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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.48
|
||||
0.3.49
|
||||
|
||||
Reference in New Issue
Block a user