Première implémentation chromecast

This commit is contained in:
2025-12-21 21:24:55 +01:00
parent 1a7e5dd790
commit 4713b7b121
10 changed files with 1444 additions and 44 deletions

View File

@@ -17,6 +17,10 @@ xmltree = "0.11.0"
crossbeam-channel = "0.5"
ratatui = { version = "0.26", default-features = false, features = ["crossterm"] }
crossterm = "0.27"
rust_cast = "0.19"
mdns = "3.0"
async-std = "1.12"
futures-util = "0.3"
# pmoserver extension support (optional)
pmoserver = { path = "../pmoserver", optional = true }

View File

@@ -0,0 +1,274 @@
//! Chromecast device discovery via mDNS.
//!
//! Chromecast devices advertise themselves using mDNS (Multicast DNS) on the
//! `_googlecast._tcp.local` service, unlike UPnP devices which use SSDP.
//! This module handles the discovery of Chromecast devices and converts them
//! into `DeviceUpdate` events that can be processed by the `DeviceRegistry`.
use std::collections::HashMap;
use std::net::IpAddr;
use std::time::SystemTime;
use crate::registry::DeviceUpdate;
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol, RendererId};
use tracing::{debug, warn};
/// Information about a discovered Chromecast device from mDNS.
#[derive(Clone, Debug)]
pub struct DiscoveredChromecast {
pub friendly_name: String,
pub host: String,
pub port: u16,
pub model: Option<String>,
pub uuid: String,
pub manufacturer: Option<String>,
pub last_seen: SystemTime,
}
/// Manages the discovery and tracking of Chromecast devices via mDNS.
pub struct ChromecastDiscoveryManager {
discovered_devices: HashMap<String, DiscoveredChromecast>,
}
impl ChromecastDiscoveryManager {
pub fn new() -> Self {
Self {
discovered_devices: HashMap::new(),
}
}
/// Adds or updates a discovered Chromecast device.
pub fn update_device(&mut self, device: DiscoveredChromecast) {
let uuid = device.uuid.clone();
self.discovered_devices.insert(uuid, device);
}
/// Retrieves a discovered device by UUID.
pub fn get_device(&self, uuid: &str) -> Option<&DiscoveredChromecast> {
self.discovered_devices.get(uuid)
}
/// Lists all discovered devices.
pub fn list_devices(&self) -> Vec<&DiscoveredChromecast> {
self.discovered_devices.values().collect()
}
}
/// Processes an mDNS response and converts it into a `DeviceUpdate` event.
///
/// This function parses mDNS service discovery responses for Chromecast
/// devices and creates the appropriate update event for the device registry.
pub fn process_mdns_response(response: mdns::Response) -> Option<DeviceUpdate> {
// Extract basic information from the mDNS response
let service_name = response.records().filter_map(|r| {
if let mdns::RecordKind::PTR(ref name) = r.kind {
Some(name.clone())
} else {
None
}
}).next()?;
debug!("Processing mDNS response for service: {}", service_name);
// Extract the friendly name from the service instance name
// Format is typically "Friendly Name._googlecast._tcp.local"
let friendly_name = service_name
.split("._googlecast._tcp.local")
.next()
.unwrap_or("Unknown Chromecast")
.to_string();
// Extract IP addresses
let addresses: Vec<IpAddr> = response
.records()
.filter_map(|r| match r.kind {
mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)),
mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)),
_ => None,
})
.collect();
if addresses.is_empty() {
warn!("No IP address found for Chromecast device: {}", friendly_name);
return None;
}
// Prefer IPv4 addresses
let host = addresses
.iter()
.find(|addr| matches!(addr, IpAddr::V4(_)))
.or_else(|| addresses.first())
.map(|addr| addr.to_string())?;
// Extract port from SRV record
let port = response
.records()
.filter_map(|r| {
if let mdns::RecordKind::SRV { port, .. } = r.kind {
Some(port)
} else {
None
}
})
.next()
.unwrap_or(8009); // Default Chromecast port
// Extract TXT records for additional metadata
let txt_records: HashMap<String, String> = response
.records()
.filter_map(|r| {
if let mdns::RecordKind::TXT(ref data) = r.kind {
Some(data.clone())
} else {
None
}
})
.flat_map(|data| {
// data is Vec<String>, each string is "key=value"
data.into_iter().filter_map(|s| {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() == 2 {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
None
}
})
})
.collect();
// Extract metadata from TXT records
let model = txt_records.get("md").cloned();
let uuid = txt_records
.get("id")
.cloned()
.unwrap_or_else(|| format!("chromecast-{}-{}", host, port));
let manufacturer = Some("Google Inc.".to_string());
debug!(
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
friendly_name, host, port, uuid, model
);
// Create RendererInfo for the registry
let renderer_info = build_renderer_info(
&uuid,
&friendly_name,
&host,
port,
model.as_deref(),
manufacturer.as_deref(),
);
Some(DeviceUpdate::RendererOnline(renderer_info))
}
/// Builds a `RendererInfo` structure for a Chromecast device.
fn build_renderer_info(
uuid: &str,
friendly_name: &str,
host: &str,
port: u16,
model: Option<&str>,
manufacturer: Option<&str>,
) -> RendererInfo {
let udn = format!("uuid:{}", uuid);
let id = RendererId(udn.clone());
// Build Chromecast capabilities
let mut capabilities = RendererCapabilities::default();
capabilities.has_chromecast = true;
// The location URL for Chromecast is just the host:port
// (not a real HTTP endpoint like UPnP, but useful for identification)
let location = format!("chromecast://{}:{}", host, port);
RendererInfo {
id,
udn,
friendly_name: friendly_name.to_string(),
model_name: model.unwrap_or("Chromecast").to_string(),
manufacturer: manufacturer.unwrap_or("Google Inc.").to_string(),
protocol: RendererProtocol::ChromecastOnly,
capabilities,
location,
server_header: "Chromecast".to_string(),
online: true,
last_seen: SystemTime::now(),
max_age: 1800, // 30 minutes
// All UPnP/OpenHome fields are None for Chromecast
avtransport_service_type: None,
avtransport_control_url: None,
rendering_control_service_type: None,
rendering_control_control_url: None,
connection_manager_service_type: None,
connection_manager_control_url: None,
oh_playlist_service_type: None,
oh_playlist_control_url: None,
oh_playlist_event_sub_url: None,
oh_info_service_type: None,
oh_info_control_url: None,
oh_info_event_sub_url: None,
oh_time_service_type: None,
oh_time_control_url: None,
oh_time_event_sub_url: None,
oh_volume_service_type: None,
oh_volume_control_url: None,
oh_radio_service_type: None,
oh_radio_control_url: None,
oh_product_service_type: None,
oh_product_control_url: None,
}
}
/// Extracts the host (IP address) from a Chromecast location URL.
///
/// The location format is `chromecast://host:port`.
pub fn extract_host_from_location(location: &str) -> Option<String> {
if let Some(stripped) = location.strip_prefix("chromecast://") {
let host = stripped.split(':').next()?;
Some(host.to_string())
} else {
None
}
}
/// Extracts the port from a Chromecast location URL.
///
/// The location format is `chromecast://host:port`.
pub fn extract_port_from_location(location: &str) -> Option<u16> {
if let Some(stripped) = location.strip_prefix("chromecast://") {
let port_str = stripped.split(':').nth(1)?;
port_str.parse().ok()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_host_from_location() {
assert_eq!(
extract_host_from_location("chromecast://192.168.1.100:8009"),
Some("192.168.1.100".to_string())
);
assert_eq!(
extract_host_from_location("http://192.168.1.100:8009"),
None
);
}
#[test]
fn test_extract_port_from_location() {
assert_eq!(
extract_port_from_location("chromecast://192.168.1.100:8009"),
Some(8009)
);
assert_eq!(
extract_port_from_location("chromecast://192.168.1.100"),
None
);
}
}

View File

@@ -0,0 +1,499 @@
//! Chromecast backend implementation using the rust_cast library.
//!
//! This module provides a `ChromecastRenderer` that implements the standard
//! transport and volume control traits, allowing Chromecast devices to be
//! controlled through the same interface as UPnP, OpenHome, and other backends.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Result};
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType};
use rust_cast::channels::receiver::CastDeviceApp;
use rust_cast::CastDevice;
use tracing::debug;
use crate::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
VolumeControl,
};
use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
use crate::model::{RendererInfo, RendererId, RendererProtocol};
use crate::openhome_client::parse_track_metadata_from_didl;
/// Default Chromecast port.
const DEFAULT_CHROMECAST_PORT: u16 = 8009;
/// Default timeout for Chromecast operations.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
/// Session state for a Chromecast connection.
///
/// This tracks session IDs and cached status to enable efficient
/// communication with the Chromecast device.
/// Note: We don't store the connection itself to avoid lifetime issues.
#[derive(Debug)]
struct ChromecastSessionState {
/// The receiver session ID obtained when launching an app.
receiver_session_id: Option<String>,
/// The media session ID obtained when loading media.
media_session_id: Option<i32>,
/// The destination transport ID (usually "web-0").
destination_id: Option<String>,
}
impl ChromecastSessionState {
fn new() -> Self {
Self {
receiver_session_id: None,
media_session_id: None,
destination_id: None,
}
}
/// Clears all session state.
fn clear(&mut self) {
self.receiver_session_id = None;
self.media_session_id = None;
self.destination_id = None;
}
}
/// Chromecast renderer backend.
///
/// Uses the rust_cast library to communicate with Chromecast devices
/// via the Cast protocol (Protocol Buffers over TLS).
#[derive(Clone, Debug)]
pub struct ChromecastRenderer {
pub info: RendererInfo,
host: String,
port: u16,
session_state: Arc<Mutex<ChromecastSessionState>>,
timeout: Duration,
}
impl ChromecastRenderer {
/// Creates a new ChromecastRenderer from RendererInfo.
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
// Extract host and port from the location URL
let host = extract_host_from_location(&info.location)
.ok_or_else(|| anyhow!("Invalid Chromecast location: {}", info.location))?;
let port = extract_port_from_location(&info.location)
.unwrap_or(DEFAULT_CHROMECAST_PORT);
debug!(
"Creating ChromecastRenderer for {} at {}:{}",
info.friendly_name, host, port
);
Ok(Self {
info,
host,
port,
session_state: Arc::new(Mutex::new(ChromecastSessionState::new())),
timeout: DEFAULT_TIMEOUT,
})
}
/// Returns the renderer ID.
pub fn id(&self) -> &RendererId {
&self.info.id
}
/// Returns the friendly name.
pub fn friendly_name(&self) -> &str {
&self.info.friendly_name
}
/// Returns the protocol.
pub fn protocol(&self) -> &RendererProtocol {
&self.info.protocol
}
/// Returns the renderer info.
pub fn info(&self) -> &RendererInfo {
&self.info
}
/// Creates a new connection to the Chromecast device.
///
/// This creates a fresh connection each time to avoid lifetime issues.
fn connect(&self) -> Result<CastDevice<'_>> {
debug!("Connecting to Chromecast at {}:{}", self.host, self.port);
let device = CastDevice::connect(&self.host, self.port)
.map_err(|e| anyhow!("Failed to connect to Chromecast: {}", e))?;
debug!("Successfully connected to Chromecast");
Ok(device)
}
/// Ensures a receiver session exists by launching the Default Media Receiver app.
///
/// This must be called before any media operations.
/// Returns a new connection with the session already established.
fn ensure_session(&self) -> Result<CastDevice<'_>> {
let device = self.connect()?;
let mut state = self.session_state.lock()
.map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?;
if state.receiver_session_id.is_none() {
debug!("Launching Default Media Receiver app");
let app = device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver)
.map_err(|e| anyhow!("Failed to launch app: {}", e))?;
state.receiver_session_id = Some(app.session_id.clone());
state.destination_id = Some(app.transport_id.clone());
debug!(
"Launched app with session_id: {}, transport_id: {}",
app.session_id, app.transport_id
);
}
Ok(device)
}
/// Converts DIDL-Lite metadata to rust_cast Media format.
fn build_media_from_didl(&self, uri: &str, didl_xml: &str) -> Result<Media> {
// Parse DIDL-Lite metadata
let metadata = parse_track_metadata_from_didl(didl_xml)
.unwrap_or_else(|| crate::model::TrackMetadata {
title: None,
artist: None,
album: None,
genre: None,
album_art_uri: None,
date: None,
track_number: None,
creator: None,
});
// Build music track metadata
let images = metadata.album_art_uri
.map(|uri| vec![Image { url: uri, dimensions: None }])
.unwrap_or_default();
let music_metadata = MusicTrackMediaMetadata {
title: metadata.title,
artist: metadata.artist,
album_name: metadata.album,
images,
release_date: metadata.date,
..Default::default()
};
// Detect content type from URI
let content_type = if uri.ends_with(".flac") {
"audio/flac"
} else if uri.ends_with(".mp3") {
"audio/mpeg"
} else if uri.ends_with(".ogg") || uri.ends_with(".oga") {
"audio/ogg"
} else if uri.ends_with(".m4a") || uri.ends_with(".aac") {
"audio/mp4"
} else {
"audio/flac" // Default to FLAC
}.to_string();
Ok(Media {
content_id: uri.to_string(),
content_type,
stream_type: StreamType::Buffered,
metadata: Some(Metadata::MusicTrack(music_metadata)),
duration: None, // Will be populated from status
})
}
/// Gets the current media status from the Chromecast.
fn get_media_status(&self) -> Result<rust_cast::channels::media::Status> {
let device = self.connect()?;
let state = self.session_state.lock()
.map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?;
let destination_id = state.destination_id.as_ref()
.ok_or_else(|| anyhow!("No destination ID available"))?
.clone();
let media_session_id = state.media_session_id;
drop(state);
device.media.get_status(destination_id, media_session_id)
.map_err(|e| anyhow!("Failed to get media status: {}", e))
}
/// Parses a HH:MM:SS time string to seconds.
fn parse_hhmmss_to_seconds(hhmmss: &str) -> Result<f64> {
let parts: Vec<&str> = hhmmss.split(':').collect();
match parts.len() {
3 => {
let hours: f64 = parts[0].parse()
.map_err(|_| anyhow!("Invalid hours in time format"))?;
let minutes: f64 = parts[1].parse()
.map_err(|_| anyhow!("Invalid minutes in time format"))?;
let seconds: f64 = parts[2].parse()
.map_err(|_| anyhow!("Invalid seconds in time format"))?;
Ok(hours * 3600.0 + minutes * 60.0 + seconds)
}
_ => Err(anyhow!("Invalid time format, expected HH:MM:SS")),
}
}
/// Formats seconds to HH:MM:SS string.
fn format_seconds_to_hhmmss(seconds: f64) -> String {
let h = (seconds / 3600.0).floor() as u32;
let m = ((seconds % 3600.0) / 60.0).floor() as u32;
let s = (seconds % 60.0).floor() as u32;
format!("{:02}:{:02}:{:02}", h, m, s)
}
}
impl TransportControl for ChromecastRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
debug!("ChromecastRenderer: play_uri({})", uri);
// Ensure we have a session and get a connection
let device = self.ensure_session()?;
// Build media from DIDL metadata
let media = self.build_media_from_didl(uri, meta)?;
// Get session IDs
let state = self.session_state.lock()
.map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?;
let destination_id = state.destination_id.as_ref()
.ok_or_else(|| anyhow!("No destination ID available"))?
.clone();
let session_id = state.receiver_session_id.as_ref()
.ok_or_else(|| anyhow!("No receiver session ID available"))?
.clone();
// Drop the lock before calling device methods
drop(state);
let status = device.media.load(
&destination_id,
&session_id,
&media,
).map_err(|e| anyhow!("Failed to load media: {}", e))?;
// Cache media session ID
let mut state = self.session_state.lock()
.map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?;
if let Some(entry) = status.entries.first() {
state.media_session_id = Some(entry.media_session_id);
debug!("Media loaded with session ID: {}", entry.media_session_id);
}
Ok(())
}
fn play(&self) -> Result<()> {
debug!("ChromecastRenderer: play()");
let device = self.connect()?;
let state = self.session_state.lock()
.map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?;
let destination_id = state.destination_id.as_ref()
.ok_or_else(|| anyhow!("No destination ID available"))?
.clone();
let media_session_id = state.media_session_id
.ok_or_else(|| anyhow!("No media session ID available"))?;
drop(state);
device.media.play(&destination_id, media_session_id)
.map_err(|e| anyhow!("Failed to play: {}", e))?;
Ok(())
}
fn pause(&self) -> Result<()> {
debug!("ChromecastRenderer: pause()");
let device = self.connect()?;
let state = self.session_state.lock()
.map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?;
let destination_id = state.destination_id.as_ref()
.ok_or_else(|| anyhow!("No destination ID available"))?
.clone();
let media_session_id = state.media_session_id
.ok_or_else(|| anyhow!("No media session ID available"))?;
drop(state);
device.media.pause(&destination_id, media_session_id)
.map_err(|e| anyhow!("Failed to pause: {}", e))?;
Ok(())
}
fn stop(&self) -> Result<()> {
debug!("ChromecastRenderer: stop()");
let device = self.connect()?;
let state = self.session_state.lock()
.map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?;
let destination_id = state.destination_id.as_ref()
.ok_or_else(|| anyhow!("No destination ID available"))?
.clone();
let media_session_id = state.media_session_id
.ok_or_else(|| anyhow!("No media session ID available"))?;
drop(state);
device.media.stop(&destination_id, media_session_id)
.map_err(|e| anyhow!("Failed to stop: {}", e))?;
Ok(())
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
let seconds = Self::parse_hhmmss_to_seconds(hhmmss)? as f32;
let device = self.connect()?;
let state = self.session_state.lock()
.map_err(|e| anyhow!("Failed to acquire session state lock: {}", e))?;
let destination_id = state.destination_id.as_ref()
.ok_or_else(|| anyhow!("No destination ID available"))?
.clone();
let media_session_id = state.media_session_id
.ok_or_else(|| anyhow!("No media session ID available"))?;
drop(state);
device.media.seek(
&destination_id,
media_session_id,
Some(seconds),
Some(rust_cast::channels::media::ResumeState::PlaybackStart),
).map_err(|e| anyhow!("Failed to seek: {}", e))?;
Ok(())
}
}
impl VolumeControl for ChromecastRenderer {
fn volume(&self) -> Result<u16> {
let device = self.connect()?;
let status = device.receiver.get_status()
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
// Convert f32 (0.0-1.0) to u16 (0-100)
let volume = (status.volume.level.unwrap_or(0.0) * 100.0).round() as u16;
Ok(volume.min(100))
}
fn set_volume(&self, v: u16) -> Result<()> {
debug!("ChromecastRenderer: set_volume({})", v);
let device = self.connect()?;
let level = (v.min(100) as f32) / 100.0;
device.receiver.set_volume(level)
.map_err(|e| anyhow!("Failed to set volume: {}", e))?;
Ok(())
}
fn mute(&self) -> Result<bool> {
let device = self.connect()?;
let status = device.receiver.get_status()
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
Ok(status.volume.muted.unwrap_or(false))
}
fn set_mute(&self, m: bool) -> Result<()> {
debug!("ChromecastRenderer: set_mute({})", m);
let device = self.connect()?;
// Use set_volume with bool (Volume implements From<bool>)
device.receiver.set_volume(m)
.map_err(|e| anyhow!("Failed to set mute: {}", e))?;
Ok(())
}
}
impl PlaybackStatus for ChromecastRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
let status = self.get_media_status()?;
if let Some(entry) = status.entries.first() {
use rust_cast::channels::media::PlayerState;
let state = match entry.player_state {
PlayerState::Playing => PlaybackState::Playing,
PlayerState::Paused => PlaybackState::Paused,
PlayerState::Idle => PlaybackState::Stopped,
PlayerState::Buffering => PlaybackState::Transitioning,
};
Ok(state)
} else {
Ok(PlaybackState::NoMedia)
}
}
}
impl PlaybackPosition for ChromecastRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
let status = self.get_media_status()?;
if let Some(entry) = status.entries.first() {
let rel_time = entry.current_time.map(|t| Self::format_seconds_to_hhmmss(t as f64));
let track_duration = entry.media.as_ref()
.and_then(|m| m.duration)
.map(|d| Self::format_seconds_to_hhmmss(d as f64));
let track_uri = entry.media.as_ref()
.map(|m| m.content_id.clone());
Ok(PlaybackPositionInfo {
track: None,
rel_time,
abs_time: None,
track_duration,
track_metadata: None,
track_uri,
})
} else {
Ok(PlaybackPositionInfo {
track: None,
rel_time: None,
abs_time: None,
track_duration: None,
track_metadata: None,
track_uri: None,
})
}
}
}

