Add a LinkPlayRenderer

This commit is contained in:
2025-11-30 14:43:29 +01:00
parent f096a8cd8f
commit 0e70a91ff9
9 changed files with 655 additions and 33 deletions

View File

@@ -0,0 +1,101 @@
use std::io;
use std::thread;
use std::time::Duration;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
PlaybackState, PlaybackStatus, VolumeControl,
};
fn main() -> io::Result<()> {
let _ = tracing_subscriber::fmt::try_init();
println!("Starting PMOMusic LinkPlay demo...");
let cp = ControlPoint::spawn(5)?;
println!("Waiting 5 seconds for SSDP discovery...");
thread::sleep(Duration::from_secs(5));
let registry = cp.registry();
let renderers = {
let reg = registry.read().unwrap();
reg.list_renderers()
};
if renderers.is_empty() {
println!("No renderers discovered.");
return Ok(());
}
println!("Discovered renderers:");
for (idx, info) in renderers.iter().enumerate() {
println!(
" [{}] {} | model={} | LinkPlay HTTP={}",
idx, info.friendly_name, info.model_name, info.capabilities.has_linkplay_http
);
}
let linkplay_renderers: Vec<MusicRenderer> = {
let reg = registry.read().unwrap();
renderers
.iter()
.filter(|info| info.capabilities.has_linkplay_http)
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), &reg))
.collect()
};
if linkplay_renderers.is_empty() {
println!("\nNo LinkPlay-capable renderers detected.");
return Ok(());
}
println!("\nLinkPlay renderer status:");
for renderer in linkplay_renderers {
println!("- {} ({})", renderer.friendly_name(), renderer.id().0);
match renderer.playback_state() {
Ok(state) => println!(" state: {}", format_playback_state(&state)),
Err(err) => println!(" state: error: {}", err),
}
match renderer.playback_position() {
Ok(pos) => println!(" position: {}", format_position(&pos)),
Err(err) => println!(" position: error: {}", err),
}
match renderer.volume() {
Ok(vol) => println!(" volume: {}", vol),
Err(err) => println!(" volume: error: {}", err),
}
match renderer.mute() {
Ok(mute) => println!(" mute: {}", mute),
Err(err) => println!(" mute: error: {}", err),
}
println!();
}
Ok(())
}
fn format_playback_state(state: &PlaybackState) -> String {
match state {
PlaybackState::Stopped => "Stopped".to_string(),
PlaybackState::Playing => "Playing".to_string(),
PlaybackState::Paused => "Paused".to_string(),
PlaybackState::Transitioning => "Transitioning".to_string(),
PlaybackState::NoMedia => "NoMedia".to_string(),
PlaybackState::Unknown(raw) => format!("Unknown({})", raw),
}
}
fn format_position(pos: &PlaybackPositionInfo) -> String {
let rel = pos.rel_time.as_deref().unwrap_or("-");
let dur = pos.track_duration.as_deref().unwrap_or("-");
let track = pos
.track
.map(|t| t.to_string())
.unwrap_or_else(|| "-".to_string());
format!("track={} rel_time={} duration={}", track, rel, dur)
}

View File

