//! Chromecast backend implementation using the cast-sender 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. //! //! ## Architecture //! //! Uses `cast-sender`, a fully asynchronous Chromecast library that handles //! heartbeats and connection management automatically. The async operations //! are wrapped in sync calls using smol::block_on for compatibility with //! the existing sync trait interfaces. use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex, Once}; use std::thread::JoinHandle; use tracing::debug; use crate::discovery::chromecast_discovery::{ extract_host_from_location, extract_port_from_location, }; use crate::errors::ControlPointError; use crate::model::{PlaybackState, RendererInfo}; use crate::music_renderer::capabilities::{ PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend, TransportControl, VolumeControl, }; use crate::music_renderer::musicrenderer::MusicRendererBackend; use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict}; use crate::music_renderer::RendererFromMediaRendererInfo; use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot}; use crate::DeviceIdentity; use rust_cast::{ channels::{ heartbeat::HeartbeatResponse, media::{Media, PlayerState as CastPlayerState, StreamType}, receiver::CastDeviceApp, }, CastDevice, ChannelMessage, }; const DEFAULT_DESTINATION_ID: &str = "receiver-0"; /// Default Chromecast port. const DEFAULT_CHROMECAST_PORT: u16 = 8009; /// Chromecast renderer backend. /// /// Uses the rust_cast library to communicate with Chromecast devices /// via the Cast protocol. For play operations, a dedicated thread is /// spawned to handle heartbeat responses from the device. #[derive(Clone)] pub struct ChromecastRenderer { host: String, port: u16, stop_signal: Arc>, /// Handle to the active heartbeat thread, if any. /// Wrapped in Arc to allow cloning and proper thread lifecycle management. thread_handle: Arc>>>, queue: Arc>, /// Flag indicating if currently playing a continuous stream (radio without duration) continuous_stream: Arc>, } impl std::fmt::Debug for ChromecastRenderer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ChromecastRenderer") .field("host", &self.host) .field("port", &self.port) .finish() } } /// Ensures the Rustls CryptoProvider is initialized exactly once. /// /// This is required by rust_cast which uses rustls for TLS connections. /// Without this, rust_cast will panic with: /// "Could not automatically determine the process-level CryptoProvider" fn ensure_crypto_provider_initialized() { static INIT: Once = Once::new(); INIT.call_once(|| { // Install the default CryptoProvider (aws-lc-rs or ring, depending on features) let _ = rustls::crypto::CryptoProvider::install_default( rustls::crypto::aws_lc_rs::default_provider(), ); tracing::debug!("Rustls CryptoProvider initialized for Chromecast connections"); }); } /// Helper function to connect to a Chromecast device. fn connect_to_device<'a>(host: &'a str, port: u16) -> Result, ControlPointError> { // Ensure rustls crypto provider is initialized before any TLS connection ensure_crypto_provider_initialized(); let device = CastDevice::connect_without_host_verification(host, port).map_err(|e| { ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e)) })?; device .connection .connect(DEFAULT_DESTINATION_ID.to_string()) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to connect channel: {}", e)) })?; // Send initial gre to establish heartbeat communication // This is critical per rust_caster.rs example device.heartbeat.ping().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to send initial heartbeat ping: {}", e)) })?; Ok(device) } /// Maps Chromecast PlayerState to our PlaybackState. fn map_player_state(player_state: &CastPlayerState) -> PlaybackState { match player_state { CastPlayerState::Idle => PlaybackState::Stopped, CastPlayerState::Playing => PlaybackState::Playing, CastPlayerState::Buffering => PlaybackState::Transitioning, CastPlayerState::Paused => PlaybackState::Paused, } } impl RendererFromMediaRendererInfo for ChromecastRenderer { fn from_renderer_info(info: &RendererInfo) -> Result { tracing::info!( "ChromecastRenderer::from_renderer_info location={} for {}", info.location(), info.friendly_name() ); let host = extract_host_from_location(info.location()).ok_or_else(|| { ControlPointError::ChromecastError(format!( "Invalid Chromecast location: {}", info.location() )) })?; let port = extract_port_from_location(info.location()).unwrap_or(DEFAULT_CHROMECAST_PORT); let stop_signal = Arc::new(Mutex::new(false)); let thread_handle = Arc::new(Mutex::new(None)); let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?)); tracing::info!( "ChromecastRenderer created for {} with host={} port={}", info.friendly_name(), host, port ); Ok(Self { host, port, stop_signal, thread_handle, queue, continuous_stream: Arc::new(Mutex::new(false)), }) } fn to_backend(self) -> MusicRendererBackend { MusicRendererBackend::Chromecast(self) } } impl ChromecastRenderer { /// Returns true if currently playing a continuous stream (radio without duration) pub fn is_continuous_stream(&self) -> bool { *self.continuous_stream.lock().unwrap() } /// Connect to the device with retry on connection failures. /// Uses exponential backoff: 200ms, 400ms, 800ms fn connect_with_retry(&self) -> Result, ControlPointError> { // Try up to 3 times with exponential backoff for attempt in 0..3 { match connect_to_device(&self.host, self.port) { Ok(device) => return Ok(device), Err(e) if attempt < 2 => { let delay = 200 * 2u64.pow(attempt); tracing::warn!( "Chromecast connection failed (attempt {}/3), retrying in {}ms: {}", attempt + 1, delay, e ); std::thread::sleep(std::time::Duration::from_millis(delay)); } Err(e) => return Err(e), } } unreachable!() } } impl TransportControl for ChromecastRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: play_uri({})", uri); // Détecte si l'URL est un flux continu let is_stream = crate::music_renderer::is_continuous_stream_url(uri); *self.continuous_stream.lock().unwrap() = is_stream; tracing::debug!( "ChromecastRenderer play_uri: URI={}, continuous_stream={}", uri, is_stream ); // Signal any existing play thread to stop if let Ok(mut stop) = self.stop_signal.lock() { *stop = true; } // Wait for the previous thread to finish (with timeout) if let Ok(mut handle_guard) = self.thread_handle.lock() { if let Some(handle) = handle_guard.take() { // Release the lock before joining to avoid deadlock drop(handle_guard); // Wait for thread to finish (it should see stop_signal and exit) // Note: device.receive() may block, so thread might take time to notice stop_signal let join_result = std::thread::spawn(move || handle.join()).join(); match join_result { Ok(Ok(())) => { tracing::debug!("Previous heartbeat thread stopped cleanly"); } Ok(Err(_)) => { tracing::warn!("Previous heartbeat thread panicked"); } Err(_) => { tracing::error!("Failed to join previous heartbeat thread"); } } } } // Reset stop signal if let Ok(mut stop) = self.stop_signal.lock() { *stop = false; } // Launch a new play thread let host = self.host.clone(); let port = self.port; let uri = uri.to_string(); let meta = meta.to_string(); let stop_signal = self.stop_signal.clone(); let handle = std::thread::spawn(move || { tracing::info!("Play thread starting for URI: {}", uri); // Connect with retry (inlined for thread context) let device = (|| { for attempt in 0..3 { match connect_to_device(&host, port) { Ok(d) => return Ok(d), Err(e) if attempt < 2 => { let delay = 200 * 2u64.pow(attempt); tracing::warn!( "Chromecast connection failed (attempt {}/3), retrying in {}ms: {}", attempt + 1, delay, e ); std::thread::sleep(std::time::Duration::from_millis(delay)); } Err(e) => return Err(e), } } unreachable!() })(); let device = match device { Ok(d) => d, Err(e) => { tracing::error!("Failed to connect in play thread: {}", e); return; } }; // Launch DefaultMediaReceiver app let app = match device .receiver .launch_app(&CastDeviceApp::DefaultMediaReceiver) { Ok(app) => app, Err(e) => { tracing::error!("Failed to launch DefaultMediaReceiver: {}", e); return; } }; // Connect to the app's transport if let Err(e) = device.connection.connect(app.transport_id.as_str()) { tracing::error!("Failed to connect to app transport: {}", e); return; } // Load the media let content_type = detect_content_type_from_meta(&uri, &meta); let media = Media { content_id: uri.clone(), content_type, stream_type: StreamType::Buffered, duration: None, metadata: None, }; match device .media .load(app.transport_id.as_str(), app.session_id.as_str(), &media) { Ok(status) => { tracing::info!("Media loaded successfully: {:?}", status); } Err(e) => { tracing::error!("Failed to load media: {}", e); return; } } // Main loop: receive messages and respond to heartbeats loop { // Check stop signal if let Ok(stop) = stop_signal.lock() { if *stop { tracing::info!("Play thread stopping (stop signal received)"); break; } } match device.receive() { Ok(ChannelMessage::Heartbeat(response)) => { tracing::trace!("[Heartbeat] {:?}", response); if let HeartbeatResponse::Ping = response { if let Err(e) = device.heartbeat.pong() { tracing::error!("Failed to send heartbeat pong: {:?}", e); break; } } } Ok(ChannelMessage::Media(response)) => { tracing::debug!("[Media] {:?}", response); // TODO: Update state from media messages } Ok(ChannelMessage::Receiver(response)) => { tracing::debug!("[Receiver] {:?}", response); } Ok(ChannelMessage::Connection(response)) => { tracing::trace!("[Connection] {:?}", response); } Ok(ChannelMessage::Raw(response)) => { tracing::trace!("[Raw] {:?}", response); } Err(e) => { tracing::error!("Error receiving message: {:?}", e); break; } } } tracing::info!("Play thread stopped"); }); // Store the thread handle for proper cleanup if let Ok(mut handle_guard) = self.thread_handle.lock() { *handle_guard = Some(handle); } Ok(()) } fn play(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: play()"); let device = self.connect_with_retry()?; // Get receiver status to find the active app let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; let app = status .applications .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; // Connect to the app device .connection .connect(app.transport_id.as_str()) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) })?; // Get media status let media_status = device .media .get_status(app.transport_id.as_str(), None) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) })?; let media_entry = media_status .entries .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; // Send play command device .media .play(app.transport_id.as_str(), media_entry.media_session_id) .map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?; Ok(()) } fn pause(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: pause()"); let device = self.connect_with_retry()?; let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; let app = status .applications .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; device .connection .connect(app.transport_id.as_str()) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) })?; let media_status = device .media .get_status(app.transport_id.as_str(), None) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) })?; let media_entry = media_status .entries .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; device .media .pause(app.transport_id.as_str(), media_entry.media_session_id) .map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?; Ok(()) } fn stop(&self) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: stop()"); // Signal the play thread to stop if let Ok(mut stop) = self.stop_signal.lock() { *stop = true; } // Note: We don't wait for the thread here as stop() should be quick. // The thread will terminate on its own when it checks stop_signal. // If a new play_uri() is called, it will properly wait for this thread. // Also send stop command to the device let device = self.connect_with_retry()?; let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; let app = status .applications .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; device .connection .connect(app.transport_id.as_str()) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) })?; let media_status = device .media .get_status(app.transport_id.as_str(), None) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) })?; let media_entry = media_status .entries .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; device .media .stop(app.transport_id.as_str(), media_entry.media_session_id) .map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?; Ok(()) } fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: seek_rel_time({})", hhmmss); let total_seconds = parse_hhmmss_strict(hhmmss)? as f32; let device = self.connect_with_retry()?; let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; let app = status .applications .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; device .connection .connect(app.transport_id.as_str()) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) })?; let media_status = device .media .get_status(app.transport_id.as_str(), None) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) })?; let media_entry = media_status .entries .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; device .media .seek( app.transport_id.as_str(), media_entry.media_session_id, Some(total_seconds), None, ) .map_err(|e| ControlPointError::ChromecastError(format!("Failed to seek: {}", e)))?; Ok(()) } } impl PlaybackStatus for ChromecastRenderer { fn playback_state(&self) -> Result { let device = self.connect_with_retry()?; // Get receiver status to find the active app let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; tracing::debug!( "Chromecast playback_state: {} apps running", status.applications.len() ); // If no app is running, return NoMedia let app = match status.applications.first() { Some(app) => { tracing::debug!("Chromecast playback_state: app={}", app.display_name); app } None => { tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia"); return Ok(PlaybackState::NoMedia); } }; // Connect to the app device .connection .connect(app.transport_id.as_str()) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) })?; // Get media status let media_status = device .media .get_status(app.transport_id.as_str(), None) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) })?; tracing::debug!( "Chromecast playback_state: {} media entries", media_status.entries.len() ); // If no media entry, return NoMedia let media_entry = match media_status.entries.first() { Some(entry) => { tracing::debug!( "Chromecast playback_state: player_state={:?}, current_time={:?}", entry.player_state, entry.current_time ); entry } None => { tracing::debug!("Chromecast playback_state: no media entries, returning NoMedia"); return Ok(PlaybackState::NoMedia); } }; Ok(map_player_state(&media_entry.player_state)) } } impl PlaybackPosition for ChromecastRenderer { fn playback_position(&self) -> Result { let device = self.connect_with_retry()?; // Get receiver status to find the active app let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; let app = status .applications .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?; // Connect to the app device .connection .connect(app.transport_id.as_str()) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)) })?; // Get media status let media_status = device .media .get_status(app.transport_id.as_str(), None) .map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)) })?; let media_entry = media_status .entries .first() .ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?; // Extract position information let rel_time = media_entry .current_time .map(|time| format_hhmmss_f64(time as f64)); let track_duration = media_entry .media .as_ref() .and_then(|m| m.duration) .map(|dur| format_hhmmss_f64(dur as f64)); let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone()); Ok(PlaybackPositionInfo { track: Some(1), rel_time, abs_time: None, track_duration, track_metadata: None, // Chromecast doesn't use DIDL-Lite track_uri, }) } } /// Detects the MIME content type from DIDL-Lite metadata or URI. /// /// The UPnP protocol_info format is: "protocol:*:contentFormat:*" /// For example: "http-get:*:audio/flac:*" /// /// This function: /// 1. Tries to parse DIDL-Lite metadata and extract protocolInfo /// 2. Falls back to detecting from URI file extension /// 3. Returns "audio/*" as a last resort fn detect_content_type_from_meta(uri: &str, meta: &str) -> String { use pmodidl::MediaMetadataParser; // Try to parse DIDL-Lite metadata if !meta.is_empty() { if let Ok(didl) = pmodidl::DIDLLite::parse(meta) { // Get the first audio resource if let Some(item) = didl.items.first() { if let Some(resource) = item.audio_resources().next() { // Protocol info format: "protocol:*:contentFormat:*" // Extract the third field (content format / MIME type) let parts: Vec<&str> = resource.protocol_info.split(':').collect(); if parts.len() >= 3 { let content_type = parts[2].trim(); if !content_type.is_empty() && content_type != "*" { tracing::debug!( "Detected content type '{}' from DIDL-Lite metadata", content_type ); return content_type.to_string(); } } } } } } // Fallback: try to detect from URI file extension let path = uri.split('?').next().unwrap_or(uri); let extension = path.split('.').last().unwrap_or("").to_lowercase(); let content_type = match extension.as_str() { "flac" => "audio/flac", "mp3" => "audio/mpeg", "m4a" | "mp4" | "aac" => "audio/mp4", "ogg" => "audio/ogg", "opus" => "audio/opus", "wav" => "audio/wav", "weba" | "webm" => "audio/webm", "oga" => "audio/ogg", _ => { // Default to generic audio type tracing::debug!( "Could not detect content type from metadata or URI extension, using audio/*" ); "audio/*" } }; content_type.to_string() } impl VolumeControl for ChromecastRenderer { fn volume(&self) -> Result { let device = self.connect_with_retry()?; let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; if let Some(level) = status.volume.level { Ok((level * 100.0) as u16) } else { Ok(50) // Default volume } } fn set_volume(&self, volume: u16) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: set_volume({})", volume); let device = self.connect_with_retry()?; let level = (volume as f32) / 100.0; device.receiver.set_volume(level).map_err(|e| { ControlPointError::ChromecastError(format!("Failed to set volume: {}", e)) })?; Ok(()) } fn mute(&self) -> Result { let device = self.connect_with_retry()?; let status = device.receiver.get_status().map_err(|e| { ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)) })?; Ok(status.volume.muted.unwrap_or(false)) } fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> { debug!("ChromecastRenderer: set_mute({})", mute); let device = self.connect_with_retry()?; device.receiver.set_volume(mute).map_err(|e| { ControlPointError::ChromecastError(format!("Failed to set mute: {}", e)) })?; Ok(()) } } impl RendererBackend for ChromecastRenderer { fn queue(&self) -> &Arc> { &self.queue } } impl QueueTransportControl for ChromecastRenderer { fn play_from_queue(&self) -> Result<(), ControlPointError> { let mut queue = self.queue.lock().unwrap(); let current_index = match queue.current_index()? { Some(idx) => idx, None => { if queue.len()? > 0 { queue.set_index(Some(0))?; 0 } else { return Err(ControlPointError::QueueError("Queue is empty".into())); } } }; let item = queue .get_item(current_index)? .ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?; let uri = item.uri.clone(); drop(queue); self.play_uri(&uri, "") } fn play_next(&self) -> Result<(), ControlPointError> { { let mut queue = self.queue.lock().unwrap(); if !queue.advance()? { return Err(ControlPointError::QueueError("No next track".into())); } } self.play_from_queue() } fn play_previous(&self) -> Result<(), ControlPointError> { { let mut queue = self.queue.lock().unwrap(); if !queue.rewind()? { return Err(ControlPointError::QueueError("No previous track".into())); } } self.play_from_queue() } fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> { { let mut queue = self.queue.lock().unwrap(); queue.set_index(Some(index))?; } self.play_from_queue() } } impl QueueBackend for ChromecastRenderer { fn len(&self) -> Result { self.queue.lock().unwrap().len() } fn track_ids(&self) -> Result, ControlPointError> { self.queue.lock().unwrap().track_ids() } fn id_to_position(&self, id: u32) -> Result { self.queue.lock().unwrap().id_to_position(id) } fn position_to_id(&self, id: usize) -> Result { self.queue.lock().unwrap().position_to_id(id) } fn current_track(&self) -> Result, ControlPointError> { self.queue.lock().unwrap().current_track() } fn current_index(&self) -> Result, ControlPointError> { self.queue.lock().unwrap().current_index() } fn queue_snapshot(&self) -> Result { self.queue.lock().unwrap().queue_snapshot() } fn set_index(&mut self, index: Option) -> Result<(), ControlPointError> { self.queue.lock().unwrap().set_index(index) } fn replace_queue( &mut self, items: Vec, current_index: Option, ) -> Result<(), ControlPointError> { self.queue .lock() .unwrap() .replace_queue(items, current_index) } fn sync_queue( &mut self, items: Vec, _cancel_token: &Arc, on_ready: Option>, ) -> Result<(), ControlPointError> { self.queue .lock() .unwrap() .sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready) } fn get_item(&self, index: usize) -> Result, ControlPointError> { self.queue.lock().unwrap().get_item(index) } fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { self.queue.lock().unwrap().replace_item(index, item) } fn enqueue_items( &mut self, items: Vec, mode: EnqueueMode, ) -> Result<(), ControlPointError> { self.queue.lock().unwrap().enqueue_items(items, mode) } }