Skip to main content

#
jellytau_lib/player/
state.rs

    1use serde::{Deserialize, Serialize};
    2
    3use super::media::MediaItem;
    4
    5/// Tracks why playback ended to determine autoplay behavior
    6///
    7/// TRACES: UR-005 | DR-001
    8#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    9#[serde(rename_all = "lowercase")]
    10pub enum EndReason {
    11    /// Track played to completion (natural end) - trigger autoplay
    12    Finished,
    13    /// User pressed next/previous - already handled, don't autoplay
    14    UserSkip,
    15    /// User stopped playback - don't autoplay
    16    UserStop,
    17    /// Playback error - don't autoplay
    18    Error,
    19    /// User selected a different track - don't autoplay
    20    NewTrackLoaded,
    21}
    22
    23/// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
    24///
    25/// TRACES: UR-005 | DR-001
    26#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
    27#[serde(tag = "kind", rename_all = "lowercase")]
    28pub enum PlayerState {
    29    #[default]
    30    /// No media loaded
    31    Idle,
    32    /// Media is being loaded/buffered
    33    Loading { media: MediaItem },
    34    /// Media is playing
    35    Playing {
    36        media: MediaItem,
    37        /// Current position in seconds
    38        position: f64,
    39        /// Total duration in seconds
    40        duration: f64,
    41    },
    42    /// Media is paused
    43    Paused {
    44        media: MediaItem,
    45        /// Current position in seconds
    46        position: f64,
    47        /// Total duration in seconds
    48        duration: f64,
    49    },
    50    /// Seeking to a new position
    51    Seeking {
    52        media: MediaItem,
    53        /// Target position in seconds
    54        target: f64,
    55    },
    56    /// An error occurred
    57    Error {
    58        media: Option<MediaItem>,
    59        error: String,
    60    },
    61}
    62
    63impl PlayerState {
    64    /// Get the current playback position if available.
    65    ///
    66    /// Note: this is the position snapshot embedded in the state at the last
    67    /// state transition, not the live backend position. For an up-to-date
    68    /// value use `PlayerController::position()`.
    69    #[allow(dead_code)]
    70    pub fn position(&self) -> Option<f64> {
    71        match self {
    72            PlayerState::Playing { position, .. } => Some(*position),
    73            PlayerState::Paused { position, .. } => Some(*position),
    74            _ => None,
    75        }
    76    }
    77
    78    /// Check if the player is currently playing
    79    pub fn is_playing(&self) -> bool {
    80        matches!(self, PlayerState::Playing { .. })
    81    }
    82
    83    /// Check if the player is currently paused
    84    #[allow(dead_code)]
    85    pub fn is_paused(&self) -> bool {
    86        matches!(self, PlayerState::Paused { .. })
    87    }
    88}
    89
    90#[cfg(test)]
    91mod tests {
    92    use super::*;
    93
    94    #[test]
    95    fn test_end_reason_finished() {
    96        let reason = EndReason::Finished;
    97        let json = serde_json::to_string(&reason);
    98        assert!(json.is_ok());
    99        let serialized = json.unwrap();
    100        assert!(serialized.contains("finished"));
    101    }
    102
    103    #[test]
    104    fn test_end_reason_user_skip() {
    105        let reason = EndReason::UserSkip;
    106        let json = serde_json::to_string(&reason);
    107        assert!(json.is_ok());
    108        let serialized = json.unwrap();
    109        assert!(serialized.contains("userskip"));
    110    }
    111
    112    #[test]
    113    fn test_end_reason_user_stop() {
    114        let reason = EndReason::UserStop;
    115        let json = serde_json::to_string(&reason).unwrap();
    116        assert!(json.contains("userstop"));
    117    }
    118
    119    #[test]
    120    fn test_end_reason_error() {
    121        let reason = EndReason::Error;
    122        let json = serde_json::to_string(&reason).unwrap();
    123        assert!(json.contains("error"));
    124    }
    125
    126    #[test]
    127    fn test_end_reason_new_track_loaded() {
    128        let reason = EndReason::NewTrackLoaded;
    129        let json = serde_json::to_string(&reason).unwrap();
    130        assert!(json.contains("newtrackloa"));
    131    }
    132
    133    #[test]
    134    fn test_end_reason_all_variants() {
    135        let reasons = vec![
    136            EndReason::Finished,
    137            EndReason::UserSkip,
    138            EndReason::UserStop,
    139            EndReason::Error,
    140            EndReason::NewTrackLoaded,
    141        ];
    142
    143        for reason in reasons {
    144            let json = serde_json::to_string(&reason);
    145            assert!(json.is_ok());
    146        }
    147    }
    148
    149    #[test]
    150    fn test_end_reason_equality() {
    151        let reason1 = EndReason::Finished;
    152        let reason2 = EndReason::Finished;
    153        assert_eq!(reason1, reason2);
    154
    155        let reason3 = EndReason::UserSkip;
    156        assert_ne!(reason1, reason3);
    157    }
    158
    159    #[test]
    160    fn test_end_reason_clone() {
    161        let reason = EndReason::Finished;
    162        // Deliberately exercising the derived `Clone` impl, not a plain copy:
    163        // `EndReason` is also `Copy`, so clippy flags the call as redundant.
    164        #[allow(clippy::clone_on_copy)]
    165        let cloned = reason.clone();
    166        assert_eq!(reason, cloned);
    167    }
    168
    169    #[test]
    170    fn test_player_state_idle_default() {
    171        let state = PlayerState::default();
    172        assert!(matches!(state, PlayerState::Idle));
    173    }
    174
    175    #[test]
    176    fn test_player_state_idle_serialization() {
    177        let state = PlayerState::Idle;
    178        let json = serde_json::to_string(&state);
    179        assert!(json.is_ok());
    180        let serialized = json.unwrap();
    181        assert!(serialized.contains("idle"));
    182    }
    183
    184    #[test]
    185    fn test_player_state_position_when_idle() {
    186        let state = PlayerState::Idle;
    187        assert_eq!(state.position(), None);
    188    }
    189
    190    #[test]
    191    fn test_player_state_is_playing_when_idle() {
    192        let state = PlayerState::Idle;
    193        assert!(!state.is_playing());
    194    }
    195
    196    #[test]
    197    fn test_player_state_is_paused_when_idle() {
    198        let state = PlayerState::Idle;
    199        assert!(!state.is_paused());
    200    }
    201
    202    #[test]
    203    fn test_player_state_loading() {
    204        let media = create_test_media_item("item-1", "Test Item");
    205        let state = PlayerState::Loading {
    206            media: media.clone(),
    207        };
    208        assert!(matches!(state, PlayerState::Loading { .. }));
    209        assert_eq!(state.position(), None);
    210        assert!(!state.is_playing());
    211    }
    212
    213    #[test]
    214    fn test_player_state_playing_position() {
    215        let media = create_test_media_item("item-2", "Playing Item");
    216        let state = PlayerState::Playing {
    217            media,
    218            position: 45.5,
    219            duration: 180.0,
    220        };
    221
    222        assert!(state.is_playing());
    223        assert!(!state.is_paused());
    224        assert_eq!(state.position(), Some(45.5));
    225    }
    226
    227    #[test]
    228    fn test_player_state_paused_position() {
    229        let media = create_test_media_item("item-3", "Paused Item");
    230        let state = PlayerState::Paused {
    231            media,
    232            position: 123.75,
    233            duration: 300.0,
    234        };
    235
    236        assert!(state.is_paused());
    237        assert!(!state.is_playing());
    238        assert_eq!(state.position(), Some(123.75));
    239    }
    240
    241    #[test]
    242    fn test_player_state_seeking() {
    243        let media = create_test_media_item("item-4", "Seeking Item");
    244        let state = PlayerState::Seeking {
    245            media,
    246            target: 60.0,
    247        };
    248
    249        assert!(!state.is_playing());
    250        assert!(!state.is_paused());
    251        assert_eq!(state.position(), None);
    252    }
    253
    254    #[test]
    255    fn test_player_state_error_with_media() {
    256        let media = create_test_media_item("item-5", "Error Item");
    257        let state = PlayerState::Error {
    258            media: Some(media),
    259            error: "Playback failed".to_string(),
    260        };
    261
    262        assert!(!state.is_playing());
    263        assert_eq!(state.position(), None);
    264    }
    265
    266    #[test]
    267    fn test_player_state_error_without_media() {
    268        let state = PlayerState::Error {
    269            media: None,
    270            error: "Connection lost".to_string(),
    271        };
    272
    273        assert!(!state.is_playing());
    274        assert_eq!(state.position(), None);
    275    }
    276
    277    #[test]
    278    fn test_player_state_clone() {
    279        let state = PlayerState::Idle;
    280        let cloned = state.clone();
    281        assert!(matches!(cloned, PlayerState::Idle));
    282    }
    283
    284    #[test]
    285    fn test_player_state_playing_serialization() {
    286        let media = create_test_media_item("item-6", "Serial Item");
    287        let state = PlayerState::Playing {
    288            media,
    289            position: 30.0,
    290            duration: 120.0,
    291        };
    292
    293        let json = serde_json::to_string(&state);
    294        assert!(json.is_ok());
    295        let serialized = json.unwrap();
    296        assert!(serialized.contains("playing"));
    297        assert!(serialized.contains("30"));
    298    }
    299
    300    #[test]
    301    fn test_player_state_multiple_positions() {
    302        let positions = vec![0.0, 45.5, 100.0, 999.99];
    303
    304        for pos in positions {
    305            let media = create_test_media_item("item-test", "Test");
    306            let state = PlayerState::Playing {
    307                media,
    308                position: pos,
    309                duration: 1000.0,
    310            };
    311
    312            assert_eq!(state.position(), Some(pos));
    313        }
    314    }
    315
    316    // Helper function to create test MediaItem instances
    317    fn create_test_media_item(id: &str, title: &str) -> MediaItem {
    318        MediaItem {
    319            // Audio and direct-URL items never negotiate a transport.
    320            transport: None,
    321            id: id.to_string(),
    322            title: title.to_string(),
    323            name: None,
    324            artist: None,
    325            album: None,
    326            album_name: None,
    327            album_id: None,
    328            artist_items: None,
    329            artists: None,
    330            primary_image_tag: None,
    331            image_id: None,
    332            item_type: Some("Video".to_string()),
    333            playlist_id: None,
    334            duration: Some(100.0),
    335            artwork_url: None,
    336            media_type: super::super::media::MediaType::Video,
    337            source: super::super::media::MediaSource::DirectUrl {
    338                url: "http://example.com/media".to_string(),
    339            },
    340            video_codec: None,
    341            needs_transcoding: false,
    342            video_width: None,
    343            video_height: None,
    344            subtitles: vec![],
    345            series_id: None,
    346            server_id: None,
    347        }
    348    }
    349}