@@ -82,6 +82,7 @@ fn main() -> Result<()> {
" [{}] {} | model={} | udn={} | location={}",
idx, info.friendly_name, info.model_name, info.udn, info.location
);
print_backend(" ", r);
print_capabilities(" ", &info.capabilities, &info.protocol);
}
@@ -96,6 +97,7 @@ fn main() -> Result<()> {
println!(" UDN : {}", info.udn);
println!(" Location : {}", info.location);
println!(" Protocol : {:?}", info.protocol);
print_backend(" ", renderer);
print_capabilities(" ", &info.capabilities, &info.protocol);
if let Some(upnp) = renderer.as_upnp() {
@@ -216,15 +218,24 @@ fn main() -> Result<()> {
fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &RendererProtocol) {
println!("{prefix}Capabilities:");
println!("{prefix} Protocol : {:?}", proto);
println!("{prefix} AVTransport : {}", caps.has_avtransport);
println!("{prefix} RendControl : {}", caps.has_rendering_control);
println!("{prefix} ConnManager : {}", caps.has_connection_manager);
println!("{prefix} OH Playlist : {}", caps.has_oh_playlist);
println!("{prefix} OH Volume : {}", caps.has_oh_volume);
println!("{prefix} OH Info : {}", caps.has_oh_info);
println!("{prefix} OH Time : {}", caps.has_oh_time);
println!("{prefix} OH Radio : {}", caps.has_oh_radio);
println!("{prefix} Protocol : {:?}", proto);
println!("{prefix} AVTransport : {}", caps.has_avtransport);
println!("{prefix} RendControl : {}", caps.has_rendering_control);
println!("{prefix} ConnManager : {}", caps.has_connection_manager);
println!("{prefix} LinkPlay HTTP : {}", caps.has_linkplay_http);
println!("{prefix} OH Playlist : {}", caps.has_oh_playlist);
println!("{prefix} OH Volume : {}", caps.has_oh_volume);
println!("{prefix} OH Info : {}", caps.has_oh_info);
println!("{prefix} OH Time : {}", caps.has_oh_time);
println!("{prefix} OH Radio : {}", caps.has_oh_radio);
}
fn print_backend(prefix: &str, renderer: &MusicRenderer) {
let backend = match renderer {
MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
};
println!("{prefix}Backend : {backend}");
}
fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {

View File

@@ -1,8 +1,6 @@
// pmocontrol/src/capabilities.rs
use anyhow::Result;
use crate::avtransport_client::PositionInfo;
/// Logical playback position across backends.
///
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que

View File

@@ -91,14 +91,14 @@ impl ControlPoint {
let reg = runtime_cp.registry.read().unwrap();
reg.list_renderers()
.into_iter()
.map(|info| UpnpRenderer::from_registry(info, &reg))
.filter_map(|info| MusicRenderer::from_registry_info(info, &reg))
.collect::<Vec<_>>()
};
let mut seen_ids = HashSet::new();
for renderer in renderers {
let info = &renderer.info;
let info = renderer.info();
// Ne pas poller les renderers offline
if !info.online {

View File

@@ -5,6 +5,7 @@ pub mod capabilities;
pub mod connection_manager_client;
pub mod control_point;
pub mod discovery;
pub mod linkplay;
pub mod model;
pub mod provider;
pub mod upnp_renderer;
@@ -18,6 +19,7 @@ pub use capabilities::{TransportControl, VolumeControl, PlaybackState, PlaybackS
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
pub use control_point::ControlPoint;
pub use rendering_control_client::RenderingControlClient;
pub use linkplay::LinkPlayRenderer;
pub use upnp_renderer::UpnpRenderer;
pub use music_renderer::MusicRenderer;

478
pmocontrol/src/linkplay.rs Normal file
View File

@@ -0,0 +1,478 @@
use std::char;
use std::collections::HashMap;
use std::fmt;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use tracing::debug;
use ureq::Agent;
use crate::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
VolumeControl,
};
use crate::model::{RendererId, RendererInfo};
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
const STATUS_COMMAND: &str = "getPlayerStatus";
/// Renderer backend for devices exposing the LinkPlay HTTP API.
#[derive(Clone)]
pub struct LinkPlayRenderer {
pub info: RendererInfo,
host: String,
timeout: Duration,
}
impl fmt::Debug for LinkPlayRenderer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LinkPlayRenderer")
.field("id", &self.info.id)
.field("friendly_name", &self.info.friendly_name)
.field("host", &self.host)
.finish()
}
}
impl LinkPlayRenderer {
/// Build a LinkPlay backend from a registry snapshot.
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
let host = extract_linkplay_host(&info.location)
.ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?;
Ok(Self {
info,
host,
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
})
}
fn agent(&self) -> Agent {
build_agent(self.timeout)
}
pub fn id(&self) -> &RendererId {
&self.info.id
}
pub fn friendly_name(&self) -> &str {
&self.info.friendly_name
}
fn send_player_command(&self, command: &str) -> Result<()> {
let url = format!(
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
self.host, command
);
self.agent()
.get(&url)
.call()
.with_context(|| format!("LinkPlay command {} failed for {}", command, self.host))?;
Ok(())
}
fn fetch_status(&self) -> Result<LinkPlayStatus> {
fetch_status_for_host(&self.host, self.timeout)
}
}
impl TransportControl for LinkPlayRenderer {
fn play_uri(&self, uri: &str, _meta: &str) -> Result<()> {
let encoded = percent_encode(uri);
self.send_player_command(&format!("play:{}", encoded))
}
fn play(&self) -> Result<()> {
self.send_player_command("resume")
}
fn pause(&self) -> Result<()> {
self.send_player_command("pause")
}
fn stop(&self) -> Result<()> {
self.send_player_command("stop")
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
let secs = parse_hhmmss_to_secs(hhmmss)
.ok_or_else(|| anyhow!("Invalid seek position format: {}", hhmmss))?;
self.send_player_command(&format!("seek:{}", secs))
}
}
impl VolumeControl for LinkPlayRenderer {
fn volume(&self) -> Result<u16> {
Ok(self.fetch_status()?.volume)
}
fn set_volume(&self, v: u16) -> Result<()> {
let value = v.min(100);
self.send_player_command(&format!("vol:{}", value))
}
fn mute(&self) -> Result<bool> {
Ok(self.fetch_status()?.mute)
}
fn set_mute(&self, m: bool) -> Result<()> {
self.send_player_command(if m { "mute:1" } else { "mute:0" })
}
}
impl PlaybackStatus for LinkPlayRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
Ok(self.fetch_status()?.playback_state())
}
}
impl PlaybackPosition for LinkPlayRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
Ok(self.fetch_status()?.position_info())
}
}
/// Detect whether a renderer exposes the LinkPlay HTTP API.
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
let Some(host) = extract_linkplay_host(location) else {
return false;
};
match fetch_status_for_host(&host, timeout) {
Ok(_) => true,
Err(err) => {
debug!(
"LinkPlay detection failed for {} (host={}): {}",
location, host, err
);
false
}
}
}
/// Extract the IP/host component from a LOCATION URL.
pub fn extract_linkplay_host(location: &str) -> Option<String> {
let (_, rest) = location.split_once("://")?;
let authority = rest.split('/').next().unwrap_or(rest);
let without_auth = authority.split('@').last().unwrap_or(authority);
if without_auth.starts_with('[') {
let end = without_auth.find(']')?;
let host = &without_auth[1..end];
if host.is_empty() {
None
} else {
Some(host.to_string())
}
} else {
let host = without_auth.split(':').next().unwrap_or("");
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
}
fn fetch_status_for_host(host: &str, timeout: Duration) -> Result<LinkPlayStatus> {
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
let mut response = build_agent(timeout)
.get(&url)
.call()
.with_context(|| format!("HTTP request failed for LinkPlay status on {}", host))?;
let body = response
.body_mut()
.read_to_string()
.context("Failed to read LinkPlay status body")?;
parse_linkplay_status(&body)
}
fn build_agent(timeout: Duration) -> Agent {
Agent::config_builder()
.timeout_global(Some(timeout))
.build()
.into()
}
#[derive(Clone, Debug)]
struct LinkPlayStatus {
state_raw: String,
curpos_ms: u64,
totlen_ms: u64,
track_index: Option<u32>,
volume: u16,
mute: bool,
}
impl LinkPlayStatus {
fn playback_state(&self) -> PlaybackState {
match self.state_raw.as_str() {
"play" => PlaybackState::Playing,
"pause" => PlaybackState::Paused,
"stop" => PlaybackState::Stopped,
"load" => PlaybackState::Transitioning,
other => PlaybackState::Unknown(other.to_string()),
}
}
fn position_info(&self) -> PlaybackPositionInfo {
PlaybackPositionInfo {
track: self.track_index,
rel_time: Some(format_hms(self.curpos_ms / 1000)),
abs_time: None,
track_duration: if self.totlen_ms > 0 {
Some(format_hms(self.totlen_ms / 1000))
} else {
None
},
}
}
}
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus> {
let mut map = parse_flat_json(body)?;
let state_raw = map
.remove("status")
.ok_or_else(|| anyhow!("LinkPlay status missing `status` field"))?;
let curpos_ms = parse_u64_field(&map, "curpos")?;
let totlen_ms = parse_u64_field(&map, "totlen")?;
let volume = parse_u16_field(&map, "vol")?;
let mute = match map.get("mute").map(|s| s.as_str()) {
Some("1") => true,
Some("0") => false,
Some(other) => {
return Err(anyhow!("Invalid LinkPlay mute value: {}", other));
}
None => return Err(anyhow!("LinkPlay status missing `mute` field")),
};
let track_index = map
.get("plicurr")
.and_then(|s| s.parse::<u32>().ok())
.filter(|idx| *idx > 0);
Ok(LinkPlayStatus {
state_raw,
curpos_ms,
totlen_ms,
track_index,
volume,
mute,
})
}
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64> {
let raw = map
.get(key)
.ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?;
raw.parse::<u64>()
.with_context(|| format!("Invalid `{}` value: {}", key, raw))
}
fn parse_u16_field(map: &HashMap<String, String>, key: &str) -> Result<u16> {
let raw = map
.get(key)
.ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?;
let value = raw
.parse::<u16>()
.with_context(|| format!("Invalid `{}` value: {}", key, raw))?;
Ok(value.min(100))
}
fn parse_flat_json(input: &str) -> Result<HashMap<String, String>> {
let mut chars = input.chars().peekable();
skip_ws(&mut chars);
if chars.next() != Some('{') {
return Err(anyhow!("LinkPlay status is not a JSON object"));
}
let mut map = HashMap::new();
loop {
skip_ws(&mut chars);
match chars.peek() {
Some('}') => {
chars.next();
break;
}
Some(_) => {}
None => return Err(anyhow!("Unexpected end of JSON object")),
}
let key = parse_json_string(&mut chars)?;
skip_ws(&mut chars);
expect_char(&mut chars, ':')?;
skip_ws(&mut chars);
let value = parse_json_value(&mut chars)?;
map.insert(key, value);
skip_ws(&mut chars);
match chars.peek() {
Some(',') => {
chars.next();
continue;
}
Some('}') => {
chars.next();
break;
}
Some(other) => {
return Err(anyhow!(
"Unexpected character '{}' while parsing JSON",
other
));
}
None => return Err(anyhow!("Unexpected end of JSON while parsing fields")),
}
}
Ok(map)
}
fn parse_json_value(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
match chars.peek() {
Some('"') => parse_json_string(chars),
Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars),
Some('t') => {
expect_literal(chars, "true")?;
Ok("true".to_string())
}
Some('f') => {
expect_literal(chars, "false")?;
Ok("false".to_string())
}
_ => Err(anyhow!("Unsupported JSON value in LinkPlay status")),
}
}
fn parse_json_string(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
) -> Result<String> {
if chars.next() != Some('"') {
return Err(anyhow!("Expected string"));
}
let mut out = String::new();
while let Some(ch) = chars.next() {
match ch {
'"' => return Ok(out),
'\\' => {
let escaped = chars.next().ok_or_else(|| anyhow!("Invalid escape"))?;
match escaped {
'"' => out.push('"'),
'\\' => out.push('\\'),
'/' => out.push('/'),
'b' => out.push('\u{0008}'),
'f' => out.push('\u{000C}'),
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'u' => {
let mut hex = String::with_capacity(4);
for _ in 0..4 {
let h = chars.next().ok_or_else(|| anyhow!("Invalid \\u escape"))?;
hex.push(h);
}
let code = u16::from_str_radix(&hex, 16)
.with_context(|| format!("Invalid unicode escape: {}", hex))?;
if let Some(c) = char::from_u32(code as u32) {
out.push(c);
} else {
return Err(anyhow!("Invalid unicode code point: {}", code));
}
}
other => return Err(anyhow!("Unsupported escape: {}", other)),
}
}
other => out.push(other),
}
}
Err(anyhow!("Unterminated JSON string"))
}
fn parse_json_number(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
) -> Result<String> {
let mut out = String::new();
if matches!(chars.peek(), Some('-')) {
out.push('-');
chars.next();
}
while let Some(ch) = chars.peek() {
if ch.is_ascii_digit() || *ch == '.' {
out.push(*ch);
chars.next();
} else {
break;
}
}
if out.is_empty() || out == "-" {
return Err(anyhow!("Invalid number"));
}
Ok(out)
}
fn expect_literal(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
literal: &str,
) -> Result<()> {
for expected in literal.chars() {
match chars.next() {
Some(ch) if ch == expected => {}
_ => return Err(anyhow!("Invalid literal while parsing JSON")),
}
}
Ok(())
}
fn expect_char(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, expected: char) -> Result<()> {
match chars.next() {
Some(ch) if ch == expected => Ok(()),
_ => Err(anyhow!("Missing '{}' while parsing JSON", expected)),
}
}
fn skip_ws(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
while matches!(chars.peek(), Some(ch) if ch.is_whitespace()) {
chars.next();
}
}
fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for b in input.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
fn parse_hhmmss_to_secs(s: &str) -> Option<u64> {
let parts: Vec<_> = s.split(':').collect();
if parts.len() != 3 {
return None;
}
let h: u64 = parts[0].parse().ok()?;
let m: u64 = parts[1].parse().ok()?;
let sec: u64 = parts[2].parse().ok()?;
Some(h * 3600 + m * 60 + sec)
}
fn format_hms(secs: u64) -> String {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
format!("{:02}:{:02}:{:02}", h, m, s)
}

