use serde::{Deserialize, Serialize}; use super::media::MediaItem; /// Tracks why playback ended to determine autoplay behavior /// /// TRACES: UR-005 | DR-001 #[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum EndReason { /// Track played to completion (natural end) - trigger autoplay Finished, /// User pressed next/previous - already handled, don't autoplay UserSkip, /// User stopped playback - don't autoplay UserStop, /// Playback error - don't autoplay Error, /// User selected a different track - don't autoplay NewTrackLoaded, } /// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error) /// /// TRACES: UR-005 | DR-001 #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum PlayerState { #[default] /// No media loaded Idle, /// Media is being loaded/buffered Loading { media: MediaItem }, /// Media is playing Playing { media: MediaItem, /// Current position in seconds position: f64, /// Total duration in seconds duration: f64, }, /// Media is paused Paused { media: MediaItem, /// Current position in seconds position: f64, /// Total duration in seconds duration: f64, }, /// Seeking to a new position Seeking { media: MediaItem, /// Target position in seconds target: f64, }, /// An error occurred Error { media: Option, error: String, }, } impl PlayerState { /// Get the current playback position if available pub fn position(&self) -> Option { match self { PlayerState::Playing { position, .. } => Some(*position), PlayerState::Paused { position, .. } => Some(*position), _ => None, } } /// Check if the player is currently playing pub fn is_playing(&self) -> bool { matches!(self, PlayerState::Playing { .. }) } /// Check if the player is currently paused #[allow(dead_code)] pub fn is_paused(&self) -> bool { matches!(self, PlayerState::Paused { .. }) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_end_reason_finished() { let reason = EndReason::Finished; let json = serde_json::to_string(&reason); assert!(json.is_ok()); let serialized = json.unwrap(); assert!(serialized.contains("finished")); } #[test] fn test_end_reason_user_skip() { let reason = EndReason::UserSkip; let json = serde_json::to_string(&reason); assert!(json.is_ok()); let serialized = json.unwrap(); assert!(serialized.contains("userskip")); } #[test] fn test_end_reason_user_stop() { let reason = EndReason::UserStop; let json = serde_json::to_string(&reason).unwrap(); assert!(json.contains("userstop")); } #[test] fn test_end_reason_error() { let reason = EndReason::Error; let json = serde_json::to_string(&reason).unwrap(); assert!(json.contains("error")); } #[test] fn test_end_reason_new_track_loaded() { let reason = EndReason::NewTrackLoaded; let json = serde_json::to_string(&reason).unwrap(); assert!(json.contains("newtrackloa")); } #[test] fn test_end_reason_all_variants() { let reasons = vec![ EndReason::Finished, EndReason::UserSkip, EndReason::UserStop, EndReason::Error, EndReason::NewTrackLoaded, ]; for reason in reasons { let json = serde_json::to_string(&reason); assert!(json.is_ok()); } } #[test] fn test_end_reason_equality() { let reason1 = EndReason::Finished; let reason2 = EndReason::Finished; assert_eq!(reason1, reason2); let reason3 = EndReason::UserSkip; assert_ne!(reason1, reason3); } #[test] fn test_end_reason_clone() { let reason = EndReason::Finished; let cloned = reason.clone(); assert_eq!(reason, cloned); } #[test] fn test_player_state_idle_default() { let state = PlayerState::default(); assert!(matches!(state, PlayerState::Idle)); } #[test] fn test_player_state_idle_serialization() { let state = PlayerState::Idle; let json = serde_json::to_string(&state); assert!(json.is_ok()); let serialized = json.unwrap(); assert!(serialized.contains("idle")); } #[test] fn test_player_state_position_when_idle() { let state = PlayerState::Idle; assert_eq!(state.position(), None); } #[test] fn test_player_state_is_playing_when_idle() { let state = PlayerState::Idle; assert!(!state.is_playing()); } #[test] fn test_player_state_is_paused_when_idle() { let state = PlayerState::Idle; assert!(!state.is_paused()); } #[test] fn test_player_state_loading() { let media = create_test_media_item("item-1", "Test Item"); let state = PlayerState::Loading { media: media.clone() }; assert!(matches!(state, PlayerState::Loading { .. })); assert_eq!(state.position(), None); assert!(!state.is_playing()); } #[test] fn test_player_state_playing_position() { let media = create_test_media_item("item-2", "Playing Item"); let state = PlayerState::Playing { media, position: 45.5, duration: 180.0, }; assert!(state.is_playing()); assert!(!state.is_paused()); assert_eq!(state.position(), Some(45.5)); } #[test] fn test_player_state_paused_position() { let media = create_test_media_item("item-3", "Paused Item"); let state = PlayerState::Paused { media, position: 123.75, duration: 300.0, }; assert!(state.is_paused()); assert!(!state.is_playing()); assert_eq!(state.position(), Some(123.75)); } #[test] fn test_player_state_seeking() { let media = create_test_media_item("item-4", "Seeking Item"); let state = PlayerState::Seeking { media, target: 60.0, }; assert!(!state.is_playing()); assert!(!state.is_paused()); assert_eq!(state.position(), None); } #[test] fn test_player_state_error_with_media() { let media = create_test_media_item("item-5", "Error Item"); let state = PlayerState::Error { media: Some(media), error: "Playback failed".to_string(), }; assert!(!state.is_playing()); assert_eq!(state.position(), None); } #[test] fn test_player_state_error_without_media() { let state = PlayerState::Error { media: None, error: "Connection lost".to_string(), }; assert!(!state.is_playing()); assert_eq!(state.position(), None); } #[test] fn test_player_state_clone() { let state = PlayerState::Idle; let cloned = state.clone(); assert!(matches!(cloned, PlayerState::Idle)); } #[test] fn test_player_state_playing_serialization() { let media = create_test_media_item("item-6", "Serial Item"); let state = PlayerState::Playing { media, position: 30.0, duration: 120.0, }; let json = serde_json::to_string(&state); assert!(json.is_ok()); let serialized = json.unwrap(); assert!(serialized.contains("playing")); assert!(serialized.contains("30")); } #[test] fn test_player_state_multiple_positions() { let positions = vec![0.0, 45.5, 100.0, 999.99]; for pos in positions { let media = create_test_media_item("item-test", "Test"); let state = PlayerState::Playing { media, position: pos, duration: 1000.0, }; assert_eq!(state.position(), Some(pos)); } } // Helper function to create test MediaItem instances fn create_test_media_item(id: &str, title: &str) -> MediaItem { MediaItem { id: id.to_string(), title: title.to_string(), name: None, artist: None, album: None, album_name: None, album_id: None, artist_items: None, artists: None, primary_image_tag: None, item_type: Some("Video".to_string()), playlist_id: None, duration: Some(100.0), artwork_url: None, media_type: super::super::media::MediaType::Video, source: super::super::media::MediaSource::DirectUrl { url: "http://example.com/media".to_string(), }, video_codec: None, needs_transcoding: false, video_width: None, video_height: None, subtitles: vec![], series_id: None, server_id: None, } } }