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,
+51 -24
View File
@@ -250,8 +250,8 @@ export async function castTrack(
};
notifyListeners();
// Start position polling for DLNA
if (state.protocol === 'dlna') {
// Start position polling for DLNA and Chromecast
if (state.protocol === 'dlna' || state.protocol === 'chromecast') {
startPositionPolling();
}
} 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 {
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;
lastTransportState = 'PLAYING';
positionPollInterval = setInterval(async () => {
if (!state.isConnected || state.protocol !== 'dlna') {
if (!state.isConnected || (state.protocol !== 'dlna' && state.protocol !== 'chromecast')) {
stopPositionPolling();
return;
}
try {
const positionInfo = await invoke<{
position_secs: number;
duration_secs: number;
transport_state: string;
}>('dlna_get_position');
let positionSecs = 0;
let durationSecs = 0;
let transportState = 'PLAYING';
let idleReason: string | null = null;
const wasPlaying = state.isPlaying;
const isNowPlaying = positionInfo.transport_state === 'PLAYING';
const transportState = positionInfo.transport_state;
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: positionInfo.position_secs,
durationSecs: positionInfo.duration_secs,
positionSecs,
durationSecs,
isPlaying: isNowPlaying
};
notifyListeners();
// Detect track ended: was PLAYING, now STOPPED, and position is near end or reset to 0
const isNearEnd = positionInfo.duration_secs > 0 &&
(positionInfo.position_secs >= positionInfo.duration_secs - 2 || positionInfo.position_secs === 0);
// Detect track ended based on protocol
let trackEnded = false;
if (lastTransportState === 'PLAYING' &&
(transportState === 'STOPPED' || transportState === 'NO_MEDIA_PRESENT') &&
!trackEndDetected) {
console.log('[CastStore] DLNA track ended, transport:', transportState, 'position:', positionInfo.position_secs);
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) {
@@ -473,7 +500,7 @@ export function startPositionPolling(): void {
*/
export function stopPositionPolling(): void {
if (positionPollInterval) {
console.log('[CastStore] Stopping DLNA position polling');
console.log('[CastStore] Stopping position polling');
clearInterval(positionPollInterval);
positionPollInterval = null;
}