push-pqqsxyupswry #21

Merged
eric merged 76 commits from push-pqqsxyupswry into main 2025-12-06 12:49:47 +01:00
13 changed files with 1235 additions and 210 deletions
Showing only changes of commit f096a8cd8f - Show all commits

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ test_upnp*.cargo/
setup-env.sh
cache
gupnp-tools
pmocontrol_[0_9]*.txt

1
Cargo.lock generated
View File

@@ -3163,6 +3163,7 @@ name = "pmocontrol"
version = "0.1.0"
dependencies = [
"anyhow",
"crossbeam-channel",
"pmoupnp",
"quick-xml 0.38.4",
"thiserror 2.0.17",

View File

@@ -12,3 +12,4 @@ tracing = "0.1.41"
tracing-subscriber = "0.3"
anyhow = "1.0"
xmltree = "0.11.0"
crossbeam-channel = "0.5"

View File

@@ -0,0 +1,197 @@
// examples/events_demo.rs
//
// Demo temps réel des RendererEvent émis par le runtime de ControlPoint :
// - SSDP discovery via `ControlPoint`
// - sélection d'un renderer (facultatif)
// - abonnement à `subscribe_events()`
// - affichage continu des événements avec horodatage HH:MM:SS
//
// Build et run (depuis la racine du crate pmocontrol) :
// cargo run --example events_demo -- # écoute tous les renderers
// cargo run --example events_demo -- 0 # filtre sur renderer index 0
// cargo run --example events_demo -- 1 # filtre sur renderer index 1, etc.
//
// Ctrl-C pour quitter.
use std::env;
use std::io;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use pmocontrol::{
ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent,
RendererId, RendererInfo,
};
fn main() -> io::Result<()> {
// Logging simple (tracing_subscriber est déjà utilisé dans les autres exemples)
let _ = tracing_subscriber::fmt::try_init();
println!("Starting PMOMusic renderer events demo...");
// 1. Lance le ControlPoint (timeout HTTP pour les descriptions UPnP)
let cp = ControlPoint::spawn(5)?;
// 2. Laisse la découverte tourner un peu avant de lister les renderers
println!("Waiting 5 seconds for SSDP discovery...");
thread::sleep(Duration::from_secs(5));
let registry = cp.registry();
let renderers: Vec<RendererInfo> = {
let reg = registry.read().unwrap();
reg.list_renderers()
};
if renderers.is_empty() {
println!("No renderers discovered. Make sure your devices are on and reachable.");
return Ok(());
}
println!("\nDiscovered renderers:");
for (idx, info) in renderers.iter().enumerate() {
println!(
" [{}] {} | model={} | udn={} | location={} | online={}",
idx, info.friendly_name, info.model_name, info.udn, info.location, info.online
);
}
// 3. Optionnel : sélection d'un renderer par index (filtrage des événements)
let args: Vec<String> = env::args().collect();
let selected_id: Option<RendererId> = if args.len() >= 2 {
match args[1].parse::<usize>() {
Ok(idx) if idx < renderers.len() => {
let info = &renderers[idx];
println!(
"\nFiltering events on renderer [{}] {} (id={})",
idx, info.friendly_name, info.id.0
);
Some(info.id.clone())
}
Ok(idx) => {
eprintln!(
"\nRenderer index {} is out of range (0..{}), listening to all renderers.",
idx,
renderers.len().saturating_sub(1)
);
None
}
Err(e) => {
eprintln!(
"\nArgument '{}' is not a valid index (error: {}), listening to all renderers.",
args[1], e
);
None
}
}
} else {
println!("\nNo renderer index provided, listening to events from all renderers.");
None
};
// 4. Abonnement aux événements du runtime
let rx = cp.subscribe_events();
println!("\nSubscribed to renderer events.");
println!("Press Ctrl-C to quit.\n");
// 5. Boucle bloquante sur les événements
loop {
match rx.recv() {
Ok(event) => {
if let Some(ref id) = selected_id {
// Filtre : on ignore les événements des autres renderers
if !event_matches_id(&event, id) {
continue;
}
}
print_event(&event);
}
Err(err) => {
eprintln!("Event channel closed: {}. Exiting.", err);
break;
}
}
}
Ok(())
}
/// Vérifie si un événement concerne un RendererId donné.
fn event_matches_id(event: &RendererEvent, id: &RendererId) -> bool {
match event {
RendererEvent::StateChanged { id: eid, .. } => eid == id,
RendererEvent::PositionChanged { id: eid, .. } => eid == id,
RendererEvent::VolumeChanged { id: eid, .. } => eid == id,
RendererEvent::MuteChanged { id: eid, .. } => eid == id,
}
}
/// Format HH:MM:SS basé sur l'heure système (UTC mod 24h).
fn now_hms() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0));
let total = now.as_secs() % 86_400;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
format!("{:02}:{:02}:{:02}", h, m, s)
}
/// Affichage lisible d'un PlaybackState.
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(s) => format!("Unknown({})", s),
}
}
/// Affichage lisible d'un PlaybackPositionInfo.
fn format_position(pos: &PlaybackPositionInfo) -> String {
let track = pos.track.map(|t| t.to_string()).unwrap_or_else(|| "-".to_string());
let rel = pos
.rel_time
.as_deref()
.unwrap_or("-")
.to_string();
let dur = pos
.track_duration
.as_deref()
.unwrap_or("-")
.to_string();
format!("track={} rel_time={} duration={}", track, rel, dur)
}
/// Affiche un RendererEvent avec horodatage.
fn print_event(event: &RendererEvent) {
let ts = now_hms();
match event {
RendererEvent::StateChanged { id, state } => {
println!(
"[{}] [{}] StateChanged: {}",
ts,
id.0,
format_playback_state(state)
);
}
RendererEvent::PositionChanged { id, position } => {
println!(
"[{}] [{}] PositionChanged: {}",
ts,
id.0,
format_position(position)
);
}
RendererEvent::VolumeChanged { id, volume } => {
println!("[{}] [{}] VolumeChanged: {}", ts, id.0, volume);
}
RendererEvent::MuteChanged { id, mute } => {
println!("[{}] [{}] MuteChanged: {}", ts, id.0, mute);
}
}
}

