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::device::CastDeviceConnection;
use crate::cast::errors::CastError; use crate::cast::errors::CastError;
use crate::cast::{CastStatus, MediaMetadata}; use crate::cast::{CastStatus, MediaMetadata, CastPositionInfo};
/// Commands sent to the Chromecast thread /// Commands sent to the Chromecast thread
pub enum CastCommand { pub enum CastCommand {
@@ -24,6 +24,9 @@ pub enum CastCommand {
GetStatus { GetStatus {
reply: Sender<Result<CastStatus, CastError>>, reply: Sender<Result<CastStatus, CastError>>,
}, },
GetMediaPosition {
reply: Sender<Result<CastPositionInfo, CastError>>,
},
LoadMedia { LoadMedia {
url: String, url: String,
content_type: String, content_type: String,
@@ -188,6 +191,17 @@ impl ChromecastHandle {
.recv() .recv()
.map_err(|_| CastError::Connection("Thread response error".to_string()))? .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 { impl Drop for ChromecastHandle {
@@ -248,6 +262,14 @@ fn chromecast_thread_main(receiver: Receiver<CastCommand>) {
let _ = reply.send(result); 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 { CastCommand::LoadMedia {
url, url,
content_type, content_type,
+6 -1
View File
@@ -7,7 +7,7 @@ use tokio::sync::Mutex;
use crate::api::models::Quality; use crate::api::models::Quality;
use crate::AppState; use crate::AppState;
use crate::cast::{ use crate::cast::{
CastError, CastStatus, DeviceDiscovery, DiscoveredDevice, MediaMetadata, MediaServer, CastError, CastStatus, CastPositionInfo, DeviceDiscovery, DiscoveredDevice, MediaMetadata, MediaServer,
}; };
use crate::cast::chromecast_thread::ChromecastHandle; use crate::cast::chromecast_thread::ChromecastHandle;
use crate::library::{AudioFormat, LibraryState}; 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()) 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 === // === Playback ===
#[tauri::command] #[tauri::command]
+60 -1
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use rust_cast::CastDevice; 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 rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus};
use crate::cast::CastError; use crate::cast::CastError;
@@ -20,6 +20,15 @@ pub struct MediaMetadata {
pub duration_secs: Option<u64>, 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 /// Device status for frontend
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct CastStatus { pub struct CastStatus {
@@ -208,6 +217,56 @@ impl CastDeviceConnection {
Ok(()) 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> { fn ensure_session(&mut self) -> Result<(), CastError> {
if self.session.is_some() { if self.session.is_some() {
return Ok(()); return Ok(());
+1 -1
View File
@@ -10,7 +10,7 @@ pub mod airplay;
pub mod dlna; pub mod dlna;
pub use commands::CastState; pub use commands::CastState;
pub use device::{CastDeviceConnection, CastStatus, MediaMetadata}; pub use device::{CastDeviceConnection, CastStatus, MediaMetadata, CastPositionInfo};
pub use discovery::{DeviceDiscovery, DiscoveredDevice}; pub use discovery::{DeviceDiscovery, DiscoveredDevice};
pub use errors::CastError; pub use errors::CastError;
pub use media_server::MediaServer; pub use media_server::MediaServer;
+1
View File
@@ -619,6 +619,7 @@ pub fn run() {
cast::commands::cast_connect, cast::commands::cast_connect,
cast::commands::cast_disconnect, cast::commands::cast_disconnect,
cast::commands::cast_get_status, cast::commands::cast_get_status,
cast::commands::cast_get_position,
cast::commands::cast_play_track, cast::commands::cast_play_track,
cast::commands::cast_play_local_track, cast::commands::cast_play_local_track,
cast::commands::cast_play, cast::commands::cast_play,
+51 -24
View File
@@ -250,8 +250,8 @@ export async function castTrack(
}; };
notifyListeners(); notifyListeners();
// Start position polling for DLNA // Start position polling for DLNA and Chromecast
if (state.protocol === 'dlna') { if (state.protocol === 'dlna' || state.protocol === 'chromecast') {
startPositionPolling(); startPositionPolling();
} }
} catch (err) { } catch (err) {
@@ -401,50 +401,77 @@ export function setOnCastTrackEnded(callback: (() => Promise<void>) | null): voi
} }
/** /**
* Start polling for DLNA position updates * Start polling for cast position updates (DLNA and Chromecast)
*/ */
export function startPositionPolling(): void { export function startPositionPolling(): void {
if (positionPollInterval) return; if (positionPollInterval) return;
if (state.protocol !== 'dlna') return; if (state.protocol !== 'dlna' && state.protocol !== 'chromecast') return;
console.log('[CastStore] Starting DLNA position polling'); console.log(`[CastStore] Starting ${state.protocol?.toUpperCase()} position polling`);
trackEndDetected = false; trackEndDetected = false;
lastTransportState = 'PLAYING'; lastTransportState = 'PLAYING';
positionPollInterval = setInterval(async () => { positionPollInterval = setInterval(async () => {
if (!state.isConnected || state.protocol !== 'dlna') { if (!state.isConnected || (state.protocol !== 'dlna' && state.protocol !== 'chromecast')) {
stopPositionPolling(); stopPositionPolling();
return; return;
} }
try { try {
const positionInfo = await invoke<{ let positionSecs = 0;
position_secs: number; let durationSecs = 0;
duration_secs: number; let transportState = 'PLAYING';
transport_state: string; let idleReason: string | null = null;
}>('dlna_get_position');
const wasPlaying = state.isPlaying; if (state.protocol === 'dlna') {
const isNowPlaying = positionInfo.transport_state === 'PLAYING'; const positionInfo = await invoke<{
const transportState = positionInfo.transport_state; 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 = {
...state, ...state,
positionSecs: positionInfo.position_secs, positionSecs,
durationSecs: positionInfo.duration_secs, durationSecs,
isPlaying: isNowPlaying isPlaying: isNowPlaying
}; };
notifyListeners(); notifyListeners();
// Detect track ended: was PLAYING, now STOPPED, and position is near end or reset to 0 // Detect track ended based on protocol
const isNearEnd = positionInfo.duration_secs > 0 && let trackEnded = false;
(positionInfo.position_secs >= positionInfo.duration_secs - 2 || positionInfo.position_secs === 0);
if (lastTransportState === 'PLAYING' && if (state.protocol === 'dlna') {
(transportState === 'STOPPED' || transportState === 'NO_MEDIA_PRESENT') && // DLNA: was PLAYING, now STOPPED or NO_MEDIA_PRESENT
!trackEndDetected) { trackEnded = lastTransportState === 'PLAYING' &&
console.log('[CastStore] DLNA track ended, transport:', transportState, 'position:', positionInfo.position_secs); (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; trackEndDetected = true;
if (onCastTrackEnded) { if (onCastTrackEnded) {
@@ -473,7 +500,7 @@ export function startPositionPolling(): void {
*/ */
export function stopPositionPolling(): void { export function stopPositionPolling(): void {
if (positionPollInterval) { if (positionPollInterval) {
console.log('[CastStore] Stopping DLNA position polling'); console.log('[CastStore] Stopping position polling');
clearInterval(positionPollInterval); clearInterval(positionPollInterval);
positionPollInterval = null; positionPollInterval = null;
} }