Duncan Tourolle 75014ee00f
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
Fix sleep bug, fix menu return
2026-07-01 23:49:51 +02:00

336 lines
11 KiB
Rust

//! Player events for frontend communication via Tauri events.
//!
//! These events are emitted from the player backend to notify the frontend
//! of playback state changes, position updates, etc.
//!
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use log::error;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::AppHandle;
use tauri_specta::Event;
use super::{MediaSessionType, SleepTimerMode};
/// Events emitted by the player backend to the frontend via Tauri events.
///
/// These are distinct from `PlayerEvent` in state.rs, which handles internal
/// state machine transitions.
///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
// NOTE: fields are intentionally snake_case on the wire. specta generates the
// TypeScript bindings with snake_case field names (it does not apply serde's
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
// serde emit camelCase payloads that no longer match the generated schema —
// tauri-specta then silently drops those events (e.g. state_changed,
// queue_changed never reach the frontend, so the mini player never appears).
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback)
PositionUpdate {
/// Current position in seconds
position: f64,
/// Total duration in seconds
duration: f64,
},
/// Player state changed
StateChanged {
/// New state: "playing", "paused", "stopped", "loading", "idle"
state: String,
/// ID of the current media item, if any
media_id: Option<String>,
},
/// Media has finished loading and is ready to play
MediaLoaded {
/// Total duration in seconds
duration: f64,
},
/// Playback has ended naturally (reached end of media)
PlaybackEnded,
/// Buffering state changed
Buffering {
/// Buffering progress (0-100)
percent: u8,
},
/// An error occurred during playback
Error {
/// Error message
message: String,
/// Whether the error is recoverable
recoverable: bool,
},
/// Volume changed
VolumeChanged {
/// New volume level (0.0-1.0)
volume: f32,
/// Whether audio is muted
muted: bool,
},
/// Sleep timer state changed
SleepTimerChanged {
/// Sleep timer mode
mode: SleepTimerMode,
/// Remaining seconds (for time-based timer)
remaining_seconds: u32,
},
/// Time-based sleep timer expired: playback must stop. The backend stops
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
/// webview outside the backend's control — the frontend pauses it on this
/// event.
SleepTimerExpired,
/// Show next episode popup with countdown
ShowNextEpisodePopup {
/// Current episode that just finished
current_episode: crate::repository::types::MediaItem,
/// Next episode to play
next_episode: crate::repository::types::MediaItem,
/// Countdown duration in seconds
countdown_seconds: u32,
/// Whether to auto-advance when countdown reaches 0
auto_advance: bool,
},
/// Countdown tick (emitted every second during autoplay countdown)
CountdownTick {
/// Remaining seconds in countdown
remaining_seconds: u32,
},
/// Queue changed (items added, removed, reordered, or playback mode changed)
QueueChanged {
/// All items in the queue
items: Vec<crate::player::media::MediaItem>,
/// Current item index
current_index: Option<usize>,
/// Whether shuffle is enabled
shuffle: bool,
/// Current repeat mode
repeat: crate::player::queue::RepeatMode,
/// Whether there's a next track available
has_next: bool,
/// Whether there's a previous track available
has_previous: bool,
},
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
SessionChanged {
/// Current session state
session: MediaSessionType,
},
/// Remote sessions updated (for cast/remote control UI)
SessionsUpdated {
/// All active controllable sessions from Jellyfin
sessions: Vec<crate::jellyfin::client::SessionInfo>,
},
/// The user asked to disconnect from the remote session and resume locally.
///
/// Emitted when the lockscreen Stop button is pressed while casting. The
/// frontend owns the two-step remote->local transfer (it must reload the
/// media item locally), so the native side only signals intent here.
RemoteDisconnectRequested,
}
/// Trait for emitting player events to the frontend.
///
/// This abstraction allows backends to emit events without depending
/// directly on Tauri, making them easier to test.
pub trait PlayerEventEmitter: Send + Sync {
/// Emit a player status event to the frontend
fn emit(&self, event: PlayerStatusEvent);
}
/// Tauri-based implementation of PlayerEventEmitter.
///
/// Uses Tauri's `AppHandle::emit()` to broadcast events to all windows.
pub struct TauriEventEmitter {
app_handle: AppHandle,
}
impl TauriEventEmitter {
/// Create a new TauriEventEmitter with the given app handle.
pub fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
}
impl PlayerEventEmitter for TauriEventEmitter {
fn emit(&self, event: PlayerStatusEvent) {
// Emitted via the tauri-specta Event trait so the payload shape and event
// name match the generated TypeScript bindings (events.playerStatusEvent).
if let Err(e) = Event::emit(&event, &self.app_handle) {
error!("Failed to emit player event: {}", e);
}
}
}
/// Thread-safe wrapper for event emitters.
#[allow(dead_code)]
pub type SharedEventEmitter = Arc<dyn PlayerEventEmitter>;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::thread;
/// Test event emitter that captures events for verification
pub struct TestEventEmitter {
events: Mutex<Vec<PlayerStatusEvent>>,
}
impl TestEventEmitter {
pub fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
pub fn events(&self) -> Vec<PlayerStatusEvent> {
self.events.lock_safe().clone()
}
}
impl PlayerEventEmitter for TestEventEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock_safe().push(event);
}
}
#[test]
fn test_position_update_serialization() {
let event = PlayerStatusEvent::PositionUpdate {
position: 30.5,
duration: 180.0,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("position_update"));
assert!(json.contains("30.5"));
assert!(json.contains("180"));
}
#[test]
fn test_state_changed_serialization() {
let event = PlayerStatusEvent::StateChanged {
state: "playing".to_string(),
media_id: Some("test-id-123".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("state_changed"));
assert!(json.contains("playing"));
assert!(json.contains("test-id-123"));
}
#[test]
fn test_state_changed_no_media_id() {
let event = PlayerStatusEvent::StateChanged {
state: "idle".to_string(),
media_id: None,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("state_changed"));
assert!(json.contains("idle"));
assert!(json.contains("null"));
}
#[test]
fn test_media_loaded_serialization() {
let event = PlayerStatusEvent::MediaLoaded { duration: 245.5 };
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("media_loaded"));
assert!(json.contains("245.5"));
}
#[test]
fn test_playback_ended_serialization() {
let event = PlayerStatusEvent::PlaybackEnded;
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("playback_ended"));
}
#[test]
fn test_buffering_serialization() {
let event = PlayerStatusEvent::Buffering { percent: 75 };
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("buffering"));
assert!(json.contains("75"));
}
#[test]
fn test_error_serialization() {
let event = PlayerStatusEvent::Error {
message: "Failed to load media".to_string(),
recoverable: true,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("error"));
assert!(json.contains("Failed to load media"));
assert!(json.contains("true"));
}
#[test]
fn test_volume_changed_serialization() {
let event = PlayerStatusEvent::VolumeChanged {
volume: 0.75,
muted: false,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("volume_changed"));
assert!(json.contains("0.75"));
assert!(json.contains("false"));
}
#[test]
fn test_event_emitter_captures_events() {
let emitter = TestEventEmitter::new();
emitter.emit(PlayerStatusEvent::PlaybackEnded);
assert_eq!(emitter.events().len(), 1);
}
#[test]
fn test_event_emitter_multiple_events() {
let emitter = TestEventEmitter::new();
emitter.emit(PlayerStatusEvent::PlaybackEnded);
emitter.emit(PlayerStatusEvent::PositionUpdate {
position: 10.0,
duration: 100.0,
});
emitter.emit(PlayerStatusEvent::StateChanged {
state: "paused".to_string(),
media_id: None,
});
assert_eq!(emitter.events().len(), 3);
}
#[test]
fn test_event_emitter_thread_safety() {
let emitter = Arc::new(TestEventEmitter::new());
let mut handles = vec![];
for i in 0..10 {
let emitter_clone = Arc::clone(&emitter);
let handle = thread::spawn(move || {
emitter_clone.emit(PlayerStatusEvent::PositionUpdate {
position: i as f64,
duration: 100.0,
});
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
assert_eq!(emitter.events().len(), 10);
}
#[test]
fn test_shared_event_emitter() {
let emitter: SharedEventEmitter = Arc::new(TestEventEmitter::new());
emitter.emit(PlayerStatusEvent::PlaybackEnded);
// Verify it compiles and works as a trait object
}
}