DLNA POSITION TRACKING: - Added GetPositionInfo SOAP action to query current playback position - Added GetTransportInfo SOAP action to get transport state (PLAYING/PAUSED/STOPPED) - New dlna_get_position command returns position, duration, and transport state - Added parse_time_string() helper for HH:MM:SS format FRONTEND INTEGRATION: - castStore: Added position polling (1s interval) when connected to DLNA - castStore: New getCastPosition() for UI components - castStore: Automatic polling start/stop on track play/disconnect - playerStore: Subscribes to castStore for position updates - playerStore: Updates currentTime from cast position when casting This enables the seekbar to display accurate position during DLNA playback. Seek functionality was already implemented (dlna_seek command). Files modified: - src-tauri/src/cast/dlna/device.rs - src-tauri/src/cast/dlna/commands.rs - src-tauri/src/cast/dlna/mod.rs - src-tauri/src/lib.rs - src/lib/stores/castStore.ts - src/lib/stores/playerStore.ts
259 lines
8.7 KiB
Rust
259 lines
8.7 KiB
Rust
//! 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, DlnaPositionInfo, DlnaStatus,
|
|
};
|
|
use crate::cast::MediaServer;
|
|
|
|
/// DLNA state shared across commands
|
|
pub struct DlnaState {
|
|
pub discovery: Arc<Mutex<DlnaDiscovery>>,
|
|
pub connection: Arc<Mutex<Option<DlnaConnection>>>,
|
|
/// Shared media server (lazily initialized)
|
|
pub media_server: Arc<Mutex<Option<MediaServer>>>,
|
|
}
|
|
|
|
impl DlnaState {
|
|
pub fn new(media_server: Arc<Mutex<Option<MediaServer>>>) -> Result<Self, DlnaError> {
|
|
Ok(Self {
|
|
discovery: Arc::new(Mutex::new(DlnaDiscovery::new())),
|
|
connection: Arc::new(Mutex::new(None)),
|
|
media_server,
|
|
})
|
|
}
|
|
|
|
/// Ensure media server is started (lazy initialization)
|
|
pub async fn ensure_media_server(&self) -> Result<(), DlnaError> {
|
|
let mut server_guard = self.media_server.lock().await;
|
|
if server_guard.is_none() {
|
|
log::info!("Starting media server on demand for DLNA");
|
|
*server_guard = Some(MediaServer::start().map_err(|e| {
|
|
DlnaError::Connection(format!("Failed to start media server: {}", e))
|
|
})?);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// === Discovery ===
|
|
|
|
#[tauri::command]
|
|
pub async fn dlna_start_discovery(state: State<'_, DlnaState>) -> Result<(), String> {
|
|
let mut discovery = state.discovery.lock().await;
|
|
discovery
|
|
.start_discovery()
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn dlna_stop_discovery(state: State<'_, DlnaState>) -> Result<(), String> {
|
|
let mut discovery = state.discovery.lock().await;
|
|
discovery.stop_discovery().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn dlna_get_devices(
|
|
state: State<'_, DlnaState>,
|
|
) -> Result<Vec<DiscoveredDlnaDevice>, String> {
|
|
let discovery = state.discovery.lock().await;
|
|
Ok(discovery.get_discovered_devices())
|
|
}
|
|
|
|
// === Connection ===
|
|
|
|
#[tauri::command]
|
|
pub async fn dlna_connect(device_id: String, state: State<'_, DlnaState>) -> Result<(), String> {
|
|
let device = {
|
|
let discovery = state.discovery.lock().await;
|
|
discovery
|
|
.get_device(&device_id)
|
|
.ok_or_else(|| DlnaError::DeviceNotFound(device_id.clone()))
|
|
.map_err(|e| e.to_string())?
|
|
};
|
|
|
|
let connection = DlnaConnection::connect(device).await.map_err(|e| e.to_string())?;
|
|
let mut state_connection = state.connection.lock().await;
|
|
*state_connection = Some(connection);
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn dlna_disconnect(state: State<'_, DlnaState>) -> Result<(), String> {
|
|
let mut connection = state.connection.lock().await;
|
|
if let Some(conn) = connection.as_mut() {
|
|
conn.disconnect().map_err(|e| e.to_string())?;
|
|
}
|
|
*connection = None;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn dlna_get_status(state: State<'_, DlnaState>) -> Result<DlnaStatus, String> {
|
|
let connection = state.connection.lock().await;
|
|
let conn = connection.as_ref().ok_or_else(|| "Not connected".to_string())?;
|
|
Ok(conn.get_status())
|
|
}
|
|
|
|
/// Get current playback position from DLNA device
|
|
#[tauri::command]
|
|
pub async fn dlna_get_position(state: State<'_, DlnaState>) -> Result<DlnaPositionInfo, String> {
|
|
let connection = state.connection.lock().await;
|
|
let conn = connection.as_ref().ok_or_else(|| "Not connected".to_string())?;
|
|
conn.get_position_info().await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
// === 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> {
|
|
log::info!("DLNA: dlna_play_track called for track_id={}", track_id);
|
|
|
|
// 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 target_ip = {
|
|
let connection = dlna_state.connection.lock().await;
|
|
connection.as_ref().map(|conn| conn.device_ip().to_string())
|
|
};
|
|
|
|
// Ensure media server is started (lazy init)
|
|
dlna_state.ensure_media_server().await.map_err(|e| e.to_string())?;
|
|
|
|
let url = {
|
|
let mut server_guard = dlna_state.media_server.lock().await;
|
|
let server = server_guard.as_mut().ok_or("Media server not initialized")?;
|
|
server.register_audio(track_id, audio_data, &content_type);
|
|
let url = match target_ip.as_deref() {
|
|
Some(ip) => server.get_audio_url_for_target(track_id, ip),
|
|
None => server.get_audio_url(track_id),
|
|
};
|
|
url.ok_or_else(|| "Failed to build media URL".to_string())?
|
|
};
|
|
|
|
log::info!("DLNA: Playing track {} via MediaServer URL: {}", track_id, url);
|
|
log::info!("DLNA: Content-Type from Qobuz: {}", content_type);
|
|
|
|
// 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, &content_type).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>,
|
|
) -> Result<(), 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().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().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().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).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())
|
|
}
|