View File

@@ -1,229 +1,345 @@
// pmocontrol/examples/renderer_demo.rs
// examples/music_renderer_demo.rs
//
// End-to-end demo using the `MusicRenderer` façade:
// - SSDP discovery via `ControlPoint`
// - selection of a renderer
// - generic `TransportControl` + `VolumeControl` + `PlaybackStatus` + `PlaybackPosition`
// - optional UPnP-specific inspection (TransportInfo, ConnectionManager)
//
// Build and run (from pmocontrol crate root):
// cargo run --example music_renderer_demo -- [index] [uri]
//
// index: optional 0-based renderer index (default: 0)
// uri : optional URI to play (default: Radio Paradise FLAC)
use anyhow::{anyhow, Result};
use pmocontrol::{ControlPoint, Renderer};
use anyhow::Result;
use pmocontrol::PlaybackPosition;
use pmocontrol::{
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
RendererProtocol, TransportControl, VolumeControl,
};
use std::env;
use std::io::{self, Write};
use std::thread;
use std::time::Duration;
// Default URI if none is provided on the CLI.
const DEFAULT_TEST_URI: &str =
"https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac";
// Extra wait after play_uri() so slow renderers (e.g. Arylic H50) have time
// to prefetch and actually start playback.
const AFTER_PLAY_WAIT_SECS: u64 = 15;
fn main() -> Result<()> {
// -------------------------------------------------------------------------
// CLI arguments
//
// Usage:
// renderer_demo
// -> URI par défaut, renderer index 0
//
// renderer_demo 1
// -> URI par défaut, renderer index 1
//
// renderer_demo https://.../track.flac
// -> cette URI, renderer index 0
//
// renderer_demo https://.../track.flac 1
// -> cette URI, renderer index 1
// -------------------------------------------------------------------------
let default_uri =
"https://audio-fb.radioparadise.com/chan/1/x/1117/4/g/1117-3.flac".to_string();
let mut uri = default_uri.clone();
let mut renderer_index: usize = 0;
let args: Vec<String> = env::args().collect();
if args.len() >= 2 {
let first = &args[1];
if let Ok(idx) = first.parse::<usize>() {
// cas: renderer_demo 1 [URI]
renderer_index = idx;
if args.len() >= 3 {
uri = args[2].clone();
}
} else {
// cas: renderer_demo URI [INDEX]
uri = first.clone();
if args.len() >= 3 {
if let Ok(idx) = args[2].parse::<usize>() {
renderer_index = idx;
}
}
}
}
println!("Using URI : {}", uri);
println!("Requested renderer index : {}", renderer_index);
// -------------------------------------------------------------------------
// 1. Démarrer le control point et laisser la découverte tourner un peu
// -------------------------------------------------------------------------
// 1. Start control point and let discovery run a bit
let cp = ControlPoint::spawn(5)?;
thread::sleep(Duration::from_secs(5));
// -------------------------------------------------------------------------
// 2. Récupérer la liste des renderers (handles haut niveau)
// -------------------------------------------------------------------------
let mut renderers: Vec<Renderer> = cp.list_renderer_handles();
// 2. Snapshot of logical music renderers
let mut renderers: Vec<MusicRenderer> = cp.list_music_renderers();
// Filtre : on ignore le renderer PMOMusic interne en développement
// Filter out the in-dev PMOMusic renderer (if present)
renderers.retain(|r| {
!r.friendly_name()
.to_ascii_lowercase()
.contains("pmomusic audio renderer")
let name = r.friendly_name().to_ascii_lowercase();
!name.contains("pmomusic audio renderer")
});
if renderers.is_empty() {
println!("No valid UPnP MediaRenderer discovered.");
println!("No valid music renderer discovered.");
return Ok(());
}
println!("\nDiscovered MediaRenderers:");
for (idx, r) in renderers.iter().enumerate() {
// 3. CLI args: [index] [uri]
let args: Vec<String> = env::args().skip(1).collect();
let selected_index: usize = if !args.is_empty() {
args[0].parse().unwrap_or(0)
} else {
0
};
let maybe_uri: Option<String> = if args.len() >= 2 {
Some(args[1].clone())
} else {
None
};
// Bounds check
if selected_index >= renderers.len() {
println!(
" [{}] {} | model={} | manufacturer={} | has_avt={} | has_rc={}",
idx,
r.friendly_name(),
r.info.model_name,
r.info.manufacturer,
r.has_avtransport(),
r.has_rendering_control(),
"Renderer index {} out of range ({} available).",
selected_index,
renderers.len()
);
return Ok(());
}
// 4. List renderers with basic info
println!("Discovered MusicRenderers:");
for (idx, r) in renderers.iter().enumerate() {
let info = r.info();
println!(
" [{}] {} | model={} | udn={} | location={}",
idx, info.friendly_name, info.model_name, info.udn, info.location
);
print_capabilities(" ", &info.capabilities, &info.protocol);
}
// 5. Select renderer
let renderer = &renderers[selected_index];
let info = renderer.info();
println!("\nSelected renderer (index {}):", selected_index);
println!(" Name : {}", info.friendly_name);
println!(" Model : {}", info.model_name);
println!(" Manufacturer: {}", info.manufacturer);
println!(" UDN : {}", info.udn);
println!(" Location : {}", info.location);
println!(" Protocol : {:?}", info.protocol);
print_capabilities(" ", &info.capabilities, &info.protocol);
if let Some(upnp) = renderer.as_upnp() {
println!(
" [UPnP] AVTransport control URL : {}",
upnp
.info
.avtransport_control_url
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] AVTransport service type: {}",
upnp
.info
.avtransport_service_type
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] RenderingControl control URL : {}",
upnp
.info
.rendering_control_control_url
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] RenderingControl service type: {}",
upnp
.info
.rendering_control_service_type
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] ConnectionManager control URL : {}",
upnp
.info
.connection_manager_control_url
.as_deref()
.unwrap_or("<none>")
);
println!(
" [UPnP] ConnectionManager service type: {}",
upnp
.info
.connection_manager_service_type
.as_deref()
.unwrap_or("<none>")
);
}
if renderer_index >= renderers.len() {
return Err(anyhow!(
"Renderer index {} out of range (0..={})",
renderer_index,
renderers.len().saturating_sub(1)
));
}
// 6. Initial state dump (generic + UPnP-specific)
dump_renderer_state(renderer, "Initial state")?;
// -------------------------------------------------------------------------
// 3. Sélection du renderer
// -------------------------------------------------------------------------
let renderer = &renderers[renderer_index];
// 7. Play URI via logical façade
let uri = maybe_uri.unwrap_or_else(|| DEFAULT_TEST_URI.to_string());
let meta = ""; // or full DIDL-Lite
println!("\nSelected renderer (index {}):", renderer_index);
println!(" Name : {}", renderer.friendly_name());
println!(" Model : {}", renderer.info.model_name);
println!(" Manufacturer: {}", renderer.info.manufacturer);
println!(" UDN : {}", renderer.info.udn);
println!(" Location : {}", renderer.info.location);
println!(" has_avt : {}", renderer.has_avtransport());
println!(" has_rc : {}", renderer.has_rendering_control());
// -------------------------------------------------------------------------
// 4. Si RenderingControl dispo : tester get/set volume + mute
// (set_* remet juste la même valeur pour ne rien changer en pratique)
// -------------------------------------------------------------------------
if renderer.has_rendering_control() {
println!("\n[RenderingControl]");
match renderer.get_master_volume() {
Ok(vol) => {
println!(" Current master volume: {}", vol);
if let Err(e) = renderer.set_master_volume(vol) {
println!(" set_master_volume({}) failed: {}", vol, e);
} else {
println!(" set_master_volume({}) OK (no-op)", vol);
}
}
Err(e) => {
println!(" get_master_volume() failed: {}", e);
}
}
match renderer.get_master_mute() {
Ok(muted) => {
println!(" Current master mute : {}", muted);
if let Err(e) = renderer.set_master_mute(muted) {
println!(" set_master_mute({}) failed: {}", muted, e);
} else {
println!(" set_master_mute({}) OK (no-op)", muted);
}
}
Err(e) => {
println!(" get_master_mute() failed: {}", e);
}
}
println!("\nCalling play_uri on music renderer...");
if let Err(e) = renderer.play_uri(&uri, meta) {
println!(" play_uri failed: {e}");
} else {
println!("\n[RenderingControl]");
println!(" Renderer has no RenderingControl service.");
println!(" play_uri: OK");
}
// -------------------------------------------------------------------------
// 5. Si AVTransport dispo : tester play_uri / seek / pause / stop
// -------------------------------------------------------------------------
if !renderer.has_avtransport() {
println!("\n[AVTransport]");
println!(" Renderer has no AVTransport service, skipping playback tests.");
return Ok(());
println!(
"Waiting {}s to let the renderer prefetch and start playback...",
AFTER_PLAY_WAIT_SECS
);
thread::sleep(Duration::from_secs(AFTER_PLAY_WAIT_SECS));
dump_renderer_state(renderer, "After play_uri")?;
// 8. Short progress polling using the PlaybackPosition façade
progress_monitor(renderer, "Progress while playing", 8, 3);
// 9. Seek (if supported)
println!("\nCalling seek_rel_time(\"00:01:00\")...");
if let Err(e) = renderer.seek_rel_time("00:01:00") {
println!(" seek_rel_time failed: {e}");
} else {
println!(" seek_rel_time: OK");
thread::sleep(Duration::from_secs(2));
dump_renderer_state(renderer, "After seek_rel_time")?;
// 9b. Second progress loop after seek: verify RelTime advances
progress_monitor(renderer, "Progress after seek", 8, 3);
}
println!("\n[AVTransport]");
// Helper pour afficher l'état courant
let dump_info = |label: &str| -> Result<()> {
let info = renderer
.avtransport()
.and_then(|_| renderer.avtransport().unwrap().get_transport_info(0))?;
println!("\n [{}]", label);
println!(" State : {}", info.current_transport_state);
println!(" Status : {}", info.current_transport_status);
println!(" Speed : {}", info.current_speed);
Ok(())
};
// Set + Play
println!("\n Calling play_uri(...)");
renderer.play_uri(&uri, "")?;
println!(" play_uri: OK");
thread::sleep(Duration::from_secs(8));
let _ = dump_info("After play_uri");
// Seek (si supporté)
println!("\n Calling seek_rel_time(\"00:01:00\")...");
match renderer.seek_rel_time("00:01:00") {
Ok(()) => {
println!(" seek_rel_time: OK");
thread::sleep(Duration::from_secs(3));
let _ = dump_info("After seek_rel_time");
}
Err(e) => {
println!(" seek_rel_time failed: {}", e);
}
// 10. Volume dance (if supported)
println!("\nProbing volume control via music façade...");
if let Err(e) = volume_demo(renderer) {
println!(" Volume control not fully usable: {e}");
}
// Pause (ENTER pour laisser jouer)
print!("\nPress ENTER to Pause...");
io::stdout().flush().ok();
let _ = io::stdin().read_line(&mut String::new());
println!("\n Calling pause()...");
match renderer.pause() {
Ok(()) => {
println!(" pause: OK");
thread::sleep(Duration::from_secs(2));
let _ = dump_info("After pause");
}
Err(e) => {
println!(" pause failed: {}", e);
}
// 11. Pause then Stop
println!("\nCalling pause() via music façade...");
if let Err(e) = renderer.pause() {
println!(" pause failed: {e}");
} else {
println!(" pause: OK");
thread::sleep(Duration::from_secs(2));
dump_renderer_state(renderer, "After pause")?;
}
// Stop
print!("\nPress ENTER to Stop...");
io::stdout().flush().ok();
let _ = io::stdin().read_line(&mut String::new());
println!("\n Calling stop()...");
match renderer.stop() {
Ok(()) => {
println!(" stop: OK");
let _ = dump_info("After stop");
}
Err(e) => {
println!(" stop failed: {}", e);
}
println!("\nCalling stop() via music façade...");
if let Err(e) = renderer.stop() {
println!(" stop failed: {e}");
} else {
println!(" stop: OK");
dump_renderer_state(renderer, "After stop")?;
}
println!("\nDone.");
Ok(())
}
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);
}
fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
println!("\n[{label}]");
if let Ok(state) = renderer.playback_state() {
println!(" Playback state (music): {:?}", state);
} else {
println!(" Playback state (music): <unavailable>");
}
// Generic volume façade
match renderer.volume() {
Ok(v) => println!(" Volume (music) : {}", v),
Err(e) => println!(" Volume not available: {e}"),
}
match renderer.mute() {
Ok(m) => println!(" Mute : {}", m),
Err(e) => println!(" Mute state unknown : {e}"),
}
if let Ok(pos) = renderer.playback_position() {
println!(" Position info:");
println!(" Track : {:?}", pos.track);
println!(" Duration : {:?}", pos.track_duration);
println!(" RelTime : {:?}", pos.rel_time);
println!(" AbsTime : {:?}", pos.abs_time);
} else {
println!(" Position info: <unavailable>");
}
// Optional UPnP-specific TransportInfo
if let Some(upnp) = renderer.as_upnp() {
if upnp.has_avtransport() {
match upnp.avtransport() {
Ok(avt) => match avt.get_transport_info(0) {
Ok(info) => {
println!(" [UPnP] TransportInfo:");
println!(" State : {}", info.current_transport_state);
println!(" Status : {}", info.current_transport_status);
println!(" Speed : {}", info.current_speed);
}
Err(e) => {
println!(" [UPnP] TransportInfo unavailable: {e}");
}
},
Err(e) => println!(" [UPnP] No AVTransport client: {e}"),
}
} else {
println!(" [UPnP] AVTransport not present on this renderer.");
}
}
Ok(())
}
fn progress_monitor(
renderer: &MusicRenderer,
label: &str,
iterations: usize,
interval_secs: u64,
) {
println!(
"\n[{label}] polling playback state/position {} times (every {} s)...",
iterations, interval_secs
);
for i in 0..iterations {
if let Ok(state) = renderer.playback_state() {
print!(" Sample {:02}: state={:?}", i + 1, state);
} else {
print!(" Sample {:02}: state=<unavailable>", i + 1);
}
if let Ok(pos) = renderer.playback_position() {
println!(
" | track={:?}, rel={:?}, dur={:?}",
pos.track, pos.rel_time, pos.track_duration
);
} else {
println!(" | position=<unavailable>");
}
thread::sleep(Duration::from_secs(interval_secs));
}
}
fn volume_demo(renderer: &MusicRenderer) -> Result<()> {
// Try to get current volume
let original = renderer.volume()?;
println!(" Current music volume : {}", original);
// Try mute toggle
let muted = renderer.mute()?;
println!(" Current mute state : {}", muted);
println!(" Setting mute = true...");
renderer.set_mute(true)?;
thread::sleep(Duration::from_secs(1));
println!(" Mute now: {}", renderer.mute()?);
println!(" Restoring mute = {}", muted);
renderer.set_mute(muted)?;
thread::sleep(Duration::from_millis(500));
// Small volume bump if possible
let new_volume = original.saturating_add(5).min(u16::MAX);
println!(" Bumping volume to : {}", new_volume);
renderer.set_volume(new_volume)?;
thread::sleep(Duration::from_secs(1));
println!(" Volume after bump : {}", renderer.volume()?);
println!(" Restoring original volume: {}", original);
renderer.set_volume(original)?;
Ok(())
}

View File

@@ -356,3 +356,70 @@ mod upnp_error_tests {
assert_eq!(err.error_description, "Invalid Action");
}
}
#[derive(Debug, Clone)]
pub struct PositionInfo {
pub track: u32,
pub track_duration: Option<String>, // HH:MM:SS or None
pub rel_time: Option<String>, // HH:MM:SS or None
pub abs_time: Option<String>, // HH:MM:SS or None
}
impl AvTransportClient {
/// AVTransport:1 — GetPositionInfo
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
let call_result = invoke_upnp_action(
&self.control_url,
&self.service_type,
"GetPositionInfo",
&args,
)?;
if !call_result.status.is_success() {
return Err(anyhow!(
"GetPositionInfo failed with HTTP status {}",
call_result.status
));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetPositionInfo response"))?;
parse_position_info(envelope)
}
}
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo> {
let response =
find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
.ok_or_else(|| anyhow!("Missing GetPositionInfoResponse element"))?;
// Helpers allow missing text (AVTransport allows empty durations)
fn opt(parent: &Element, name: &str) -> Option<String> {
find_child_with_suffix(parent, name)
.and_then(|e| e.get_text())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
let track = opt(response, "Track")
.unwrap_or_else(|| "0".into())
.parse::<u32>()
.unwrap_or(0);
let track_duration = opt(response, "TrackDuration");
let rel_time = opt(response, "RelTime");
let abs_time = opt(response, "AbsTime");
Ok(PositionInfo {
track,
track_duration,
rel_time,
abs_time,
})
}

View File

@@ -0,0 +1,101 @@
// 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
/// tu préfères pour la façade; ici je reste en String pour garder la
/// même granularité que UPnP sans parser.
#[derive(Clone, Debug)]
pub struct PlaybackPositionInfo {
pub track: Option<u32>,
pub rel_time: Option<String>, // position courante
pub abs_time: Option<String>, // si pertinent
pub track_duration: Option<String>, // durée totale
}
pub trait PlaybackPosition {
fn playback_position(&self) -> Result<PlaybackPositionInfo>;
}
/// High-level playback state across backends.
#[derive(Clone, Debug)]
pub enum PlaybackState {
Stopped,
Playing,
Paused,
Transitioning,
NoMedia,
/// Backend-specific or unknown state string.
Unknown(String),
}
impl PlaybackState {
/// Map a raw UPnP AVTransport CurrentTransportState string
/// to a logical PlaybackState.
pub fn from_upnp_state(raw: &str) -> Self {
let s = raw.trim().to_ascii_uppercase();
match s.as_str() {
"STOPPED" => PlaybackState::Stopped,
"PLAYING" => PlaybackState::Playing,
"PAUSED_PLAYBACK" => PlaybackState::Paused,
// States from the AVTransport spec that we normalize:
"PAUSED_RECORDING" => PlaybackState::Paused,
"RECORDING" => PlaybackState::Playing,
// Common vendor-specific states:
"TRANSITIONING" => PlaybackState::Transitioning,
"BUFFERING" | "PREPARING" => PlaybackState::Transitioning,
"NO_MEDIA_PRESENT" => PlaybackState::NoMedia,
_ => PlaybackState::Unknown(raw.to_string()),
}
}
}
/// Generic abstraction for playback status (transport state).
///
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
/// For OpenHome, a future implementation will adapt from OH Info/Time.
pub trait PlaybackStatus {
fn playback_state(&self) -> Result<PlaybackState>;
}
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...).
pub trait TransportControl {
/// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture.
///
/// Selon l'implémentation, cette méthode peut soit :
/// - faire un "Set...URI" + "Play" (cas UPnP AV),
/// - ou configurer la file de lecture (cas OpenHome, etc.).
fn play_uri(&self, uri: &str, meta: &str) -> Result<()>;
/// Démarre ou reprend la lecture.
fn play(&self) -> Result<()>;
/// Met la lecture en pause.
fn pause(&self) -> Result<()>;
/// Arrête la lecture.
fn stop(&self) -> Result<()>;
/// Seek à un temps relatif (HH:MM:SS) si supporté.
fn seek_rel_time(&self, hhmmss: &str) -> Result<()>;
}
/// Abstraction générique des capacités de contrôle de volume / mute.
pub trait VolumeControl {
/// Retourne le volume logique courant (échelle dépendante du renderer).
fn volume(&self) -> Result<u16>;
/// Définit le volume logique (échelle dépendante du renderer).
fn set_volume(&self, v: u16) -> Result<()>;
/// Indique si le renderer est muet (mute activé).
fn mute(&self) -> Result<bool>;
/// Active ou désactive le mute.
fn set_mute(&self, m: bool) -> Result<()>;
}

