feat(cast): add position tracking and auto-advance for Chromecast

- Add cast_get_position command for Chromecast media position
- Add CastPositionInfo struct with position, duration, player_state, idle_reason
- Extend position polling to work with both DLNA and Chromecast
- Detect track end on Chromecast via IDLE state with FINISHED reason
- Unify polling logic for both protocols

cpvsc
This commit is contained in:
vicrodh committed 2026-01-26 10:41:35 -06:00
1 parent 5a2eba97d9
commit 1384a1d276
6 files changed
+142 -28

No files matched your search

+23 -1
View File
@@ -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,
+6 -1
View File
@@ -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]
+60 -1
View File
@@ -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(());
+1 -1
View File
@@ -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;
+1
View File
@@ -619,6 +619,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,