View File

@@ -166,6 +166,57 @@ impl ControlPoint {
});
});
// Thread de découverte mDNS pour Chromecast
let registry_for_mdns = Arc::clone(&registry);
thread::spawn(move || {
use crate::chromecast_discovery;
use futures_util::StreamExt;
debug!("Starting mDNS discovery thread for Chromecast devices");
const SERVICE_NAME: &str = "_googlecast._tcp.local";
// Run async discovery in a blocking task
async_std::task::block_on(async {
// Create mDNS discovery stream with 30 second query interval
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(30)) {
Ok(discovery) => {
let stream = discovery.listen();
futures_util::pin_mut!(stream);
debug!("mDNS discovery stream started for Chromecast devices");
// Listen to mDNS responses
while let Some(result) = stream.next().await {
match result {
Ok(response) => {
debug!("Received mDNS response with {} records",
response.records().count());
// Process the mDNS response
if let Some(update) = chromecast_discovery::process_mdns_response(response) {
debug!("Processed Chromecast device update: {:?}", update);
// Update the registry
let mut registry = registry_for_mdns.write().unwrap();
registry.apply_update(update);
}
}
Err(e) => {
warn!("mDNS discovery error: {}", e);
}
}
}
warn!("mDNS discovery stream ended unexpectedly");
}
Err(e) => {
error!("Failed to start mDNS discovery: {}", e);
}
}
});
});
let runtime_cp = ControlPoint {
registry: Arc::clone(&registry),
event_bus: event_bus.clone(),

View File

@@ -4,6 +4,8 @@ mod media_server_events;
pub mod arylic_tcp;
pub mod avtransport_client;
pub mod capabilities;
pub mod chromecast_discovery;
pub mod chromecast_renderer;
pub mod connection_manager_client;
pub mod control_point;
pub mod discovery;
@@ -40,6 +42,7 @@ pub use capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
VolumeControl,
};
pub use chromecast_renderer::ChromecastRenderer;
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
pub use control_point::{ControlPoint, PlaylistBinding};
pub use linkplay::LinkPlayRenderer;

