Skip to main content

jellytau_lib/commands/
playback_mode.rs

1//! Playback-mode transfer commands (local ↔ remote).
2//!
3//! TRACES: UR-010 | DR-059
4
5use std::sync::Arc;
6use tauri::State;
7
8use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
9
10/// Wrapper for PlaybackModeManager to manage in Tauri state
11pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
12
13/// Get the current playback mode
14#[tauri::command]
15#[specta::specta]
16pub fn playback_mode_get_current(
17    manager: State<'_, PlaybackModeManagerWrapper>,
18) -> Result<PlaybackMode, String> {
19    Ok(manager.0.get_mode())
20}
21
22/// Set the playback mode (internal/testing use)
23#[tauri::command]
24#[specta::specta]
25pub fn playback_mode_set(
26    manager: State<'_, PlaybackModeManagerWrapper>,
27    mode: PlaybackMode,
28) -> Result<(), String> {
29    manager.0.set_mode(mode);
30    Ok(())
31}
32
33/// Check if currently transferring between playback modes
34#[tauri::command]
35#[specta::specta]
36pub fn playback_mode_is_transferring(
37    manager: State<'_, PlaybackModeManagerWrapper>,
38) -> Result<bool, String> {
39    Ok(manager.0.is_transferring())
40}
41
42/// Transfer playback from local device to a remote Jellyfin session
43#[tauri::command]
44#[specta::specta]
45pub async fn playback_mode_transfer_to_remote(
46    manager: State<'_, PlaybackModeManagerWrapper>,
47    session_id: String,
48    position: Option<f64>,
49) -> Result<(), String> {
50    log::info!(
51        "[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
52        session_id,
53        position
54    );
55    manager.0.transfer_to_remote(session_id, position).await
56}
57
58/// Set the transferring flag on the playback mode manager.
59///
60/// Used by the frontend remote->local flow to mark the whole two-step sequence
61/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
62/// casting back to the remote session it's leaving. Always pair `true` with a
63/// later `false` (including on error) so the flag can't stick.
64#[tauri::command]
65#[specta::specta]
66pub async fn playback_mode_set_transferring(
67    manager: State<'_, PlaybackModeManagerWrapper>,
68    transferring: bool,
69) -> Result<(), String> {
70    manager.0.set_transferring(transferring);
71    Ok(())
72}
73
74/// Transfer playback from remote session back to local device
75///
76/// Parameters:
77/// - current_item_id: The Jellyfin item ID currently playing on remote
78/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
79#[tauri::command]
80#[specta::specta]
81pub async fn playback_mode_transfer_to_local(
82    manager: State<'_, PlaybackModeManagerWrapper>,
83    current_item_id: String,
84    position_ticks: i64,
85) -> Result<(), String> {
86    log::info!(
87        "[PlaybackModeCommands] Transferring to local: item_id={}, position={}",
88        current_item_id,
89        position_ticks
90    );
91    manager
92        .0
93        .transfer_to_local(current_item_id, position_ticks)
94        .await
95}
96
97/// Get remote session status (for polling position/duration)
98#[tauri::command]
99#[specta::specta]
100pub async fn playback_mode_get_remote_status(
101    manager: State<'_, PlaybackModeManagerWrapper>,
102    player: State<'_, crate::commands::PlayerStateWrapper>,
103) -> Result<RemoteSessionStatus, String> {
104    let mode = manager.0.get_mode();
105
106    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
107        // Get Jellyfin client from player controller - clone before await
108        let client = {
109            let controller = player.0.lock().await;
110            let client_arc = controller.jellyfin_client();
111            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
112            client_opt
113                .as_ref()
114                .ok_or("Jellyfin client not configured")?
115                .clone()
116        };
117
118        // Get session info
119        match client.get_session(&session_id).await {
120            Ok(Some(session)) => {
121                let position_ticks = session
122                    .play_state
123                    .as_ref()
124                    .and_then(|ps| ps.position_ticks)
125                    .unwrap_or(0);
126
127                let duration_ticks = session
128                    .now_playing_item
129                    .as_ref()
130                    .and_then(|item| item.run_time_ticks)
131                    .unwrap_or(0);
132
133                let is_paused = session
134                    .play_state
135                    .as_ref()
136                    .and_then(|ps| ps.is_paused)
137                    .unwrap_or(true);
138
139                Ok(RemoteSessionStatus {
140                    position: position_ticks as f64 / 10_000_000.0,
141                    duration: if duration_ticks > 0 {
142                        Some(duration_ticks as f64 / 10_000_000.0)
143                    } else {
144                        None
145                    },
146                    is_playing: !is_paused,
147                    now_playing_item: session.now_playing_item.clone(),
148                })
149            }
150            Ok(None) => Err("Remote session not found".to_string()),
151            Err(e) => Err(format!("Failed to get session status: {}", e)),
152        }
153    } else {
154        Err("Not in remote playback mode".to_string())
155    }
156}
157
158/// Remote session status for UI updates
159#[derive(specta::Type, serde::Serialize)]
160#[serde(rename_all = "camelCase")]
161pub struct RemoteSessionStatus {
162    pub position: f64,
163    pub duration: Option<f64>,
164    pub is_playing: bool,
165    pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_playback_mode_serialization() {
174        // Test Local playback mode
175        let local_mode = PlaybackMode::Local;
176        let json = serde_json::to_string(&local_mode);
177        assert!(json.is_ok());
178
179        // Test Remote playback mode
180        let remote_mode = PlaybackMode::Remote {
181            session_id: "session-123".to_string(),
182        };
183        let json = serde_json::to_string(&remote_mode);
184        assert!(json.is_ok());
185    }
186
187    #[test]
188    fn test_remote_session_status_serialization() {
189        let status = RemoteSessionStatus {
190            position: 123.45,
191            duration: Some(600.0),
192            is_playing: true,
193            now_playing_item: None,
194        };
195
196        // Should serialize successfully
197        let json = serde_json::to_string(&status);
198        assert!(json.is_ok());
199
200        let serialized = json.unwrap();
201        assert!(serialized.contains("123.45"));
202        assert!(serialized.contains("600"));
203        assert!(serialized.contains("true"));
204    }
205
206    #[test]
207    fn test_remote_session_status_with_no_duration() {
208        let status = RemoteSessionStatus {
209            position: 0.0,
210            duration: None,
211            is_playing: false,
212            now_playing_item: None,
213        };
214
215        let json = serde_json::to_string(&status).unwrap();
216        assert!(json.contains("null") || json.contains("\"duration\":null"));
217    }
218
219    #[test]
220    fn test_remote_session_status_various_positions() {
221        let positions = vec![0.0, 30.5, 100.0, 3600.0];
222
223        for pos in positions {
224            let status = RemoteSessionStatus {
225                position: pos,
226                duration: Some(7200.0),
227                is_playing: true,
228                now_playing_item: None,
229            };
230
231            let json = serde_json::to_string(&status).unwrap();
232            assert!(json.contains(&pos.to_string()));
233        }
234    }
235
236    #[test]
237    fn test_playback_mode_deserialization_from_frontend() {
238        // Test what frontend sends for Idle mode
239        let idle_json = r#"{"type":"idle"}"#;
240        let mode: PlaybackMode =
241            serde_json::from_str(idle_json).expect("Failed to deserialize idle");
242        assert_eq!(mode, PlaybackMode::Idle);
243
244        // Test what frontend sends for Local mode
245        let local_json = r#"{"type":"local"}"#;
246        let mode: PlaybackMode =
247            serde_json::from_str(local_json).expect("Failed to deserialize local");
248        assert_eq!(mode, PlaybackMode::Local);
249
250        // Test what frontend sends for Remote mode
251        let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
252        let mode: PlaybackMode =
253            serde_json::from_str(remote_json).expect("Failed to deserialize remote");
254        match mode {
255            PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
256            _ => panic!("Expected Remote mode"),
257        }
258    }
259
260    #[test]
261    fn test_play_tracks_context_deserialization() {
262        use crate::commands::PlayTracksContext;
263
264        // Test Search context (the recently fixed issue)
265        let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
266        let context: PlayTracksContext =
267            serde_json::from_str(search_json).expect("Failed to deserialize search context");
268        match context {
269            PlayTracksContext::Search { search_query } => {
270                assert_eq!(search_query, "test query");
271            }
272            _ => panic!("Expected Search context"),
273        }
274
275        // Test Playlist context
276        let playlist_json =
277            r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
278        let context: PlayTracksContext =
279            serde_json::from_str(playlist_json).expect("Failed to deserialize playlist context");
280        match context {
281            PlayTracksContext::Playlist {
282                playlist_id,
283                playlist_name,
284            } => {
285                assert_eq!(playlist_id, "pl-123");
286                assert_eq!(playlist_name, "My Playlist");
287            }
288            _ => panic!("Expected Playlist context"),
289        }
290
291        // Test Custom context
292        let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
293        let context: PlayTracksContext =
294            serde_json::from_str(custom_json).expect("Failed to deserialize custom context");
295        match context {
296            PlayTracksContext::Custom { label } => {
297                assert_eq!(label, Some("Custom Queue".to_string()));
298            }
299            _ => panic!("Expected Custom context"),
300        }
301    }
302}