multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
283 lines
9.5 KiB
Rust
283 lines
9.5 KiB
Rust
use std::sync::Arc;
|
|
use tauri::State;
|
|
|
|
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
|
|
|
|
/// Wrapper for PlaybackModeManager to manage in Tauri state
|
|
pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
|
|
|
|
/// Get the current playback mode
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub fn playback_mode_get_current(
|
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
|
) -> Result<PlaybackMode, String> {
|
|
Ok(manager.0.get_mode())
|
|
}
|
|
|
|
/// Set the playback mode (internal/testing use)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub fn playback_mode_set(
|
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
|
mode: PlaybackMode,
|
|
) -> Result<(), String> {
|
|
manager.0.set_mode(mode);
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if currently transferring between playback modes
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub fn playback_mode_is_transferring(
|
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
|
) -> Result<bool, String> {
|
|
Ok(manager.0.is_transferring())
|
|
}
|
|
|
|
/// Transfer playback from local device to a remote Jellyfin session
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn playback_mode_transfer_to_remote(
|
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
|
session_id: String,
|
|
position: Option<f64>,
|
|
) -> Result<(), String> {
|
|
log::info!(
|
|
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
|
|
session_id,
|
|
position
|
|
);
|
|
manager.0.transfer_to_remote(session_id, position).await
|
|
}
|
|
|
|
/// Set the transferring flag on the playback mode manager.
|
|
///
|
|
/// Used by the frontend remote->local flow to mark the whole two-step sequence
|
|
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
|
|
/// casting back to the remote session it's leaving. Always pair `true` with a
|
|
/// later `false` (including on error) so the flag can't stick.
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn playback_mode_set_transferring(
|
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
|
transferring: bool,
|
|
) -> Result<(), String> {
|
|
manager.0.set_transferring(transferring);
|
|
Ok(())
|
|
}
|
|
|
|
/// Transfer playback from remote session back to local device
|
|
///
|
|
/// Parameters:
|
|
/// - current_item_id: The Jellyfin item ID currently playing on remote
|
|
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn playback_mode_transfer_to_local(
|
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
|
current_item_id: String,
|
|
position_ticks: i64,
|
|
) -> Result<(), String> {
|
|
log::info!(
|
|
"[PlaybackModeCommands] Transferring to local: item_id={}, position={}",
|
|
current_item_id,
|
|
position_ticks
|
|
);
|
|
manager
|
|
.0
|
|
.transfer_to_local(current_item_id, position_ticks)
|
|
.await
|
|
}
|
|
|
|
/// Get remote session status (for polling position/duration)
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
pub async fn playback_mode_get_remote_status(
|
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
|
player: State<'_, crate::commands::PlayerStateWrapper>,
|
|
) -> Result<RemoteSessionStatus, String> {
|
|
let mode = manager.0.get_mode();
|
|
|
|
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
|
|
// Get Jellyfin client from player controller - clone before await
|
|
let client = {
|
|
let controller = player.0.lock().await;
|
|
let client_arc = controller.jellyfin_client();
|
|
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
|
|
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
|
|
};
|
|
|
|
// Get session info
|
|
match client.get_session(&session_id).await {
|
|
Ok(Some(session)) => {
|
|
let position_ticks = session.play_state.as_ref()
|
|
.and_then(|ps| ps.position_ticks)
|
|
.unwrap_or(0);
|
|
|
|
let duration_ticks = session.now_playing_item.as_ref()
|
|
.and_then(|item| item.run_time_ticks)
|
|
.unwrap_or(0);
|
|
|
|
let is_paused = session.play_state.as_ref()
|
|
.and_then(|ps| ps.is_paused)
|
|
.unwrap_or(true);
|
|
|
|
Ok(RemoteSessionStatus {
|
|
position: position_ticks as f64 / 10_000_000.0,
|
|
duration: if duration_ticks > 0 {
|
|
Some(duration_ticks as f64 / 10_000_000.0)
|
|
} else {
|
|
None
|
|
},
|
|
is_playing: !is_paused,
|
|
now_playing_item: session.now_playing_item.clone(),
|
|
})
|
|
}
|
|
Ok(None) => Err("Remote session not found".to_string()),
|
|
Err(e) => Err(format!("Failed to get session status: {}", e)),
|
|
}
|
|
} else {
|
|
Err("Not in remote playback mode".to_string())
|
|
}
|
|
}
|
|
|
|
/// Remote session status for UI updates
|
|
#[derive(specta::Type, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RemoteSessionStatus {
|
|
pub position: f64,
|
|
pub duration: Option<f64>,
|
|
pub is_playing: bool,
|
|
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_playback_mode_serialization() {
|
|
// Test Local playback mode
|
|
let local_mode = PlaybackMode::Local;
|
|
let json = serde_json::to_string(&local_mode);
|
|
assert!(json.is_ok());
|
|
|
|
// Test Remote playback mode
|
|
let remote_mode = PlaybackMode::Remote {
|
|
session_id: "session-123".to_string(),
|
|
};
|
|
let json = serde_json::to_string(&remote_mode);
|
|
assert!(json.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_remote_session_status_serialization() {
|
|
let status = RemoteSessionStatus {
|
|
position: 123.45,
|
|
duration: Some(600.0),
|
|
is_playing: true,
|
|
now_playing_item: None,
|
|
};
|
|
|
|
// Should serialize successfully
|
|
let json = serde_json::to_string(&status);
|
|
assert!(json.is_ok());
|
|
|
|
let serialized = json.unwrap();
|
|
assert!(serialized.contains("123.45"));
|
|
assert!(serialized.contains("600"));
|
|
assert!(serialized.contains("true"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_remote_session_status_with_no_duration() {
|
|
let status = RemoteSessionStatus {
|
|
position: 0.0,
|
|
duration: None,
|
|
is_playing: false,
|
|
now_playing_item: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&status).unwrap();
|
|
assert!(json.contains("null") || json.contains("\"duration\":null"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_remote_session_status_various_positions() {
|
|
let positions = vec![0.0, 30.5, 100.0, 3600.0];
|
|
|
|
for pos in positions {
|
|
let status = RemoteSessionStatus {
|
|
position: pos,
|
|
duration: Some(7200.0),
|
|
is_playing: true,
|
|
now_playing_item: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&status).unwrap();
|
|
assert!(json.contains(&pos.to_string()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_playback_mode_deserialization_from_frontend() {
|
|
// Test what frontend sends for Idle mode
|
|
let idle_json = r#"{"type":"idle"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
|
assert_eq!(mode, PlaybackMode::Idle);
|
|
|
|
// Test what frontend sends for Local mode
|
|
let local_json = r#"{"type":"local"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
|
|
assert_eq!(mode, PlaybackMode::Local);
|
|
|
|
// Test what frontend sends for Remote mode
|
|
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
|
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
|
match mode {
|
|
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
|
_ => panic!("Expected Remote mode"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_play_tracks_context_deserialization() {
|
|
use crate::commands::PlayTracksContext;
|
|
|
|
// Test Search context (the recently fixed issue)
|
|
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
|
let context: PlayTracksContext = serde_json::from_str(search_json)
|
|
.expect("Failed to deserialize search context");
|
|
match context {
|
|
PlayTracksContext::Search { search_query } => {
|
|
assert_eq!(search_query, "test query");
|
|
}
|
|
_ => panic!("Expected Search context"),
|
|
}
|
|
|
|
// Test Playlist context
|
|
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
|
let context: PlayTracksContext = serde_json::from_str(playlist_json)
|
|
.expect("Failed to deserialize playlist context");
|
|
match context {
|
|
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
|
|
assert_eq!(playlist_id, "pl-123");
|
|
assert_eq!(playlist_name, "My Playlist");
|
|
}
|
|
_ => panic!("Expected Playlist context"),
|
|
}
|
|
|
|
// Test Custom context
|
|
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
|
let context: PlayTracksContext = serde_json::from_str(custom_json)
|
|
.expect("Failed to deserialize custom context");
|
|
match context {
|
|
PlayTracksContext::Custom { label } => {
|
|
assert_eq!(label, Some("Custom Queue".to_string()));
|
|
}
|
|
_ => panic!("Expected Custom context"),
|
|
}
|
|
}
|
|
}
|