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; the frontend pauses the active adapter
89    /// on this event, which reaches a webview `<audio>` element where one plays.
90    SleepTimerExpired,
91    /// Show next episode popup with countdown
92    ShowNextEpisodePopup {
93        /// Current episode that just finished
94        current_episode: crate::repository::types::MediaItem,
95        /// Next episode to play
96        next_episode: crate::repository::types::MediaItem,
97        /// Countdown duration in seconds
98        countdown_seconds: u32,
99        /// Whether to auto-advance when countdown reaches 0
100        auto_advance: bool,
101    },
102    /// Countdown tick (emitted every second during autoplay countdown)
103    CountdownTick {
104        /// Remaining seconds in countdown
105        remaining_seconds: u32,
106    },
107    /// Queue changed (items added, removed, reordered, or playback mode changed)
108    QueueChanged {
109        /// All items in the queue
110        items: Vec<crate::player::media::MediaItem>,
111        /// Current item index
112        current_index: Option<usize>,
113        /// Whether shuffle is enabled
114        shuffle: bool,
115        /// Current repeat mode
116        repeat: crate::player::queue::RepeatMode,
117        /// Whether there's a next track available
118        has_next: bool,
119        /// Whether there's a previous track available
120        has_previous: bool,
121    },
122    /// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
123    SessionChanged {
124        /// Current session state
125        session: MediaSessionType,
126    },
127    /// Remote sessions updated (for cast/remote control UI)
128    SessionsUpdated {
129        /// All active controllable sessions from Jellyfin
130        sessions: Vec<crate::jellyfin::client::SessionInfo>,
131    },
132    /// The authoritative playback mode changed in the Rust backend.
133    ///
134    /// The Rust `PlaybackModeManager` is the single source of truth for which
135    /// device playback commands route to (local vs a remote session). The
136    /// frontend keeps a mirror store for the UI; without this event that mirror
137    /// drifts out of sync (e.g. a mode transition happens inside a transfer or a
138    /// local stop that the frontend never learns about), and controls then route
139    /// to the wrong device — the classic "it keeps playing on the remote" bug.
140    /// The frontend reconciles its store to this payload whenever it fires.
141    PlaybackModeChanged {
142        /// New mode: "local", "remote", or "idle".
143        mode: String,
144        /// Session id when `mode == "remote"`, otherwise `None`.
145        session_id: Option<String>,
146    },
147    /// The user asked to disconnect from the remote session and resume locally.
148    ///
149    /// Emitted when the lockscreen Stop button is pressed while casting. The
150    /// frontend owns the two-step remote->local transfer (it must reload the
151    /// media item locally), so the native side only signals intent here.
152    RemoteDisconnectRequested,
153    /// Backend-originated control command targeting the active frontend player
154    /// adapter — the webview `<audio>` element, which Rust cannot drive
155    /// directly. Emitted by control paths like the sleep timer, lockscreen, or
156    /// remote so they can pause/play/seek/stop it.
157    /// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
158    ControlCommand {
159        /// One of: "play", "pause", "stop", "seek".
160        action: String,
161        /// Target position in seconds (only meaningful for "seek").
162        position: Option<f64>,
163    },
164    /// Ask the frontend webview `<audio>` element to load and play a stream.
165    ///
166    /// Emitted by `WebviewAudioBackend` on a desktop with no native audio
167    /// backend (none that ships: Linux and Windows have mpv): audio-only
168    /// playback is rendered by an `<audio>` element in the webview. The element
169    /// then reports its state/position back
170    /// through the `player_report_*` commands, so the Rust controller stays the
171    /// single source of truth. Subsequent play/pause/seek/stop reach the element
172    /// via `ControlCommand`.
173    WebviewAudioLoad {
174        /// Stream URL for the `<audio>` element to play.
175        url: String,
176        /// Jellyfin item id, used as the media_id when reporting state back.
177        media_id: Option<String>,
178        /// Resume position in seconds (0 = start from the beginning).
179        position: f64,
180        /// Whether to begin playing immediately after loading.
181        autoplay: bool,
182    },
183}
184
185/// Trait for emitting player events to the frontend.
186///
187/// This abstraction allows backends to emit events without depending
188/// directly on Tauri, making them easier to test.
189pub trait PlayerEventEmitter: Send + Sync {
190    /// Emit a player status event to the frontend
191    fn emit(&self, event: PlayerStatusEvent);
192}
193
194/// Tauri-based implementation of PlayerEventEmitter.
195///
196/// Uses Tauri's `AppHandle::emit()` to broadcast events to all windows.
197pub struct TauriEventEmitter {
198    app_handle: AppHandle,
199}
200
201impl TauriEventEmitter {
202    /// Create a new TauriEventEmitter with the given app handle.
203    pub fn new(app_handle: AppHandle) -> Self {
204        Self { app_handle }
205    }
206}
207
208impl PlayerEventEmitter for TauriEventEmitter {
209    fn emit(&self, event: PlayerStatusEvent) {
210        // Emitted via the tauri-specta Event trait so the payload shape and event
211        // name match the generated TypeScript bindings (events.playerStatusEvent).
212        if let Err(e) = Event::emit(&event, &self.app_handle) {
213            error!("Failed to emit player event: {}", e);
214        }
215    }
216}
217
218/// Thread-safe wrapper for event emitters.
219#[allow(dead_code)]
220pub type SharedEventEmitter = Arc<dyn PlayerEventEmitter>;
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::sync::Mutex;
226    use std::thread;
227
228    /// Test event emitter that captures events for verification
229    pub struct TestEventEmitter {
230        events: Mutex<Vec<PlayerStatusEvent>>,
231    }
232
233    impl TestEventEmitter {
234        pub fn new() -> Self {
235            Self {
236                events: Mutex::new(Vec::new()),
237            }
238        }
239
240        pub fn events(&self) -> Vec<PlayerStatusEvent> {
241            self.events.lock_safe().clone()
242        }
243    }
244
245    impl PlayerEventEmitter for TestEventEmitter {
246        fn emit(&self, event: PlayerStatusEvent) {
247            self.events.lock_safe().push(event);
248        }
249    }
250
251    #[test]
252    fn test_position_update_serialization() {
253        let event = PlayerStatusEvent::PositionUpdate {
254            position: 30.5,
255            duration: 180.0,
256        };
257        let json = serde_json::to_string(&event).unwrap();
258        assert!(json.contains("position_update"));
259        assert!(json.contains("30.5"));
260        assert!(json.contains("180"));
261    }
262
263    #[test]
264    fn test_state_changed_serialization() {
265        let event = PlayerStatusEvent::StateChanged {
266            state: "playing".to_string(),
267            media_id: Some("test-id-123".to_string()),
268        };
269        let json = serde_json::to_string(&event).unwrap();
270        assert!(json.contains("state_changed"));
271        assert!(json.contains("playing"));
272        assert!(json.contains("test-id-123"));
273    }
274
275    #[test]
276    fn test_state_changed_no_media_id() {
277        let event = PlayerStatusEvent::StateChanged {
278            state: "idle".to_string(),
279            media_id: None,
280        };
281        let json = serde_json::to_string(&event).unwrap();
282        assert!(json.contains("state_changed"));
283        assert!(json.contains("idle"));
284        assert!(json.contains("null"));
285    }
286
287    #[test]
288    fn test_media_loaded_serialization() {
289        let event = PlayerStatusEvent::MediaLoaded { duration: 245.5 };
290        let json = serde_json::to_string(&event).unwrap();
291        assert!(json.contains("media_loaded"));
292        assert!(json.contains("245.5"));
293    }
294
295    #[test]
296    fn test_playback_ended_serialization() {
297        let event = PlayerStatusEvent::PlaybackEnded;
298        let json = serde_json::to_string(&event).unwrap();
299        assert!(json.contains("playback_ended"));
300    }
301
302    #[test]
303    fn test_buffering_serialization() {
304        let event = PlayerStatusEvent::Buffering { percent: 75 };
305        let json = serde_json::to_string(&event).unwrap();
306        assert!(json.contains("buffering"));
307        assert!(json.contains("75"));
308    }
309
310    #[test]
311    fn test_error_serialization() {
312        let event = PlayerStatusEvent::Error {
313            message: "Failed to load media".to_string(),
314            recoverable: true,
315        };
316        let json = serde_json::to_string(&event).unwrap();
317        assert!(json.contains("error"));
318        assert!(json.contains("Failed to load media"));
319        assert!(json.contains("true"));
320    }
321
322    #[test]
323    fn test_volume_changed_serialization() {
324        let event = PlayerStatusEvent::VolumeChanged {
325            volume: 0.75,
326            muted: false,
327        };
328        let json = serde_json::to_string(&event).unwrap();
329        assert!(json.contains("volume_changed"));
330        assert!(json.contains("0.75"));
331        assert!(json.contains("false"));
332    }
333
334    #[test]
335    fn test_event_emitter_captures_events() {
336        let emitter = TestEventEmitter::new();
337        emitter.emit(PlayerStatusEvent::PlaybackEnded);
338        assert_eq!(emitter.events().len(), 1);
339    }
340
341    #[test]
342    fn test_event_emitter_multiple_events() {
343        let emitter = TestEventEmitter::new();
344        emitter.emit(PlayerStatusEvent::PlaybackEnded);
345        emitter.emit(PlayerStatusEvent::PositionUpdate {
346            position: 10.0,
347            duration: 100.0,
348        });
349        emitter.emit(PlayerStatusEvent::StateChanged {
350            state: "paused".to_string(),
351            media_id: None,
352        });
353        assert_eq!(emitter.events().len(), 3);
354    }
355
356    #[test]
357    fn test_event_emitter_thread_safety() {
358        let emitter = Arc::new(TestEventEmitter::new());
359        let mut handles = vec![];
360
361        for i in 0..10 {
362            let emitter_clone = Arc::clone(&emitter);
363            let handle = thread::spawn(move || {
364                emitter_clone.emit(PlayerStatusEvent::PositionUpdate {
365                    position: i as f64,
366                    duration: 100.0,
367                });
368            });
369            handles.push(handle);
370        }
371
372        for handle in handles {
373            handle.join().unwrap();
374        }
375
376        assert_eq!(emitter.events().len(), 10);
377    }
378
379    #[test]
380    fn test_shared_event_emitter() {
381        let emitter: SharedEventEmitter = Arc::new(TestEventEmitter::new());
382        emitter.emit(PlayerStatusEvent::PlaybackEnded);
383        // Verify it compiles and works as a trait object
384    }
385}