diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index 87f78b2..6e9e305 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -1,9 +1,13 @@ //! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001 -// Remote Jellyfin session control commands (casting) live in their own submodule -// and are re-exported so the command names remain at `commands::player::*`. +// Cohesive command clusters live in their own submodules and are re-exported so +// the command names remain at `commands::player::*` (invoke_handler unchanged). mod remote; +mod session; +mod timers; pub use remote::*; +pub use session::*; +pub use timers::*; use crate::utils::lock::MutexSafe; use log::{debug, error, info, warn}; @@ -19,7 +23,7 @@ use crate::jellyfin::{JellyfinClient, JellyfinConfig}; use crate::player::{ determine_video_seek_strategy, AutoplaySettings, MediaItem, MediaSource, MediaType, MediaSessionManager, PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, - SleepTimerMode, SleepTimerState, VideoSeekStrategy, + VideoSeekStrategy, }; use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}}; use crate::settings::{AudioSettings, VideoSettings}; @@ -280,7 +284,7 @@ pub enum AudioTrackSwitchResponse { /// /// PlayItemRequest is now video-only, so we create a video MediaItem. /// Audio playback uses player_play_tracks which fetches full metadata from backend. -async fn create_media_item( +pub(super) async fn create_media_item( req: PlayItemRequest, db: Option<&DatabaseWrapper>, ) -> Result { @@ -1213,7 +1217,7 @@ pub async fn player_get_video_settings( Ok(current.clone()) } -fn get_player_status(controller: &PlayerController) -> PlayerStatus { +pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus { // Determine backend at compile time based on platform let (backend, use_html5_element) = if cfg!(target_os = "android") { // Android uses ExoPlayer native backend @@ -2116,255 +2120,6 @@ pub async fn player_disable_jellyfin( Ok(()) } -// ===== Sleep Timer Commands ===== - -/// Set sleep timer mode -#[tauri::command] -pub async fn player_set_sleep_timer( - player: State<'_, PlayerStateWrapper>, - mode: SleepTimerMode, -) -> Result { - let controller = player.0.lock().await; - controller.set_sleep_timer(mode); - Ok(controller.sleep_timer_state()) -} - -/// Cancel sleep timer -#[tauri::command] -pub async fn player_cancel_sleep_timer( - player: State<'_, PlayerStateWrapper>, -) -> Result { - let controller = player.0.lock().await; - controller.cancel_sleep_timer(); - Ok(controller.sleep_timer_state()) -} - -/// Get current sleep timer state -#[tauri::command] -pub async fn player_get_sleep_timer( - player: State<'_, PlayerStateWrapper>, -) -> Result { - let controller = player.0.lock().await; - Ok(controller.sleep_timer_state()) -} - -// ===== Autoplay Commands ===== - -/// Get autoplay settings -#[tauri::command] -pub async fn player_get_autoplay_settings( - player: State<'_, PlayerStateWrapper>, -) -> Result { - let controller = player.0.lock().await; - Ok(controller.autoplay_settings()) -} - -/// Set autoplay settings and persist to database -#[tauri::command] -pub async fn player_set_autoplay_settings( - player: State<'_, PlayerStateWrapper>, - db: State<'_, DatabaseWrapper>, - user_id: String, - settings: AutoplaySettings, -) -> Result { - let validated = settings.with_validated_countdown(); - - // Set in controller - { - let controller = player.0.lock().await; - controller.set_autoplay_settings(validated.clone()); - } - - // Persist to database - let db_service = { - let database = db.0.lock().map_err(|e| e.to_string())?; - Arc::new(database.service()) - }; - - let query = Query::with_params( - "INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, autoplay_max_episodes, updated_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) - ON CONFLICT(user_id) DO UPDATE SET - autoplay_next_episode = excluded.autoplay_next_episode, - autoplay_countdown_seconds = excluded.autoplay_countdown_seconds, - autoplay_max_episodes = excluded.autoplay_max_episodes, - updated_at = CURRENT_TIMESTAMP", - vec![ - QueryParam::String(user_id), - QueryParam::Int(if validated.enabled { 1 } else { 0 }), - QueryParam::Int(validated.countdown_seconds as i32), - QueryParam::Int(validated.max_episodes as i32), - ], - ); - - db_service.execute(query).await.map_err(|e| e.to_string())?; - - Ok(validated) -} - -/// Cancel active autoplay countdown -#[tauri::command] -pub async fn player_cancel_autoplay_countdown( - player: State<'_, PlayerStateWrapper>, -) -> Result<(), String> { - let controller = player.0.lock().await; - controller.cancel_autoplay_countdown(); - Ok(()) -} - -/// Play next episode (user confirmed from popup) -#[tauri::command] -pub async fn player_play_next_episode( - player: State<'_, PlayerStateWrapper>, - db: State<'_, DatabaseWrapper>, - item: PlayItemRequest, -) -> Result { - // Convert request to MediaItem - let media_item = create_media_item(item, Some(&db)).await?; - - let controller = player.0.lock().await; - controller.play_item(media_item).map_err(|e| e.to_string())?; - - Ok(get_player_status(&controller)) -} - -/// Handle playback ended event - triggers autoplay decision logic -/// This is called from: -/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video -/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed -/// - Android JNI callback also triggers this logic directly -#[tauri::command] -pub async fn player_on_playback_ended( - player: State<'_, PlayerStateWrapper>, - repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, - item_id: Option, - repository_handle: Option, -) -> Result<(), String> { - use crate::player::autoplay::AutoplayDecision; - use crate::player::PlayerStatusEvent; - - let controller_arc = player.0.clone(); - - // Run autoplay decision logic - // If item_id is provided (HTML5 video case), use the video-specific path - // that bypasses the backend queue and stale end_reason - let decision = { - let controller = controller_arc.lock().await; - if let Some(ref id) = item_id { - // Video path: need repository to look up episode info - let repo = repository_handle - .as_ref() - .and_then(|handle| repository_manager.0.get(handle)); - if let Some(repo) = repo { - controller.on_video_playback_ended(id, repo).await? - } else { - log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id); - AutoplayDecision::Stop - } - } else { - controller.on_playback_ended().await? - } - }; - - // Handle the decision - match decision { - AutoplayDecision::Stop => { - log::debug!("[Autoplay] Decision: Stop playback"); - let controller = controller_arc.lock().await; - if let Some(emitter) = controller.event_emitter() { - // Emit StateChanged to idle to clear the current media from mini player - // Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop - // (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...) - emitter.emit(PlayerStatusEvent::StateChanged { - state: "idle".to_string(), - media_id: None, - }); - } - } - AutoplayDecision::AdvanceToNext => { - log::debug!("[Autoplay] Decision: Advance to next track"); - // Advance to next track in queue - let controller = controller_arc.lock().await; - if let Err(e) = controller.next() { - log::error!("[Autoplay] Failed to advance to next track: {}", e); - // Emit PlaybackEnded event on error - if let Some(emitter) = controller.event_emitter() { - emitter.emit(PlayerStatusEvent::PlaybackEnded); - } - } else { - // Emit queue changed event so frontend updates UI with new current track - controller.emit_queue_changed(); - } - } - AutoplayDecision::ShowNextEpisodePopup { - current_episode, - next_episode, - countdown_seconds, - auto_advance, - } => { - log::info!( - "[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})", - countdown_seconds, - auto_advance - ); - - // Emit popup event to frontend - if let Some(emitter) = controller_arc.lock().await.event_emitter() { - emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup { - current_episode: current_episode.clone(), - next_episode: next_episode.clone(), - countdown_seconds, - auto_advance, - }); - } - - // Start countdown if auto_advance enabled - if auto_advance { - controller_arc - .lock() - .await - .start_autoplay_countdown(next_episode, countdown_seconds); - } - } - } - - Ok(()) -} - -/// Get the current media session state -#[tauri::command] -pub async fn player_get_session( - session: State<'_, MediaSessionManagerWrapper>, -) -> Result { - let manager = session.0.lock().map_err(|e| e.to_string())?; - Ok(manager.current().clone()) -} - -/// Dismiss the current media session (returns to Idle) -#[tauri::command] -pub async fn player_dismiss_session( - session: State<'_, MediaSessionManagerWrapper>, - player: State<'_, PlayerStateWrapper>, -) -> Result<(), String> { - log::info!("[Session] Dismissing current media session"); - - // Dismiss the session - { - let mut manager = session.0.lock().map_err(|e| e.to_string())?; - manager.dismiss(); - } - - // Emit session changed event - if let Some(emitter) = player.0.lock().await.event_emitter() { - let manager = session.0.lock().map_err(|e| e.to_string())?; - emitter.emit(PlayerStatusEvent::SessionChanged { - session: manager.current().clone(), - }); - } - - Ok(()) -} - #[cfg(test)] mod tests { /// Test track index finding in album diff --git a/src-tauri/src/commands/player/session.rs b/src-tauri/src/commands/player/session.rs new file mode 100644 index 0000000..91e84e7 --- /dev/null +++ b/src-tauri/src/commands/player/session.rs @@ -0,0 +1,43 @@ +//! Media session state commands. +//! +//! Read and dismiss the current media session (the Now Playing surface backing +//! lockscreen/notification controls). + +use tauri::State; + +use super::{MediaSessionManagerWrapper, PlayerStateWrapper}; +use crate::player::PlayerStatusEvent; + +/// Get the current media session state +#[tauri::command] +pub async fn player_get_session( + session: State<'_, MediaSessionManagerWrapper>, +) -> Result { + let manager = session.0.lock().map_err(|e| e.to_string())?; + Ok(manager.current().clone()) +} + +/// Dismiss the current media session (returns to Idle) +#[tauri::command] +pub async fn player_dismiss_session( + session: State<'_, MediaSessionManagerWrapper>, + player: State<'_, PlayerStateWrapper>, +) -> Result<(), String> { + log::info!("[Session] Dismissing current media session"); + + // Dismiss the session + { + let mut manager = session.0.lock().map_err(|e| e.to_string())?; + manager.dismiss(); + } + + // Emit session changed event + if let Some(emitter) = player.0.lock().await.event_emitter() { + let manager = session.0.lock().map_err(|e| e.to_string())?; + emitter.emit(PlayerStatusEvent::SessionChanged { + session: manager.current().clone(), + }); + } + + Ok(()) +} diff --git a/src-tauri/src/commands/player/timers.rs b/src-tauri/src/commands/player/timers.rs new file mode 100644 index 0000000..aa3bca3 --- /dev/null +++ b/src-tauri/src/commands/player/timers.rs @@ -0,0 +1,229 @@ +//! Sleep-timer and autoplay commands. +//! +//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay +//! logic, plus persistence of autoplay settings to the database. + +use std::sync::Arc; +use tauri::State; + +use super::{ + create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus, + PlayerStateWrapper, +}; +use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState}; +use crate::storage::db_service::{DatabaseService, Query, QueryParam}; + +// ===== Sleep Timer Commands ===== + +/// Set sleep timer mode +#[tauri::command] +pub async fn player_set_sleep_timer( + player: State<'_, PlayerStateWrapper>, + mode: SleepTimerMode, +) -> Result { + let controller = player.0.lock().await; + controller.set_sleep_timer(mode); + Ok(controller.sleep_timer_state()) +} + +/// Cancel sleep timer +#[tauri::command] +pub async fn player_cancel_sleep_timer( + player: State<'_, PlayerStateWrapper>, +) -> Result { + let controller = player.0.lock().await; + controller.cancel_sleep_timer(); + Ok(controller.sleep_timer_state()) +} + +/// Get current sleep timer state +#[tauri::command] +pub async fn player_get_sleep_timer( + player: State<'_, PlayerStateWrapper>, +) -> Result { + let controller = player.0.lock().await; + Ok(controller.sleep_timer_state()) +} + +// ===== Autoplay Commands ===== + +/// Get autoplay settings +#[tauri::command] +pub async fn player_get_autoplay_settings( + player: State<'_, PlayerStateWrapper>, +) -> Result { + let controller = player.0.lock().await; + Ok(controller.autoplay_settings()) +} + +/// Set autoplay settings and persist to database +#[tauri::command] +pub async fn player_set_autoplay_settings( + player: State<'_, PlayerStateWrapper>, + db: State<'_, DatabaseWrapper>, + user_id: String, + settings: AutoplaySettings, +) -> Result { + let validated = settings.with_validated_countdown(); + + // Set in controller + { + let controller = player.0.lock().await; + controller.set_autoplay_settings(validated.clone()); + } + + // Persist to database + let db_service = { + let database = db.0.lock().map_err(|e| e.to_string())?; + Arc::new(database.service()) + }; + + let query = Query::with_params( + "INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, autoplay_max_episodes, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + autoplay_next_episode = excluded.autoplay_next_episode, + autoplay_countdown_seconds = excluded.autoplay_countdown_seconds, + autoplay_max_episodes = excluded.autoplay_max_episodes, + updated_at = CURRENT_TIMESTAMP", + vec![ + QueryParam::String(user_id), + QueryParam::Int(if validated.enabled { 1 } else { 0 }), + QueryParam::Int(validated.countdown_seconds as i32), + QueryParam::Int(validated.max_episodes as i32), + ], + ); + + db_service.execute(query).await.map_err(|e| e.to_string())?; + + Ok(validated) +} + +/// Cancel active autoplay countdown +#[tauri::command] +pub async fn player_cancel_autoplay_countdown( + player: State<'_, PlayerStateWrapper>, +) -> Result<(), String> { + let controller = player.0.lock().await; + controller.cancel_autoplay_countdown(); + Ok(()) +} + +/// Play next episode (user confirmed from popup) +#[tauri::command] +pub async fn player_play_next_episode( + player: State<'_, PlayerStateWrapper>, + db: State<'_, DatabaseWrapper>, + item: PlayItemRequest, +) -> Result { + // Convert request to MediaItem + let media_item = create_media_item(item, Some(&db)).await?; + + let controller = player.0.lock().await; + controller.play_item(media_item).map_err(|e| e.to_string())?; + + Ok(get_player_status(&controller)) +} + +/// Handle playback ended event - triggers autoplay decision logic +/// This is called from: +/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video +/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed +/// - Android JNI callback also triggers this logic directly +#[tauri::command] +pub async fn player_on_playback_ended( + player: State<'_, PlayerStateWrapper>, + repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>, + item_id: Option, + repository_handle: Option, +) -> Result<(), String> { + use crate::player::autoplay::AutoplayDecision; + use crate::player::PlayerStatusEvent; + + let controller_arc = player.0.clone(); + + // Run autoplay decision logic + // If item_id is provided (HTML5 video case), use the video-specific path + // that bypasses the backend queue and stale end_reason + let decision = { + let controller = controller_arc.lock().await; + if let Some(ref id) = item_id { + // Video path: need repository to look up episode info + let repo = repository_handle + .as_ref() + .and_then(|handle| repository_manager.0.get(handle)); + if let Some(repo) = repo { + controller.on_video_playback_ended(id, repo).await? + } else { + log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id); + AutoplayDecision::Stop + } + } else { + controller.on_playback_ended().await? + } + }; + + // Handle the decision + match decision { + AutoplayDecision::Stop => { + log::debug!("[Autoplay] Decision: Stop playback"); + let controller = controller_arc.lock().await; + if let Some(emitter) = controller.event_emitter() { + // Emit StateChanged to idle to clear the current media from mini player + // Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop + // (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...) + emitter.emit(PlayerStatusEvent::StateChanged { + state: "idle".to_string(), + media_id: None, + }); + } + } + AutoplayDecision::AdvanceToNext => { + log::debug!("[Autoplay] Decision: Advance to next track"); + // Advance to next track in queue + let controller = controller_arc.lock().await; + if let Err(e) = controller.next() { + log::error!("[Autoplay] Failed to advance to next track: {}", e); + // Emit PlaybackEnded event on error + if let Some(emitter) = controller.event_emitter() { + emitter.emit(PlayerStatusEvent::PlaybackEnded); + } + } else { + // Emit queue changed event so frontend updates UI with new current track + controller.emit_queue_changed(); + } + } + AutoplayDecision::ShowNextEpisodePopup { + current_episode, + next_episode, + countdown_seconds, + auto_advance, + } => { + log::info!( + "[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})", + countdown_seconds, + auto_advance + ); + + // Emit popup event to frontend + if let Some(emitter) = controller_arc.lock().await.event_emitter() { + emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup { + current_episode: current_episode.clone(), + next_episode: next_episode.clone(), + countdown_seconds, + auto_advance, + }); + } + + // Start countdown if auto_advance enabled + if auto_advance { + controller_arc + .lock() + .await + .start_autoplay_countdown(next_episode, countdown_seconds); + } + } + } + + Ok(()) +}