//! 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] #[specta::specta] 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] #[specta::specta] 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] #[specta::specta] 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] #[specta::specta] 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] #[specta::specta] 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] #[specta::specta] 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] #[specta::specta] 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] #[specta::specta] 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(()) }