View File

@@ -18,6 +18,7 @@ pub struct RendererCapabilities {
pub has_avtransport: bool,
pub has_rendering_control: bool,
pub has_connection_manager: bool,
pub has_linkplay_http: bool,
pub has_oh_playlist: bool,
pub has_oh_volume: bool,

View File

@@ -2,20 +2,25 @@
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
use crate::model::{RendererId, RendererInfo, RendererProtocol};
use crate::{DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, TransportControl, UpnpRenderer, VolumeControl};
use crate::{
DeviceRegistry, LinkPlayRenderer, PlaybackPosition, PlaybackState, TransportControl,
UpnpRenderer, VolumeControl,
};
use anyhow::Result;
use tracing::warn;
/// Music view of a renderer, independent of the underlying protocol/backend.
///
/// For now, only UPnP AV renderers are supported via [`UpnpRenderer`],
/// but this type is designed to host additional backends (e.g. OpenHome)
/// later on.
/// Currently supported backends:
/// - [`UpnpRenderer`] (AVTransport + RenderingControl)
/// - [`LinkPlayRenderer`] (LinkPlay HTTP API)
/// Additional backends (e.g. OpenHome) can be integrated later.
#[derive(Clone, Debug)]
pub enum MusicRenderer {
/// UPnP AV / DLNA backend.
Upnp(UpnpRenderer),
// Future backends could be added here, e.g.:
// OpenHome(OpenHomeRenderer),
/// LinkPlay HTTP backend (Arylic and similar devices).
LinkPlay(LinkPlayRenderer),
}
impl MusicRenderer {
@@ -23,6 +28,7 @@ impl MusicRenderer {
pub fn id(&self) -> &RendererId {
match self {
MusicRenderer::Upnp(r) => r.id(),
MusicRenderer::LinkPlay(r) => r.id(),
}
}
@@ -30,20 +36,20 @@ impl MusicRenderer {
pub fn friendly_name(&self) -> &str {
match self {
MusicRenderer::Upnp(r) => r.friendly_name(),
MusicRenderer::LinkPlay(r) => r.friendly_name(),
}
}
/// Protocol classification (UPnP AV only, OpenHome only, hybrid).
pub fn protocol(&self) -> &RendererProtocol {
match self {
MusicRenderer::Upnp(r) => &r.info.protocol,
}
&self.info().protocol
}
/// Full static info as stored in the registry.
pub fn info(&self) -> &RendererInfo {
match self {
MusicRenderer::Upnp(r) => &r.info,
MusicRenderer::LinkPlay(r) => &r.info,
}
}
@@ -51,19 +57,30 @@ impl MusicRenderer {
pub fn as_upnp(&self) -> Option<&UpnpRenderer> {
match self {
MusicRenderer::Upnp(r) => Some(r),
_ => None,
}
}
/// Construct a music renderer from a [`RendererInfo`] and the registry.
///
/// Returns `None` when no supported backend can be built for this renderer.
/// Currently, only UPnP AV / hybrid renderers are mapped to [`MusicRenderer::Upnp`].
/// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported)
/// or [`MusicRenderer::Upnp`].
pub fn from_registry_info(
info: RendererInfo,
registry: &DeviceRegistry,
) -> Option<MusicRenderer> {
match info.protocol {
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
if info.capabilities.has_linkplay_http {
match LinkPlayRenderer::from_renderer_info(info.clone()) {
Ok(renderer) => return Some(MusicRenderer::LinkPlay(renderer)),
Err(err) => warn!(
"Failed to build LinkPlay backend for {}: {}. Falling back to UPnP.",
info.friendly_name, err
),
}
}
Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(info, registry)))
}
RendererProtocol::OpenHomeOnly => {
@@ -76,78 +93,85 @@ impl MusicRenderer {
/// Implémentation générique de `TransportControl` pour [`MusicRenderer`].
///
/// Pour l'instant, seule la variante [`MusicRenderer::Upnp`] est supportée,
/// et la délégation se fait vers l'implémentation UPnP AV.
/// Les variantes UPnP et LinkPlay délèguent aux backends correspondants.
impl TransportControl for MusicRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.play_uri(uri, meta),
MusicRenderer::LinkPlay(r) => r.play_uri(uri, meta),
}
}
fn play(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => TransportControl::play(r),
MusicRenderer::LinkPlay(r) => r.play(),
}
}
fn pause(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.pause(),
MusicRenderer::LinkPlay(r) => r.pause(),
}
}
fn stop(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.stop(),
MusicRenderer::LinkPlay(r) => r.stop(),
}
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.seek_rel_time(hhmmss),
MusicRenderer::LinkPlay(r) => r.seek_rel_time(hhmmss),
}
}
}
/// Implémentation générique de `VolumeControl` pour [`MusicRenderer`].
///
/// Pour l'instant, seule la variante [`MusicRenderer::Upnp`] est supportée,
/// et la délégation se fait vers l'implémentation UPnP RenderingControl.
/// Les variantes UPnP et LinkPlay délèguent aux backends correspondants.
impl VolumeControl for MusicRenderer {
fn volume(&self) -> Result<u16> {
match self {
MusicRenderer::Upnp(r) => r.get_master_volume(),
MusicRenderer::LinkPlay(r) => r.volume(),
}
}
fn set_volume(&self, v: u16) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.set_master_volume(v),
MusicRenderer::LinkPlay(r) => r.set_volume(v),
}
}
fn mute(&self) -> Result<bool> {
match self {
MusicRenderer::Upnp(r) => r.get_master_mute(),
MusicRenderer::LinkPlay(r) => r.mute(),
}
}
fn set_mute(&self, m: bool) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.set_master_mute(m),
MusicRenderer::LinkPlay(r) => r.set_mute(m),
}
}
}
/// Implémentation générique de `PlaybackStatus` pour [`MusicRenderer`].
///
/// La variante UPnP délègue à [`UpnpRenderer`]. Les backends OpenHome
/// futurs mapperont l'état OH vers `PlaybackState`.
/// Chaque backend fournit sa propre source d'état (UPnP AVTransport ou LinkPlay HTTP).
impl PlaybackStatus for MusicRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
match self {
MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r),
MusicRenderer::LinkPlay(r) => r.playback_state(),
}
}
}
@@ -156,7 +180,7 @@ impl PlaybackPosition for MusicRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
match self {
MusicRenderer::Upnp(r) => r.playback_position(),
// MusicRenderer::OpenHome(r) => r.playback_position(), plus tard
MusicRenderer::LinkPlay(r) => r.playback_position(),
}
}
}
}

View File

@@ -1,5 +1,5 @@
use std::io::BufReader;
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
use quick_xml::{Error as XmlError, Reader, events::Event};
use thiserror::Error;
@@ -7,6 +7,7 @@ use tracing::{debug, warn};
use crate::avtransport_client::AvTransportClient;
use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint};
use crate::linkplay::detect_linkplay_http;
use crate::model::{
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
RendererInfo, RendererProtocol,
@@ -273,7 +274,13 @@ impl HttpXmlDescriptionProvider {
.as_deref()
.unwrap_or_else(|| endpoint.udn.as_str());
let udn = raw_udn.to_ascii_lowercase();
let caps = detect_renderer_capabilities(&parsed.service_types);
let mut caps = detect_renderer_capabilities(&parsed.service_types);
if detect_linkplay_http(
&endpoint.location,
Duration::from_secs(self.timeout_secs.max(1)),
) {
caps.has_linkplay_http = true;
}
let protocol = detect_renderer_protocol(&caps);
let now = SystemTime::now();