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:
1 parent
5a2eba97d9
commit
1384a1d276
6 files changed
+142
-28
No files matched your search
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user