diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index 6e9e305..55130f6 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -2,11 +2,15 @@ // Cohesive command clusters live in their own submodules and are re-exported so // the command names remain at `commands::player::*` (invoke_handler unchanged). +mod queue; mod remote; mod session; +mod settings; mod timers; +pub use queue::*; pub use remote::*; pub use session::*; +pub use settings::*; pub use timers::*; use crate::utils::lock::MutexSafe; @@ -21,12 +25,12 @@ use super::DatabaseWrapper; use crate::download::cache::{CacheConfig, SmartCache}; use crate::jellyfin::{JellyfinClient, JellyfinConfig}; use crate::player::{ - determine_video_seek_strategy, AutoplaySettings, MediaItem, MediaSource, MediaType, + determine_video_seek_strategy, MediaItem, MediaSource, MediaType, MediaSessionManager, PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy, }; use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}}; -use crate::settings::{AudioSettings, VideoSettings}; +use crate::settings::VideoSettings; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; /// SmartCache wrapper for Tauri state management @@ -338,7 +342,7 @@ pub(super) async fn create_media_item( } /// Check if an item has a completed download -async fn check_for_local_download( +pub(super) async fn check_for_local_download( db: &DatabaseWrapper, item_id: &str, ) -> Result, String> { @@ -1166,56 +1170,6 @@ pub async fn player_get_queue(player: State<'_, PlayerStateWrapper>) -> Result, - settings: AudioSettings, -) -> Result { - let mut controller = player.0.lock().await; - controller - .set_audio_settings(&settings) - .map_err(|e| e.to_string())?; - Ok(controller.audio_settings()) -} - -#[tauri::command] -pub async fn player_get_audio_settings( - player: State<'_, PlayerStateWrapper>, -) -> Result { - let controller = player.0.lock().await; - Ok(controller.audio_settings()) -} - -#[tauri::command] -pub async fn player_set_video_settings( - video_settings: State<'_, VideoSettingsWrapper>, - player: State<'_, PlayerStateWrapper>, - settings: VideoSettings, -) -> Result { - let validated = settings.with_countdown_clamped(); - { - let mut current = video_settings.0.lock().map_err(|e| e.to_string())?; - *current = validated.clone(); - } // Drop MutexGuard before await - - // Sync to PlayerController's autoplay settings so on_playback_ended() uses current values - let controller = player.0.lock().await; - controller.set_autoplay_settings(AutoplaySettings { - enabled: validated.auto_play_next_episode, - countdown_seconds: validated.auto_play_countdown_seconds, - max_episodes: validated.auto_play_max_episodes, - }); - - Ok(validated) -} - -#[tauri::command] -pub async fn player_get_video_settings( - video_settings: State<'_, VideoSettingsWrapper>, -) -> Result { - let current = video_settings.0.lock().map_err(|e| e.to_string())?; - Ok(current.clone()) -} pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus { // Determine backend at compile time based on platform @@ -1245,7 +1199,7 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus { } } -fn get_queue_status(controller: &PlayerController) -> QueueStatus { +pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus { let queue = controller.queue(); let queue_lock = queue.lock_safe(); @@ -1259,351 +1213,6 @@ fn get_queue_status(controller: &PlayerController) -> QueueStatus { } } -/// Request to add items to queue -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AddToQueueRequest { - pub items: Vec, - pub position: String, // "next" or "end" -} - -/// Request to add a track by ID - backend fetches metadata -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AddTrackByIdRequest { - pub track_id: String, - pub position: String, // "next" or "end" -} - -/// Request to add multiple tracks by IDs - backend fetches metadata -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AddTracksByIdsRequest { - pub track_ids: Vec, - pub position: String, // "next" or "end" -} - -#[tauri::command] -pub async fn player_add_to_queue( - player: State<'_, PlayerStateWrapper>, - db: State<'_, DatabaseWrapper>, - request: AddToQueueRequest, -) -> Result { - use crate::player::queue::AddPosition; - - let position = match request.position.as_str() { - "next" => AddPosition::Next, - _ => AddPosition::End, - }; - - // Create media items first (without holding any locks during await) - let mut items: Vec = Vec::new(); - for req in request.items { - items.push(create_media_item(req, Some(&db)).await?); - } - - // Now add to queue - let controller = player.0.lock().await; - let queue = controller.queue(); - let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; - - queue_lock.add(items, position); - - let result = QueueStatus { - items: queue_lock.items().to_vec(), - current_index: queue_lock.current_index(), - shuffle: queue_lock.is_shuffle(), - repeat: queue_lock.repeat_mode(), - has_next: queue_lock.has_next(), - has_previous: queue_lock.has_previous(), - }; - - drop(queue_lock); - controller.emit_queue_changed(); - - Ok(result) -} - -#[tauri::command] -pub async fn player_remove_from_queue( - player: State<'_, PlayerStateWrapper>, - index: usize, -) -> Result { - let controller = player.0.lock().await; - let queue = controller.queue(); - let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; - - queue_lock.remove(index); - - let result = QueueStatus { - items: queue_lock.items().to_vec(), - current_index: queue_lock.current_index(), - shuffle: queue_lock.is_shuffle(), - repeat: queue_lock.repeat_mode(), - has_next: queue_lock.has_next(), - has_previous: queue_lock.has_previous(), - }; - - drop(queue_lock); - controller.emit_queue_changed(); - - Ok(result) -} - -#[tauri::command] -pub async fn player_move_in_queue( - player: State<'_, PlayerStateWrapper>, - from_index: usize, - to_index: usize, -) -> Result { - let controller = player.0.lock().await; - let queue = controller.queue(); - let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; - - if !queue_lock.move_item(from_index, to_index) { - return Err("Invalid indices for move operation".to_string()); - } - - let result = QueueStatus { - items: queue_lock.items().to_vec(), - current_index: queue_lock.current_index(), - shuffle: queue_lock.is_shuffle(), - repeat: queue_lock.repeat_mode(), - has_next: queue_lock.has_next(), - has_previous: queue_lock.has_previous(), - }; - - drop(queue_lock); - controller.emit_queue_changed(); - - Ok(result) -} - -/// Add a track to queue by ID - backend fetches metadata and constructs URLs -#[tauri::command] -pub async fn player_add_track_by_id( - player: State<'_, PlayerStateWrapper>, - db: State<'_, DatabaseWrapper>, - repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, - repository_handle: String, - request: AddTrackByIdRequest, -) -> Result { - use crate::player::queue::AddPosition; - - info!("player_add_track_by_id called: track_id={}, position={}", - request.track_id, request.position); - - // Get repository (hybrid - supports offline/online) - let repository = repository_manager.0.get(&repository_handle) - .ok_or("Repository not found - user may need to log in")?; - - // Fetch track metadata via repository - info!("Fetching metadata for track {} via repository", request.track_id); - let track = repository.get_item(&request.track_id).await - .map_err(|e| format!("Failed to fetch track metadata: {}", e))?; - - // Check for local download first - let local_path = check_for_local_download(&db, &request.track_id).await?; - - let source = if let Some(path) = local_path { - info!("Using local download for track {}", request.track_id); - MediaSource::Local { - file_path: PathBuf::from(path), - jellyfin_item_id: Some(track.id.clone()), - } - } else { - // Get stream URL from repository (works online/offline) - let stream_url = repository.get_audio_stream_url(&track.id).await - .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?; - - MediaSource::Remote { - stream_url, - jellyfin_item_id: track.id.clone(), - } - }; - - // Build MediaItem with artwork URL from repository and frontend-compatible fields - let primary_image_tag_for_url = track.primary_image_tag.clone(); - let media_item = MediaItem { - id: track.id.clone(), - title: track.name.clone(), - name: Some(track.name.clone()), // Frontend compatibility - artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())), - album: track.album_name.clone(), - album_name: track.album_name.clone(), // Frontend compatibility - album_id: track.album_id.clone(), - artist_items: track.artist_items.clone(), // For clickable artist links - artists: track.artists.clone(), // Fallback artist info - primary_image_tag: track.primary_image_tag.clone(), // For frontend image display - item_type: Some(track.item_type.clone()), // Frontend compatibility - playlist_id: None, - duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), - artwork_url: primary_image_tag_for_url.and_then(|tag| { - track.album_id.as_ref().map(|album_id| { - repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions { - max_width: Some(300), - tag: Some(tag), - ..Default::default() - })) - }) - }), - media_type: MediaType::Audio, - source, - video_codec: None, - needs_transcoding: false, - video_width: None, - video_height: None, - subtitles: vec![], - series_id: None, - server_id: None, - }; - - // Add to queue at specified position - let position = match request.position.as_str() { - "next" => AddPosition::Next, - _ => AddPosition::End, - }; - - let controller = player.0.lock().await; - let queue = controller.queue(); - let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; - - queue_lock.add(vec![media_item], position); - - let result = get_queue_status(&controller); - drop(queue_lock); - controller.emit_queue_changed(); - - info!("Successfully added track {} to queue", request.track_id); - Ok(result) -} - -/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs -#[tauri::command] -pub async fn player_add_tracks_by_ids( - player: State<'_, PlayerStateWrapper>, - db: State<'_, DatabaseWrapper>, - repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, - repository_handle: String, - request: AddTracksByIdsRequest, -) -> Result { - use crate::player::queue::AddPosition; - - info!("player_add_tracks_by_ids called: {} tracks, position={}", - request.track_ids.len(), request.position); - - // Get repository (hybrid - supports offline/online) - let repository = repository_manager.0.get(&repository_handle) - .ok_or("Repository not found - user may need to log in")?; - - // Fetch metadata and build MediaItems for all tracks - let mut media_items = Vec::new(); - for track_id in &request.track_ids { - info!("Fetching metadata for track {} via repository", track_id); - let track = repository.get_item(track_id).await - .map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?; - - // Check for local download first - let local_path = check_for_local_download(&db, track_id).await?; - - let source = if let Some(path) = local_path { - info!("Using local download for track {}", track_id); - MediaSource::Local { - file_path: PathBuf::from(path), - jellyfin_item_id: Some(track.id.clone()), - } - } else { - // Get stream URL from repository (works online/offline) - let stream_url = repository.get_audio_stream_url(&track.id).await - .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?; - - MediaSource::Remote { - stream_url, - jellyfin_item_id: track.id.clone(), - } - }; - - // Build MediaItem with artwork URL from repository and frontend-compatible fields - let primary_image_tag_for_url = track.primary_image_tag.clone(); - let media_item = MediaItem { - id: track.id.clone(), - title: track.name.clone(), - name: Some(track.name.clone()), // Frontend compatibility - artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())), - album: track.album_name.clone(), - album_name: track.album_name.clone(), // Frontend compatibility - album_id: track.album_id.clone(), - artist_items: track.artist_items.clone(), // For clickable artist links - artists: track.artists.clone(), // Fallback artist info - primary_image_tag: track.primary_image_tag.clone(), // For frontend image display - item_type: Some(track.item_type.clone()), // Frontend compatibility - playlist_id: None, - duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), - artwork_url: primary_image_tag_for_url.and_then(|tag| { - track.album_id.as_ref().map(|album_id| { - repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions { - max_width: Some(300), - tag: Some(tag), - ..Default::default() - })) - }) - }), - media_type: MediaType::Audio, - source, - video_codec: None, - needs_transcoding: false, - video_width: None, - video_height: None, - subtitles: vec![], - series_id: None, - server_id: None, - }; - - media_items.push(media_item); - } - - // Add to queue at specified position - let position = match request.position.as_str() { - "next" => AddPosition::Next, - _ => AddPosition::End, - }; - - let controller = player.0.lock().await; - let queue = controller.queue(); - let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; - - queue_lock.add(media_items, position); - - let result = get_queue_status(&controller); - drop(queue_lock); - controller.emit_queue_changed(); - - info!("Successfully added {} tracks to queue", request.track_ids.len()); - Ok(result) -} - -#[tauri::command] -pub async fn player_skip_to( - player: State<'_, PlayerStateWrapper>, - index: usize, -) -> Result { - let controller = player.0.lock().await; - - // Skip to the index and get the item to play - let item = { - let queue = controller.queue(); - let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; - queue_lock.skip_to(index).cloned().ok_or("Invalid index")? - }; - - // Play the item without modifying the queue - controller.load_and_play(&item).map_err(|e| e.to_string())?; - - // Emit queue changed event - controller.emit_queue_changed(); - - Ok(get_player_status(&controller)) -} /// Play a track from an album - backend fetches all album tracks and builds queue #[tauri::command] diff --git a/src-tauri/src/commands/player/queue.rs b/src-tauri/src/commands/player/queue.rs new file mode 100644 index 0000000..f8e5783 --- /dev/null +++ b/src-tauri/src/commands/player/queue.rs @@ -0,0 +1,362 @@ +//! Queue manipulation commands (add / remove / move / skip). + +use std::path::PathBuf; + +use log::info; +use serde::Deserialize; +use tauri::State; + +use super::{ + check_for_local_download, create_media_item, get_player_status, get_queue_status, + DatabaseWrapper, PlayItemRequest, PlayerStateWrapper, PlayerStatus, QueueStatus, +}; +use crate::commands::repository::RepositoryManagerWrapper; +use crate::player::{MediaItem, MediaSource, MediaType}; +use crate::repository::types::{ImageOptions, ImageType}; +use crate::repository::MediaRepository; + +/// Request to add items to queue +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddToQueueRequest { + pub items: Vec, + pub position: String, // "next" or "end" +} + +/// Request to add a track by ID - backend fetches metadata +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddTrackByIdRequest { + pub track_id: String, + pub position: String, // "next" or "end" +} + +/// Request to add multiple tracks by IDs - backend fetches metadata +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddTracksByIdsRequest { + pub track_ids: Vec, + pub position: String, // "next" or "end" +} + +#[tauri::command] +pub async fn player_add_to_queue( + player: State<'_, PlayerStateWrapper>, + db: State<'_, DatabaseWrapper>, + request: AddToQueueRequest, +) -> Result { + use crate::player::queue::AddPosition; + + let position = match request.position.as_str() { + "next" => AddPosition::Next, + _ => AddPosition::End, + }; + + // Create media items first (without holding any locks during await) + let mut items: Vec = Vec::new(); + for req in request.items { + items.push(create_media_item(req, Some(&db)).await?); + } + + // Now add to queue + let controller = player.0.lock().await; + let queue = controller.queue(); + let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; + + queue_lock.add(items, position); + + let result = QueueStatus { + items: queue_lock.items().to_vec(), + current_index: queue_lock.current_index(), + shuffle: queue_lock.is_shuffle(), + repeat: queue_lock.repeat_mode(), + has_next: queue_lock.has_next(), + has_previous: queue_lock.has_previous(), + }; + + drop(queue_lock); + controller.emit_queue_changed(); + + Ok(result) +} + +#[tauri::command] +pub async fn player_remove_from_queue( + player: State<'_, PlayerStateWrapper>, + index: usize, +) -> Result { + let controller = player.0.lock().await; + let queue = controller.queue(); + let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; + + queue_lock.remove(index); + + let result = QueueStatus { + items: queue_lock.items().to_vec(), + current_index: queue_lock.current_index(), + shuffle: queue_lock.is_shuffle(), + repeat: queue_lock.repeat_mode(), + has_next: queue_lock.has_next(), + has_previous: queue_lock.has_previous(), + }; + + drop(queue_lock); + controller.emit_queue_changed(); + + Ok(result) +} + +#[tauri::command] +pub async fn player_move_in_queue( + player: State<'_, PlayerStateWrapper>, + from_index: usize, + to_index: usize, +) -> Result { + let controller = player.0.lock().await; + let queue = controller.queue(); + let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; + + if !queue_lock.move_item(from_index, to_index) { + return Err("Invalid indices for move operation".to_string()); + } + + let result = QueueStatus { + items: queue_lock.items().to_vec(), + current_index: queue_lock.current_index(), + shuffle: queue_lock.is_shuffle(), + repeat: queue_lock.repeat_mode(), + has_next: queue_lock.has_next(), + has_previous: queue_lock.has_previous(), + }; + + drop(queue_lock); + controller.emit_queue_changed(); + + Ok(result) +} + +/// Add a track to queue by ID - backend fetches metadata and constructs URLs +#[tauri::command] +pub async fn player_add_track_by_id( + player: State<'_, PlayerStateWrapper>, + db: State<'_, DatabaseWrapper>, + repository_manager: State<'_, RepositoryManagerWrapper>, + repository_handle: String, + request: AddTrackByIdRequest, +) -> Result { + use crate::player::queue::AddPosition; + + info!("player_add_track_by_id called: track_id={}, position={}", + request.track_id, request.position); + + // Get repository (hybrid - supports offline/online) + let repository = repository_manager.0.get(&repository_handle) + .ok_or("Repository not found - user may need to log in")?; + + // Fetch track metadata via repository + info!("Fetching metadata for track {} via repository", request.track_id); + let track = repository.get_item(&request.track_id).await + .map_err(|e| format!("Failed to fetch track metadata: {}", e))?; + + // Check for local download first + let local_path = check_for_local_download(&db, &request.track_id).await?; + + let source = if let Some(path) = local_path { + info!("Using local download for track {}", request.track_id); + MediaSource::Local { + file_path: PathBuf::from(path), + jellyfin_item_id: Some(track.id.clone()), + } + } else { + // Get stream URL from repository (works online/offline) + let stream_url = repository.get_audio_stream_url(&track.id).await + .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?; + + MediaSource::Remote { + stream_url, + jellyfin_item_id: track.id.clone(), + } + }; + + // Build MediaItem with artwork URL from repository and frontend-compatible fields + let primary_image_tag_for_url = track.primary_image_tag.clone(); + let media_item = MediaItem { + id: track.id.clone(), + title: track.name.clone(), + name: Some(track.name.clone()), // Frontend compatibility + artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())), + album: track.album_name.clone(), + album_name: track.album_name.clone(), // Frontend compatibility + album_id: track.album_id.clone(), + artist_items: track.artist_items.clone(), // For clickable artist links + artists: track.artists.clone(), // Fallback artist info + primary_image_tag: track.primary_image_tag.clone(), // For frontend image display + item_type: Some(track.item_type.clone()), // Frontend compatibility + playlist_id: None, + duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), + artwork_url: primary_image_tag_for_url.and_then(|tag| { + track.album_id.as_ref().map(|album_id| { + repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions { + max_width: Some(300), + tag: Some(tag), + ..Default::default() + })) + }) + }), + media_type: MediaType::Audio, + source, + video_codec: None, + needs_transcoding: false, + video_width: None, + video_height: None, + subtitles: vec![], + series_id: None, + server_id: None, + }; + + // Add to queue at specified position + let position = match request.position.as_str() { + "next" => AddPosition::Next, + _ => AddPosition::End, + }; + + let controller = player.0.lock().await; + let queue = controller.queue(); + let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; + + queue_lock.add(vec![media_item], position); + + let result = get_queue_status(&controller); + drop(queue_lock); + controller.emit_queue_changed(); + + info!("Successfully added track {} to queue", request.track_id); + Ok(result) +} + +/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs +#[tauri::command] +pub async fn player_add_tracks_by_ids( + player: State<'_, PlayerStateWrapper>, + db: State<'_, DatabaseWrapper>, + repository_manager: State<'_, RepositoryManagerWrapper>, + repository_handle: String, + request: AddTracksByIdsRequest, +) -> Result { + use crate::player::queue::AddPosition; + + info!("player_add_tracks_by_ids called: {} tracks, position={}", + request.track_ids.len(), request.position); + + // Get repository (hybrid - supports offline/online) + let repository = repository_manager.0.get(&repository_handle) + .ok_or("Repository not found - user may need to log in")?; + + // Fetch metadata and build MediaItems for all tracks + let mut media_items = Vec::new(); + for track_id in &request.track_ids { + info!("Fetching metadata for track {} via repository", track_id); + let track = repository.get_item(track_id).await + .map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?; + + // Check for local download first + let local_path = check_for_local_download(&db, track_id).await?; + + let source = if let Some(path) = local_path { + info!("Using local download for track {}", track_id); + MediaSource::Local { + file_path: PathBuf::from(path), + jellyfin_item_id: Some(track.id.clone()), + } + } else { + // Get stream URL from repository (works online/offline) + let stream_url = repository.get_audio_stream_url(&track.id).await + .map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?; + + MediaSource::Remote { + stream_url, + jellyfin_item_id: track.id.clone(), + } + }; + + // Build MediaItem with artwork URL from repository and frontend-compatible fields + let primary_image_tag_for_url = track.primary_image_tag.clone(); + let media_item = MediaItem { + id: track.id.clone(), + title: track.name.clone(), + name: Some(track.name.clone()), // Frontend compatibility + artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())), + album: track.album_name.clone(), + album_name: track.album_name.clone(), // Frontend compatibility + album_id: track.album_id.clone(), + artist_items: track.artist_items.clone(), // For clickable artist links + artists: track.artists.clone(), // Fallback artist info + primary_image_tag: track.primary_image_tag.clone(), // For frontend image display + item_type: Some(track.item_type.clone()), // Frontend compatibility + playlist_id: None, + duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0), + artwork_url: primary_image_tag_for_url.and_then(|tag| { + track.album_id.as_ref().map(|album_id| { + repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions { + max_width: Some(300), + tag: Some(tag), + ..Default::default() + })) + }) + }), + media_type: MediaType::Audio, + source, + video_codec: None, + needs_transcoding: false, + video_width: None, + video_height: None, + subtitles: vec![], + series_id: None, + server_id: None, + }; + + media_items.push(media_item); + } + + // Add to queue at specified position + let position = match request.position.as_str() { + "next" => AddPosition::Next, + _ => AddPosition::End, + }; + + let controller = player.0.lock().await; + let queue = controller.queue(); + let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; + + queue_lock.add(media_items, position); + + let result = get_queue_status(&controller); + drop(queue_lock); + controller.emit_queue_changed(); + + info!("Successfully added {} tracks to queue", request.track_ids.len()); + Ok(result) +} + +#[tauri::command] +pub async fn player_skip_to( + player: State<'_, PlayerStateWrapper>, + index: usize, +) -> Result { + let controller = player.0.lock().await; + + // Skip to the index and get the item to play + let item = { + let queue = controller.queue(); + let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; + queue_lock.skip_to(index).cloned().ok_or("Invalid index")? + }; + + // Play the item without modifying the queue + controller.load_and_play(&item).map_err(|e| e.to_string())?; + + // Emit queue changed event + controller.emit_queue_changed(); + + Ok(get_player_status(&controller)) +} diff --git a/src-tauri/src/commands/player/settings.rs b/src-tauri/src/commands/player/settings.rs new file mode 100644 index 0000000..7bfdbf7 --- /dev/null +++ b/src-tauri/src/commands/player/settings.rs @@ -0,0 +1,58 @@ +//! Audio and video playback settings commands. + +use tauri::State; + +use super::{PlayerStateWrapper, VideoSettingsWrapper}; +use crate::player::AutoplaySettings; +use crate::settings::{AudioSettings, VideoSettings}; + +#[tauri::command] +pub async fn player_set_audio_settings( + player: State<'_, PlayerStateWrapper>, + settings: AudioSettings, +) -> Result { + let mut controller = player.0.lock().await; + controller + .set_audio_settings(&settings) + .map_err(|e| e.to_string())?; + Ok(controller.audio_settings()) +} + +#[tauri::command] +pub async fn player_get_audio_settings( + player: State<'_, PlayerStateWrapper>, +) -> Result { + let controller = player.0.lock().await; + Ok(controller.audio_settings()) +} + +#[tauri::command] +pub async fn player_set_video_settings( + video_settings: State<'_, VideoSettingsWrapper>, + player: State<'_, PlayerStateWrapper>, + settings: VideoSettings, +) -> Result { + let validated = settings.with_countdown_clamped(); + { + let mut current = video_settings.0.lock().map_err(|e| e.to_string())?; + *current = validated.clone(); + } // Drop MutexGuard before await + + // Sync to PlayerController's autoplay settings so on_playback_ended() uses current values + let controller = player.0.lock().await; + controller.set_autoplay_settings(AutoplaySettings { + enabled: validated.auto_play_next_episode, + countdown_seconds: validated.auto_play_countdown_seconds, + max_episodes: validated.auto_play_max_episodes, + }); + + Ok(validated) +} + +#[tauri::command] +pub async fn player_get_video_settings( + video_settings: State<'_, VideoSettingsWrapper>, +) -> Result { + let current = video_settings.0.lock().map_err(|e| e.to_string())?; + Ok(current.clone()) +}