Files
pmomusic/pmocontrol/src/music_renderer/upnp_renderer.rs
Eric Coissac 9b5a028f3f 🎨 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
2026-04-13 04:14:18 +02:00

347 lines
12 KiB
Rust

use std::sync::{atomic::AtomicBool, Arc, Mutex};
use crate::errors::ControlPointError;
use crate::model::PlaybackState;
use crate::music_renderer::capabilities::{
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::{
build_didl_lite_metadata, parse_didl_duration, MusicRendererBackend,
};
use crate::music_renderer::HasQueue;
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::upnp_clients::{
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
RenderingControlClient,
};
use crate::{DeviceIdentity, RendererInfo};
/// High-level handle representing a renderer and its optional AVTransport client.
#[derive(Clone, Debug)]
pub struct UpnpRenderer {
avtransport: Option<AvTransportClient>,
rendering_control: Option<RenderingControlClient>,
connection_manager: Option<ConnectionManagerClient>,
has_avtransport_set_next: bool,
queue: Arc<Mutex<MusicQueue>>,
/// Durée extraite du DIDL-Lite (fallback si l'ampli ne la retourne pas)
cached_duration: Arc<Mutex<Option<String>>>,
/// Flag indicating if currently playing a continuous stream (radio without duration)
continuous_stream: Arc<Mutex<bool>>,
}
impl UpnpRenderer {
/// Retourne true si le renderer à un service de type AVTransport.
pub fn has_avtransport(&self) -> bool {
self.avtransport.is_some()
}
/// Retourne true si le renderer à un service de type RenderingControl.
pub fn has_rendering_control(&self) -> bool {
self.rendering_control.is_some()
}
/// Retourne true si le renderer à un service de type ConnectionManager.
pub fn has_connection_manager(&self) -> bool {
self.connection_manager.is_some()
}
/// Retourne le client de type AVTransport contenant les URL de controle et d'abonnement.
pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> {
self.avtransport.as_ref().ok_or_else(|| {
ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer")
})
}
/// Retourne le client de type RenderingControl contenant les URL de controle et d'abonnement.
pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> {
self.rendering_control.as_ref().ok_or_else(|| {
ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer")
})
}
/// Retourne le client de type ConnectionManager contenant les URL de controle et d'abonnement.
pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> {
self.connection_manager.as_ref().ok_or_else(|| {
ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer")
})
}
pub fn protocol_info(&self) -> Result<ProtocolInfo, ControlPointError> {
let cm = self.connection_manager()?;
cm.get_protocol_info()
}
pub fn connection_ids(&self) -> Result<Vec<i32>, ControlPointError> {
let cm = self.connection_manager()?;
cm.get_current_connection_ids()
}
pub fn connection_info(&self, connection_id: i32) -> Result<ConnectionInfo, ControlPointError> {
let cm = self.connection_manager()?;
cm.get_current_connection_info(connection_id)
}
pub fn set_next_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
if !self.has_avtransport_set_next {
return Err(ControlPointError::upnp_operation_not_supported(
"SetNextAVTransportURI",
"Renderer",
));
}
let avt = self.avtransport()?;
avt.set_next_av_transport_uri(uri, meta)
}
}
impl UpnpRenderer {
pub fn new(
avtransport: Option<AvTransportClient>,
rendering_control: Option<RenderingControlClient>,
connection_manager: Option<ConnectionManagerClient>,
has_avtransport_set_next: bool,
queue: Arc<Mutex<MusicQueue>>,
) -> Self {
Self {
avtransport,
rendering_control,
connection_manager,
has_avtransport_set_next,
queue,
cached_duration: Arc::new(Mutex::new(None)),
continuous_stream: Arc::new(Mutex::new(false)),
}
}
/// Returns true if currently playing a continuous stream (radio without duration)
pub fn is_continuous_stream(&self) -> bool {
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
}
}
impl RendererFromMediaRendererInfo for UpnpRenderer {
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
// Prepare le service AVTTransport
let avtransport = match (
info.avtransport_control_url(),
info.avtransport_service_type(),
) {
(Some(url), Some(service)) => Some(AvTransportClient::new(url, service)),
_ => None,
};
// Prepare le service RenderingControl
let rendering_control = match (
info.rendering_control_control_url(),
info.rendering_control_service_type(),
) {
(Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)),
_ => None,
};
// Prepare le service ConnectionManager
let connection_manager = match (
info.connection_manager_control_url(),
info.connection_manager_service_type(),
) {
(Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)),
_ => None,
};
// Exige AVTTransport et RenderingControl au minimum
if avtransport.is_none() || rendering_control.is_none() {
return Err(ControlPointError::UpnpError(format!(
"Some mandatory services are missing on {:?}",
info.id(),
)));
}
// Create the internal queue
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
Ok(Self {
avtransport,
rendering_control,
connection_manager,
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
queue,
cached_duration: Arc::new(Mutex::new(None)),
continuous_stream: Arc::new(Mutex::new(false)),
})
}
fn to_backend(self) -> MusicRendererBackend {
MusicRendererBackend::Upnp(self)
}
}
impl QueueTransportControl for UpnpRenderer {
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
let metadata = if let Some(ref track_metadata) = item.metadata {
build_didl_lite_metadata(track_metadata, &item.uri, &item.protocol_info)
} else {
format!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="0" parentID="-1" restricted="1"><res protocolInfo="{}">{}</res></item></DIDL-Lite>"#,
item.protocol_info, item.uri
)
};
let duration = parse_didl_duration(&metadata);
if let Some(ref dur) = duration {
tracing::debug!("Caching duration from queue DIDL: {}", dur);
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = Some(dur.clone());
} else {
tracing::debug!("No duration to cache from queue DIDL");
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = None;
}
let avt = self.avtransport()?;
avt.set_av_transport_uri(&item.uri, &metadata)?;
avt.play(0, "1")?;
Ok(())
}
}
impl HasQueue for UpnpRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl HasContinuousStream for UpnpRenderer {
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
&self.continuous_stream
}
}
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
///
/// Cette impl se base sur AVTransport (InstanceID = 0).
impl TransportControl for UpnpRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
// Log du DIDL complet pour déboguer
if !meta.is_empty() {
tracing::debug!(
"play_uri DIDL-Lite metadata: {}",
&meta[..meta.len().min(500)]
);
}
// Parse le DIDL pour extraire la durée (fallback pour certains amplis)
let duration = parse_didl_duration(meta);
if let Some(ref dur) = duration {
tracing::debug!("Caching duration from DIDL: {}", dur);
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = Some(dur.clone());
} else {
tracing::debug!("No duration to cache from DIDL");
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = None;
}
let avt = self.avtransport()?;
avt.set_av_transport_uri(uri, meta)?;
avt.play(0, "1")
}
fn play(&self) -> Result<(), ControlPointError> {
let avt = self.avtransport()?;
avt.play(0, "1")
}
fn pause(&self) -> Result<(), ControlPointError> {
let avt = self.avtransport()?;
avt.pause(0)
}
fn stop(&self) -> Result<(), ControlPointError> {
let avt = self.avtransport()?;
avt.stop(0)
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
let avt = self.avtransport()?;
avt.seek(0, "REL_TIME", hhmmss)
}
}
/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`].
///
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
impl VolumeControl for UpnpRenderer {
fn volume(&self) -> Result<u16, ControlPointError> {
let rc = self.rendering_control()?;
rc.get_volume(0, "Master")
}
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
let rc = self.rendering_control()?;
rc.set_volume(0, "Master", v)
}
fn mute(&self) -> Result<bool, ControlPointError> {
let rc = self.rendering_control()?;
rc.get_mute(0, "Master")
}
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
let rc = self.rendering_control()?;
rc.set_mute(0, "Master", m)
}
}
/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`].
///
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
impl PlaybackStatus for UpnpRenderer {
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
let avt = self.avtransport()?;
let info = avt.get_transport_info(0)?;
Ok(PlaybackState::from_upnp_state(
&info.current_transport_state,
))
}
}
impl PlaybackPosition for UpnpRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
let avt = self.avtransport()?;
let raw: PositionInfo = avt.get_position_info(0)?;
tracing::trace!(
"UPnP GetPositionInfo returned: track_duration={:?}, rel_time={:?}",
raw.track_duration,
raw.rel_time
);
// Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration)
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 mut position = PlaybackPositionInfo {
track: Some(raw.track),
rel_time: raw.rel_time,
abs_time: raw.abs_time,
track_duration,
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)
}
}