Implement Chromecast frontend and DLNA AVTransport casting

Frontend:
- Add castStore for managing cast state (connect, disconnect, play, pause, etc)
- Update CastPicker to use castStore, show connected device banner with disconnect
- Wire isCastConnected to NowPlayingBar and ExpandedPlayer for visual indicator
- Modify playbackService to cast when connected instead of local playback

DLNA Backend:
- Implement full AVTransport SOAP client with:
  - SetAVTransportURI with DIDL-Lite metadata
  - Play, Pause, Stop, Seek actions
  - RenderingControl SetVolume
- Add dlna_play_track command that fetches stream, downloads audio, and serves via media server
- Share media server between Chromecast and DLNA
This commit is contained in:
vicrodh committed 2026-01-12 10:11:09 -06:00
1 parent a4c09041a4
commit 1ac6d15551
8 files changed
+1025 -143

No files matched your search

+109 -12
View File
@@ -1,24 +1,29 @@
//! Tauri commands for DLNA/UPnP casting (discovery + scaffolding)
//! Tauri commands for DLNA/UPnP casting
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex;
use crate::api::models::Quality;
use crate::AppState;
use crate::cast::dlna::{
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaStatus,
};
use crate::cast::MediaServer;
/// DLNA state shared across commands
pub struct DlnaState {
pub discovery: Arc<Mutex<DlnaDiscovery>>,
pub connection: Arc<Mutex<Option<DlnaConnection>>>,
pub media_server: Arc<Mutex<MediaServer>>,
}
impl DlnaState {
pub fn new() -> Result<Self, DlnaError> {
pub fn new(media_server: Arc<Mutex<MediaServer>>) -> Result<Self, DlnaError> {
Ok(Self {
discovery: Arc::new(Mutex::new(DlnaDiscovery::new())),
connection: Arc::new(Mutex::new(None)),
media_server,
})
}
}
@@ -83,42 +88,134 @@ pub async fn dlna_get_status(state: State<'_, DlnaState>) -> Result<DlnaStatus,
Ok(conn.get_status())
}
// === Playback (stubs) ===
// === Playback ===
/// Play a Qobuz track on the DLNA device
#[tauri::command]
pub async fn dlna_play_track(
track_id: u64,
metadata: DlnaMetadata,
dlna_state: State<'_, DlnaState>,
app_state: State<'_, AppState>,
) -> Result<(), String> {
// Get stream URL from Qobuz
let stream_url = {
let client = app_state.client.lock().await;
client
.get_stream_url_with_fallback(track_id, Quality::HiRes)
.await
.map_err(|e| format!("Failed to get stream URL: {}", e))?
};
let content_type = stream_url.mime_type.clone();
let cache = app_state.audio_cache.clone();
// Download audio data
let audio_data = if let Some(cached) = cache.get(track_id) {
cached.data
} else {
let data = download_audio(&stream_url.url).await?;
cache.insert(track_id, data.clone());
data
};
// Register with media server to get a URL the DLNA device can access
let url = {
let mut server = dlna_state.media_server.lock().await;
server.register_audio(track_id, audio_data, &content_type);
server
.get_audio_url(track_id)
.ok_or_else(|| "Failed to build media URL".to_string())?
};
// Load media on DLNA device
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.load_media(&url, &metadata).await.map_err(|e| e.to_string())?;
}
// Start playback
{
let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.play().await.map_err(|e| e.to_string())?;
}
Ok(())
}
/// Load media without starting playback (for compatibility with existing castStore)
#[tauri::command]
pub async fn dlna_load_media(
metadata: DlnaMetadata,
state: State<'_, DlnaState>,
_metadata: DlnaMetadata,
_state: State<'_, DlnaState>,
) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.load_media(metadata).map_err(|e| e.to_string())
// This is a stub - actual loading happens in dlna_play_track
// The castStore calls this but we need the track_id for actual loading
log::warn!("dlna_load_media called without track_id - use dlna_play_track instead");
Ok(())
}
#[tauri::command]
pub async fn dlna_play(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.play().map_err(|e| e.to_string())
conn.play().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn dlna_pause(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.pause().map_err(|e| e.to_string())
conn.pause().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn dlna_stop(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.stop().map_err(|e| e.to_string())
conn.stop().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn dlna_seek(position_secs: u64, state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.seek(position_secs).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn dlna_set_volume(volume: f32, state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?;
conn.set_volume(volume).map_err(|e| e.to_string())
conn.set_volume(volume).await.map_err(|e| e.to_string())
}
async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
use std::time::Duration;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let response = client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| format!("Failed to fetch audio: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read audio bytes: {}", e))?;
Ok(bytes.to_vec())
}
+335 -27
View File
@@ -1,6 +1,8 @@
//! DLNA device connection and playback (placeholder)
//! DLNA device connection and playback via AVTransport SOAP
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::cast::dlna::{DiscoveredDlnaDevice, DlnaError};
@@ -14,32 +16,78 @@ pub struct DlnaMetadata {
pub duration_secs: Option<u64>,
}
/// DLNA device status (minimal for now)
/// DLNA device status
#[derive(Debug, Clone, Serialize)]
pub struct DlnaStatus {
pub device_id: String,
pub device_name: String,
pub is_connected: bool,
pub is_playing: bool,
pub current_uri: Option<String>,
}
/// DLNA connection stub (AVTransport integration pending)
/// DLNA connection with AVTransport and RenderingControl support
pub struct DlnaConnection {
device: DiscoveredDlnaDevice,
connected: bool,
client: Client,
// Service control URLs (discovered from device description)
av_transport_url: Option<String>,
rendering_control_url: Option<String>,
// Current media URI
current_uri: Option<String>,
is_playing: bool,
}
impl DlnaConnection {
/// Connect to a DLNA device
/// Connect to a DLNA device and discover service URLs
pub fn connect(device: DiscoveredDlnaDevice) -> Result<Self, DlnaError> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| DlnaError::Connection(format!("Failed to create HTTP client: {}", e)))?;
// For now, construct URLs based on device URL
// The actual control URLs would come from parsing the device description XML
let base_url = device.url.trim_end_matches('/');
// Common UPnP control URL patterns
let av_transport_url = if device.has_av_transport {
Some(format!("{}/AVTransport/control", base_url))
} else {
None
};
let rendering_control_url = if device.has_rendering_control {
Some(format!("{}/RenderingControl/control", base_url))
} else {
None
};
log::info!(
"DLNA: Connected to {} (AVT: {:?}, RC: {:?})",
device.name,
av_transport_url.is_some(),
rendering_control_url.is_some()
);
Ok(Self {
device,
connected: true,
client,
av_transport_url,
rendering_control_url,
current_uri: None,
is_playing: false,
})
}
/// Disconnect from the device
pub fn disconnect(&mut self) -> Result<(), DlnaError> {
self.connected = false;
self.current_uri = None;
self.is_playing = false;
log::info!("DLNA: Disconnected from {}", self.device.name);
Ok(())
}
@@ -49,39 +97,299 @@ impl DlnaConnection {
device_id: self.device.id.clone(),
device_name: self.device.name.clone(),
is_connected: self.connected,
is_playing: self.is_playing,
current_uri: self.current_uri.clone(),
}
}
/// Load media (requires AVTransport integration)
pub fn load_media(&mut self, _metadata: DlnaMetadata) -> Result<(), DlnaError> {
Err(DlnaError::NotImplemented(
"AVTransport integration not implemented".to_string(),
))
/// Set the media URI and start playback
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata) -> Result<(), DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
let av_url = self.av_transport_url.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
// Build DIDL-Lite metadata
let didl_metadata = build_didl_metadata(uri, metadata);
// SetAVTransportURI SOAP action
let soap_body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<CurrentURI>{}</CurrentURI>
<CurrentURIMetaData>{}</CurrentURIMetaData>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>"#,
xml_escape(uri),
xml_escape(&didl_metadata)
);
self.send_soap_action(
av_url,
"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI",
&soap_body,
).await?;
self.current_uri = Some(uri.to_string());
log::info!("DLNA: Set URI to {}", uri);
Ok(())
}
/// Play/pause/stop controls
pub fn play(&mut self) -> Result<(), DlnaError> {
Err(DlnaError::NotImplemented(
"AVTransport integration not implemented".to_string(),
))
/// Start/resume playback
pub async fn play(&mut self) -> Result<(), DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
let av_url = self.av_transport_url.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
let soap_body = r#"<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Speed>1</Speed>
</u:Play>
</s:Body>
</s:Envelope>"#;
self.send_soap_action(
av_url,
"urn:schemas-upnp-org:service:AVTransport:1#Play",
soap_body,
).await?;
self.is_playing = true;
log::info!("DLNA: Play");
Ok(())
}
pub fn pause(&mut self) -> Result<(), DlnaError> {
Err(DlnaError::NotImplemented(
"AVTransport integration not implemented".to_string(),
))
/// Pause playback
pub async fn pause(&mut self) -> Result<(), DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
let av_url = self.av_transport_url.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
let soap_body = r#"<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Pause xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:Pause>
</s:Body>
</s:Envelope>"#;
self.send_soap_action(
av_url,
"urn:schemas-upnp-org:service:AVTransport:1#Pause",
soap_body,
).await?;
self.is_playing = false;
log::info!("DLNA: Pause");
Ok(())
}
pub fn stop(&mut self) -> Result<(), DlnaError> {
Err(DlnaError::NotImplemented(
"AVTransport integration not implemented".to_string(),
))
/// Stop playback
pub async fn stop(&mut self) -> Result<(), DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
let av_url = self.av_transport_url.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
let soap_body = r#"<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:Stop>
</s:Body>
</s:Envelope>"#;
self.send_soap_action(
av_url,
"urn:schemas-upnp-org:service:AVTransport:1#Stop",
soap_body,
).await?;
self.is_playing = false;
self.current_uri = None;
log::info!("DLNA: Stop");
Ok(())
}
/// Volume control (0.0 - 1.0)
pub fn set_volume(&mut self, _volume: f32) -> Result<(), DlnaError> {
Err(DlnaError::NotImplemented(
"RenderingControl integration not implemented".to_string(),
))
/// Seek to position
pub async fn seek(&mut self, position_secs: u64) -> Result<(), DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
let av_url = self.av_transport_url.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
let hours = position_secs / 3600;
let minutes = (position_secs % 3600) / 60;
let seconds = position_secs % 60;
let time_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
let soap_body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Seek xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Unit>REL_TIME</Unit>
<Target>{}</Target>
</u:Seek>
</s:Body>
</s:Envelope>"#,
time_str
);
self.send_soap_action(
av_url,
"urn:schemas-upnp-org:service:AVTransport:1#Seek",
&soap_body,
).await?;
log::info!("DLNA: Seek to {}", time_str);
Ok(())
}
/// Set volume (0.0 - 1.0)
pub async fn set_volume(&mut self, volume: f32) -> Result<(), DlnaError> {
if !self.connected {
return Err(DlnaError::NotConnected);
}
let rc_url = self.rendering_control_url.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no RenderingControl service".to_string()))?;
// DLNA volume is typically 0-100
let dlna_volume = ((volume.clamp(0.0, 1.0) * 100.0) as u32).min(100);
let soap_body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetVolume xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
<InstanceID>0</InstanceID>
<Channel>Master</Channel>
<DesiredVolume>{}</DesiredVolume>
</u:SetVolume>
</s:Body>
</s:Envelope>"#,
dlna_volume
);
self.send_soap_action(
rc_url,
"urn:schemas-upnp-org:service:RenderingControl:1#SetVolume",
&soap_body,
).await?;
log::info!("DLNA: Set volume to {}", dlna_volume);
Ok(())
}
/// Send a SOAP action request
async fn send_soap_action(
&self,
url: &str,
action: &str,
body: &str,
) -> Result<String, DlnaError> {
let response = self.client
.post(url)
.header("Content-Type", "text/xml; charset=utf-8")
.header("SOAPAction", format!("\"{}\"", action))
.body(body.to_string())
.send()
.await
.map_err(|e| DlnaError::Playback(format!("SOAP request failed: {}", e)))?;
let status = response.status();
let body = response.text().await
.map_err(|e| DlnaError::Playback(format!("Failed to read response: {}", e)))?;
if !status.is_success() {
log::error!("DLNA SOAP error ({}): {}", status, body);
return Err(DlnaError::Playback(format!("SOAP error ({}): {}", status, parse_soap_error(&body))));
}
Ok(body)
}
}
/// Build DIDL-Lite metadata for a track
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
let duration = metadata.duration_secs.map(|d| {
let hours = d / 3600;
let minutes = (d % 3600) / 60;
let seconds = d % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}).unwrap_or_else(|| "00:00:00".to_string());
let artwork = metadata.artwork_url.as_ref()
.map(|url| format!(r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#, xml_escape(url)))
.unwrap_or_default();
format!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="0" parentID="-1" restricted="1">
<dc:title>{}</dc:title>
<dc:creator>{}</dc:creator>
<upnp:album>{}</upnp:album>
<upnp:artist>{}</upnp:artist>
{}
<res duration="{}" protocolInfo="http-get:*:audio/flac:*">{}</res>
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
</item>
</DIDL-Lite>"#,
xml_escape(&metadata.title),
xml_escape(&metadata.artist),
xml_escape(&metadata.album),
xml_escape(&metadata.artist),
artwork,
duration,
xml_escape(uri)
)
}
/// Escape special XML characters
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// Parse error message from SOAP fault response
fn parse_soap_error(body: &str) -> String {
// Try to extract faultstring or errorDescription
if let Some(start) = body.find("<faultstring>") {
if let Some(end) = body.find("</faultstring>") {
return body[start + 13..end].to_string();
}
}
if let Some(start) = body.find("<errorDescription>") {
if let Some(end) = body.find("</errorDescription>") {
return body[start + 18..end].to_string();
}
}
"Unknown error".to_string()
}
+4 -1
View File
@@ -108,9 +108,10 @@ pub fn run() {
.expect("Failed to initialize library database");
// Initialize casting state (Chromecast, DLNA, AirPlay)
// CastState creates the media server, DLNA shares it
let cast_state = cast::CastState::new()
.expect("Failed to initialize Chromecast state");
let dlna_state = cast::dlna::commands::DlnaState::new()
let dlna_state = cast::dlna::commands::DlnaState::new(cast_state.media_server.clone())
.expect("Failed to initialize DLNA state");
let airplay_state = cast::airplay::commands::AirPlayState::new()
.expect("Failed to initialize AirPlay state");
@@ -361,10 +362,12 @@ pub fn run() {
cast::dlna::commands::dlna_connect,
cast::dlna::commands::dlna_disconnect,
cast::dlna::commands::dlna_get_status,
cast::dlna::commands::dlna_play_track,
cast::dlna::commands::dlna_load_media,
cast::dlna::commands::dlna_play,
cast::dlna::commands::dlna_pause,
cast::dlna::commands::dlna_stop,
cast::dlna::commands::dlna_seek,
cast::dlna::commands::dlna_set_volume,
// AirPlay casting commands
cast::airplay::commands::airplay_start_discovery,