//! Player events for frontend communication via Tauri events. //! //! These events are emitted from the player backend to notify the frontend //! of playback state changes, position updates, etc. //! //! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 #[cfg(test)] use crate::utils::lock::MutexSafe; use log::error; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::AppHandle; use tauri_specta::Event; use super::{MediaSessionType, SleepTimerMode}; /// Events emitted by the player backend to the frontend via Tauri events. /// /// These are distinct from `PlayerEvent` in state.rs, which handles internal /// state machine transitions. /// /// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)] // NOTE: fields are intentionally snake_case on the wire. specta generates the // TypeScript bindings with snake_case field names (it does not apply serde's // `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes // serde emit camelCase payloads that no longer match the generated schema — // tauri-specta then silently drops those events (e.g. state_changed, // queue_changed never reach the frontend, so the mini player never appears). // Keep serde and specta agreeing: snake_case fields, snake_case variant tags. #[serde(tag = "type", rename_all = "snake_case")] pub enum PlayerStatusEvent { /// Playback position updated (emitted periodically during playback) PositionUpdate { /// Current position in seconds position: f64, /// Total duration in seconds duration: f64, }, /// Player state changed StateChanged { /// New state: "playing", "paused", "stopped", "loading", "idle" state: String, /// ID of the current media item, if any media_id: Option, }, /// Media has finished loading and is ready to play MediaLoaded { /// Total duration in seconds duration: f64, }, /// Playback has ended naturally (reached end of media) PlaybackEnded, /// Buffering state changed Buffering { /// Buffering progress (0-100) percent: u8, }, /// An error occurred during playback Error { /// Error message message: String, /// Whether the error is recoverable recoverable: bool, }, /// Volume changed VolumeChanged { /// New volume level (0.0-1.0) volume: f32, /// Whether audio is muted muted: bool, }, /// Sleep timer state changed SleepTimerChanged { /// Sleep timer mode mode: SleepTimerMode, /// Remaining seconds (for time-based timer) remaining_seconds: u32, }, /// Time-based sleep timer expired: playback must stop. The backend stops /// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the /// webview outside the backend's control — the frontend pauses it on this /// event. SleepTimerExpired, /// Show next episode popup with countdown ShowNextEpisodePopup { /// Current episode that just finished current_episode: crate::repository::types::MediaItem, /// Next episode to play next_episode: crate::repository::types::MediaItem, /// Countdown duration in seconds countdown_seconds: u32, /// Whether to auto-advance when countdown reaches 0 auto_advance: bool, }, /// Countdown tick (emitted every second during autoplay countdown) CountdownTick { /// Remaining seconds in countdown remaining_seconds: u32, }, /// Queue changed (items added, removed, reordered, or playback mode changed) QueueChanged { /// All items in the queue items: Vec, /// Current item index current_index: Option, /// Whether shuffle is enabled shuffle: bool, /// Current repeat mode repeat: crate::player::queue::RepeatMode, /// Whether there's a next track available has_next: bool, /// Whether there's a previous track available has_previous: bool, }, /// Media session changed (activity context changed: Audio/Movie/TvShow/Idle) SessionChanged { /// Current session state session: MediaSessionType, }, /// Remote sessions updated (for cast/remote control UI) SessionsUpdated { /// All active controllable sessions from Jellyfin sessions: Vec, }, /// The user asked to disconnect from the remote session and resume locally. /// /// Emitted when the lockscreen Stop button is pressed while casting. The /// frontend owns the two-step remote->local transfer (it must reload the /// media item locally), so the native side only signals intent here. RemoteDisconnectRequested, } /// Trait for emitting player events to the frontend. /// /// This abstraction allows backends to emit events without depending /// directly on Tauri, making them easier to test. pub trait PlayerEventEmitter: Send + Sync { /// Emit a player status event to the frontend fn emit(&self, event: PlayerStatusEvent); } /// Tauri-based implementation of PlayerEventEmitter. /// /// Uses Tauri's `AppHandle::emit()` to broadcast events to all windows. pub struct TauriEventEmitter { app_handle: AppHandle, } impl TauriEventEmitter { /// Create a new TauriEventEmitter with the given app handle. pub fn new(app_handle: AppHandle) -> Self { Self { app_handle } } } impl PlayerEventEmitter for TauriEventEmitter { fn emit(&self, event: PlayerStatusEvent) { // Emitted via the tauri-specta Event trait so the payload shape and event // name match the generated TypeScript bindings (events.playerStatusEvent). if let Err(e) = Event::emit(&event, &self.app_handle) { error!("Failed to emit player event: {}", e); } } } /// Thread-safe wrapper for event emitters. #[allow(dead_code)] pub type SharedEventEmitter = Arc; #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; use std::thread; /// Test event emitter that captures events for verification pub struct TestEventEmitter { events: Mutex>, } impl TestEventEmitter { pub fn new() -> Self { Self { events: Mutex::new(Vec::new()), } } pub fn events(&self) -> Vec { self.events.lock_safe().clone() } } impl PlayerEventEmitter for TestEventEmitter { fn emit(&self, event: PlayerStatusEvent) { self.events.lock_safe().push(event); } } #[test] fn test_position_update_serialization() { let event = PlayerStatusEvent::PositionUpdate { position: 30.5, duration: 180.0, }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("position_update")); assert!(json.contains("30.5")); assert!(json.contains("180")); } #[test] fn test_state_changed_serialization() { let event = PlayerStatusEvent::StateChanged { state: "playing".to_string(), media_id: Some("test-id-123".to_string()), }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("state_changed")); assert!(json.contains("playing")); assert!(json.contains("test-id-123")); } #[test] fn test_state_changed_no_media_id() { let event = PlayerStatusEvent::StateChanged { state: "idle".to_string(), media_id: None, }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("state_changed")); assert!(json.contains("idle")); assert!(json.contains("null")); } #[test] fn test_media_loaded_serialization() { let event = PlayerStatusEvent::MediaLoaded { duration: 245.5 }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("media_loaded")); assert!(json.contains("245.5")); } #[test] fn test_playback_ended_serialization() { let event = PlayerStatusEvent::PlaybackEnded; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("playback_ended")); } #[test] fn test_buffering_serialization() { let event = PlayerStatusEvent::Buffering { percent: 75 }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("buffering")); assert!(json.contains("75")); } #[test] fn test_error_serialization() { let event = PlayerStatusEvent::Error { message: "Failed to load media".to_string(), recoverable: true, }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("error")); assert!(json.contains("Failed to load media")); assert!(json.contains("true")); } #[test] fn test_volume_changed_serialization() { let event = PlayerStatusEvent::VolumeChanged { volume: 0.75, muted: false, }; let json = serde_json::to_string(&event).unwrap(); assert!(json.contains("volume_changed")); assert!(json.contains("0.75")); assert!(json.contains("false")); } #[test] fn test_event_emitter_captures_events() { let emitter = TestEventEmitter::new(); emitter.emit(PlayerStatusEvent::PlaybackEnded); assert_eq!(emitter.events().len(), 1); } #[test] fn test_event_emitter_multiple_events() { let emitter = TestEventEmitter::new(); emitter.emit(PlayerStatusEvent::PlaybackEnded); emitter.emit(PlayerStatusEvent::PositionUpdate { position: 10.0, duration: 100.0, }); emitter.emit(PlayerStatusEvent::StateChanged { state: "paused".to_string(), media_id: None, }); assert_eq!(emitter.events().len(), 3); } #[test] fn test_event_emitter_thread_safety() { let emitter = Arc::new(TestEventEmitter::new()); let mut handles = vec![]; for i in 0..10 { let emitter_clone = Arc::clone(&emitter); let handle = thread::spawn(move || { emitter_clone.emit(PlayerStatusEvent::PositionUpdate { position: i as f64, duration: 100.0, }); }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } assert_eq!(emitter.events().len(), 10); } #[test] fn test_shared_event_emitter() { let emitter: SharedEventEmitter = Arc::new(TestEventEmitter::new()); emitter.emit(PlayerStatusEvent::PlaybackEnded); // Verify it compiles and works as a trait object } }