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}