- 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
167 lines
5.3 KiB
Rust
167 lines
5.3 KiB
Rust
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, 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,
|
|
pub service_type: String,
|
|
}
|
|
|
|
impl RenderingControlClient {
|
|
pub fn new(control_url: String, service_type: String) -> Self {
|
|
Self {
|
|
control_url,
|
|
service_type,
|
|
}
|
|
}
|
|
|
|
/// RenderingControl:1 — GetVolume
|
|
pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result<u16, ControlPointError> {
|
|
debug!("GetVolume: {}", instance_id);
|
|
let instance_id_str = instance_id.to_string();
|
|
let args = [
|
|
("InstanceID", instance_id_str.as_str()),
|
|
("Channel", channel),
|
|
];
|
|
|
|
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)?;
|
|
|
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
|
ControlPointError::UpnpError(format!("Missing SOAP envelope in GetVolume response"))
|
|
})?;
|
|
|
|
if let Some(err) = parse_upnp_error(envelope) {
|
|
return Err(ControlPointError::UpnpError(format!(
|
|
"GetVolume returned UPnP error {}: {} (HTTP status {})",
|
|
err.error_code, err.error_description, call_result.status
|
|
)));
|
|
}
|
|
|
|
let response = find_child_with_suffix(&envelope.body.content, "GetVolumeResponse")
|
|
.ok_or_else(|| {
|
|
ControlPointError::UpnpError(format!(
|
|
"Missing GetVolumeResponse element in SOAP body"
|
|
))
|
|
})?;
|
|
|
|
let text = extract_child_text(response, "CurrentVolume")?;
|
|
let volume = text.parse::<u16>().map_err(|_| {
|
|
ControlPointError::UpnpError(format!("Invalid CurrentVolume value: {}", text))
|
|
})?;
|
|
|
|
Ok(volume)
|
|
}
|
|
|
|
/// RenderingControl:1 — SetVolume
|
|
pub fn set_volume(
|
|
&self,
|
|
instance_id: u32,
|
|
channel: &str,
|
|
volume: u16,
|
|
) -> Result<(), ControlPointError> {
|
|
let instance_id_str = instance_id.to_string();
|
|
let volume_str = volume.to_string();
|
|
let args = [
|
|
("InstanceID", instance_id_str.as_str()),
|
|
("Channel", channel),
|
|
("DesiredVolume", volume_str.as_str()),
|
|
];
|
|
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?;
|
|
|
|
handle_action_response("SetVolume", &call_result)
|
|
}
|
|
|
|
/// RenderingControl:1 — GetMute
|
|
pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result<bool, ControlPointError> {
|
|
let instance_id_str = instance_id.to_string();
|
|
let args = [
|
|
("InstanceID", instance_id_str.as_str()),
|
|
("Channel", channel),
|
|
];
|
|
|
|
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)?;
|
|
|
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
|
ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response"))
|
|
})?;
|
|
|
|
if let Some(err) = parse_upnp_error(envelope) {
|
|
return Err(ControlPointError::UpnpError(format!(
|
|
"GetMute returned UPnP error {}: {} (HTTP status {})",
|
|
err.error_code, err.error_description, call_result.status
|
|
)));
|
|
}
|
|
|
|
let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse")
|
|
.ok_or_else(|| {
|
|
ControlPointError::UpnpError(format!(
|
|
"Missing GetMuteResponse element in SOAP body"
|
|
))
|
|
})?;
|
|
|
|
let text = extract_child_text(response, "CurrentMute")?;
|
|
let mute = match text.as_str() {
|
|
"0" => false,
|
|
"1" => true,
|
|
_ => {
|
|
return Err(ControlPointError::UpnpError(format!(
|
|
"Invalid CurrentMute value: {} (expected 0 or 1)",
|
|
text
|
|
)));
|
|
}
|
|
};
|
|
|
|
Ok(mute)
|
|
}
|
|
|
|
/// RenderingControl:1 — SetMute
|
|
pub fn set_mute(
|
|
&self,
|
|
instance_id: u32,
|
|
channel: &str,
|
|
mute: bool,
|
|
) -> Result<(), ControlPointError> {
|
|
let instance_id_str = instance_id.to_string();
|
|
let mute_str = if mute { "1" } else { "0" };
|
|
let args = [
|
|
("InstanceID", instance_id_str.as_str()),
|
|
("Channel", channel),
|
|
("DesiredMute", mute_str),
|
|
];
|
|
|
|
let call_result =
|
|
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
|
|
|
|
handle_action_response("SetMute", &call_result)
|
|
}
|
|
}
|