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}