Skip to main content

jellytau_lib/player/
events.rs

1//! Player events for frontend communication via Tauri events.
2//!
3//! These events are emitted from the player backend to notify the frontend
4//! of playback state changes, position updates, etc.
5//!
6//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
7
8#[cfg(test)]
9use crate::utils::lock::MutexSafe;
10use log::error;
11use serde::{Deserialize, Serialize};
12use std::sync::Arc;
13use tauri::AppHandle;
14use tauri_specta::Event;
15
16use super::{MediaSessionType, SleepTimerMode};
17
18/// Events emitted by the player backend to the frontend via Tauri events.
19///
20/// These are distinct from `PlayerEvent` in state.rs, which handles internal
21/// state machine transitions.
22///
23/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
24#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
25// NOTE: fields are intentionally snake_case on the wire. specta generates the
26// TypeScript bindings with snake_case field names (it does not apply serde's
27// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
28// serde emit camelCase payloads that no longer match the generated schema —
29// tauri-specta then silently drops those events (e.g. state_changed,
30// queue_changed never reach the frontend, so the mini player never appears).
31// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
32#[serde(tag = "type", rename_all = "snake_case")]
33// `ShowNextEpisodePopup` carries two `MediaItem`s, so it dwarfs the small
34// position/state variants. Boxing them is rejected deliberately: this is a
35// serde + specta wire type whose generated TypeScript must not shift, and the
36// events are emitted a few times a second at most — never bulk-allocated — so
37// the size difference costs nothing measurable.
38#[allow(clippy::large_enum_variant)]
39pub enum PlayerStatusEvent {
40    /// Playback position updated (emitted periodically during playback)
41    PositionUpdate {
42        /// Current position in seconds
43        position: f64,
44        /// Total duration in seconds
45        duration: f64,
46    },
47    /// Player state changed
48    StateChanged {
49        /// New state: "playing", "paused", "stopped", "loading", "idle"
50        state: String,
51        /// ID of the current media item, if any
52        media_id: Option<String>,
53    },
54    /// Media has finished loading and is ready to play
55    MediaLoaded {
56        /// Total duration in seconds
57        duration: f64,
58    },
59    /// Playback has ended naturally (reached end of media)
60    PlaybackEnded,
61    /// Buffering state changed
62    Buffering {
63        /// Buffering progress (0-100)
64        percent: u8,
65    },
66    /// An error occurred during playback
67    Error {
68        /// Error message
69        message: String,
70        /// Whether the error is recoverable
71        recoverable: bool,
72    },
73    /// Volume changed
74    VolumeChanged {
75        /// New volume level (0.0-1.0)
76        volume: f32,
77        /// Whether audio is muted
78        muted: bool,
79    },
80    /// Sleep timer state changed
81    SleepTimerChanged {
82        /// Sleep timer mode
83        mode: SleepTimerMode,
84        /// Remaining seconds (for time-based timer)
85        remaining_seconds: u32,
86    },
87    /// Time-based sleep timer expired: playback must stop. The backend stops
88    /// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
89    /// webview outside the backend's control — the frontend pauses it on this
90    /// event.
91    SleepTimerExpired,
92    /// Show next episode popup with countdown
93    ShowNextEpisodePopup {
94        /// Current episode that just finished
95        current_episode: crate::repository::types::MediaItem,
96        /// Next episode to play
97        next_episode: crate::repository::types::MediaItem,
98        /// Countdown duration in seconds
99        countdown_seconds: u32,
100        /// Whether to auto-advance when countdown reaches 0
101        auto_advance: bool,
102    },
103    /// Countdown tick (emitted every second during autoplay countdown)
104    CountdownTick {
105        /// Remaining seconds in countdown
106        remaining_seconds: u32,
107    },
108    /// Queue changed (items added, removed, reordered, or playback mode changed)
109    QueueChanged {
110        /// All items in the queue
111        items: Vec<crate::player::media::MediaItem>,
112        /// Current item index
113        current_index: Option<usize>,
114        /// Whether shuffle is enabled
115        shuffle: bool,
116        /// Current repeat mode
117        repeat: crate::player::queue::RepeatMode,
118        /// Whether there's a next track available
119        has_next: bool,
120        /// Whether there's a previous track available
121        has_previous: bool,
122    },
123    /// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
124    SessionChanged {
125        /// Current session state
126        session: MediaSessionType,
127    },
128    /// Remote sessions updated (for cast/remote control UI)
129    SessionsUpdated {
130        /// All active controllable sessions from Jellyfin
131        sessions: Vec<crate::jellyfin::client::SessionInfo>,
132    },
133    /// The authoritative playback mode changed in the Rust backend.
134    ///
135    /// The Rust `PlaybackModeManager` is the single source of truth for which
136    /// device playback commands route to (local vs a remote session). The
137    /// frontend keeps a mirror store for the UI; without this event that mirror
138    /// drifts out of sync (e.g. a mode transition happens inside a transfer or a
139    /// local stop that the frontend never learns about), and controls then route
140    /// to the wrong device — the classic "it keeps playing on the remote" bug.
141    /// The frontend reconciles its store to this payload whenever it fires.
142    PlaybackModeChanged {
143        /// New mode: "local", "remote", or "idle".
144        mode: String,
145        /// Session id when `mode == "remote"`, otherwise `None`.
146        session_id: Option<String>,
147    },
148    /// The user asked to disconnect from the remote session and resume locally.
149    ///
150    /// Emitted when the lockscreen Stop button is pressed while casting. The
151    /// frontend owns the two-step remote->local transfer (it must reload the
152    /// media item locally), so the native side only signals intent here.
153    RemoteDisconnectRequested,
154    /// Backend-originated control command targeting the active frontend player
155    /// adapter (the HTML5 <video> that lives in the webview, which Rust cannot
156    /// drive directly). Emitted by control paths like the sleep timer, lockscreen,
157    /// or remote so they can pause/play/seek/stop the webview element.
158    /// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
159    ControlCommand {
160        /// One of: "play", "pause", "stop", "seek".
161        action: String,
162        /// Target position in seconds (only meaningful for "seek").
163        position: Option<f64>,
164    },
165    /// Ask the frontend webview `<audio>` element to load and play a stream.
166    ///
167    /// Emitted by `WebviewAudioBackend` on platforms with no native audio
168    /// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
169    /// element in the webview, mirroring how all video already renders through
170    /// the webview `<video>`. The element then reports its state/position back
171    /// through the `player_report_*` commands, so the Rust controller stays the
172    /// single source of truth. Subsequent play/pause/seek/stop reach the element
173    /// via `ControlCommand`.
174    WebviewAudioLoad {
175        /// Stream URL for the `<audio>` element to play.
176        url: String,
177        /// Jellyfin item id, used as the media_id when reporting state back.
178        media_id: Option<String>,
179        /// Resume position in seconds (0 = start from the beginning).
180        position: f64,
181        /// Whether to begin playing immediately after loading.
182        autoplay: bool,
183    },
184}
185
186/// Trait for emitting player events to the frontend.
187///
188/// This abstraction allows backends to emit events without depending
189/// directly on Tauri, making them easier to test.
190pub trait PlayerEventEmitter: Send + Sync {
191    /// Emit a player status event to the frontend
192    fn emit(&self, event: PlayerStatusEvent);
193}
194
195/// Tauri-based implementation of PlayerEventEmitter.
196///
197/// Uses Tauri's `AppHandle::emit()` to broadcast events to all windows.
198pub struct TauriEventEmitter {
199    app_handle: AppHandle,
200}
201
202impl TauriEventEmitter {
203    /// Create a new TauriEventEmitter with the given app handle.
204    pub fn new(app_handle: AppHandle) -> Self {
205        Self { app_handle }
206    }
207}
208
209impl PlayerEventEmitter for TauriEventEmitter {
210    fn emit(&self, event: PlayerStatusEvent) {
211        // Emitted via the tauri-specta Event trait so the payload shape and event
212        // name match the generated TypeScript bindings (events.playerStatusEvent).
213        if let Err(e) = Event::emit(&event, &self.app_handle) {
214            error!("Failed to emit player event: {}", e);
215        }
216    }
217}
218
219/// Thread-safe wrapper for event emitters.
220#[allow(dead_code)]
221pub type SharedEventEmitter = Arc<dyn PlayerEventEmitter>;
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::sync::Mutex;
227    use std::thread;
228
229    /// Test event emitter that captures events for verification
230    pub struct TestEventEmitter {
231        events: Mutex<Vec<PlayerStatusEvent>>,
232    }
233
234    impl TestEventEmitter {
235        pub fn new() -> Self {
236            Self {
237                events: Mutex::new(Vec::new()),
238            }
239        }
240
241        pub fn events(&self) -> Vec<PlayerStatusEvent> {
242            self.events.lock_safe().clone()
243        }
244    }
245
246    impl PlayerEventEmitter for TestEventEmitter {
247        fn emit(&self, event: PlayerStatusEvent) {
248            self.events.lock_safe().push(event);
249        }
250    }
251
252    #[test]
253    fn test_position_update_serialization() {
254        let event = PlayerStatusEvent::PositionUpdate {
255            position: 30.5,
256            duration: 180.0,
257        };
258        let json = serde_json::to_string(&event).unwrap();
259        assert!(json.contains("position_update"));
260        assert!(json.contains("30.5"));
261        assert!(json.contains("180"));
262    }
263
264    #[test]
265    fn test_state_changed_serialization() {
266        let event = PlayerStatusEvent::StateChanged {
267            state: "playing".to_string(),
268            media_id: Some("test-id-123".to_string()),
269        };
270        let json = serde_json::to_string(&event).unwrap();
271        assert!(json.contains("state_changed"));
272        assert!(json.contains("playing"));
273        assert!(json.contains("test-id-123"));
274    }
275
276    #[test]
277    fn test_state_changed_no_media_id() {
278        let event = PlayerStatusEvent::StateChanged {
279            state: "idle".to_string(),
280            media_id: None,
281        };
282        let json = serde_json::to_string(&event).unwrap();
283        assert!(json.contains("state_changed"));
284        assert!(json.contains("idle"));
285        assert!(json.contains("null"));
286    }
287
288    #[test]
289    fn test_media_loaded_serialization() {
290        let event = PlayerStatusEvent::MediaLoaded { duration: 245.5 };
291        let json = serde_json::to_string(&event).unwrap();
292        assert!(json.contains("media_loaded"));
293        assert!(json.contains("245.5"));
294    }
295
296    #[test]
297    fn test_playback_ended_serialization() {
298        let event = PlayerStatusEvent::PlaybackEnded;
299        let json = serde_json::to_string(&event).unwrap();
300        assert!(json.contains("playback_ended"));
301    }
302
303    #[test]
304    fn test_buffering_serialization() {
305        let event = PlayerStatusEvent::Buffering { percent: 75 };
306        let json = serde_json::to_string(&event).unwrap();
307        assert!(json.contains("buffering"));
308        assert!(json.contains("75"));
309    }
310
311    #[test]
312    fn test_error_serialization() {
313        let event = PlayerStatusEvent::Error {
314            message: "Failed to load media".to_string(),
315            recoverable: true,
316        };
317        let json = serde_json::to_string(&event).unwrap();
318        assert!(json.contains("error"));
319        assert!(json.contains("Failed to load media"));
320        assert!(json.contains("true"));
321    }
322
323    #[test]
324    fn test_volume_changed_serialization() {
325        let event = PlayerStatusEvent::VolumeChanged {
326            volume: 0.75,
327            muted: false,
328        };
329        let json = serde_json::to_string(&event).unwrap();
330        assert!(json.contains("volume_changed"));
331        assert!(json.contains("0.75"));
332        assert!(json.contains("false"));
333    }
334
335    #[test]
336    fn test_event_emitter_captures_events() {
337        let emitter = TestEventEmitter::new();
338        emitter.emit(PlayerStatusEvent::PlaybackEnded);
339        assert_eq!(emitter.events().len(), 1);
340    }
341
342    #[test]
343    fn test_event_emitter_multiple_events() {
344        let emitter = TestEventEmitter::new();
345        emitter.emit(PlayerStatusEvent::PlaybackEnded);
346        emitter.emit(PlayerStatusEvent::PositionUpdate {
347            position: 10.0,
348            duration: 100.0,
349        });
350        emitter.emit(PlayerStatusEvent::StateChanged {
351            state: "paused".to_string(),
352            media_id: None,
353        });
354        assert_eq!(emitter.events().len(), 3);
355    }
356
357    #[test]
358    fn test_event_emitter_thread_safety() {
359        let emitter = Arc::new(TestEventEmitter::new());
360        let mut handles = vec![];
361
362        for i in 0..10 {
363            let emitter_clone = Arc::clone(&emitter);
364            let handle = thread::spawn(move || {
365                emitter_clone.emit(PlayerStatusEvent::PositionUpdate {
366                    position: i as f64,
367                    duration: 100.0,
368                });
369            });
370            handles.push(handle);
371        }
372
373        for handle in handles {
374            handle.join().unwrap();
375        }
376
377        assert_eq!(emitter.events().len(), 10);
378    }
379
380    #[test]
381    fn test_shared_event_emitter() {
382        let emitter: SharedEventEmitter = Arc::new(TestEventEmitter::new());
383        emitter.emit(PlayerStatusEvent::PlaybackEnded);
384        // Verify it compiles and works as a trait object
385    }
386}