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}