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}