Files
pmomusic/pmocontrol/src/music_renderer/chromecast_renderer.rs
Eric Coissac 9b5a028f3f 🎨 Improve mobile safe-area support and refine UPnP/Chromecast rendering
- Add viewport-fit=cover to HTML meta for iOS safe-area support
- Extend bottom drawers and tab bar into system navigation area using env(safe_area_inset_bottom)
- Adjust queue drawer transforms to account for safe-area padding
+ Add enrich_position_from_queue() helper and integrate across UPnP, Arylic TCP,
  LinkPlay & Chromecast backends to ensure queue-authoritative metadata
+ Add transient error retry logic for UPnP control actions (2 retries, 300ms delay)
+ Separate timeouts: short poll timeout for GetTransportInfo/GetPosition (3s),
  longer actiontimeout SetAVTURI/SetNext... for slow devices
+ Remove duplicate continuous-stream detection from play_uri() methods (now handled centrally)
- Bump version to 0.3.49
2026-04-13 04:14:18 +02:00

656 lines
24 KiB
Rust

//! 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::{
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
use crate::music_renderer::HasQueue;
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, StatusEntry, 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<Mutex<bool>>,
/// Handle to the active heartbeat thread, if any.
/// Wrapped in Arc<Mutex> to allow cloning and proper thread lifecycle management.
thread_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
queue: Arc<Mutex<MusicQueue>>,
/// Flag indicating if currently playing a continuous stream (radio without duration)
continuous_stream: Arc<Mutex<bool>>,
}
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<CastDevice<'a>, 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<Self, ControlPointError> {
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().expect("continuous_stream mutex poisoned")
}
/// Returns `(transport_id, media_entry)` for the currently active Cast session.
///
/// This encapsulates the repeated sequence:
/// connect device → get receiver status → get active app → connect to app → get media status
///
/// Used by all transport operations (play/pause/stop/seek) and by
/// playback_state/playback_position to avoid duplicating this boilerplate.
fn get_active_media_entry<'d>(
&self,
device: &'d CastDevice<'d>,
) -> Result<(String, StatusEntry), ControlPointError> {
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
let app = status
.applications
.into_iter()
.next()
.ok_or_else(|| ControlPointError::ChromecastError("No active app found".into()))?;
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 entry = media_status
.entries
.into_iter()
.next()
.ok_or_else(|| ControlPointError::ChromecastError("No media session found".into()))?;
Ok((app.transport_id, entry))
}
/// Connect to the device with retry on connection failures.
/// Uses exponential backoff: 200ms, 400ms, 800ms
fn connect_with_retry(&self) -> Result<CastDevice<'_>, 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);
// 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()?;
let (transport_id, entry) = self.get_active_media_entry(&device)?;
device
.media
.play(transport_id.as_str(), 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 (transport_id, entry) = self.get_active_media_entry(&device)?;
device
.media
.pause(transport_id.as_str(), 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 first.
// The thread terminates on its own; play_uri() will wait for it if needed.
if let Ok(mut stop) = self.stop_signal.lock() {
*stop = true;
}
let device = self.connect_with_retry()?;
let (transport_id, entry) = self.get_active_media_entry(&device)?;
device
.media
.stop(transport_id.as_str(), 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 (transport_id, entry) = self.get_active_media_entry(&device)?;
device
.media
.seek(transport_id.as_str(), 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<PlaybackState, ControlPointError> {
let device = self.connect_with_retry()?;
// If no app is running there is no media — return NoMedia without error.
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 status.applications.is_empty() {
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
return Ok(PlaybackState::NoMedia);
}
match self.get_active_media_entry(&device) {
Ok((_, entry)) => {
tracing::debug!(
"Chromecast playback_state: player_state={:?}, current_time={:?}",
entry.player_state, entry.current_time
);
Ok(map_player_state(&entry.player_state))
}
// No media session → device is idle
Err(_) => {
tracing::debug!("Chromecast playback_state: no media session, returning NoMedia");
Ok(PlaybackState::NoMedia)
}
}
}
}
impl PlaybackPosition for ChromecastRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
let device = self.connect_with_retry()?;
let (_, entry) = self.get_active_media_entry(&device)?;
let rel_time = entry.current_time.map(|t| format_hhmmss_f64(t as f64));
let track_duration = entry.media.as_ref().and_then(|m| m.duration).map(|d| format_hhmmss_f64(d as f64));
let track_uri = entry.media.as_ref().map(|m| m.content_id.clone());
let mut position = PlaybackPositionInfo {
track: Some(1),
rel_time,
abs_time: None,
track_duration,
track_metadata: None,
track_uri,
};
// Replace device metadata with queue metadata (queue is authoritative;
// Chromecast does not return DIDL-Lite natively so the queue is the only source).
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
Ok(position)
}
}
/// 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<u16, ControlPointError> {
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<bool, ControlPointError> {
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 QueueTransportControl for ChromecastRenderer {
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
self.play_uri(&item.uri, "")
}
}
impl HasQueue for ChromecastRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl HasContinuousStream for ChromecastRenderer {
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
&self.continuous_stream
}
}