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}