Couche d'abstraction supplementaire
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ test_upnp*.cargo/
|
||||
setup-env.sh
|
||||
cache
|
||||
gupnp-tools
|
||||
pmocontrol_[0_9]*.txt
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3163,6 +3163,7 @@ name = "pmocontrol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossbeam-channel",
|
||||
"pmoupnp",
|
||||
"quick-xml 0.38.4",
|
||||
"thiserror 2.0.17",
|
||||
|
||||
@@ -12,3 +12,4 @@ tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3"
|
||||
anyhow = "1.0"
|
||||
xmltree = "0.11.0"
|
||||
crossbeam-channel = "0.5"
|
||||
|
||||
197
pmocontrol/examples/event_demo.rs
Normal file
197
pmocontrol/examples/event_demo.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
println!("\nCalling play_uri on music renderer...");
|
||||
if let Err(e) = renderer.play_uri(&uri, meta) {
|
||||
println!(" play_uri failed: {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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("\n[RenderingControl]");
|
||||
println!(" Renderer has no RenderingControl service.");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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!("\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!(
|
||||
"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(3));
|
||||
let _ = dump_info("After seek_rel_time");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" seek_rel_time failed: {}", e);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Pause (ENTER pour laisser jouer)
|
||||
print!("\nPress ENTER to Pause...");
|
||||
io::stdout().flush().ok();
|
||||
let _ = io::stdin().read_line(&mut String::new());
|
||||
// 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}");
|
||||
}
|
||||
|
||||
println!("\n Calling pause()...");
|
||||
match renderer.pause() {
|
||||
Ok(()) => {
|
||||
// 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));
|
||||
let _ = dump_info("After pause");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" pause failed: {}", e);
|
||||
}
|
||||
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!("\nCalling stop() via music façade...");
|
||||
if let Err(e) = renderer.stop() {
|
||||
println!(" stop failed: {e}");
|
||||
} else {
|
||||
println!(" stop: OK");
|
||||
let _ = dump_info("After stop");
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" stop failed: {}", e);
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
101
pmocontrol/src/capabilities.rs
Normal file
101
pmocontrol/src/capabilities.rs
Normal 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<()>;
|
||||
}
|
||||
@@ -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(®istry),
|
||||
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, ®))
|
||||
.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, ®))
|
||||
.map(|info| UpnpRenderer::from_registry(info, ®))
|
||||
.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, ®))
|
||||
.map(|info| UpnpRenderer::from_registry(info, ®))
|
||||
}
|
||||
|
||||
/// 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, ®))
|
||||
.map(|info| UpnpRenderer::from_registry(info, ®))
|
||||
}
|
||||
|
||||
/// 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, ®))
|
||||
.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, ®))
|
||||
}
|
||||
|
||||
/// 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, ®))
|
||||
}
|
||||
|
||||
/// 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
33
pmocontrol/src/events.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
162
pmocontrol/src/music_renderer.rs
Normal file
162
pmocontrol/src/music_renderer.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ®istry);
|
||||
let renderer = UpnpRenderer::from_registry(info, ®istry);
|
||||
|
||||
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, ®istry);
|
||||
let renderer = UpnpRenderer::from_registry(info, ®istry);
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user