- 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
347 lines
12 KiB
Rust
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)
|
|
}
|
|
}
|