- 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
417 lines
13 KiB
Rust
417 lines
13 KiB
Rust
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use serde::Deserialize;
|
|
use tracing::debug;
|
|
|
|
use crate::arylic_client::{
|
|
send_command_no_response, send_command_optional, send_command_required, ARYLIC_TCP_PORT,
|
|
DEFAULT_TIMEOUT_SECS,
|
|
};
|
|
use crate::errors::ControlPointError;
|
|
use crate::linkplay_client::extract_linkplay_host;
|
|
use crate::model::{PlaybackState, RendererInfo};
|
|
use crate::music_renderer::capabilities::{
|
|
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
|
QueueTransportControl, TransportControl, VolumeControl,
|
|
};
|
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
|
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
|
use crate::music_renderer::HasQueue;
|
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
|
use crate::DeviceIdentity;
|
|
|
|
/// Raw response from Arylic MCU+PINFGET command
|
|
#[derive(Debug, Deserialize)]
|
|
struct ArylicPlaybackInfoRaw {
|
|
status: String,
|
|
curpos: String,
|
|
totlen: String,
|
|
#[serde(default)]
|
|
vol: Option<String>,
|
|
#[serde(default)]
|
|
mute: Option<String>,
|
|
#[serde(default)]
|
|
plicount: Option<String>,
|
|
#[serde(default)]
|
|
plicurr: Option<String>,
|
|
}
|
|
|
|
/// Backend speaking the Arylic TCP control protocol (port 8899).
|
|
#[derive(Clone, Debug)]
|
|
pub struct ArylicTcpRenderer {
|
|
host: String,
|
|
port: u16,
|
|
timeout: Duration,
|
|
queue: Arc<Mutex<MusicQueue>>,
|
|
/// Flag indicating if currently playing a continuous stream (radio without duration)
|
|
continuous_stream: Arc<Mutex<bool>>,
|
|
}
|
|
|
|
impl ArylicTcpRenderer {
|
|
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String, ControlPointError> {
|
|
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
|
|
}
|
|
|
|
fn send_optional(
|
|
&self,
|
|
cmd: &str,
|
|
expected: &[&str],
|
|
) -> Result<Option<String>, ControlPointError> {
|
|
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
|
|
}
|
|
|
|
fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> {
|
|
send_command_no_response(&self.host, self.port, self.timeout, cmd)
|
|
}
|
|
|
|
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
|
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
|
|
match parse_playback_info(&payload) {
|
|
Ok(info) => Ok(info),
|
|
Err(err) => {
|
|
debug!(
|
|
"Failed to parse Arylic playback info for {}: {}",
|
|
self.host, err
|
|
);
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_volume_command(value: u16) -> String {
|
|
format!("MCU+VOL+{:03}", value.min(100))
|
|
}
|
|
|
|
fn parse_volume_payload(payload: &str) -> Result<u16, ControlPointError> {
|
|
let data = payload.strip_prefix("AXX+VOL+").ok_or_else(|| {
|
|
ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload))
|
|
})?;
|
|
let value: u16 = data.trim().parse().map_err(|_| {
|
|
ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data))
|
|
})?;
|
|
Ok(value.min(100))
|
|
}
|
|
|
|
fn parse_mute_payload(payload: &str) -> Result<bool, ControlPointError> {
|
|
let data = payload.strip_prefix("AXX+MUT+").ok_or_else(|| {
|
|
ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload))
|
|
})?;
|
|
match data.trim() {
|
|
"000" | "0" => Ok(false),
|
|
"001" | "1" => Ok(true),
|
|
other => Err(ControlPointError::ArilycTcpError(format!(
|
|
"Invalid mute value: {}",
|
|
other
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
|
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
let host = extract_linkplay_host(info.location()).ok_or_else(|| {
|
|
ControlPointError::ArilycTcpError(format!(
|
|
"Renderer {} has no valid LOCATION host",
|
|
info.udn()
|
|
))
|
|
})?;
|
|
|
|
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
|
|
|
Ok(Self {
|
|
host,
|
|
port: ARYLIC_TCP_PORT,
|
|
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
|
queue,
|
|
continuous_stream: Arc::new(Mutex::new(false)),
|
|
})
|
|
}
|
|
|
|
fn to_backend(self) -> MusicRendererBackend {
|
|
MusicRendererBackend::ArylicTcp(self)
|
|
}
|
|
}
|
|
|
|
impl ArylicTcpRenderer {
|
|
/// 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")
|
|
}
|
|
|
|
/// Create an ArylicTcpRenderer with a shared queue (for HybridUpnpArylic)
|
|
pub fn with_shared_queue(
|
|
info: &RendererInfo,
|
|
shared_queue: Arc<Mutex<MusicQueue>>,
|
|
) -> Result<Self, ControlPointError> {
|
|
let host = extract_linkplay_host(info.location()).ok_or_else(|| {
|
|
ControlPointError::ArilycTcpError(format!(
|
|
"Renderer {} has no valid LOCATION host",
|
|
info.udn()
|
|
))
|
|
})?;
|
|
|
|
Ok(Self {
|
|
host,
|
|
port: ARYLIC_TCP_PORT,
|
|
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
|
queue: shared_queue,
|
|
continuous_stream: Arc::new(Mutex::new(false)),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl TransportControl for ArylicTcpRenderer {
|
|
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
|
Err(ControlPointError::upnp_operation_not_supported(
|
|
"Arylic TCP backend does not support direct URL loading.",
|
|
"ArylicTcpRenderer",
|
|
))
|
|
}
|
|
|
|
fn play(&self) -> Result<(), ControlPointError> {
|
|
self.send_no_response("MCU+PLY-PLA")
|
|
}
|
|
|
|
fn pause(&self) -> Result<(), ControlPointError> {
|
|
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
|
|
Ok(())
|
|
}
|
|
|
|
fn stop(&self) -> Result<(), ControlPointError> {
|
|
self.send_no_response("MCU+PLY-STP")
|
|
}
|
|
|
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
let _ = parse_hhmmss_strict(hhmmss)?;
|
|
Err(ControlPointError::ArilycTcpError(
|
|
"Arylic TCP seek_rel_time is not implemented yet for this device.".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
impl VolumeControl for ArylicTcpRenderer {
|
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
|
if let Ok(info) = self.fetch_playback_info() {
|
|
if let Some(vol) = info.volume {
|
|
return Ok(vol);
|
|
}
|
|
debug!(
|
|
"Arylic playback info for {} missing volume, falling back to VOL GET",
|
|
self.host
|
|
);
|
|
}
|
|
let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?;
|
|
Self::parse_volume_payload(&payload)
|
|
}
|
|
|
|
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
|
let command = Self::format_volume_command(v);
|
|
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
|
|
Ok(())
|
|
}
|
|
|
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
|
if let Ok(info) = self.fetch_playback_info() {
|
|
if let Some(mute) = info.mute {
|
|
return Ok(mute);
|
|
}
|
|
debug!(
|
|
"Arylic playback info for {} missing mute, falling back to MUT GET",
|
|
self.host
|
|
);
|
|
}
|
|
let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?;
|
|
Self::parse_mute_payload(&payload)
|
|
}
|
|
|
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
|
|
let payload = self.send_required(command, &["AXX+MUT+"])?;
|
|
let _ = Self::parse_mute_payload(&payload)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl PlaybackStatus for ArylicTcpRenderer {
|
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
let info = self.fetch_playback_info()?;
|
|
Ok(info.playback_state())
|
|
}
|
|
}
|
|
|
|
impl PlaybackPosition for ArylicTcpRenderer {
|
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
let info = self.fetch_playback_info().map_err(|e| {
|
|
tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e);
|
|
e
|
|
})?;
|
|
|
|
tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info);
|
|
|
|
let mut position = info.position_info();
|
|
tracing::debug!(
|
|
"ArylicTcp position_info: track_duration={:?}, rel_time={:?}",
|
|
position.track_duration,
|
|
position.rel_time,
|
|
);
|
|
|
|
// Replace device metadata with queue metadata (queue is authoritative).
|
|
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
|
|
|
Ok(position)
|
|
}
|
|
}
|
|
|
|
|
|
impl QueueTransportControl for ArylicTcpRenderer {
|
|
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
|
self.play_uri(&item.uri, "")
|
|
}
|
|
|
|
}
|
|
|
|
impl HasQueue for ArylicTcpRenderer {
|
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
|
&self.queue
|
|
}
|
|
}
|
|
|
|
impl HasContinuousStream for ArylicTcpRenderer {
|
|
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
|
&self.continuous_stream
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ArylicPlaybackInfo {
|
|
status_raw: String,
|
|
curpos_ms: u64,
|
|
totlen_ms: u64,
|
|
volume: Option<u16>,
|
|
mute: Option<bool>,
|
|
playlist_size: Option<u32>,
|
|
track_index: Option<u32>,
|
|
}
|
|
|
|
impl ArylicPlaybackInfo {
|
|
fn playback_state(&self) -> PlaybackState {
|
|
match self.status_raw.as_str() {
|
|
"play" => PlaybackState::Playing,
|
|
"pause" => PlaybackState::Paused,
|
|
"stop" => PlaybackState::Stopped,
|
|
other => PlaybackState::Unknown(other.to_string()),
|
|
}
|
|
}
|
|
|
|
fn position_info(&self) -> PlaybackPositionInfo {
|
|
let track = match (self.track_index, self.playlist_size) {
|
|
(Some(idx), Some(count)) if count > 0 => Some(idx.min(count)),
|
|
(Some(idx), _) => Some(idx),
|
|
_ => None,
|
|
};
|
|
|
|
PlaybackPositionInfo {
|
|
track,
|
|
rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))),
|
|
abs_time: None,
|
|
track_duration: if self.totlen_ms > 0 {
|
|
Some(format_hhmmss(ms_to_seconds(self.totlen_ms)))
|
|
} else {
|
|
None
|
|
},
|
|
track_metadata: None,
|
|
track_uri: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
|
let json_blob = payload.strip_prefix("AXX+PLY+INF").ok_or_else(|| {
|
|
ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload))
|
|
})?;
|
|
|
|
// Extract JSON object between { and }, ignoring trailing & and other garbage
|
|
// Arylic devices send: AXX+PLY+INF{...json...}&
|
|
let json_start = json_blob.find('{').ok_or_else(|| {
|
|
ControlPointError::ArilycTcpError(format!(
|
|
"No JSON object found in playback info: {}",
|
|
payload
|
|
))
|
|
})?;
|
|
|
|
let json_end = json_blob.rfind('}').ok_or_else(|| {
|
|
ControlPointError::ArilycTcpError(format!(
|
|
"No JSON object end found in playback info: {}",
|
|
payload
|
|
))
|
|
})?;
|
|
|
|
let json_blob = &json_blob[json_start..=json_end];
|
|
|
|
let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob).map_err(|e| {
|
|
ControlPointError::ArilycTcpError(format!(
|
|
"Failed to parse Arylic playback info JSON: {}",
|
|
e
|
|
))
|
|
})?;
|
|
|
|
let curpos_ms = raw.curpos.parse::<u64>().map_err(|_| {
|
|
ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos))
|
|
})?;
|
|
|
|
let totlen_ms = raw.totlen.parse::<u64>().map_err(|_| {
|
|
ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen))
|
|
})?;
|
|
|
|
let volume = raw.vol.and_then(|raw_vol| match raw_vol.parse::<u16>() {
|
|
Ok(value) => Some(value.min(100)),
|
|
Err(err) => {
|
|
debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err);
|
|
None
|
|
}
|
|
});
|
|
|
|
let mute = raw.mute.and_then(|raw_mute| match raw_mute.as_str() {
|
|
"1" => Some(true),
|
|
"0" => Some(false),
|
|
other => {
|
|
debug!("Invalid Arylic `mute` value {}", other);
|
|
None
|
|
}
|
|
});
|
|
|
|
let playlist_size = raw
|
|
.plicount
|
|
.and_then(|raw_count| match raw_count.parse::<u32>() {
|
|
Ok(count) if count > 0 => Some(count),
|
|
Ok(_) => None,
|
|
Err(err) => {
|
|
debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err);
|
|
None
|
|
}
|
|
});
|
|
|
|
let track_index = raw
|
|
.plicurr
|
|
.and_then(|raw_idx| match raw_idx.parse::<u32>() {
|
|
Ok(idx) if idx > 0 => Some(idx),
|
|
Ok(_) => None,
|
|
Err(err) => {
|
|
debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err);
|
|
None
|
|
}
|
|
});
|
|
|
|
Ok(ArylicPlaybackInfo {
|
|
status_raw: raw.status,
|
|
curpos_ms,
|
|
totlen_ms,
|
|
volume,
|
|
mute,
|
|
playlist_size,
|
|
track_index,
|
|
})
|
|
}
|