View File

@@ -1,15 +1,20 @@
use std::collections::{HashMap, HashSet};
use std::io;
use std::sync::{Arc, RwLock};
use std::thread;
use std::time::Duration;
use crossbeam_channel::Receiver;
use pmoupnp::ssdp::SsdpClient;
use crate::MusicRenderer;
use crate::capabilities::{PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus};
use crate::discovery::DiscoveryManager;
use crate::model::RendererId;
use crate::events::RendererEventBus;
use crate::model::{RendererEvent, RendererId, RendererProtocol};
use crate::provider::HttpXmlDescriptionProvider;
use crate::registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
use crate::renderer::Renderer;
use crate::upnp_renderer::UpnpRenderer;
/// Control point minimal :
/// - lance un SsdpClient dans un thread,
@@ -17,6 +22,7 @@ use crate::renderer::Renderer;
/// - applique les DeviceUpdate dans le DeviceRegistry.
pub struct ControlPoint {
registry: Arc<RwLock<DeviceRegistry>>,
event_bus: RendererEventBus,
}
impl ControlPoint {
@@ -25,6 +31,7 @@ impl ControlPoint {
/// `timeout_secs` : timeout HTTP pour la récupération des descriptions UPnP.
pub fn spawn(timeout_secs: u64) -> io::Result<Self> {
let registry = Arc::new(RwLock::new(DeviceRegistry::new()));
let event_bus = RendererEventBus::new();
// SsdpClient
let client = SsdpClient::new()?; // pmoupnp::ssdp::SsdpClient
@@ -71,7 +78,100 @@ impl ControlPoint {
});
});
Ok(Self { registry })
let runtime_cp = ControlPoint {
registry: Arc::clone(&registry),
event_bus: event_bus.clone(),
};
thread::spawn(move || {
let mut cache: HashMap<RendererId, RendererRuntimeSnapshot> = HashMap::new();
loop {
let renderers = {
let reg = runtime_cp.registry.read().unwrap();
reg.list_renderers()
.into_iter()
.map(|info| UpnpRenderer::from_registry(info, &reg))
.collect::<Vec<_>>()
};
let mut seen_ids = HashSet::new();
for renderer in renderers {
let info = &renderer.info;
// Ne pas poller les renderers offline
if !info.online {
continue;
}
match info.protocol {
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {}
RendererProtocol::OpenHomeOnly => continue,
}
let renderer_id = info.id.clone();
seen_ids.insert(renderer_id.clone());
let entry = cache
.entry(renderer_id.clone())
.or_insert_with(RendererRuntimeSnapshot::default);
// Keep a snapshot of the previous position to compute logical
// state transitions based on time deltas.
let prev_position = entry.position.clone();
// 1) Poll position first, so that the state logic can use the
// freshly updated position when available.
if let Ok(position) = renderer.playback_position() {
let has_changed = match entry.position.as_ref() {
Some(prev) => !playback_position_equal(prev, &position),
None => true,
};
if has_changed {
runtime_cp.emit_renderer_event(RendererEvent::PositionChanged {
id: renderer_id.clone(),
position: position.clone(),
});
}
entry.position = Some(position);
}
// 2) Poll raw playback state and compute a logical state that
// compensates for buggy devices (Arylic / LinkPlay).
if let Ok(raw_state) = renderer.playback_state() {
let logical_state = compute_logical_playback_state(
&raw_state,
prev_position.as_ref(),
entry.position.as_ref(),
);
let has_changed = match entry.state.as_ref() {
Some(prev) => !playback_state_equal(prev, &logical_state),
None => true,
};
if has_changed {
runtime_cp.emit_renderer_event(RendererEvent::StateChanged {
id: renderer_id.clone(),
state: logical_state.clone(),
});
entry.state = Some(logical_state);
}
}
}
cache.retain(|id, _| seen_ids.contains(id));
thread::sleep(Duration::from_secs(1));
}
});
Ok(Self {
registry,
event_bus,
})
}
/// Accès au DeviceRegistry partagé.
@@ -80,27 +180,168 @@ impl ControlPoint {
}
/// Snapshot list of renderers currently known by the registry.
pub fn list_renderer_handles(&self) -> Vec<Renderer> {
pub fn list_upnp_renderers(&self) -> Vec<UpnpRenderer> {
let reg = self.registry.read().unwrap();
reg.list_renderers()
.into_iter()
.map(|info| Renderer::from_registry(info, &reg))
.map(|info| UpnpRenderer::from_registry(info, &reg))
.collect()
}
/// Return the first renderer in the registry, if any.
pub fn default_renderer(&self) -> Option<Renderer> {
pub fn default_upnp_renderer(&self) -> Option<UpnpRenderer> {
let reg = self.registry.read().unwrap();
reg.list_renderers()
.into_iter()
.next()
.map(|info| Renderer::from_registry(info, &reg))
.map(|info| UpnpRenderer::from_registry(info, &reg))
}
/// Lookup a renderer by id.
pub fn renderer_by_id(&self, id: &RendererId) -> Option<Renderer> {
pub fn upnp_renderer_by_id(&self, id: &RendererId) -> Option<UpnpRenderer> {
let reg = self.registry.read().unwrap();
reg.get_renderer(id)
.map(|info| Renderer::from_registry(info, &reg))
.map(|info| UpnpRenderer::from_registry(info, &reg))
}
/// Snapshot list of music renderers (protocol-agnostic view).
///
/// For now, only UPnP AV / hybrid renderers are wrapped as
/// [`MusicRenderer::Upnp`]. OpenHome-only devices will be
/// ignored until an OpenHome backend is implemented.
pub fn list_music_renderers(&self) -> Vec<MusicRenderer> {
let reg = self.registry.read().unwrap();
reg.list_renderers()
.into_iter()
.filter_map(|info| MusicRenderer::from_registry_info(info, &reg))
.collect()
}
/// Return the first music renderer in the registry, if any.
pub fn default_music_renderer(&self) -> Option<MusicRenderer> {
let reg = self.registry.read().unwrap();
reg.list_renderers()
.into_iter()
.find_map(|info| MusicRenderer::from_registry_info(info, &reg))
}
/// Lookup a music renderer by id.
pub fn music_renderer_by_id(&self, id: &RendererId) -> Option<MusicRenderer> {
let reg = self.registry.read().unwrap();
reg.get_renderer(id)
.and_then(|info| MusicRenderer::from_registry_info(info, &reg))
}
/// Subscribe to renderer events emitted by the control point runtime.
///
/// Each subscriber receives all future events independently.
pub fn subscribe_events(&self) -> Receiver<RendererEvent> {
self.event_bus.subscribe()
}
#[allow(dead_code)]
pub(crate) fn emit_renderer_event(&self, event: RendererEvent) {
self.event_bus.broadcast(event);
}
}
#[derive(Default)]
struct RendererRuntimeSnapshot {
state: Option<PlaybackState>,
position: Option<PlaybackPositionInfo>,
}
/// Parse "HH:MM:SS" style time strings to seconds.
///
/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--".
fn parse_hms_to_secs(s: &str) -> Option<u64> {
let s = s.trim();
if s.is_empty() {
return None;
}
// Common sentinel values for "no information" in UPnP implementations.
if s == "NOT_IMPLEMENTED" || s == "-:--:--" {
return None;
}
let parts: Vec<_> = s.split(':').collect();
if parts.len() != 3 {
return None;
}
let hours: u64 = parts[0].parse().ok()?;
let minutes: u64 = parts[1].parse().ok()?;
let seconds: u64 = parts[2].parse().ok()?;
Some(hours * 3600 + minutes * 60 + seconds)
}
fn parse_optional_hms_to_secs(value: &Option<String>) -> Option<u64> {
value.as_ref().and_then(|s| parse_hms_to_secs(s))
}
/// Compute a logical playback state by combining the raw AVTransport state
/// with previous and current position information.
///
/// This is designed to compensate for buggy LinkPlay/Arylic devices that
/// report:
/// - STOPPED while the time actually advances,
/// - NO_MEDIA_PRESENT while track duration is known.
fn compute_logical_playback_state(
raw: &PlaybackState,
prev_position: Option<&PlaybackPositionInfo>,
current_position: Option<&PlaybackPositionInfo>,
) -> PlaybackState {
use PlaybackState::*;
// Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is
// actually playing. If we detect that the relative time advances between
// two polls, we treat this as Playing.
if let Stopped = raw {
if let (Some(prev), Some(curr)) = (prev_position, current_position) {
if let (Some(prev_rel), Some(curr_rel)) = (
parse_optional_hms_to_secs(&prev.rel_time),
parse_optional_hms_to_secs(&curr.rel_time),
) {
if curr_rel > prev_rel {
let delta = curr_rel - prev_rel;
// Our poll loop runs every 1s; accept small jitter in the delta.
if delta <= 5 {
return Playing;
}
}
}
}
}
// Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero
// track duration. In practice this behaves like a stopped transport with
// a loaded track.
if let NoMedia = raw {
let duration_secs = current_position
.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))
.or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration)));
if matches!(duration_secs, Some(d) if d > 0) {
return Stopped;
}
}
// Fallback: keep the raw (already normalized) state.
raw.clone()
}
fn playback_state_equal(a: &PlaybackState, b: &PlaybackState) -> bool {
match (a, b) {
(PlaybackState::Unknown(lhs), PlaybackState::Unknown(rhs)) => lhs == rhs,
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
}
}
fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -> bool {
a.track == b.track
&& a.rel_time == b.rel_time
&& a.abs_time == b.abs_time
&& a.track_duration == b.track_duration
}

