Cette mise à jour implémente la détection des flux continus (radio, streaming) et améliore la gestion de la barre de progression pour ces flux. Les changements incluent : - Ajout d'une méthode predicat `is_playing_a_stream()` au niveau de `MusicRenderer` - Développement d'une fonction utilitaire `is_continuous_stream_url()` pour analyser les headers HTTP - Mise à jour des backends (UPnP, OpenHome, LinkPlay, ArylicTcp, Chromecast) pour détecter les flux continus - Ajout d'un flag `continuous_stream` dans chaque backend - Export du module de détection de flux - Mise à jour de l'API REST et des événements SSE pour transmettre l'état du flux - Correction de la gestion de la position de lecture pour les flux continus Les flux continus sont maintenant correctement détectés via une analyse HTTP des headers, permettant une gestion appropriée de la barre de progression dans l'interface web.
860 lines
29 KiB
Rust
860 lines
29 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::{Arc, Mutex, Once};
|
|
use std::thread::JoinHandle;
|
|
|
|
use tracing::debug;
|
|
|
|
use crate::DeviceIdentity;
|
|
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::RendererFromMediaRendererInfo;
|
|
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::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
|
|
|
use rust_cast::{
|
|
CastDevice, ChannelMessage,
|
|
channels::{
|
|
heartbeat::HeartbeatResponse,
|
|
media::{Media, PlayerState as CastPlayerState, StreamType},
|
|
receiver::CastDeviceApp,
|
|
},
|
|
};
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
let device = match connect_to_device(&host, port) {
|
|
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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
// 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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
// 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))
|
|
})?;
|
|
|
|
// If no app is running, return NoMedia
|
|
let app = match status.applications.first() {
|
|
Some(app) => app,
|
|
None => 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))
|
|
})?;
|
|
|
|
// If no media entry, return NoMedia
|
|
let media_entry = match media_status.entries.first() {
|
|
Some(entry) => entry,
|
|
None => 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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
// 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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
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 = connect_to_device(&self.host, self.port)?;
|
|
|
|
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>) -> Result<(), ControlPointError> {
|
|
self.queue.lock().unwrap().sync_queue(items)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|