First working POC
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
//! 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.
|
||||
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[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,
|
||||
},
|
||||
/// 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>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Tauri event name for player status events
|
||||
pub const PLAYER_EVENT_NAME: &str = "player-event";
|
||||
|
||||
/// 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) {
|
||||
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) {
|
||||
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().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
self.events.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerEventEmitter for TestEventEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
self.events.lock().unwrap().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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user