//! Sleep-timer and autoplay commands. //! //! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049 //! //! 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, PlayerStateWrapper, PlayerStatus, }; 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 /// /// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052, DR-129 #[tauri::command] #[specta::specta] pub async fn player_on_playback_ended( player: State<'_, PlayerStateWrapper>, repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>, db: State<'_, DatabaseWrapper>, 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::info!("[Autoplay] Decision: Stop playback"); let controller = controller_arc.lock().await; // Clear the queue so the frontend's currentQueueItem becomes null and // the mini player hides. Without this, the queue still holds the last // track and the bar would linger (the frontend keeps the bar visible // through transient idle blips as long as a queue item exists). controller.clear_queue(); controller.emit_queue_changed(); 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::info!("[Autoplay] Decision: Advance to next track"); // Advance to next track in queue let controller = controller_arc.lock().await; // Prefer downloads that completed since the queue was built (e.g. // preloaded upcoming tracks) over continuing to stream. if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await { log::warn!("[Autoplay] Failed to refresh local sources: {}", e); } 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, }); } // Advance if auto_advance is enabled. This is the path that actually // runs on Android: the JNI callback's own decision is swallowed by the // NewTrackLoaded end reason set at load, so it returns Stop, emits // PlaybackEnded, and the frontend echoes it back into this command — // which is where the real decision lands. if auto_advance { controller_arc .lock() .await .auto_advance_to_next_episode(next_episode, countdown_seconds) .await; } } AutoplayDecision::ResumeStream { position } => { // The stream was cut short by the network, not by the media ending. // Re-open it where it died — no queue clearing, no PlaybackEnded, and // above all no leaving the player parked in ExoPlayer's STATE_ENDED, // where the next play intent restarts the item from 0:00. log::info!( "[Autoplay] Decision: Resume truncated stream at {:.1}s", position ); let controller = controller_arc.lock().await; if let Err(e) = controller.resume_stream_at(position).await { log::error!("[Autoplay] Failed to resume truncated stream: {}", e); if let Some(emitter) = controller.event_emitter() { emitter.emit(PlayerStatusEvent::PlaybackEnded); } } } } Ok(()) } /// Try to recover playback after a **recoverable** player error, reporting /// whether it was handled. /// /// The frontend's error handler stops the player, which is right for a real /// failure and wrong for a network blip — it turned every hiccup into "playback /// died". This is the echo path for backends that cannot decide in-process: /// MpvBackend is constructed before `PlayerController` exists ([`lib.rs`]), so /// its event thread has no controller to ask. It emits the error, the frontend /// echoes it here, and the decision stays in Rust — the same shape as /// `PlaybackEnded` → `player_on_playback_ended`. /// /// Returns `true` when the stream was re-opened and the caller must NOT stop the /// player; `false` when the error is real and should be surfaced as before. /// Android decides inside its JNI callback and only emits errors it has already /// declined to recover, so this reports `false` for those without a second /// opinion — the shared attempt budget is spent by then either way. /// /// TRACES: UR-004, UR-040 | DR-130 | UT-117 #[tauri::command] #[specta::specta] pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Result { let (position, delay_secs) = { let controller = player.0.lock().await; match controller.recoverable_error_resume() { Some(resume) => resume, None => return Ok(false), } }; log::warn!( "[Recovery] Stream failed — re-opening at {:.1}s in {}s", position, delay_secs ); // Give a brief outage time to clear; retrying instantly just burns the budget. tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await; let controller = player.0.lock().await; match controller.resume_stream_at(position).await { Ok(()) => Ok(true), Err(e) => { log::error!("[Recovery] Failed to re-open stream: {}", e); Ok(false) } } } // ===== HTML5 video state-report commands ===== // // On platforms where video renders in the webview (Linux WebKitGTK HTML5 //