View File

@@ -22,6 +22,7 @@ pub enum RendererProtocol {
UpnpAvOnly,
OpenHomeOnly,
Hybrid,
ChromecastOnly,
}
#[derive(Clone, Debug, Default)]
@@ -41,6 +42,8 @@ pub struct RendererCapabilities {
pub has_oh_info: bool,
pub has_oh_time: bool,
pub has_oh_radio: bool,
pub has_chromecast: bool,
}
impl RendererCapabilities {

View File

@@ -1,7 +1,7 @@
//! Backend-agnostic music renderer façade for PMOMusic.
//!
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome,
//! LinkPlay HTTP, Arylic TCP, and the hybrid UPnP + Arylic pairing) behind a
//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a
//! single control surface. Higher layers in PMOMusic must only interact with
//! renderers through this type so that transport, volume, and state queries
//! stay backend-neutral.
@@ -18,8 +18,8 @@ use crate::openhome_client::parse_track_metadata_from_didl;
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
use crate::queue_backend::PlaybackItem;
use crate::{
ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition,
PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
ArylicTcpRenderer, ChromecastRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer,
PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
};
use anyhow::{Result, anyhow};
use tracing::warn;
@@ -35,6 +35,8 @@ pub enum MusicRenderer {
LinkPlay(LinkPlayRenderer),
/// Renderer reachable through the Arylic TCP control protocol (port 8899).
ArylicTcp(ArylicTcpRenderer),
/// Renderer controlled via the Google Cast protocol (Chromecast).
Chromecast(ChromecastRenderer),
/// Combined backend using UPnP for transport + volume writes and Arylic TCP
/// to read detailed playback information as well as live volume/mute state.
HybridUpnpArylic {
@@ -81,6 +83,7 @@ impl MusicRenderer {
MusicRenderer::Upnp(r) => r.id(),
MusicRenderer::LinkPlay(r) => r.id(),
MusicRenderer::ArylicTcp(r) => r.id(),
MusicRenderer::Chromecast(r) => r.id(),
}
}
@@ -92,6 +95,7 @@ impl MusicRenderer {
MusicRenderer::Upnp(r) => r.friendly_name(),
MusicRenderer::LinkPlay(r) => r.friendly_name(),
MusicRenderer::ArylicTcp(r) => r.friendly_name(),
MusicRenderer::Chromecast(r) => r.friendly_name(),
}
}
@@ -108,6 +112,7 @@ impl MusicRenderer {
MusicRenderer::Upnp(r) => &r.info,
MusicRenderer::LinkPlay(r) => &r.info,
MusicRenderer::ArylicTcp(r) => &r.info,
MusicRenderer::Chromecast(r) => &r.info,
}
}
@@ -148,6 +153,17 @@ impl MusicRenderer {
}
}
if matches!(info.protocol, RendererProtocol::ChromecastOnly) {
if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) {
return Some(MusicRenderer::Chromecast(renderer));
}
warn!(
renderer = info.friendly_name.as_str(),
"Failed to build Chromecast renderer"
);
return None;
}
match info.protocol {
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
let has_arylic = info.capabilities.has_arylic_tcp;
@@ -184,6 +200,7 @@ impl MusicRenderer {
)))
}
RendererProtocol::OpenHomeOnly => None,
RendererProtocol::ChromecastOnly => None,
}
}
@@ -310,6 +327,7 @@ impl MusicRenderer {
MusicRenderer::OpenHome(_) => "OpenHome",
MusicRenderer::LinkPlay(_) => "LinkPlay",
MusicRenderer::ArylicTcp(_) => "ArylicTcp",
MusicRenderer::Chromecast(_) => "Chromecast",
MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic",
}
}
@@ -370,6 +388,7 @@ impl TransportControl for MusicRenderer {
MusicRenderer::OpenHome(oh) => oh.play_uri(uri, meta),
MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta),
MusicRenderer::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")),
MusicRenderer::Chromecast(cc) => cc.play_uri(uri, meta),
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
}
}
@@ -380,6 +399,7 @@ impl TransportControl for MusicRenderer {
MusicRenderer::OpenHome(oh) => oh.play(),
MusicRenderer::LinkPlay(lp) => lp.play(),
MusicRenderer::ArylicTcp(ary) => ary.play(),
MusicRenderer::Chromecast(cc) => cc.play(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(),
}
}
@@ -390,6 +410,7 @@ impl TransportControl for MusicRenderer {
MusicRenderer::OpenHome(oh) => oh.pause(),
MusicRenderer::LinkPlay(lp) => lp.pause(),
MusicRenderer::ArylicTcp(ary) => ary.pause(),
MusicRenderer::Chromecast(cc) => cc.pause(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(),
}
}
@@ -400,6 +421,7 @@ impl TransportControl for MusicRenderer {
MusicRenderer::OpenHome(oh) => oh.stop(),
MusicRenderer::LinkPlay(lp) => lp.stop(),
MusicRenderer::ArylicTcp(ary) => ary.stop(),
MusicRenderer::Chromecast(cc) => cc.stop(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(),
}
}
@@ -410,6 +432,7 @@ impl TransportControl for MusicRenderer {
MusicRenderer::OpenHome(oh) => oh.seek_rel_time(hhmmss),
MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
MusicRenderer::ArylicTcp(_) => Err(op_not_supported("seek_rel_time", "ArylicTcp")),
MusicRenderer::Chromecast(cc) => cc.seek_rel_time(hhmmss),
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
}
}
@@ -427,6 +450,7 @@ impl VolumeControl for MusicRenderer {
MusicRenderer::OpenHome(oh) => oh.volume(),
MusicRenderer::Upnp(upnp) => upnp.volume(),
MusicRenderer::LinkPlay(lp) => lp.volume(),
MusicRenderer::Chromecast(cc) => cc.volume(),
}
}
@@ -437,6 +461,7 @@ impl VolumeControl for MusicRenderer {
MusicRenderer::OpenHome(oh) => oh.set_volume(vol),
MusicRenderer::Upnp(upnp) => upnp.set_volume(vol),
MusicRenderer::LinkPlay(lp) => lp.set_volume(vol),
MusicRenderer::Chromecast(cc) => cc.set_volume(vol),
}
}
@@ -447,6 +472,7 @@ impl VolumeControl for MusicRenderer {
MusicRenderer::Upnp(r) => r.get_master_mute(),
MusicRenderer::LinkPlay(r) => r.mute(),
MusicRenderer::ArylicTcp(r) => r.mute(),
MusicRenderer::Chromecast(cc) => cc.mute(),
}
}
@@ -457,6 +483,7 @@ impl VolumeControl for MusicRenderer {
MusicRenderer::Upnp(r) => r.set_master_mute(m),
MusicRenderer::LinkPlay(r) => r.set_mute(m),
MusicRenderer::ArylicTcp(r) => r.set_mute(m),
MusicRenderer::Chromecast(cc) => cc.set_mute(m),
}
}
}
@@ -472,6 +499,7 @@ impl PlaybackStatus for MusicRenderer {
MusicRenderer::OpenHome(r) => PlaybackStatus::playback_state(r),
MusicRenderer::LinkPlay(r) => r.playback_state(),
MusicRenderer::ArylicTcp(r) => r.playback_state(),
MusicRenderer::Chromecast(cc) => cc.playback_state(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
}
}
@@ -486,6 +514,7 @@ impl PlaybackPosition for MusicRenderer {
MusicRenderer::OpenHome(r) => r.playback_position(),
MusicRenderer::LinkPlay(r) => r.playback_position(),
MusicRenderer::ArylicTcp(r) => r.playback_position(),
MusicRenderer::Chromecast(cc) => cc.playback_position(),
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
}
}

View File

@@ -38,6 +38,7 @@ pub enum RendererProtocolSummary {
Upnp,
Openhome,
Hybrid,
Chromecast,
}
/// Drapeaux de capacités renderer (transport, volume, services OpenHome, etc.)
@@ -55,6 +56,7 @@ pub struct RendererCapabilitiesSummary {
pub has_oh_info: bool,
pub has_oh_time: bool,
pub has_oh_radio: bool,
pub has_chromecast: bool,
}
/// État détaillé d'un renderer

View File

@@ -1891,6 +1891,7 @@ fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary {
RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp,
RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome,
RendererProtocol::Hybrid => RendererProtocolSummary::Hybrid,
RendererProtocol::ChromecastOnly => RendererProtocolSummary::Chromecast,
}
}
@@ -1908,6 +1909,7 @@ fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummar
has_oh_info: caps.has_oh_info,
has_oh_time: caps.has_oh_time,
has_oh_radio: caps.has_oh_radio,
has_chromecast: caps.has_chromecast,
}
}