From 7b1d35cebc0d6bb4e2b864de03d83872d4070e75 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 1 Dec 2025 19:06:07 +0100 Subject: [PATCH] Fixing MusicRenderer contrat --- pmocontrol/src/control_point.rs | 26 ++++++++++++++- pmocontrol/src/music_renderer.rs | 55 +++++++++++++++++++++----------- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 62e01f1b..f241ceb6 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -8,7 +8,9 @@ use crossbeam_channel::Receiver; use pmoupnp::ssdp::SsdpClient; use crate::MusicRenderer; -use crate::capabilities::{PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus}; +use crate::capabilities::{ + PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, VolumeControl, +}; use crate::discovery::DiscoveryManager; use crate::events::RendererEventBus; use crate::model::{RendererEvent, RendererId, RendererProtocol}; @@ -161,6 +163,26 @@ impl ControlPoint { entry.state = Some(logical_state); } } + + if let Ok(volume) = renderer.volume() { + if entry.last_volume != Some(volume) { + runtime_cp.emit_renderer_event(RendererEvent::VolumeChanged { + id: renderer_id.clone(), + volume, + }); + entry.last_volume = Some(volume); + } + } + + if let Ok(mute) = renderer.mute() { + if entry.last_mute != Some(mute) { + runtime_cp.emit_renderer_event(RendererEvent::MuteChanged { + id: renderer_id.clone(), + mute, + }); + entry.last_mute = Some(mute); + } + } } cache.retain(|id, _| seen_ids.contains(id)); @@ -249,6 +271,8 @@ impl ControlPoint { struct RendererRuntimeSnapshot { state: Option, position: Option, + last_volume: Option, + last_mute: Option, } /// Parse "HH:MM:SS" style time strings to seconds. diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index e3306059..d20f0b8f 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -1,4 +1,10 @@ -// pmocontrol/src/music_renderer.rs +//! Backend-agnostic music renderer façade for PMOMusic. +//! +//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, LinkPlay HTTP, +//! Arylic TCP, 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. +//! OpenHome-only renderers are intentionally unsupported for now. use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; use crate::model::{RendererId, RendererInfo, RendererProtocol}; @@ -6,27 +12,35 @@ use crate::{ ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl, }; -use anyhow::Result; -use tracing::{debug, warn}; +use anyhow::{anyhow, Result}; +use tracing::warn; -/// Music view of a renderer, independent of the underlying protocol/backend. -/// -/// Currently supported backends: -/// - [`UpnpRenderer`] (AVTransport + RenderingControl) -/// - [`LinkPlayRenderer`] (LinkPlay HTTP API) -/// - [`ArylicTcpRenderer`] (Arylic TCP control port 8899) -/// Additional backends (e.g. OpenHome) can be integrated later. +/// Backend-agnostic façade exposing transport, volume, and status contracts. #[derive(Clone, Debug)] pub enum MusicRenderer { + /// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl). Upnp(UpnpRenderer), + /// Renderer controlled via the LinkPlay HTTP API. LinkPlay(LinkPlayRenderer), + /// Renderer reachable through the Arylic TCP control protocol (port 8899). ArylicTcp(ArylicTcpRenderer), + /// 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, }, } +/// Build a standardized error when an operation is not supported by a backend. +fn op_not_supported(op: &str, backend: &str) -> anyhow::Error { + anyhow!( + "MusicRenderer operation '{}' is not supported by backend '{}'", + op, + backend + ) +} + impl MusicRenderer { /// Renderer identifier (stable within the registry). pub fn id(&self) -> &RendererId { @@ -120,15 +134,14 @@ impl MusicRenderer { } } -/// Implémentation générique de `TransportControl` pour [`MusicRenderer`]. -/// -/// Les variantes UPnP et LinkPlay délèguent aux backends correspondants. +/// 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 MusicRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<()> { match self { MusicRenderer::Upnp(upnp) => upnp.play_uri(uri, meta), MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta), - MusicRenderer::ArylicTcp(ary) => ary.play_uri(uri, meta), + MusicRenderer::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")), MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta), } } @@ -164,15 +177,16 @@ impl TransportControl for MusicRenderer { match self { MusicRenderer::Upnp(upnp) => upnp.seek_rel_time(hhmmss), MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss), - MusicRenderer::ArylicTcp(ary) => ary.seek_rel_time(hhmmss), + MusicRenderer::ArylicTcp(_) => Err(op_not_supported("seek_rel_time", "ArylicTcp")), MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss), } } } -/// Implémentation générique de `VolumeControl` pour [`MusicRenderer`]. +/// Volume and mute controls exposed via the façade. /// -/// Les variantes UPnP et LinkPlay délèguent aux backends correspondants. +/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers +/// always depend on a single [`VolumeControl`] entry point. impl VolumeControl for MusicRenderer { fn volume(&self) -> Result { match self { @@ -211,9 +225,10 @@ impl VolumeControl for MusicRenderer { } } -/// Implémentation générique de `PlaybackStatus` pour [`MusicRenderer`]. +/// Playback-state queries sourced from the backend best suited for the job. /// -/// Chaque backend fournit sa propre source d'état (UPnP AVTransport ou LinkPlay HTTP). +/// Each backend reports into [`PlaybackState`], ensuring consumers never have +/// to reason about protocol-specific state machines. impl PlaybackStatus for MusicRenderer { fn playback_state(&self) -> Result { match self { @@ -225,6 +240,8 @@ impl PlaybackStatus for MusicRenderer { } } +/// Playback-position queries that always yield a [`PlaybackPositionInfo`] +/// regardless of the backend providing the raw transport data. impl PlaybackPosition for MusicRenderer { fn playback_position(&self) -> Result { match self {