33
pmocontrol/src/events.rs Normal file
View File

@@ -0,0 +1,33 @@
use std::sync::{Arc, Mutex};
use crossbeam_channel::{unbounded, Receiver, Sender};
use crate::model::RendererEvent;
#[derive(Clone, Default)]
pub(crate) struct RendererEventBus {
subscribers: Arc<Mutex<Vec<Sender<RendererEvent>>>>,
}
impl RendererEventBus {
pub(crate) fn new() -> Self {
Self {
subscribers: Arc::new(Mutex::new(Vec::new())),
}
}
pub(crate) fn subscribe(&self) -> Receiver<RendererEvent> {
let (tx, rx) = unbounded::<RendererEvent>();
{
let mut subscribers = self.subscribers.lock().unwrap();
subscribers.push(tx);
}
rx
}
#[allow(dead_code)]
pub(crate) fn broadcast(&self, event: RendererEvent) {
let mut subscribers = self.subscribers.lock().unwrap();
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
}
}

View File

@@ -1,24 +1,30 @@
mod events;
pub mod avtransport_client;
pub mod capabilities;
pub mod connection_manager_client;
pub mod control_point;
pub mod discovery;
pub mod model;
pub mod provider;
pub mod renderer;
pub mod upnp_renderer;
pub mod rendering_control_client;
pub mod registry;
pub mod soap_client;
pub mod music_renderer;
pub use avtransport_client::{AvTransportClient, TransportInfo};
pub use avtransport_client::{AvTransportClient, TransportInfo, PositionInfo};
pub use capabilities::{TransportControl, VolumeControl, PlaybackState, PlaybackStatus, PlaybackPosition, PlaybackPositionInfo};
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
pub use control_point::ControlPoint;
pub use rendering_control_client::RenderingControlClient;
pub use renderer::Renderer;
pub use upnp_renderer::UpnpRenderer;
pub use music_renderer::MusicRenderer;
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
pub use model::{
MediaServerCapabilities, MediaServerId, MediaServerInfo, RendererCapabilities, RendererId,
RendererInfo, RendererProtocol,
RendererEvent, RendererInfo, RendererProtocol,
};
pub use provider::HttpXmlDescriptionProvider;
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};

