Première implémentation chromecast
This commit is contained in:
@@ -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 }
|
||||
|
||||
274
pmocontrol/src/chromecast_discovery.rs
Normal file
274
pmocontrol/src/chromecast_discovery.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
499
pmocontrol/src/chromecast_renderer.rs
Normal file
499
pmocontrol/src/chromecast_renderer.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,57 @@ impl ControlPoint {
|
||||
});
|
||||
});
|
||||
|
||||
// Thread de découverte mDNS pour Chromecast
|
||||
let registry_for_mdns = Arc::clone(®istry);
|
||||
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(®istry),
|
||||
event_bus: event_bus.clone(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user