Merge branch 'fix/chromecast-parity' into pre-release
This commit is contained in:
commit
302fd200ae
17 files changed
+661
-49
No files matched your search
@@ -9,7 +9,7 @@ use std::time::Duration;
|
||||
|
||||
use crate::cast::device::CastDeviceConnection;
|
||||
use crate::cast::errors::CastError;
|
||||
use crate::cast::{CastStatus, MediaMetadata};
|
||||
use crate::cast::{CastStatus, MediaMetadata, CastPositionInfo};
|
||||
|
||||
/// Commands sent to the Chromecast thread
|
||||
pub enum CastCommand {
|
||||
@@ -24,6 +24,9 @@ pub enum CastCommand {
|
||||
GetStatus {
|
||||
reply: Sender<Result<CastStatus, CastError>>,
|
||||
},
|
||||
GetMediaPosition {
|
||||
reply: Sender<Result<CastPositionInfo, CastError>>,
|
||||
},
|
||||
LoadMedia {
|
||||
url: String,
|
||||
content_type: String,
|
||||
@@ -188,6 +191,17 @@ impl ChromecastHandle {
|
||||
.recv()
|
||||
.map_err(|_| CastError::Connection("Thread response error".to_string()))?
|
||||
}
|
||||
|
||||
/// Get media position for seekbar updates
|
||||
pub fn get_media_position(&self) -> Result<CastPositionInfo, CastError> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
self.sender
|
||||
.send(CastCommand::GetMediaPosition { reply: reply_tx })
|
||||
.map_err(|_| CastError::Connection("Thread communication error".to_string()))?;
|
||||
reply_rx
|
||||
.recv()
|
||||
.map_err(|_| CastError::Connection("Thread response error".to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChromecastHandle {
|
||||
@@ -248,6 +262,14 @@ fn chromecast_thread_main(receiver: Receiver<CastCommand>) {
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
|
||||
CastCommand::GetMediaPosition { reply } => {
|
||||
let result = match connection.as_mut() {
|
||||
Some(conn) => conn.get_media_position(),
|
||||
None => Err(CastError::NotConnected),
|
||||
};
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
|
||||
CastCommand::LoadMedia {
|
||||
url,
|
||||
content_type,
|
||||
|
||||
@@ -7,7 +7,7 @@ use tokio::sync::Mutex;
|
||||
use crate::api::models::Quality;
|
||||
use crate::AppState;
|
||||
use crate::cast::{
|
||||
CastError, CastStatus, DeviceDiscovery, DiscoveredDevice, MediaMetadata, MediaServer,
|
||||
CastError, CastStatus, CastPositionInfo, DeviceDiscovery, DiscoveredDevice, MediaMetadata, MediaServer,
|
||||
};
|
||||
use crate::cast::chromecast_thread::ChromecastHandle;
|
||||
use crate::library::{AudioFormat, LibraryState};
|
||||
@@ -110,6 +110,11 @@ pub async fn cast_get_status(state: State<'_, CastState>) -> Result<CastStatus,
|
||||
state.chromecast.get_status().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cast_get_position(state: State<'_, CastState>) -> Result<CastPositionInfo, String> {
|
||||
state.chromecast.get_media_position().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// === Playback ===
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use rust_cast::CastDevice;
|
||||
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType};
|
||||
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType, PlayerState, IdleReason};
|
||||
use rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus};
|
||||
|
||||
use crate::cast::CastError;
|
||||
@@ -20,6 +20,15 @@ pub struct MediaMetadata {
|
||||
pub duration_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// Media position info for seekbar updates
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CastPositionInfo {
|
||||
pub position_secs: f64,
|
||||
pub duration_secs: f64,
|
||||
pub player_state: String,
|
||||
pub idle_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Device status for frontend
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CastStatus {
|
||||
@@ -208,6 +217,56 @@ impl CastDeviceConnection {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current media position for seekbar updates
|
||||
pub fn get_media_position(&mut self) -> Result<CastPositionInfo, CastError> {
|
||||
let session = self.session.as_ref().ok_or(CastError::NotConnected)?;
|
||||
let destination = &session.transport_id;
|
||||
|
||||
let status = self
|
||||
.device
|
||||
.media
|
||||
.get_status(destination.as_str(), None)
|
||||
.map_err(|e| CastError::Media(e.to_string()))?;
|
||||
|
||||
// Get the first media entry (current playing item)
|
||||
if let Some(entry) = status.entries.first() {
|
||||
let player_state = match entry.player_state {
|
||||
PlayerState::Idle => "IDLE",
|
||||
PlayerState::Playing => "PLAYING",
|
||||
PlayerState::Paused => "PAUSED",
|
||||
PlayerState::Buffering => "BUFFERING",
|
||||
};
|
||||
|
||||
let idle_reason = entry.idle_reason.as_ref().map(|r| match r {
|
||||
IdleReason::Cancelled => "CANCELLED".to_string(),
|
||||
IdleReason::Interrupted => "INTERRUPTED".to_string(),
|
||||
IdleReason::Finished => "FINISHED".to_string(),
|
||||
IdleReason::Error => "ERROR".to_string(),
|
||||
});
|
||||
|
||||
let duration = entry.media.as_ref()
|
||||
.and_then(|m| m.duration)
|
||||
.unwrap_or(0.0) as f64;
|
||||
|
||||
let position = entry.current_time.unwrap_or(0.0) as f64;
|
||||
|
||||
Ok(CastPositionInfo {
|
||||
position_secs: position,
|
||||
duration_secs: duration,
|
||||
player_state: player_state.to_string(),
|
||||
idle_reason,
|
||||
})
|
||||
} else {
|
||||
// No media playing
|
||||
Ok(CastPositionInfo {
|
||||
position_secs: 0.0,
|
||||
duration_secs: 0.0,
|
||||
player_state: "IDLE".to_string(),
|
||||
idle_reason: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_session(&mut self) -> Result<(), CastError> {
|
||||
if self.session.is_some() {
|
||||
return Ok(());
|
||||
|
||||
@@ -7,7 +7,7 @@ use tokio::sync::Mutex;
|
||||
use crate::api::models::Quality;
|
||||
use crate::AppState;
|
||||
use crate::cast::dlna::{
|
||||
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaStatus,
|
||||
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaPositionInfo, DlnaStatus,
|
||||
};
|
||||
use crate::cast::MediaServer;
|
||||
|
||||
@@ -101,6 +101,14 @@ pub async fn dlna_get_status(state: State<'_, DlnaState>) -> Result<DlnaStatus,
|
||||
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
|
||||
@@ -111,6 +119,8 @@ pub async fn dlna_play_track(
|
||||
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;
|
||||
@@ -152,11 +162,14 @@ pub async fn dlna_play_track(
|
||||
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).await.map_err(|e| e.to_string())?;
|
||||
conn.load_media(&url, &metadata, &content_type).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Start playback
|
||||
|
||||
@@ -17,6 +17,14 @@ pub struct DlnaMetadata {
|
||||
pub duration_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// DLNA playback position info
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DlnaPositionInfo {
|
||||
pub position_secs: u64,
|
||||
pub duration_secs: u64,
|
||||
pub transport_state: String, // PLAYING, PAUSED_PLAYBACK, STOPPED, etc.
|
||||
}
|
||||
|
||||
/// DLNA device status
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DlnaStatus {
|
||||
@@ -101,7 +109,7 @@ impl DlnaConnection {
|
||||
}
|
||||
|
||||
/// Set the media URI and start playback
|
||||
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata) -> Result<(), DlnaError> {
|
||||
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata, content_type: &str) -> Result<(), DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
@@ -109,8 +117,12 @@ impl DlnaConnection {
|
||||
let av_service = self.av_transport_service.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);
|
||||
// Build DIDL-Lite metadata with actual content type
|
||||
let didl_metadata = build_didl_metadata(uri, metadata, content_type);
|
||||
|
||||
log::info!("DLNA: Loading media URI: {}", uri);
|
||||
log::info!("DLNA: Content-Type: {}", content_type);
|
||||
log::info!("DLNA: DIDL Metadata:\n{}", didl_metadata);
|
||||
|
||||
let payload = format!(
|
||||
"<InstanceID>0</InstanceID><CurrentURI>{}</CurrentURI><CurrentURIMetaData>{}</CurrentURIMetaData>",
|
||||
@@ -118,11 +130,21 @@ impl DlnaConnection {
|
||||
xml_escape(&didl_metadata)
|
||||
);
|
||||
|
||||
av_service
|
||||
.action(&self.device_url, "SetAVTransportURI", &payload)
|
||||
.await
|
||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
av_service.action(&self.device_url, "SetAVTransportURI", &payload)
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
log::error!("DLNA: SetAVTransportURI timed out after 10s");
|
||||
DlnaError::Playback("SetAVTransportURI timed out".to_string())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
log::error!("DLNA: SetAVTransportURI failed: {}", e);
|
||||
DlnaError::Playback(e.to_string())
|
||||
})?;
|
||||
|
||||
log::info!("DLNA: SetAVTransportURI response: {:?}", response);
|
||||
self.current_uri = Some(uri.to_string());
|
||||
log::info!("DLNA: Set URI to {}", uri);
|
||||
|
||||
@@ -138,13 +160,23 @@ impl DlnaConnection {
|
||||
let av_service = self.av_transport_service.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
av_service
|
||||
.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
|
||||
.await
|
||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||
let response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
av_service.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
log::error!("DLNA: Play action timed out after 10s");
|
||||
DlnaError::Playback("Play action timed out".to_string())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
log::error!("DLNA: Play action failed: {}", e);
|
||||
DlnaError::Playback(e.to_string())
|
||||
})?;
|
||||
|
||||
log::info!("DLNA: Play response: {:?}", response);
|
||||
self.is_playing = true;
|
||||
log::info!("DLNA: Play");
|
||||
log::info!("DLNA: Play started successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -241,10 +273,77 @@ impl DlnaConnection {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current playback position and transport state
|
||||
pub async fn get_position_info(&self) -> Result<DlnaPositionInfo, DlnaError> {
|
||||
if !self.connected {
|
||||
return Err(DlnaError::NotConnected);
|
||||
}
|
||||
|
||||
let av_service = self.av_transport_service.as_ref()
|
||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||
|
||||
// Get position info
|
||||
let position_response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
av_service.action(&self.device_url, "GetPositionInfo", "<InstanceID>0</InstanceID>")
|
||||
)
|
||||
.await
|
||||
.map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))?
|
||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||
|
||||
// Get transport state
|
||||
let transport_response = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
av_service.action(&self.device_url, "GetTransportInfo", "<InstanceID>0</InstanceID>")
|
||||
)
|
||||
.await
|
||||
.map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))?
|
||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||
|
||||
// Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS"
|
||||
let rel_time = position_response.get("RelTime")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("0:00:00");
|
||||
let position_secs = parse_time_string(rel_time);
|
||||
|
||||
// Parse TrackDuration - format: "HH:MM:SS"
|
||||
let track_duration = position_response.get("TrackDuration")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("0:00:00");
|
||||
let duration_secs = parse_time_string(track_duration);
|
||||
|
||||
// Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.)
|
||||
let transport_state = transport_response.get("CurrentTransportState")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||
|
||||
Ok(DlnaPositionInfo {
|
||||
position_secs,
|
||||
duration_secs,
|
||||
transport_state,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds
|
||||
fn parse_time_string(time: &str) -> u64 {
|
||||
let parts: Vec<&str> = time.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let hours: u64 = parts[0].parse().unwrap_or(0);
|
||||
let minutes: u64 = parts[1].parse().unwrap_or(0);
|
||||
let seconds: u64 = parts[2].split('.').next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
hours * 3600 + minutes * 60 + seconds
|
||||
}
|
||||
|
||||
/// Build DIDL-Lite metadata for a track
|
||||
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
|
||||
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata, content_type: &str) -> String {
|
||||
let duration = metadata.duration_secs.map(|d| {
|
||||
let hours = d / 3600;
|
||||
let minutes = (d % 3600) / 60;
|
||||
@@ -256,6 +355,10 @@ fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
|
||||
.map(|url| format!(r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#, xml_escape(url)))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Use actual content type for protocolInfo - critical for DLNA compatibility
|
||||
// Many devices reject content if protocolInfo doesn't match actual MIME type
|
||||
let protocol_info = format!("http-get:*:{}:*", content_type);
|
||||
|
||||
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">
|
||||
@@ -264,7 +367,7 @@ fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
|
||||
<upnp:album>{}</upnp:album>
|
||||
<upnp:artist>{}</upnp:artist>
|
||||
{}
|
||||
<res duration="{}" protocolInfo="http-get:*:audio/flac:*">{}</res>
|
||||
<res duration="{}" protocolInfo="{}">{}</res>
|
||||
<upnp:class>object.item.audioItem.musicTrack</upnp:class>
|
||||
</item>
|
||||
</DIDL-Lite>"#,
|
||||
@@ -274,6 +377,7 @@ fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata) -> String {
|
||||
xml_escape(&metadata.artist),
|
||||
artwork,
|
||||
duration,
|
||||
protocol_info,
|
||||
xml_escape(uri)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ pub mod discovery;
|
||||
pub mod errors;
|
||||
|
||||
pub use commands::DlnaState;
|
||||
pub use device::{DlnaConnection, DlnaMetadata, DlnaStatus};
|
||||
pub use device::{DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus};
|
||||
pub use discovery::{DiscoveredDlnaDevice, DlnaDiscovery};
|
||||
pub use errors::DlnaError;
|
||||
@@ -38,8 +38,14 @@ pub struct MediaServer {
|
||||
|
||||
impl MediaServer {
|
||||
/// Start server on available port
|
||||
/// Uses port 9876 by default for DLNA compatibility, falls back to random port if busy
|
||||
pub fn start() -> Result<Self, CastError> {
|
||||
let server = Server::http("0.0.0.0:0")
|
||||
// Try fixed port first for easier firewall configuration
|
||||
let server = Server::http("0.0.0.0:9876")
|
||||
.or_else(|_| {
|
||||
log::warn!("MediaServer: Port 9876 busy, using random port");
|
||||
Server::http("0.0.0.0:0")
|
||||
})
|
||||
.map_err(|e| CastError::Server(format!("Failed to start HTTP server: {}", e)))?;
|
||||
|
||||
let port = server
|
||||
@@ -50,14 +56,18 @@ impl MediaServer {
|
||||
|
||||
let base_ip = local_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
||||
let base_url = format_base_url(base_ip, port);
|
||||
|
||||
log::info!("MediaServer: Started on {}:{} (base_url: {})", base_ip, port, base_url);
|
||||
|
||||
let entries = Arc::new(Mutex::new(HashMap::new()));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let entries_clone = entries.clone();
|
||||
let shutdown_clone = shutdown.clone();
|
||||
let port_for_log = port;
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
log::info!("MediaServer: Thread started, listening on port {}", port_for_log);
|
||||
while !shutdown_clone.load(Ordering::SeqCst) {
|
||||
match server.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(Some(request)) => {
|
||||
@@ -65,9 +75,13 @@ impl MediaServer {
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(_) => break,
|
||||
Err(e) => {
|
||||
log::error!("MediaServer: Thread error: {:?}, exiting", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("MediaServer: Thread exiting");
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
@@ -177,18 +191,31 @@ fn handle_request(
|
||||
request: &tiny_http::Request,
|
||||
entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
// Log all incoming requests for debugging
|
||||
log::info!("MediaServer: {} request from {:?} for {}", method, request.remote_addr(), url);
|
||||
|
||||
if method != &Method::Get {
|
||||
log::warn!("MediaServer: Rejected non-GET request: {}", method);
|
||||
return Response::from_data(Vec::new()).with_status_code(StatusCode(405));
|
||||
}
|
||||
|
||||
let id = match parse_audio_id(url) {
|
||||
Some(id) => id,
|
||||
None => return Response::from_data(Vec::new()).with_status_code(StatusCode(404)),
|
||||
None => {
|
||||
log::warn!("MediaServer: 404 - Could not parse audio ID from URL: {}", url);
|
||||
return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
|
||||
}
|
||||
};
|
||||
|
||||
let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) {
|
||||
Some(entry) => entry,
|
||||
None => return Response::from_data(Vec::new()).with_status_code(StatusCode(404)),
|
||||
Some(entry) => {
|
||||
log::info!("MediaServer: Found entry for ID {}, content-type: {}, size: {} bytes", id, entry.content_type, entry.size);
|
||||
entry
|
||||
}
|
||||
None => {
|
||||
log::warn!("MediaServer: 404 - No entry found for ID: {}", id);
|
||||
return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
|
||||
}
|
||||
};
|
||||
|
||||
let range_header = request
|
||||
|
||||
@@ -10,7 +10,7 @@ pub mod airplay;
|
||||
pub mod dlna;
|
||||
|
||||
pub use commands::CastState;
|
||||
pub use device::{CastDeviceConnection, CastStatus, MediaMetadata};
|
||||
pub use device::{CastDeviceConnection, CastStatus, MediaMetadata, CastPositionInfo};
|
||||
pub use discovery::{DeviceDiscovery, DiscoveredDevice};
|
||||
pub use errors::CastError;
|
||||
pub use media_server::MediaServer;
|
||||
|
||||
@@ -390,6 +390,20 @@ pub fn run() {
|
||||
log::info!("Close to tray: hiding window instead of closing");
|
||||
let _ = window.hide();
|
||||
api.prevent_close();
|
||||
} else {
|
||||
// Cleanup cast devices on actual close
|
||||
log::info!("App closing: cleaning up cast devices");
|
||||
|
||||
// Disconnect Chromecast if connected (sends message through channel)
|
||||
if let Some(cast_state) = window.app_handle().try_state::<cast::CastState>() {
|
||||
log::info!("Disconnecting Chromecast on app exit");
|
||||
cast_state.chromecast.disconnect();
|
||||
}
|
||||
|
||||
// Note: DLNA connection will be dropped when the app exits,
|
||||
// which will naturally close the connection. The tokio Mutex
|
||||
// prevents us from synchronously stopping playback here.
|
||||
log::info!("DLNA connection will be cleaned up on drop");
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -619,6 +633,7 @@ pub fn run() {
|
||||
cast::commands::cast_connect,
|
||||
cast::commands::cast_disconnect,
|
||||
cast::commands::cast_get_status,
|
||||
cast::commands::cast_get_position,
|
||||
cast::commands::cast_play_track,
|
||||
cast::commands::cast_play_local_track,
|
||||
cast::commands::cast_play,
|
||||
@@ -633,6 +648,7 @@ pub fn run() {
|
||||
cast::dlna::commands::dlna_connect,
|
||||
cast::dlna::commands::dlna_disconnect,
|
||||
cast::dlna::commands::dlna_get_status,
|
||||
cast::dlna::commands::dlna_get_position,
|
||||
cast::dlna::commands::dlna_play_track,
|
||||
cast::dlna::commands::dlna_load_media,
|
||||
cast::dlna::commands::dlna_play,
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"$HOME/.cache/qbz/**",
|
||||
"$HOME/.config/qbz/**",
|
||||
"$HOME/.local/share/qbz/**",
|
||||
"$HOME/**",
|
||||
"/mnt/**",
|
||||
"/media/**",
|
||||
"/run/media/**"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { getDevicePrettyName, isExternalDevice } from '$lib/utils/audioDeviceNames';
|
||||
import { isCasting, getConnectedDevice, getConnectedProtocol, subscribe as subscribeCast } from '$lib/stores/castStore';
|
||||
|
||||
interface AudioSettings {
|
||||
output_device: string | null;
|
||||
@@ -44,6 +45,11 @@
|
||||
let pipewireSinks = $state<PipewireSink[]>([]);
|
||||
let hardwareStatus = $state<HardwareAudioStatus | null>(null);
|
||||
let isHovering = $state(false);
|
||||
|
||||
// Cast state
|
||||
let castConnected = $state(false);
|
||||
let castDeviceName = $state<string | null>(null);
|
||||
let castProtocol = $state<string | null>(null);
|
||||
|
||||
// Ticker animation for long device names
|
||||
let deviceNameRef: HTMLDivElement | null = $state(null);
|
||||
@@ -133,9 +139,9 @@
|
||||
return 'DAC Passthrough activo';
|
||||
});
|
||||
|
||||
// Whether to show badges at all (only if at least one setting is enabled or device is external)
|
||||
// Whether to show badges at all (only if at least one setting is enabled or device is external or casting)
|
||||
const shouldShowBadges = $derived(
|
||||
settings?.dac_passthrough || settings?.exclusive_mode || isExternal || isAlsaDirect
|
||||
castConnected || settings?.dac_passthrough || settings?.exclusive_mode || isExternal || isAlsaDirect
|
||||
);
|
||||
|
||||
async function loadStatus() {
|
||||
@@ -174,12 +180,19 @@
|
||||
|
||||
onMount(() => {
|
||||
loadStatus();
|
||||
|
||||
// Subscribe to cast state
|
||||
const unsubscribeCast = subscribeCast(() => {
|
||||
castConnected = isCasting();
|
||||
castDeviceName = getConnectedDevice()?.name ?? null;
|
||||
castProtocol = getConnectedProtocol();
|
||||
});
|
||||
|
||||
// Lightweight polling: ONLY update hardware status (no device enumeration)
|
||||
// This reads /proc/asound which is very cheap, no CPAL/ALSA enumeration
|
||||
const pollInterval = setInterval(async () => {
|
||||
// Only poll if using bit-perfect modes
|
||||
if (settings?.dac_passthrough || settings?.backend_type === 'Alsa') {
|
||||
// Only poll if using bit-perfect modes AND not casting
|
||||
if (!castConnected && (settings?.dac_passthrough || settings?.backend_type === 'Alsa')) {
|
||||
try {
|
||||
hardwareStatus = await invoke<HardwareAudioStatus>('get_hardware_audio_status').catch(() => null);
|
||||
} catch (err) {
|
||||
@@ -189,7 +202,10 @@
|
||||
}, 1000);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => clearInterval(pollInterval);
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
unsubscribeCast();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -200,8 +216,22 @@
|
||||
onmouseenter={() => isHovering = true}
|
||||
onmouseleave={() => isHovering = false}
|
||||
>
|
||||
<!-- DAC Badge or HW Badge (mutually exclusive) -->
|
||||
{#if isAlsaDirect}
|
||||
<!-- Casting Badge (shown when casting to external device) -->
|
||||
{#if castConnected}
|
||||
<div class="badge cast-badge active">
|
||||
<span class="badge-label">
|
||||
{#if castProtocol === 'dlna'}
|
||||
DLNA
|
||||
{:else if castProtocol === 'chromecast'}
|
||||
CAST
|
||||
{:else if castProtocol === 'airplay'}
|
||||
AIR
|
||||
{:else}
|
||||
CAST
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else if isAlsaDirect}
|
||||
<!-- Hardware Direct Badge (ALSA Direct mode) -->
|
||||
<div class="badge hw-badge active">
|
||||
<span class="badge-label">HW</span>
|
||||
@@ -218,20 +248,33 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Exclusive Mode Badge -->
|
||||
<!-- Exclusive Mode Badge (only show when not casting) -->
|
||||
{#if !castConnected}
|
||||
<div
|
||||
class="badge"
|
||||
class:active={exclusiveModeActive}
|
||||
>
|
||||
<span class="badge-label">EXC</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Unified Tooltip on hover -->
|
||||
{#if isHovering && showTooltips}
|
||||
<div class="device-tooltip">
|
||||
<!-- Output Device -->
|
||||
<div class="tooltip-section">
|
||||
<div class="tooltip-label">Output Device</div>
|
||||
<div class="tooltip-label">{castConnected ? 'Casting to' : 'Output Device'}</div>
|
||||
{#if castConnected}
|
||||
<div class="tooltip-device cast-device">
|
||||
{#if castProtocol === 'dlna'}
|
||||
<img src="/dlna.svg" alt="DLNA" class="cast-icon-svg" />
|
||||
{:else}
|
||||
<img src="/cast_audio.svg" alt="Cast" class="cast-icon-svg" />
|
||||
{/if}
|
||||
<span class="device-name-text">{castDeviceName || 'Unknown Device'}</span>
|
||||
</div>
|
||||
<div class="tooltip-raw">{castProtocol?.toUpperCase()} Streaming</div>
|
||||
{:else}
|
||||
<div
|
||||
class="tooltip-device"
|
||||
class:scrollable={deviceNameOverflow > 0}
|
||||
@@ -251,10 +294,11 @@
|
||||
<span class="volume-value">{currentVolume}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Audio Settings -->
|
||||
{#if isAlsaDirect || settings?.dac_passthrough || settings?.exclusive_mode}
|
||||
<!-- Audio Settings (only show when not casting) -->
|
||||
{#if !castConnected && (isAlsaDirect || settings?.dac_passthrough || settings?.exclusive_mode)}
|
||||
<div class="tooltip-section">
|
||||
<div class="tooltip-label">Audio Settings</div>
|
||||
{#if isAlsaDirect}
|
||||
@@ -355,6 +399,13 @@
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Cast Badge - Purple for network streaming */
|
||||
.badge.cast-badge {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
border-color: rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -433,6 +484,20 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Cast device styling in tooltip */
|
||||
.tooltip-device.cast-device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.cast-icon-svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
filter: invert(55%) sepia(80%) saturate(2000%) hue-rotate(240deg) brightness(100%);
|
||||
}
|
||||
|
||||
.tooltip-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1939,6 +1939,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Offline Library Section -->
|
||||
<section class="section" bind:this={downloadsSection}>
|
||||
<h3 class="section-title">{$t('settings.offlineLibrary.title')}</h3>
|
||||
@@ -1953,13 +1954,6 @@
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-with-description">
|
||||
<span class="setting-label">Show in Local Library</span>
|
||||
<span class="setting-description">Display offline Qobuz™ tracks in your Local Library</span>
|
||||
</div>
|
||||
<Toggle enabled={showQobuzDownloadsInLibrary} onchange={handleShowDownloadsChange} />
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-with-description">
|
||||
<span class="setting-label">Repair Offline Library</span>
|
||||
@@ -1985,6 +1979,105 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Flatpak Section (only shown when running in Flatpak) -->
|
||||
{#if isFlatpak}
|
||||
<section class="section flatpak-section">
|
||||
<h3 class="section-title">Flatpak Sandbox</h3>
|
||||
<div class="flatpak-info">
|
||||
<p class="flatpak-intro">
|
||||
QBZ is running inside a Flatpak sandbox. For offline libraries on NAS, network mounts, or external disks, direct filesystem access is required.
|
||||
</p>
|
||||
<div class="flatpak-guide">
|
||||
<h4>Grant Filesystem Access</h4>
|
||||
<p>Use <strong>Flatseal</strong> (GUI) or run this command for each folder you want to add:</p>
|
||||
<CopyableCommand command="flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz" />
|
||||
<h4>Examples</h4>
|
||||
<CopyableCommand command="# CIFS / Samba mount\nflatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz\n\n# SSHFS mount\nflatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz\n\n# Custom folder (edit as needed)\nflatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz" />
|
||||
<p class="flatpak-note">
|
||||
<strong>Note:</strong> This setting is persistent and survives reboots and updates.<br />
|
||||
<strong>Tip:</strong> You can repeat the command for as many folders as you need.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flatpak-guide" style="margin-top:2em;">
|
||||
<h4>Chromecast & DLNA Device Discovery</h4>
|
||||
<p>
|
||||
To detect Chromecast and DLNA devices on your network, you must grant network sharing permissions to the app:
|
||||
</p>
|
||||
<CopyableCommand command="flatpak override --user --share=network com.blitzfc.qbz" />
|
||||
<p class="flatpak-note">
|
||||
<strong>Note:</strong> Without this, device discovery will not work.<br />
|
||||
You only need to do this once.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<script lang="ts">
|
||||
// ...existing code...
|
||||
import { onMount } from 'svelte';
|
||||
// ...existing code...
|
||||
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
|
||||
|
||||
// Componente para comandos copiables
|
||||
export let CopyableCommand: any = (props: { command: string }) => {
|
||||
let copied = false;
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await copyToClipboard(props.command);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1200);
|
||||
} catch {
|
||||
// fallback
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.command);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1200);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div class="copyable-command">
|
||||
<pre class="code-block">{props.command}</pre>
|
||||
<button class="copy-btn" on:click={handleCopy}>{copied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.copyable-command {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.copyable-command .code-block {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
user-select: all;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.copy-btn {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background: var(--accent-secondary);
|
||||
}
|
||||
</style>
|
||||
<!-- Library Section -->
|
||||
<section class="section" bind:this={librarySection}>
|
||||
<h3 class="section-title">{$t('settings.library.title')}</h3>
|
||||
|
||||
+175
-3
@@ -16,12 +16,21 @@ export interface CastDevice {
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface CastPositionInfo {
|
||||
positionSecs: number;
|
||||
durationSecs: number;
|
||||
transportState: string;
|
||||
}
|
||||
|
||||
interface CastState {
|
||||
isConnected: boolean;
|
||||
protocol: CastProtocol | null;
|
||||
device: CastDevice | null;
|
||||
isPlaying: boolean;
|
||||
currentTrackId: number | null;
|
||||
// Position tracking for DLNA
|
||||
positionSecs: number;
|
||||
durationSecs: number;
|
||||
}
|
||||
|
||||
let state: CastState = {
|
||||
@@ -29,9 +38,25 @@ let state: CastState = {
|
||||
protocol: null,
|
||||
device: null,
|
||||
isPlaying: false,
|
||||
currentTrackId: null
|
||||
currentTrackId: null,
|
||||
positionSecs: 0,
|
||||
durationSecs: 0
|
||||
};
|
||||
|
||||
// Polling interval for DLNA position updates
|
||||
let positionPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
const POSITION_POLL_INTERVAL_MS = 1000;
|
||||
|
||||
// Callback for when track ends (for auto-advance)
|
||||
let onCastTrackEnded: (() => Promise<void>) | null = null;
|
||||
|
||||
// Callback for when cast disconnects (to reset player state)
|
||||
let onCastDisconnected: (() => void) | null = null;
|
||||
|
||||
// Track end detection state
|
||||
let lastTransportState: string = '';
|
||||
let trackEndDetected = false;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notifyListeners(): void {
|
||||
@@ -115,6 +140,9 @@ export async function connectToDevice(device: CastDevice, protocol: CastProtocol
|
||||
export async function disconnect(): Promise<void> {
|
||||
if (!state.isConnected || !state.protocol) return;
|
||||
|
||||
// Stop position polling first
|
||||
stopPositionPolling();
|
||||
|
||||
try {
|
||||
await castStop();
|
||||
switch (state.protocol) {
|
||||
@@ -137,9 +165,23 @@ export async function disconnect(): Promise<void> {
|
||||
protocol: null,
|
||||
device: null,
|
||||
isPlaying: false,
|
||||
currentTrackId: null
|
||||
currentTrackId: null,
|
||||
positionSecs: 0,
|
||||
durationSecs: 0
|
||||
};
|
||||
notifyListeners();
|
||||
|
||||
// Notify player to reset its state
|
||||
if (onCastDisconnected) {
|
||||
onCastDisconnected();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for when cast disconnects
|
||||
*/
|
||||
export function setOnCastDisconnected(callback: () => void): void {
|
||||
onCastDisconnected = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,9 +244,16 @@ export async function castTrack(
|
||||
state = {
|
||||
...state,
|
||||
isPlaying: true,
|
||||
currentTrackId: trackId
|
||||
currentTrackId: trackId,
|
||||
positionSecs: 0,
|
||||
durationSecs: metadata.durationSecs || 0
|
||||
};
|
||||
notifyListeners();
|
||||
|
||||
// Start position polling for DLNA and Chromecast
|
||||
if (state.protocol === 'dlna' || state.protocol === 'chromecast') {
|
||||
startPositionPolling();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CastStore] Failed to cast track:', err);
|
||||
throw err;
|
||||
@@ -333,3 +382,126 @@ export async function castSetVolume(volume: number): Promise<void> {
|
||||
console.error('[CastStore] Failed to set volume:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cast position (for seekbar display)
|
||||
*/
|
||||
export function getCastPosition(): { positionSecs: number; durationSecs: number } {
|
||||
return {
|
||||
positionSecs: state.positionSecs,
|
||||
durationSecs: state.durationSecs
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for when cast track ends (for auto-advance)
|
||||
*/
|
||||
export function setOnCastTrackEnded(callback: (() => Promise<void>) | null): void {
|
||||
onCastTrackEnded = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for cast position updates (DLNA and Chromecast)
|
||||
*/
|
||||
export function startPositionPolling(): void {
|
||||
if (positionPollInterval) return;
|
||||
if (state.protocol !== 'dlna' && state.protocol !== 'chromecast') return;
|
||||
|
||||
console.log(`[CastStore] Starting ${state.protocol?.toUpperCase()} position polling`);
|
||||
trackEndDetected = false;
|
||||
lastTransportState = 'PLAYING';
|
||||
|
||||
positionPollInterval = setInterval(async () => {
|
||||
if (!state.isConnected || (state.protocol !== 'dlna' && state.protocol !== 'chromecast')) {
|
||||
stopPositionPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let positionSecs = 0;
|
||||
let durationSecs = 0;
|
||||
let transportState = 'PLAYING';
|
||||
let idleReason: string | null = null;
|
||||
|
||||
if (state.protocol === 'dlna') {
|
||||
const positionInfo = await invoke<{
|
||||
position_secs: number;
|
||||
duration_secs: number;
|
||||
transport_state: string;
|
||||
}>('dlna_get_position');
|
||||
|
||||
positionSecs = positionInfo.position_secs;
|
||||
durationSecs = positionInfo.duration_secs;
|
||||
transportState = positionInfo.transport_state;
|
||||
} else if (state.protocol === 'chromecast') {
|
||||
const positionInfo = await invoke<{
|
||||
position_secs: number;
|
||||
duration_secs: number;
|
||||
player_state: string;
|
||||
idle_reason: string | null;
|
||||
}>('cast_get_position');
|
||||
|
||||
positionSecs = positionInfo.position_secs;
|
||||
durationSecs = positionInfo.duration_secs;
|
||||
transportState = positionInfo.player_state;
|
||||
idleReason = positionInfo.idle_reason;
|
||||
}
|
||||
|
||||
const isNowPlaying = transportState === 'PLAYING';
|
||||
|
||||
state = {
|
||||
...state,
|
||||
positionSecs,
|
||||
durationSecs,
|
||||
isPlaying: isNowPlaying
|
||||
};
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// Detect track ended based on protocol
|
||||
let trackEnded = false;
|
||||
|
||||
if (state.protocol === 'dlna') {
|
||||
// DLNA: was PLAYING, now STOPPED or NO_MEDIA_PRESENT
|
||||
trackEnded = lastTransportState === 'PLAYING' &&
|
||||
(transportState === 'STOPPED' || transportState === 'NO_MEDIA_PRESENT');
|
||||
} else if (state.protocol === 'chromecast') {
|
||||
// Chromecast: IDLE state with FINISHED reason
|
||||
trackEnded = transportState === 'IDLE' && idleReason === 'FINISHED';
|
||||
}
|
||||
|
||||
if (trackEnded && !trackEndDetected) {
|
||||
console.log(`[CastStore] ${state.protocol?.toUpperCase()} track ended, state:`, transportState, 'idle_reason:', idleReason);
|
||||
trackEndDetected = true;
|
||||
|
||||
if (onCastTrackEnded) {
|
||||
try {
|
||||
await onCastTrackEnded();
|
||||
} catch (err) {
|
||||
console.error('[CastStore] Failed to auto-advance:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset track end detection when a new track starts playing
|
||||
if (transportState === 'PLAYING' && trackEndDetected) {
|
||||
trackEndDetected = false;
|
||||
}
|
||||
|
||||
lastTransportState = transportState;
|
||||
} catch (err) {
|
||||
// Silently ignore polling errors (device may be temporarily unavailable)
|
||||
}
|
||||
}, POSITION_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for position updates
|
||||
*/
|
||||
export function stopPositionPolling(): void {
|
||||
if (positionPollInterval) {
|
||||
console.log('[CastStore] Stopping position polling');
|
||||
clearInterval(positionPollInterval);
|
||||
positionPollInterval = null;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
castPause,
|
||||
castSeek,
|
||||
castSetVolume,
|
||||
castStop
|
||||
castStop,
|
||||
getCastPosition,
|
||||
subscribe as subscribeToCast,
|
||||
setOnCastTrackEnded,
|
||||
setOnCastDisconnected
|
||||
} from '$lib/stores/castStore';
|
||||
|
||||
// ============ Types ============
|
||||
@@ -64,6 +68,7 @@ let volume = 75;
|
||||
let isFavorite = false;
|
||||
// Event listener state (replaces polling)
|
||||
let eventUnlisten: UnlistenFn | null = null;
|
||||
let castUnsubscribe: (() => void) | null = null;
|
||||
let isAdvancingTrack = false;
|
||||
let isSkipping = false;
|
||||
let queueEnded = false;
|
||||
@@ -350,6 +355,8 @@ export async function stop(): Promise<void> {
|
||||
*/
|
||||
export function setOnTrackEnded(callback: () => Promise<void>): void {
|
||||
onTrackEnded = callback;
|
||||
// Also set the same callback for cast track ended (DLNA auto-advance)
|
||||
setOnCastTrackEnded(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -401,6 +408,25 @@ export async function startPolling(): Promise<void> {
|
||||
} catch (err) {
|
||||
console.error('Failed to start playback event listener:', err);
|
||||
}
|
||||
|
||||
// Also subscribe to cast store for DLNA position updates
|
||||
if (!castUnsubscribe) {
|
||||
castUnsubscribe = subscribeToCast(() => {
|
||||
if (isCasting()) {
|
||||
const castPos = getCastPosition();
|
||||
if (castPos.positionSecs !== currentTime) {
|
||||
currentTime = castPos.positionSecs;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set callback for when cast disconnects - reset player state
|
||||
setOnCastDisconnected(() => {
|
||||
isPlaying = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -412,6 +438,10 @@ export function stopPolling(): void {
|
||||
eventUnlisten = null;
|
||||
console.log('Stopped listening for playback events');
|
||||
}
|
||||
if (castUnsubscribe) {
|
||||
castUnsubscribe();
|
||||
castUnsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 6.75C2 5.50736 3.00736 4.5 4.25 4.5H23.75C24.9926 4.5 26 5.50736 26 6.75V21.25C26 22.4926 24.9926 23.5 23.75 23.5H4.25C3.00736 23.5 2 22.4926 2 21.25V6.75ZM5.5 11.25C5.5 11.6642 5.83579 12 6.25 12C10.2577 12 13.505 15.2417 13.505 19.2476C13.505 19.6618 13.8408 19.9976 14.255 19.9976C14.6692 19.9976 15.005 19.6618 15.005 19.2476C15.005 14.4115 11.0843 10.5 6.25 10.5C5.83579 10.5 5.5 10.8358 5.5 11.25ZM6.25 13.9989C5.83579 13.9989 5.5 14.3347 5.5 14.7489C5.5 15.1631 5.83579 15.4989 6.25 15.4989C8.32036 15.4989 9.99872 17.1773 9.99872 19.2476C9.99872 19.6618 10.3345 19.9976 10.7487 19.9976C11.1629 19.9976 11.4987 19.6618 11.4987 19.2476C11.4987 16.3488 9.14879 13.9989 6.25 13.9989ZM6.75 19.9991C7.44036 19.9991 8 19.4394 8 18.7491C8 18.0587 7.44036 17.4991 6.75 17.4991C6.05964 17.4991 5.5 18.0587 5.5 18.7491C5.5 19.4394 6.05964 19.9991 6.75 19.9991Z" fill="#212121"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="mdi-cast-audio" width="24" height="24" viewBox="0 0 24 24"><path d="M2 11V13C7 13 11 17 11 22H13C13 15.9 8.1 11 2 11M20 2H10C8.9 2 8 2.9 8 4V10.5C9 11 9.9 11.7 10.7 12.4C11.6 11 13.2 10 15 10C17.8 10 20 12.2 20 15S17.8 20 15 20H14.8C14.9 20.7 15 21.3 15 22H20C21.1 22 22 21.1 22 20V4C22 2.9 21.1 2 20 2M15 8C13.9 8 13 7.1 13 6C13 4.9 13.9 4 15 4C16.1 4 17 4.9 17 6S16.1 8 15 8M15 18C14.8 18 14.5 18 14.3 17.9C13.8 16.4 13.1 15.1 12.2 13.9C12.6 12.8 13.7 11.9 15 11.9C16.7 11.9 18 13.2 18 14.9S16.7 18 15 18M2 15V17C4.8 17 7 19.2 7 22H9C9 18.1 5.9 15 2 15M2 19V22H5C5 20.3 3.7 19 2 19" /></svg>
|
||||
|
After Width: | Height: | Size: 830 B |
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>DLNA icon</title><path d="M23.254,12.667H13.019c-1.051,0-2.1,0.489-2.692,1.266v-0.017c-0.591,0.816-1.544,1.353-2.629,1.353 c-1.796,0-3.249-1.458-3.249-3.251c0-1.795,1.454-3.251,3.249-3.251c1.085,0,2.039,0.536,2.629,1.353v-0.013 c0.592,0.776,1.641,1.273,2.692,1.273h10.129c0.186-0.005,0.873-0.095,0.848-0.981c-0.884-5.086-5.879-8.987-11.923-8.987 c-3.721,0-7.047,1.48-9.262,3.803c-0.356,0.527,0.014,0.689,0.35,0.734h6.608c1.05,0,2.1-0.498,2.691-1.277V4.69 c0.591-0.817,1.546-1.355,2.63-1.355c1.795,0,3.249,1.456,3.249,3.251c0,1.793-1.454,3.252-3.249,3.252 c-1.084,0-2.039-0.537-2.63-1.355v0.019c-0.591-0.779-1.641-1.266-2.691-1.266h-7.22l0.028,0.003c0,0-1.068-0.06-1.719,0.859 C0.361,8.9,0,10.621,0,12.002c0,1.388,0.198,2.649,0.867,3.923c0.564,0.908,1.71,0.849,1.71,0.849L2.535,16.78h7.233 c1.05,0,2.1-0.49,2.691-1.268v0.019c0.591-0.817,1.546-1.355,2.63-1.355c1.795,0,3.249,1.458,3.249,3.251 c0,1.795-1.454,3.251-3.249,3.251c-1.084,0-2.039-0.537-2.63-1.354v0.018c-0.591-0.779-1.641-1.277-2.691-1.277H3.164 c-0.328,0.042-0.698,0.198-0.379,0.699c2.216,2.337,5.555,3.824,9.288,3.824c6.054,0,11.056-3.911,11.926-9.009 C23.995,12.866,23.51,12.702,23.254,12.667"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
Reference in new issue
Block a user