View File

@@ -1,3 +1,5 @@
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RendererId(pub String);
@@ -71,3 +73,23 @@ pub struct MediaServerInfo {
pub last_seen: std::time::SystemTime,
pub max_age: u32,
}
#[derive(Clone, Debug)]
pub enum RendererEvent {
StateChanged {
id: RendererId,
state: PlaybackState,
},
PositionChanged {
id: RendererId,
position: PlaybackPositionInfo,
},
VolumeChanged {
id: RendererId,
volume: u16,
},
MuteChanged {
id: RendererId,
mute: bool,
},
}

View File

@@ -0,0 +1,162 @@
// pmocontrol/src/music_renderer.rs
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
use crate::model::{RendererId, RendererInfo, RendererProtocol};
use crate::{DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, TransportControl, UpnpRenderer, VolumeControl};
use anyhow::Result;
/// 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.
#[derive(Clone, Debug)]
pub enum MusicRenderer {
/// UPnP AV / DLNA backend.
Upnp(UpnpRenderer),
// Future backends could be added here, e.g.:
// OpenHome(OpenHomeRenderer),
}
impl MusicRenderer {
/// Renderer identifier (stable within the registry).
pub fn id(&self) -> &RendererId {
match self {
MusicRenderer::Upnp(r) => r.id(),
}
}
/// Human-friendly name reported by the device.
pub fn friendly_name(&self) -> &str {
match self {
MusicRenderer::Upnp(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,
}
}
/// Full static info as stored in the registry.
pub fn info(&self) -> &RendererInfo {
match self {
MusicRenderer::Upnp(r) => &r.info,
}
}
/// Return a reference to the underlying UPnP backend, if any.
pub fn as_upnp(&self) -> Option<&UpnpRenderer> {
match self {
MusicRenderer::Upnp(r) => Some(r),
}
}
/// 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`].
pub fn from_registry_info(
info: RendererInfo,
registry: &DeviceRegistry,
) -> Option<MusicRenderer> {
match info.protocol {
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(info, registry)))
}
RendererProtocol::OpenHomeOnly => {
// OpenHome-only backend not implemented yet.
None
}
}
}
}
/// 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.
impl TransportControl for MusicRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.play_uri(uri, meta),
}
}
fn play(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => TransportControl::play(r),
}
}
fn pause(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.pause(),
}
}
fn stop(&self) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.stop(),
}
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
match self {
MusicRenderer::Upnp(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.
impl VolumeControl for MusicRenderer {
fn volume(&self) -> Result<u16> {
match self {
MusicRenderer::Upnp(r) => r.get_master_volume(),
}
}
fn set_volume(&self, v: u16) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.set_master_volume(v),
}
}
fn mute(&self) -> Result<bool> {
match self {
MusicRenderer::Upnp(r) => r.get_master_mute(),
}
}
fn set_mute(&self, m: bool) -> Result<()> {
match self {
MusicRenderer::Upnp(r) => r.set_master_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`.
impl PlaybackStatus for MusicRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
match self {
MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r),
}
}
}
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
}
}
}

View File

@@ -1,20 +1,22 @@
use anyhow::{anyhow, Result};
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
use crate::connection_manager_client::{
ConnectionInfo, ConnectionManagerClient, ProtocolInfo,
};
use crate::rendering_control_client::RenderingControlClient;
use crate::{AvTransportClient, DeviceRegistry, RendererId, RendererInfo};
use crate::{AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId, RendererInfo, TransportControl, VolumeControl};
/// High-level handle representing a renderer and its optional AVTransport client.
pub struct Renderer {
#[derive(Clone, Debug)]
pub struct UpnpRenderer {
pub info: RendererInfo,
avtransport: Option<AvTransportClient>,
rendering_control: Option<RenderingControlClient>,
connection_manager: Option<ConnectionManagerClient>,
}
impl Renderer {
impl UpnpRenderer {
pub fn id(&self) -> &RendererId {
&self.info.id
}
@@ -167,7 +169,7 @@ mod tests {
fn renderer_without_avtransport() {
let info = renderer_info("no-avt", false);
let registry = registry_with_renderer(info.clone());
let renderer = Renderer::from_registry(info, &registry);
let renderer = UpnpRenderer::from_registry(info, &registry);
assert_eq!(renderer.has_avtransport(), false);
assert_eq!(renderer.id().0, "renderer-no-avt");
@@ -177,9 +179,84 @@ mod tests {
fn renderer_with_avtransport() {
let info = renderer_info("with-avt", true);
let registry = registry_with_renderer(info.clone());
let renderer = Renderer::from_registry(info, &registry);
let renderer = UpnpRenderer::from_registry(info, &registry);
assert!(renderer.has_avtransport());
assert_eq!(renderer.friendly_name(), "Renderer with-avt");
}
}
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
///
/// Cette impl se base sur AVTransport (InstanceID = 0).
impl TransportControl for UpnpRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
self.play_uri(uri, meta)
}
fn play(&self) -> Result<()> {
let avt = self.avtransport()?;
avt.play(0, "1")
}
fn pause(&self) -> Result<()> {
self.pause()
}
fn stop(&self) -> Result<()> {
self.stop()
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
self.seek_rel_time(hhmmss)
}
}
/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`].
///
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
impl VolumeControl for UpnpRenderer {
fn volume(&self) -> Result<u16> {
self.get_master_volume()
}
fn set_volume(&self, v: u16) -> Result<()> {
self.set_master_volume(v)
}
fn mute(&self) -> Result<bool> {
self.get_master_mute()
}
fn set_mute(&self, m: bool) -> Result<()> {
self.set_master_mute(m)
}
}
/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`].
///
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
impl PlaybackStatus for UpnpRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
let avt = self.avtransport()?;
let info = avt.get_transport_info(0)?;
Ok(PlaybackState::from_upnp_state(
&info.current_transport_state,
))
}
}
impl PlaybackPosition for UpnpRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
let avt = self.avtransport()?;
let raw: PositionInfo = avt.get_position_info(0)?;
Ok(PlaybackPositionInfo {
track: Some(raw.track),
rel_time: raw.rel_time,
abs_time: raw.abs_time,
track_duration: raw.track_duration,
})
}
}