- Add `SyncCancelled` error variant for non-fatal cancellations - Refactor queue sync to async via `MusicQueue::schedule_sync()` - Extract browse+conversion into internal helper - Update all `QueueBackend::sync_queue()` signatures to accept cancel token and on_ready callback - Implement early-start logic (on pivot preservation or first insert) - Add `QueueReadyToPlay` and ‘ QueueSyncCancelled➔ SSE events - Replace blocking `refresh_attached_queue_for()` with non-blocking async dispatch in control_point.rs - Bump version to v0.3.41
938 lines
32 KiB
Rust
938 lines
32 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::{
|
|
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<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().unwrap()
|
|
}
|
|
|
|
/// 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);
|
|
|
|
// 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<PlaybackState, ControlPointError> {
|
|
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<PlaybackPositionInfo, ControlPointError> {
|
|
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<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 RendererBackend for ChromecastRenderer {
|
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
|
&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<usize, ControlPointError> {
|
|
self.queue.lock().unwrap().len()
|
|
}
|
|
|
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
|
self.queue.lock().unwrap().track_ids()
|
|
}
|
|
|
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
|
self.queue.lock().unwrap().id_to_position(id)
|
|
}
|
|
|
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
|
self.queue.lock().unwrap().position_to_id(id)
|
|
}
|
|
|
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
|
self.queue.lock().unwrap().current_track()
|
|
}
|
|
|
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
|
self.queue.lock().unwrap().current_index()
|
|
}
|
|
|
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
|
self.queue.lock().unwrap().queue_snapshot()
|
|
}
|
|
|
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
|
self.queue.lock().unwrap().set_index(index)
|
|
}
|
|
|
|
fn replace_queue(
|
|
&mut self,
|
|
items: Vec<PlaybackItem>,
|
|
current_index: Option<usize>,
|
|
) -> Result<(), ControlPointError> {
|
|
self.queue
|
|
.lock()
|
|
.unwrap()
|
|
.replace_queue(items, current_index)
|
|
}
|
|
|
|
fn sync_queue(
|
|
&mut self,
|
|
items: Vec<PlaybackItem>,
|
|
_cancel_token: &Arc<AtomicBool>,
|
|
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
|
) -> Result<(), ControlPointError> {
|
|
self.queue
|
|
.lock()
|
|
.unwrap()
|
|
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
|
}
|
|
|
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, 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<PlaybackItem>,
|
|
mode: EnqueueMode,
|
|
) -> Result<(), ControlPointError> {
|
|
self.queue.lock().unwrap().enqueue_items(items, mode)
|
|
}
|
|
}
|