738 lines
27 KiB
Rust
738 lines
27 KiB
Rust
//! Backend-agnostic music renderer façade for PMOMusic.
|
|
//!
|
|
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome,
|
|
//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a
|
|
//! single control surface. Higher layers in PMOMusic must only interact with
|
|
//! renderers through this type so that transport, volume, and state queries
|
|
//! stay backend-neutral.
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::SystemTime;
|
|
|
|
use crate::control_point::PlaylistBinding;
|
|
use crate::errors::ControlPointError;
|
|
use crate::model::{RendererInfo, RendererProtocol, TrackMetadata, PlaybackState};
|
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
|
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
|
use crate::music_renderer::capabilities::{
|
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
|
VolumeControl,
|
|
};
|
|
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
|
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
|
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
|
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
|
use crate::online::DeviceConnectionState;
|
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot};
|
|
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
|
|
|
use tracing::warn;
|
|
|
|
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
|
#[derive(Clone, Debug)]
|
|
pub enum MusicRendererBackend {
|
|
/// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl).
|
|
Upnp(UpnpRenderer),
|
|
/// Renderer powered by OpenHome services.
|
|
OpenHome(OpenHomeRenderer),
|
|
/// Renderer controlled via the LinkPlay HTTP API.
|
|
LinkPlay(LinkPlayRenderer),
|
|
/// Renderer reachable through the Arylic TCP control protocol (port 8899).
|
|
ArylicTcp(ArylicTcpRenderer),
|
|
/// Renderer controlled via the Google Cast protocol (Chromecast).
|
|
Chromecast(ChromecastRenderer),
|
|
/// Combined backend using UPnP for transport + volume writes and Arylic TCP
|
|
/// to read detailed playback information as well as live volume/mute state.
|
|
HybridUpnpArylic {
|
|
upnp: UpnpRenderer,
|
|
arylic: ArylicTcpRenderer,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MusicRenderer {
|
|
info: RendererInfo,
|
|
connection: Arc<Mutex<DeviceConnectionState>>,
|
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
|
queue: Arc<Mutex<MusicQueue>>,
|
|
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
|
}
|
|
|
|
impl MusicRenderer {
|
|
pub fn new(
|
|
info: RendererInfo,
|
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
|
queue: Arc<Mutex<MusicQueue>>,
|
|
) -> Arc<MusicRenderer> {
|
|
let connection = DeviceConnectionState::new();
|
|
|
|
let renderer = MusicRenderer {
|
|
info,
|
|
connection: Arc::new(Mutex::new(connection)),
|
|
backend,
|
|
queue,
|
|
playlist_binding: Arc::new(Mutex::new(None)),
|
|
};
|
|
|
|
Arc::new(renderer)
|
|
}
|
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
|
|
let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
|
|
let backend = MusicRendererBackend::make_from_renderer_info(info)?;
|
|
let queue = MusicQueue::make_from_renderer_info(info)?;
|
|
|
|
let renderer = MusicRenderer {
|
|
info: info.clone(),
|
|
connection,
|
|
backend,
|
|
queue,
|
|
playlist_binding: Arc::new(Mutex::new(None)),
|
|
};
|
|
Ok(renderer)
|
|
}
|
|
|
|
pub fn info(&self) -> &RendererInfo {
|
|
&self.info
|
|
}
|
|
|
|
/// Returns the protocol.
|
|
fn protocol(&self) -> RendererProtocol {
|
|
self.info.protocol()
|
|
}
|
|
|
|
fn is_upnp(&self) -> bool {
|
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
MusicRendererBackend::Upnp(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn is_openhome(&self) -> bool {
|
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
MusicRendererBackend::OpenHome(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn is_linkplay(&self) -> bool {
|
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
MusicRendererBackend::LinkPlay(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn is_arylictcp(&self) -> bool {
|
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
MusicRendererBackend::ArylicTcp(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn is_chromecast(&self) -> bool {
|
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
MusicRendererBackend::Chromecast(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn is_hybridupnparylic(&self) -> bool {
|
|
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
|
MusicRendererBackend::HybridUpnpArylic { .. } => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Returns true if this renderer is known to support SetNextAVTransportURI.
|
|
pub fn supports_set_next(&self) -> bool {
|
|
self.info.capabilities().supports_set_next()
|
|
}
|
|
|
|
/// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
|
|
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
|
|
// Clear the queue first
|
|
self.queue
|
|
.lock()
|
|
.expect("Queue mutex poisoned")
|
|
.clear_queue()?;
|
|
|
|
// Then stop playback (ignore errors if already stopped)
|
|
self.backend
|
|
.lock()
|
|
.expect("Backend mutex poisoned")
|
|
.stop()
|
|
.or_else(|err| {
|
|
warn!(
|
|
renderer = self.id().0.as_str(),
|
|
error = %err,
|
|
"Stop failed when preparing for playlist attach (continuing anyway)"
|
|
);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Get the current queue snapshot.
|
|
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
|
self.queue
|
|
.lock()
|
|
.expect("Queue mutex poisoned")
|
|
.queue_snapshot()
|
|
}
|
|
|
|
/// Play the current item from the queue.
|
|
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
|
|
|
// Get the current item from the queue
|
|
let (item, _remaining) = queue.peek_current()?.ok_or_else(|| {
|
|
ControlPointError::QueueError("Queue is empty, cannot play current".to_string())
|
|
})?;
|
|
|
|
// Build DIDL metadata if available
|
|
let metadata_xml = item
|
|
.metadata
|
|
.as_ref()
|
|
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
|
.unwrap_or_default();
|
|
|
|
// Play the URI using the backend
|
|
backend.play_uri(&item.uri, &metadata_xml)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Advance to and play the next item from the queue.
|
|
pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> {
|
|
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
|
|
|
// Dequeue the next item
|
|
let (item, _remaining) = queue
|
|
.dequeue_next()?
|
|
.ok_or_else(|| ControlPointError::QueueError("No next item in queue".to_string()))?;
|
|
|
|
// Build DIDL metadata if available
|
|
let metadata_xml = item
|
|
.metadata
|
|
.as_ref()
|
|
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
|
.unwrap_or_default();
|
|
|
|
// Play the URI using the backend
|
|
backend.play_uri(&item.uri, &metadata_xml)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Transport control: play URI with metadata
|
|
pub fn play_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
|
|
self.backend
|
|
.lock()
|
|
.expect("Backend mutex poisoned")
|
|
.play_uri(uri, metadata)
|
|
}
|
|
|
|
/// Transport control: play
|
|
pub fn play(&self) -> Result<(), ControlPointError> {
|
|
self.backend.lock().expect("Backend mutex poisoned").play()
|
|
}
|
|
|
|
/// Transport control: pause
|
|
pub fn pause(&self) -> Result<(), ControlPointError> {
|
|
self.backend.lock().expect("Backend mutex poisoned").pause()
|
|
}
|
|
|
|
/// Transport control: stop
|
|
pub fn stop(&self) -> Result<(), ControlPointError> {
|
|
self.backend.lock().expect("Backend mutex poisoned").stop()
|
|
}
|
|
|
|
/// Transport control: seek to relative time
|
|
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
self.backend
|
|
.lock()
|
|
.expect("Backend mutex poisoned")
|
|
.seek_rel_time(hhmmss)
|
|
}
|
|
|
|
/// Volume control: get current volume
|
|
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
|
self.backend
|
|
.lock()
|
|
.expect("Backend mutex poisoned")
|
|
.volume()
|
|
}
|
|
|
|
/// Volume control: set volume
|
|
pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
|
self.backend
|
|
.lock()
|
|
.expect("Backend mutex poisoned")
|
|
.set_volume(vol)
|
|
}
|
|
|
|
/// Volume control: get mute state
|
|
pub fn mute(&self) -> Result<bool, ControlPointError> {
|
|
self.backend.lock().expect("Backend mutex poisoned").mute()
|
|
}
|
|
|
|
/// Volume control: set mute state
|
|
pub fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
self.backend
|
|
.lock()
|
|
.expect("Backend mutex poisoned")
|
|
.set_mute(m)
|
|
}
|
|
|
|
/// Get playback state
|
|
pub fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
self.backend
|
|
.lock()
|
|
.expect("Backend mutex poisoned")
|
|
.playback_state()
|
|
}
|
|
|
|
/// Get playback position
|
|
pub fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
self.backend
|
|
.lock()
|
|
.expect("Backend mutex poisoned")
|
|
.playback_position()
|
|
}
|
|
|
|
/// Sets the playlist binding for this renderer.
|
|
pub fn set_playlist_binding(&self, binding: Option<PlaylistBinding>) {
|
|
*self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = binding;
|
|
}
|
|
|
|
/// Gets the current playlist binding, if any.
|
|
pub fn get_playlist_binding(&self) -> Option<PlaylistBinding> {
|
|
self.playlist_binding.lock().expect("Playlist binding mutex poisoned").clone()
|
|
}
|
|
|
|
/// Clears the playlist binding.
|
|
pub fn clear_playlist_binding(&self) {
|
|
*self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = None;
|
|
}
|
|
|
|
/// Clears the renderer's queue using the generic QueueBackend trait.
|
|
pub fn clear_queue(&self) -> Result<(), ControlPointError> {
|
|
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
queue.clear_queue()
|
|
}
|
|
|
|
/// Adds a track to the queue.
|
|
///
|
|
/// This is primarily for backends with persistent queues (OpenHome).
|
|
/// For backends without this capability, it returns an error.
|
|
///
|
|
/// Returns the backend-specific track ID if applicable.
|
|
pub fn add_track_to_queue(
|
|
&self,
|
|
_uri: &str,
|
|
_metadata: &str,
|
|
_after_id: Option<u32>,
|
|
_play: bool,
|
|
) -> Result<Option<u32>, ControlPointError> {
|
|
// This operation requires backend-specific APIs (especially for OpenHome)
|
|
// that aren't exposed through the generic QueueBackend trait.
|
|
// The generic implementation cannot support this without more context
|
|
// (media_server_id, didl_id, protocol_info, etc.).
|
|
Err(ControlPointError::QueueError(
|
|
"add_track_to_queue requires backend-specific implementation".to_string()
|
|
))
|
|
}
|
|
|
|
/// Selects and plays a specific track from the queue by ID.
|
|
///
|
|
/// Converts the track ID to a position using the generic QueueBackend trait,
|
|
/// then plays the track.
|
|
pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> {
|
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
|
|
// Convert track ID to position using generic QueueBackend trait
|
|
let position = queue.id_to_position(track_id)?;
|
|
drop(queue);
|
|
|
|
// Set the index
|
|
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
queue.set_index(Some(position))?;
|
|
|
|
// Get the item to play
|
|
let item = queue.get_item(position)?
|
|
.ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?;
|
|
drop(queue);
|
|
|
|
// Build metadata XML
|
|
let metadata_xml = item
|
|
.metadata
|
|
.as_ref()
|
|
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
|
.unwrap_or_default();
|
|
|
|
// Play the item using TransportControl
|
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
|
backend.play_uri(&item.uri, &metadata_xml)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Synchronizes the queue state with the backend.
|
|
///
|
|
/// For backends with persistent queues (OpenHome), this refreshes the local view.
|
|
/// For others, this is essentially a no-op (just reads the current state).
|
|
pub fn sync_queue_state(&self) -> Result<(), ControlPointError> {
|
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
// Calling queue_snapshot() triggers a refresh for backends that need it
|
|
let _ = queue.queue_snapshot()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Plays the current item from the backend queue.
|
|
///
|
|
/// This is primarily for backends with persistent queues (OpenHome).
|
|
pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> {
|
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
|
|
// Get current track ID using generic QueueBackend trait
|
|
let track_id = queue.current_track()?
|
|
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
|
|
|
|
drop(queue);
|
|
|
|
// Play it using select_queue_track
|
|
self.select_queue_track(track_id)
|
|
}
|
|
|
|
/// Returns a reference to the queue (read-only access via lock).
|
|
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
|
|
pub fn get_queue(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
|
self.queue.lock().unwrap()
|
|
}
|
|
|
|
/// Returns a mutable reference to the queue (write access via lock).
|
|
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
|
|
pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
|
self.queue.lock().unwrap()
|
|
}
|
|
}
|
|
|
|
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
|
|
fn build_didl_lite_metadata(metadata: &TrackMetadata, uri: &str, protocol_info: &str) -> String {
|
|
format!(
|
|
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
|
<item id="0" parentID="-1" restricted="1">
|
|
<dc:title>{}</dc:title>
|
|
<dc:creator>{}</dc:creator>
|
|
<upnp:artist>{}</upnp:artist>
|
|
<upnp:album>{}</upnp:album>
|
|
{}
|
|
<res protocolInfo="{}">{}</res>
|
|
</item>
|
|
</DIDL-Lite>"#,
|
|
metadata.title.as_deref().unwrap_or("Unknown Title"),
|
|
metadata
|
|
.creator
|
|
.as_deref()
|
|
.or(metadata.artist.as_deref())
|
|
.unwrap_or("Unknown Artist"),
|
|
metadata.artist.as_deref().unwrap_or("Unknown Artist"),
|
|
metadata.album.as_deref().unwrap_or("Unknown Album"),
|
|
metadata
|
|
.album_art_uri
|
|
.as_ref()
|
|
.map(|art_uri| format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", art_uri))
|
|
.unwrap_or_default(),
|
|
protocol_info,
|
|
uri
|
|
)
|
|
}
|
|
|
|
impl DeviceIdentity for MusicRenderer {
|
|
fn id(&self) -> DeviceId {
|
|
self.info.id()
|
|
}
|
|
|
|
fn udn(&self) -> &str {
|
|
&*self.info.udn()
|
|
}
|
|
|
|
fn friendly_name(&self) -> &str {
|
|
&self.info.friendly_name()
|
|
}
|
|
|
|
fn model_name(&self) -> &str {
|
|
&self.info.model_name()
|
|
}
|
|
fn manufacturer(&self) -> &str {
|
|
&self.info.manufacturer()
|
|
}
|
|
fn location(&self) -> &str {
|
|
&self.info.location()
|
|
}
|
|
fn server_header(&self) -> &str {
|
|
&self.info.server_header()
|
|
}
|
|
}
|
|
|
|
impl DeviceOnline for MusicRenderer {
|
|
fn is_online(&self) -> bool {
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.is_online()
|
|
}
|
|
|
|
fn last_seen(&self) -> SystemTime {
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.last_seen()
|
|
}
|
|
|
|
fn has_been_seen_now(&self, max_age: u32) {
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.has_been_seen_now(max_age)
|
|
}
|
|
|
|
fn mark_as_offline(&self) {
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.mark_as_offline()
|
|
}
|
|
|
|
fn max_age(&self) -> u32 {
|
|
self.connection
|
|
.lock()
|
|
.expect("Connection mutex poisoned")
|
|
.max_age()
|
|
}
|
|
}
|
|
|
|
impl RendererFromMediaRendererInfo for MusicRendererBackend {
|
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
// Try OpenHome first for OpenHome-capable renderers
|
|
match info.protocol() {
|
|
RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => {
|
|
if let Ok(backend) = OpenHomeRenderer::build_from_renderer_info(info) {
|
|
return Ok(backend);
|
|
}
|
|
// If OpenHomeOnly failed, it's an error
|
|
if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) {
|
|
return Err(ControlPointError::MusicRendererBackendBuild(format!(
|
|
"OpenHomeOnly renderer {} has no usable OpenHome services",
|
|
info.friendly_name()
|
|
)));
|
|
}
|
|
// OpenHomeHybrid: fall through to try UPnP-based backends
|
|
}
|
|
RendererProtocol::ChromecastOnly => {
|
|
return ChromecastRenderer::build_from_renderer_info(info);
|
|
}
|
|
RendererProtocol::UpnpAvOnly => {
|
|
// Will be handled below
|
|
}
|
|
}
|
|
|
|
// UPnP-based renderers (UpnpAvOnly or OpenHomeHybrid fallback)
|
|
let has_arylic = info.capabilities().has_arylic_tcp;
|
|
let has_avtransport = info.capabilities().has_avtransport();
|
|
|
|
// Try Hybrid UPnP + Arylic (special case: combines two backends)
|
|
if has_arylic && has_avtransport {
|
|
let upnp_backend = UpnpRenderer::build_from_renderer_info(info)?;
|
|
if let MusicRendererBackend::Upnp(upnp) = upnp_backend {
|
|
match ArylicTcpRenderer::build_from_renderer_info(info) {
|
|
Ok(MusicRendererBackend::ArylicTcp(arylic)) => {
|
|
return Ok(MusicRendererBackend::HybridUpnpArylic { upnp, arylic });
|
|
}
|
|
Err(err) => {
|
|
warn!(
|
|
renderer = info.friendly_name(),
|
|
error = %err,
|
|
"Failed to build Arylic TCP backend, falling back to UPnP only"
|
|
);
|
|
return Ok(MusicRendererBackend::Upnp(upnp));
|
|
}
|
|
_ => unreachable!(
|
|
"ArylicTcpRenderer::build_from_renderer_info should return ArylicTcp variant"
|
|
),
|
|
}
|
|
} else {
|
|
unreachable!("UpnpRenderer::build_from_renderer_info should return Upnp variant");
|
|
}
|
|
}
|
|
|
|
// Try LinkPlay
|
|
if info.capabilities().has_linkplay_http() {
|
|
if let Ok(backend) = LinkPlayRenderer::build_from_renderer_info(info) {
|
|
return Ok(backend);
|
|
}
|
|
}
|
|
|
|
// Fallback to UPnP
|
|
if has_avtransport {
|
|
return UpnpRenderer::build_from_renderer_info(info);
|
|
}
|
|
|
|
// No suitable backend found
|
|
Err(ControlPointError::MusicRendererBackendBuild(format!(
|
|
"No suitable backend for renderer {}",
|
|
info.friendly_name()
|
|
)))
|
|
}
|
|
|
|
fn to_backend(self) -> MusicRendererBackend {
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Transport control façade that dispatches to whichever backend can fulfill
|
|
/// the request, returning a standardized error if the backend lacks support.
|
|
impl TransportControl for MusicRendererBackend {
|
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta),
|
|
MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta),
|
|
MusicRendererBackend::ArylicTcp(_) => Err(
|
|
ControlPointError::upnp_operation_not_supported("play_uri", "ArylicTcp"),
|
|
),
|
|
MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta),
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
|
}
|
|
}
|
|
|
|
fn play(&self) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.play(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.play(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.play(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.play(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.play(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
|
}
|
|
}
|
|
|
|
fn pause(&self) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.pause(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.pause(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.pause(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.pause(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.pause(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
|
}
|
|
}
|
|
|
|
fn stop(&self) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.stop(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.stop(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.stop(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.stop(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.stop(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
|
}
|
|
}
|
|
|
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
|
|
MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
|
|
MusicRendererBackend::ArylicTcp(_) => Err(
|
|
ControlPointError::upnp_operation_not_supported("seek_rel_time", "ArylicTcp"),
|
|
),
|
|
MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Volume and mute controls exposed via the façade.
|
|
///
|
|
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
|
|
/// always depend on a single [`VolumeControl`] entry point.
|
|
impl VolumeControl for MusicRendererBackend {
|
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.volume(),
|
|
MusicRendererBackend::OpenHome(oh) => oh.volume(),
|
|
MusicRendererBackend::Upnp(upnp) => upnp.volume(),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.volume(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.volume(),
|
|
}
|
|
}
|
|
|
|
fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
|
MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol),
|
|
MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol),
|
|
MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol),
|
|
MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol),
|
|
MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol),
|
|
}
|
|
}
|
|
|
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
|
MusicRendererBackend::OpenHome(r) => r.mute(),
|
|
MusicRendererBackend::Upnp(r) => r.mute(),
|
|
MusicRendererBackend::LinkPlay(r) => r.mute(),
|
|
MusicRendererBackend::ArylicTcp(r) => r.mute(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.mute(),
|
|
}
|
|
}
|
|
|
|
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
|
MusicRendererBackend::OpenHome(r) => r.set_mute(m),
|
|
MusicRendererBackend::Upnp(r) => r.set_mute(m),
|
|
MusicRendererBackend::LinkPlay(r) => r.set_mute(m),
|
|
MusicRendererBackend::ArylicTcp(r) => r.set_mute(m),
|
|
MusicRendererBackend::Chromecast(cc) => cc.set_mute(m),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Playback-state queries sourced from the backend best suited for the job.
|
|
///
|
|
/// Each backend reports into [`PlaybackState`], ensuring consumers never have
|
|
/// to reason about protocol-specific state machines.
|
|
impl PlaybackStatus for MusicRendererBackend {
|
|
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r),
|
|
MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r),
|
|
MusicRendererBackend::LinkPlay(r) => r.playback_state(),
|
|
MusicRendererBackend::ArylicTcp(r) => r.playback_state(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.playback_state(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Playback-position queries that always yield a [`PlaybackPositionInfo`]
|
|
/// regardless of the backend providing the raw transport data.
|
|
impl PlaybackPosition for MusicRendererBackend {
|
|
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
|
match self {
|
|
MusicRendererBackend::Upnp(r) => r.playback_position(),
|
|
MusicRendererBackend::OpenHome(r) => r.playback_position(),
|
|
MusicRendererBackend::LinkPlay(r) => r.playback_position(),
|
|
MusicRendererBackend::ArylicTcp(r) => r.playback_position(),
|
|
MusicRendererBackend::Chromecast(cc) => cc.playback_position(),
|
|
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
|
}
|
|
}
|
|
}
|