Files
qbz/src-tauri/src/cast/dlna/commands.rs
T
vicrodh 71ae2f92c1 feat(dlna): add position tracking and seekbar support
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
2026-01-26 09:41:11 -06:00

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())
}