jellytau/src-tauri/src/commands/playback_mode.rs
Duncan Tourolle f1d25c4f4d Add support for fusing/unfusing JellyLMS zones into synchronized
multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
2026-06-26 19:27:37 +02:00

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"),
}
}
}