// Player module - Complete playback control system // TRACES: UR-003, UR-004, UR-005, UR-019, UR-023, UR-026 | // IR-003, IR-004, IR-006, IR-008 | // DR-001, DR-004, DR-005, DR-009, DR-028, DR-029, DR-047 pub mod autoplay; pub mod backend; pub mod events; pub mod media; pub mod queue; pub mod session; pub mod sleep_timer; pub mod state; #[cfg(test)] mod mpv_backend_test; // Platform-specific backends #[cfg(target_os = "android")] pub mod android; #[cfg(target_os = "linux")] pub mod mpv_backend; // Re-export commonly used types pub use autoplay::{AutoplayDecision, AutoplaySettings}; pub use backend::{NullBackend, PlayerBackend, PlayerError}; pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter}; pub use media::{MediaItem, MediaSource, MediaType, QueueContext}; pub use queue::{QueueManager, RepeatMode}; pub use session::{MediaSessionManager, MediaSessionType}; pub use sleep_timer::{SleepTimerMode, SleepTimerState}; pub use state::{EndReason, PlayerState}; // Re-export platform-specific backends #[cfg(target_os = "android")] pub use android::ExoPlayerBackend; #[cfg(target_os = "linux")] pub use mpv_backend::MpvBackend; #[cfg(target_os = "android")] pub use android::{ MediaCommandHandler, RemoteVolumeHandler, enable_remote_volume, disable_remote_volume, set_media_command_handler, set_remote_volume_handler, get_detected_codecs, }; use log::{debug, error, warn}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::Mutex as TokioMutex; use crate::jellyfin::JellyfinClient; use crate::settings::AudioSettings; use crate::repository::MediaRepository; use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation, PlaybackContext}; /// Central player controller that coordinates playback pub struct PlayerController { backend: Arc>>, queue: Arc>, jellyfin_client: Arc>>, muted: bool, // Sleep timer state sleep_timer: Arc>, // Autoplay settings autoplay_settings: Arc>, // Repository for fetching next episodes repository: Arc>>>, // Event emitter for notifications event_emitter: Arc>>>, // Countdown cancellation handle countdown_cancel: Arc>>>>, // Playback reporting (dual sync: local DB + server) playback_reporter: Arc>>, // Event throttler to prevent spam from position updates // Will be used when position update hooks are added to backends #[allow(dead_code)] position_throttler: Arc, // End reason tracking for autoplay decision making end_reason: Arc>>, // Auto-play episode counter (session-based, resets on manual play) autoplay_episode_count: Arc>, } impl PlayerController { pub fn new( backend: Box, playback_reporter: Arc>>, position_throttler: Arc, ) -> Self { let controller = Self { backend: Arc::new(Mutex::new(backend)), queue: Arc::new(Mutex::new(QueueManager::new())), jellyfin_client: Arc::new(Mutex::new(None)), muted: false, sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())), autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())), repository: Arc::new(Mutex::new(None)), event_emitter: Arc::new(Mutex::new(None)), countdown_cancel: Arc::new(Mutex::new(None)), playback_reporter, position_throttler, end_reason: Arc::new(Mutex::new(None)), autoplay_episode_count: Arc::new(Mutex::new(0)), }; // Start background timer thread for sleep timer countdown controller.start_timer_thread(); controller } /// Configure the Jellyfin API client for automatic playback reporting pub fn set_jellyfin_client(&self, client: Option) { let mut jellyfin = self.jellyfin_client.lock().unwrap(); *jellyfin = client; log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some()); } /// Get a reference to the Jellyfin client (for remote session control) pub fn jellyfin_client(&self) -> Arc>> { self.jellyfin_client.clone() } /// Configure the playback reporter for dual sync (local DB + server) /// Will be called from initialization commands after login #[allow(dead_code)] pub async fn set_playback_reporter(&self, reporter: Option) { let mut reporter_guard = self.playback_reporter.lock().await; *reporter_guard = reporter; log::info!("[PlayerController] Playback reporter configured: {}", reporter_guard.is_some()); } /// Get a reference to the playback reporter (for backend position updates) /// Will be used when position update hooks are added to backends #[allow(dead_code)] pub fn playback_reporter(&self) -> Arc>> { self.playback_reporter.clone() } /// Get a reference to the position throttler (for backend position updates) /// Will be used when position update hooks are added to backends #[allow(dead_code)] pub fn position_throttler(&self) -> Arc { self.position_throttler.clone() } /// Set the end reason for the next playback end event fn set_end_reason(&self, reason: EndReason) { log::debug!("[PlayerController] Setting end reason: {:?}", reason); *self.end_reason.lock().unwrap() = Some(reason); } /// Get and clear the current end reason fn take_end_reason(&self) -> Option { self.end_reason.lock().unwrap().take() } /// Increment autoplay episode counter. Returns true if limit is reached. fn increment_autoplay_count(&self) -> bool { let max = self.autoplay_settings.lock().unwrap().max_episodes; if max == 0 { // Unlimited return false; } let mut count = self.autoplay_episode_count.lock().unwrap(); *count += 1; debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max); *count >= max } /// Reset autoplay episode counter (called on manual play actions) fn reset_autoplay_count(&self) { let mut count = self.autoplay_episode_count.lock().unwrap(); if *count > 0 { debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count); } *count = 0; } /// Load and play a single item (also sets the queue to contain only this item) pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> { debug!("[PlayerController] play_item: {}", item.title); // Reset autoplay counter on manual play self.reset_autoplay_count(); // Update queue with this single item { let mut queue = self.queue.lock().unwrap(); queue.set_queue(vec![item.clone()], 0); } // Load and play the item self.load_and_play(&item)?; Ok(()) } /// Load and play an item without modifying the queue /// Use this when the queue is already set up and you just want to play a specific item from it pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> { debug!("[PlayerController] load_and_play: {}", item.title); // Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track self.set_end_reason(EndReason::NewTrackLoaded); let mut backend = self.backend.lock().unwrap(); backend.load(item)?; backend.play()?; drop(backend); // Report playback start using PlaybackReporter (dual sync: local DB + server) if let Some(jellyfin_id) = item.jellyfin_id() { let jellyfin_id = jellyfin_id.to_string(); let reporter = self.playback_reporter.clone(); // Build playback context from item metadata let context = if item.album_id.is_some() { Some(PlaybackContext { context_type: "container".to_string(), context_id: item.album_id.clone(), }) } else { None }; // Spawn on Tokio runtime if available, otherwise use a new thread if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { let reporter_guard = reporter.lock().await; if let Some(reporter_instance) = reporter_guard.as_ref() { log::info!("[PlayerController] Reporting playback start via PlaybackReporter: {}", jellyfin_id); let operation = PlaybackOperation::Start { item_id: jellyfin_id.clone(), position_ticks: 0, context, }; // Note: PlaybackReporter internally checks connectivity // We pass is_online=true as default; reporter will check actual status match reporter_instance.report(operation, true).await { Ok(_) => log::info!("[PlayerController] Successfully reported playback start (local DB + server sync)"), Err(e) => log::error!("[PlayerController] Failed to report playback start: {}", e), } } else { log::warn!("[PlayerController] PlaybackReporter not initialized - using fallback JellyfinClient"); // Fallback to legacy JellyfinClient (will be removed after full migration) } }); } else { // Fallback: spawn in a new thread with its own runtime std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { let reporter_guard = reporter.lock().await; if let Some(reporter_instance) = reporter_guard.as_ref() { log::info!("[PlayerController] Reporting playback start via PlaybackReporter: {}", jellyfin_id); let operation = PlaybackOperation::Start { item_id: jellyfin_id.clone(), position_ticks: 0, context, }; match reporter_instance.report(operation, true).await { Ok(_) => log::info!("[PlayerController] Successfully reported playback start"), Err(e) => log::error!("[PlayerController] Failed to report playback start: {}", e), } } }); }); } } Ok(()) } /// Set the queue and start playing from the specified index pub fn play_queue(&self, items: Vec, start_index: usize) -> Result<(), PlayerError> { debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index); // Reset autoplay counter on manual queue start self.reset_autoplay_count(); { let mut queue = self.queue.lock().unwrap(); queue.set_queue(items, start_index); } // Play the current item (without modifying the queue we just set) if let Some(item) = self.queue.lock().unwrap().current().cloned() { self.load_and_play(&item)?; } Ok(()) } /// Play/resume playback pub fn play(&self) -> Result<(), PlayerError> { debug!("[PlayerController] play"); let mut backend = self.backend.lock().unwrap(); backend.play() } /// Pause playback pub fn pause(&self) -> Result<(), PlayerError> { let mut backend = self.backend.lock().unwrap(); backend.pause() } /// Toggle play/pause pub fn toggle_playback(&self) -> Result<(), PlayerError> { let mut backend = self.backend.lock().unwrap(); if backend.state().is_playing() { backend.pause() } else { backend.play() } } /// Stop playback pub fn stop(&self) -> Result<(), PlayerError> { // Set end reason to UserStop to prevent autoplay self.set_end_reason(EndReason::UserStop); // Get current playback info before stopping let jellyfin_id = { let queue = self.queue.lock().unwrap(); queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string())) }; let position_ticks = { let backend = self.backend.lock().unwrap(); (backend.position() * 10_000_000.0) as i64 }; let mut backend = self.backend.lock().unwrap(); backend.stop()?; drop(backend); // Report playback stopped using PlaybackReporter (dual sync: local DB + server) if let Some(jellyfin_id) = jellyfin_id { let reporter = self.playback_reporter.clone(); // Spawn on Tokio runtime if available, otherwise use a new thread if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { let reporter_guard = reporter.lock().await; if let Some(reporter_instance) = reporter_guard.as_ref() { log::info!("[PlayerController] Reporting playback stopped via PlaybackReporter: {}", jellyfin_id); let operation = PlaybackOperation::Stopped { item_id: jellyfin_id.clone(), position_ticks, }; match reporter_instance.report(operation, true).await { Ok(_) => log::info!("[PlayerController] Successfully reported playback stopped (local DB + server sync)"), Err(e) => log::error!("[PlayerController] Failed to report playback stopped: {}", e), } } else { log::warn!("[PlayerController] PlaybackReporter not initialized"); } }); } else { // Fallback: spawn in a new thread with its own runtime std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { let reporter_guard = reporter.lock().await; if let Some(reporter_instance) = reporter_guard.as_ref() { log::info!("[PlayerController] Reporting playback stopped via PlaybackReporter: {}", jellyfin_id); let operation = PlaybackOperation::Stopped { item_id: jellyfin_id.clone(), position_ticks, }; match reporter_instance.report(operation, true).await { Ok(_) => log::info!("[PlayerController] Successfully reported playback stopped"), Err(e) => log::error!("[PlayerController] Failed to report playback stopped: {}", e), } } }); }); } } Ok(()) } /// Skip to next track /// /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay /// from triggering when the current track's EndFile event fires pub fn next(&self) -> Result<(), PlayerError> { // Reset autoplay counter on manual skip self.reset_autoplay_count(); let next_item = { let mut queue = self.queue.lock().unwrap(); queue.next().cloned() }; debug!("[PlayerController] next: {:?}", next_item.as_ref().map(|i| &i.title)); if let Some(item) = next_item { self.load_and_play(&item) } else { debug!("[PlayerController] No next item, stopping"); self.stop() } } /// Skip to previous track /// /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay /// from triggering when the current track's EndFile event fires pub fn previous(&self) -> Result<(), PlayerError> { // Reset autoplay counter on manual skip self.reset_autoplay_count(); // If we're more than 3 seconds in, restart current track { let backend = self.backend.lock().unwrap(); if backend.position() > 3.0 { debug!("[PlayerController] previous: restarting current track (position > 3s)"); drop(backend); return self.seek(0.0); } } let prev_item = { let mut queue = self.queue.lock().unwrap(); queue.previous().cloned() }; debug!("[PlayerController] previous: {:?}", prev_item.as_ref().map(|i| &i.title)); if let Some(item) = prev_item { self.load_and_play(&item) } else { self.seek(0.0) } } /// Seek to a position in seconds pub fn seek(&self, position: f64) -> Result<(), PlayerError> { let mut backend = self.backend.lock().unwrap(); backend.seek(position) } /// Set volume (0.0 - 1.0) pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> { self.backend.lock().unwrap().set_volume(volume) } /// Set the active audio track by stream index pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> { let mut backend = self.backend.lock().unwrap(); backend.set_audio_track(stream_index) } /// Set the active subtitle track by stream index (None to disable subtitles) pub fn set_subtitle_track(&self, stream_index: Option) -> Result<(), PlayerError> { let mut backend = self.backend.lock().unwrap(); backend.set_subtitle_track(stream_index) } /// Get current state pub fn state(&self) -> PlayerState { self.backend.lock().unwrap().state() } /// Get current position pub fn position(&self) -> f64 { self.backend.lock().unwrap().position() } /// Get duration pub fn duration(&self) -> Option { self.backend.lock().unwrap().duration() } /// Get queue reference pub fn queue(&self) -> Arc> { self.queue.clone() } /// Toggle shuffle pub fn toggle_shuffle(&self) { self.queue.lock().unwrap().toggle_shuffle(); } /// Cycle repeat mode pub fn cycle_repeat(&self) { self.queue.lock().unwrap().cycle_repeat(); } /// Check if shuffle is enabled pub fn is_shuffle(&self) -> bool { self.queue.lock().unwrap().is_shuffle() } /// Get repeat mode pub fn repeat_mode(&self) -> RepeatMode { self.queue.lock().unwrap().repeat_mode() } /// Get current volume (0.0 - 1.0) pub fn volume(&self) -> f32 { self.backend.lock().unwrap().volume() } /// Check if muted pub fn muted(&self) -> bool { self.muted } /// Set audio settings (crossfade, gapless, normalization) pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> { self.backend.lock().unwrap().set_audio_settings(settings) } /// Get current audio settings pub fn audio_settings(&self) -> AudioSettings { self.backend.lock().unwrap().audio_settings() } // ===== Sleep Timer Methods ===== /// Set the event emitter for notifications pub fn set_event_emitter(&self, emitter: Arc) { let mut event_emitter = self.event_emitter.lock().unwrap(); *event_emitter = Some(emitter); } /// Get the event emitter pub fn event_emitter(&self) -> Option> { self.event_emitter.lock().unwrap().clone() } /// Get sleep timer state pub fn sleep_timer_state(&self) -> SleepTimerState { self.sleep_timer.lock().unwrap().clone() } /// Set sleep timer mode (in-memory only, not persisted) pub fn set_sleep_timer(&self, mode: SleepTimerMode) { let mut timer = self.sleep_timer.lock().unwrap(); timer.mode = mode.clone(); if let SleepTimerMode::Time { end_time } = mode { let now = chrono::Utc::now().timestamp_millis(); timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32; } else { timer.remaining_seconds = 0; } drop(timer); // Emit event to frontend for display update self.emit_sleep_timer_changed(); } /// Cancel sleep timer pub fn cancel_sleep_timer(&self) { self.set_sleep_timer(SleepTimerMode::Off); } /// Start background timer thread for sleep timer countdown updates fn start_timer_thread(&self) { let sleep_timer = self.sleep_timer.clone(); let event_emitter = self.event_emitter.clone(); let backend = self.backend.clone(); std::thread::spawn(move || { loop { std::thread::sleep(Duration::from_secs(1)); let mut timer = sleep_timer.lock().unwrap(); if timer.is_active() { timer.update_remaining_seconds(); // Time-based timer expired: stop playback if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 { debug!("[SleepTimer] Time-based timer expired, stopping playback"); timer.cancel(); // Emit cancelled state if let Some(emitter) = event_emitter.lock().unwrap().as_ref() { emitter.emit(PlayerStatusEvent::SleepTimerChanged { mode: SleepTimerMode::Off, remaining_seconds: 0, }); } drop(timer); // Stop the backend if let Err(e) = backend.lock().unwrap().stop() { error!("[SleepTimer] Failed to stop playback: {}", e); } continue; } // Emit update event if let Some(emitter) = event_emitter.lock().unwrap().as_ref() { emitter.emit(PlayerStatusEvent::SleepTimerChanged { mode: timer.mode.clone(), remaining_seconds: timer.remaining_seconds, }); } } drop(timer); } }); } /// Emit sleep timer changed event to frontend fn emit_sleep_timer_changed(&self) { let timer = self.sleep_timer.lock().unwrap().clone(); if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() { emitter.emit(PlayerStatusEvent::SleepTimerChanged { mode: timer.mode, remaining_seconds: timer.remaining_seconds, }); } } /// Emit queue changed event to frontend pub fn emit_queue_changed(&self) { let queue = self.queue.lock().unwrap(); debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}", queue.items().len(), queue.current_index()); if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() { emitter.emit(PlayerStatusEvent::QueueChanged { items: queue.items().to_vec(), current_index: queue.current_index(), shuffle: queue.is_shuffle(), repeat: queue.repeat_mode(), has_next: queue.has_next(), has_previous: queue.has_previous(), }); } else { warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!"); } } // ===== Autoplay Methods ===== /// Get autoplay settings pub fn autoplay_settings(&self) -> AutoplaySettings { self.autoplay_settings.lock().unwrap().clone() } /// Set autoplay settings (in-memory only, persistence handled by command layer) pub fn set_autoplay_settings(&self, settings: AutoplaySettings) { let validated = settings.with_validated_countdown(); *self.autoplay_settings.lock().unwrap() = validated; } /// Cancel active autoplay countdown pub fn cancel_autoplay_countdown(&self) { if let Some(cancel_flag) = self.countdown_cancel.lock().unwrap().as_ref() { *cancel_flag.lock().unwrap() = true; } } /// Handle playback ended event - decides what to do next /// /// Only triggers autoplay if the track finished naturally (EndReason::Finished or None). /// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay. pub async fn on_playback_ended(&self) -> Result { // Check why playback ended let end_reason = self.take_end_reason(); debug!("[PlayerController] on_playback_ended: end_reason={:?}", end_reason); // Only proceed with autoplay logic if track finished naturally match end_reason { None | Some(EndReason::Finished) => { // Track ended naturally, proceed with autoplay logic debug!("[PlayerController] Track finished naturally, checking autoplay"); } Some(EndReason::NewTrackLoaded) => { // User loaded a new track, don't autoplay debug!("[PlayerController] NewTrackLoaded - stopping without autoplay"); return Ok(AutoplayDecision::Stop); } Some(EndReason::UserStop) => { // User stopped playback, don't autoplay debug!("[PlayerController] UserStop - stopping without autoplay"); return Ok(AutoplayDecision::Stop); } Some(EndReason::UserSkip) => { // User skipped, already handled by next/previous debug!("[PlayerController] UserSkip - stopping without autoplay"); return Ok(AutoplayDecision::Stop); } Some(EndReason::Error) => { // Playback error, don't autoplay debug!("[PlayerController] Error - stopping without autoplay"); return Ok(AutoplayDecision::Stop); } } let current_item = { let queue = self.queue.lock().unwrap(); queue.current().cloned() }; let Some(current) = current_item else { return Ok(AutoplayDecision::Stop); }; // Check sleep timer state let timer_mode = { let timer = self.sleep_timer.lock().unwrap(); timer.mode.clone() }; match &timer_mode { SleepTimerMode::Time { end_time } => { // If time has expired, stop instead of playing next let now = chrono::Utc::now().timestamp_millis(); if now >= *end_time { debug!("[PlayerController] Time-based sleep timer expired at track boundary"); self.sleep_timer.lock().unwrap().cancel(); self.emit_sleep_timer_changed(); return Ok(AutoplayDecision::Stop); } } SleepTimerMode::EndOfTrack => { // Stop at end of track self.sleep_timer.lock().unwrap().cancel(); self.emit_sleep_timer_changed(); return Ok(AutoplayDecision::Stop); } SleepTimerMode::Episodes { .. } => { // Only count TV episodes (not audio tracks or movies) let is_episode = current.media_type == MediaType::Video && self.is_episode_item(¤t).await; if is_episode { let should_stop = self.sleep_timer.lock().unwrap().decrement_episode(); self.emit_sleep_timer_changed(); if should_stop { return Ok(AutoplayDecision::Stop); } } } _ => { // No action needed for other modes } } // For video episodes, fetch next episode and show popup // Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended). // It's here for the Android ExoPlayer path where video items may be in the backend queue. if current.media_type == MediaType::Video && self.is_episode_item(¤t).await { let repo = self.repository.lock().unwrap().clone(); let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id); let next_ep_result = if let Some(repo) = &repo { self.fetch_next_episode_for_item(jellyfin_id, repo).await? } else { debug!("[PlayerController] No repository available for audio-path episode lookup"); None }; if let Some(next_ep) = next_ep_result { let settings = self.autoplay_settings.lock().unwrap().clone(); // Check if auto-play episode limit is reached let limit_reached = self.increment_autoplay_count(); if limit_reached { debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes); } return Ok(AutoplayDecision::ShowNextEpisodePopup { current_episode: next_ep.0, // Repository MediaItem next_episode: next_ep.1, countdown_seconds: settings.countdown_seconds, auto_advance: settings.enabled && !limit_reached, }); } // No next episode found return Ok(AutoplayDecision::Stop); } // For audio/movies, check if there's a next track in the queue let has_next = { let queue = self.queue.lock().unwrap(); queue.has_next() }; if has_next { // Advance to next track Ok(AutoplayDecision::AdvanceToNext) } else { // End of queue Ok(AutoplayDecision::Stop) } } /// Handle video playback ended from HTML5 video element. /// /// HTML5 video plays independently of the Rust backend, so the backend /// queue has no knowledge of the video item. This method bypasses the /// queue lookup and end_reason check, using the provided Jellyfin item ID /// to look up the item and check for next episodes. pub async fn on_video_playback_ended( &self, item_id: &str, repo: Arc, ) -> Result { // Clear any stale end_reason (e.g., UserStop from stopping audio before video) let stale_reason = self.take_end_reason(); if stale_reason.is_some() { debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason); } debug!("[PlayerController] on_video_playback_ended: item_id={}", item_id); // Check sleep timer state let timer_mode = { let timer = self.sleep_timer.lock().unwrap(); timer.mode.clone() }; match &timer_mode { SleepTimerMode::Time { end_time } => { let now = chrono::Utc::now().timestamp_millis(); if now >= *end_time { debug!("[PlayerController] Time-based sleep timer expired at video end"); self.sleep_timer.lock().unwrap().cancel(); self.emit_sleep_timer_changed(); return Ok(AutoplayDecision::Stop); } } SleepTimerMode::EndOfTrack => { self.sleep_timer.lock().unwrap().cancel(); self.emit_sleep_timer_changed(); return Ok(AutoplayDecision::Stop); } SleepTimerMode::Episodes { .. } => { let should_stop = self.sleep_timer.lock().unwrap().decrement_episode(); self.emit_sleep_timer_changed(); if should_stop { return Ok(AutoplayDecision::Stop); } } _ => {} } // Fetch next episode for the video that just ended if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? { let settings = self.autoplay_settings.lock().unwrap().clone(); let limit_reached = self.increment_autoplay_count(); if limit_reached { debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes); } return Ok(AutoplayDecision::ShowNextEpisodePopup { current_episode: next_ep.0, next_episode: next_ep.1, countdown_seconds: settings.countdown_seconds, auto_advance: settings.enabled && !limit_reached, }); } // No next episode found debug!("[PlayerController] No next episode found for {}", item_id); Ok(AutoplayDecision::Stop) } /// Check if a media item is an episode (has Jellyfin ID to query) async fn is_episode_item(&self, item: &MediaItem) -> bool { // For now, assume video items are episodes // In production, we'd check item metadata or query Jellyfin item.media_type == MediaType::Video } /// Fetch next episode for a series by looking up the season's episodes /// sorted by index number and picking the one after the current episode. /// /// This is deterministic and doesn't depend on Jellyfin's "Next Up" API /// (which relies on watch history that may not be updated yet due to /// the async nature of playback progress reporting). async fn fetch_next_episode_for_item( &self, item_id: &str, repo: &Arc, ) -> Result, String> { use crate::repository::types::GetItemsOptions; // Get the current item details from repository let current_repo_item = repo.get_item(item_id) .await .map_err(|e| format!("Failed to get current item: {}", e))?; // Need season_id to fetch sibling episodes let season_id = match ¤t_repo_item.season_id { Some(sid) => sid.clone(), None => { debug!("[PlayerController] Current item has no season_id, cannot find next episode"); return Ok(None); } }; // Fetch all episodes in the season sorted by episode number let options = GetItemsOptions { sort_by: Some("IndexNumber".to_string()), sort_order: Some("Ascending".to_string()), limit: Some(500), include_item_types: Some(vec!["Episode".to_string()]), ..Default::default() }; let result = repo.get_items(&season_id, Some(options)) .await .map_err(|e| format!("Failed to fetch season episodes: {}", e))?; // Sort client-side by index_number to ensure correct ordering // (offline repo ignores sort_by and sorts by sort_name instead) let mut episodes = result.items; episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX)); debug!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id); // Find the current episode by ID and return the next one if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) { if current_idx + 1 < episodes.len() { let next = &episodes[current_idx + 1]; debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1); return Ok(Some((current_repo_item, next.clone()))); } else { debug!("[PlayerController] Current episode is the last in the season"); } } else { debug!("[PlayerController] Current episode not found in season episodes"); } Ok(None) } /// Start autoplay countdown thread pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) { // Create cancellation flag let cancel_flag = Arc::new(Mutex::new(false)); *self.countdown_cancel.lock().unwrap() = Some(cancel_flag.clone()); let event_emitter = self.event_emitter.clone(); std::thread::spawn(move || { let mut remaining = countdown_seconds; while remaining > 0 { std::thread::sleep(Duration::from_secs(1)); // Check cancellation if *cancel_flag.lock().unwrap() { log::info!("[PlayerController] Autoplay countdown cancelled"); return; } remaining -= 1; // Emit countdown tick event if let Some(emitter) = event_emitter.lock().unwrap().as_ref() { emitter.emit(PlayerStatusEvent::CountdownTick { remaining_seconds: remaining, }); } } // Countdown finished (final tick at 0 was already emitted inside the loop) log::info!("[PlayerController] Autoplay countdown finished"); }); } } impl Default for PlayerController { fn default() -> Self { let playback_reporter = Arc::new(TokioMutex::new(None)); let position_throttler = Arc::new(EventThrottler::new()); Self::new(Box::new(NullBackend::new()), playback_reporter, position_throttler) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_controller_volume_default() { let controller = PlayerController::default(); assert_eq!(controller.volume(), 1.0); } #[test] fn test_controller_set_volume() { let controller = PlayerController::default(); controller.set_volume(0.5).unwrap(); assert_eq!(controller.volume(), 0.5); } #[test] fn test_controller_muted_default() { let controller = PlayerController::default(); assert!(!controller.muted()); } #[test] fn test_controller_volume_delegates_to_backend() { let controller = PlayerController::default(); // Set volume through controller controller.set_volume(0.75).unwrap(); // Verify it's reflected in both controller.volume() and backend assert_eq!(controller.volume(), 0.75); } fn create_test_items(count: usize) -> Vec { (0..count) .map(|i| MediaItem { id: format!("item_{}", i), title: format!("Track {}", i + 1), name: Some(format!("Track {}", i + 1)), artist: Some("Test Artist".to_string()), album: Some("Test Album".to_string()), album_name: Some("Test Album".to_string()), album_id: None, artist_items: None, artists: Some(vec!["Test Artist".to_string()]), primary_image_tag: None, item_type: Some("Audio".to_string()), playlist_id: None, duration: Some(180.0), artwork_url: None, media_type: MediaType::Audio, source: MediaSource::DirectUrl { url: format!("http://example.com/track_{}.mp3", i), }, video_codec: None, needs_transcoding: false, video_width: None, video_height: None, subtitles: vec![], series_id: None, server_id: None, }) .collect() } #[test] fn test_skip_preserves_queue() { let controller = PlayerController::default(); // Create a queue with 5 items let items = create_test_items(5); let items_clone = items.clone(); // Play the queue starting at index 0 controller.play_queue(items, 0).unwrap(); // Verify initial state { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items"); assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0"); assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0"); } // Skip to next track controller.next().unwrap(); // Verify queue is intact and index advanced { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip"); assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1"); assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1"); // Verify all original items are still present let current_items = queue_lock.items(); for (i, original) in items_clone.iter().enumerate() { assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i); assert_eq!(current_items[i].title, original.title, "Item {} title should be unchanged", i); } } // Skip again controller.next().unwrap(); // Verify queue still intact and index advanced again { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip"); assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2"); assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2"); } // Skip multiple times to reach the end controller.next().unwrap(); // -> item_3 controller.next().unwrap(); // -> item_4 // Verify we're at the last item { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end"); assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)"); assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4"); } } #[test] fn test_skip_at_end_without_repeat() { let controller = PlayerController::default(); // Create a queue with 3 items let items = create_test_items(3); controller.play_queue(items, 0).unwrap(); // Skip to last item controller.next().unwrap(); // -> item_1 controller.next().unwrap(); // -> item_2 // Verify we're at the last item { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item"); } // Try to skip past the end (without repeat mode) // This should succeed but stop playback while preserving the queue controller.next().unwrap(); // Verify queue is still intact { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end"); // When we skip past the end, the queue index should stay at the last item // or become None (depending on implementation) // The key is the queue items themselves should be preserved } } #[test] fn test_skip_with_repeat_all() { let controller = PlayerController::default(); // Create a queue with 3 items let items = create_test_items(3); controller.play_queue(items, 0).unwrap(); // Enable repeat all controller.cycle_repeat(); // Skip to last item controller.next().unwrap(); // -> item_1 controller.next().unwrap(); // -> item_2 // Skip again - should wrap to beginning controller.next().unwrap(); // Verify we wrapped to the first item { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items"); assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0"); assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0"); } } #[test] fn test_previous_preserves_queue() { let controller = PlayerController::default(); // Create a queue with 5 items, start at item 3 let items = create_test_items(5); let items_clone = items.clone(); controller.play_queue(items, 3).unwrap(); // Verify starting position { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3"); } // Go to previous track controller.previous().unwrap(); // Verify queue is intact and index moved back { let queue = controller.queue(); let queue_lock = queue.lock().unwrap(); assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous"); assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2"); assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2"); // Verify all original items are still present let current_items = queue_lock.items(); for (i, original) in items_clone.iter().enumerate() { assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i); } } } #[test] fn test_seek_updates_position() { let controller = PlayerController::default(); // Create and play a single item let item = create_test_items(1).into_iter().next().unwrap(); controller.play_item(item).unwrap(); // Verify initial position assert_eq!(controller.position(), 0.0, "Initial position should be 0"); // Seek to 30 seconds controller.seek(30.0).unwrap(); assert_eq!(controller.position(), 30.0, "Position should be 30 after seeking"); // Seek to 60 seconds controller.seek(60.0).unwrap(); assert_eq!(controller.position(), 60.0, "Position should be 60 after seeking"); // Seek backward to 15 seconds controller.seek(15.0).unwrap(); assert_eq!(controller.position(), 15.0, "Position should be 15 after seeking backward"); } #[test] fn test_seek_while_paused() { let controller = PlayerController::default(); // Create and play a single item let item = create_test_items(1).into_iter().next().unwrap(); controller.play_item(item).unwrap(); // Pause playback controller.pause().unwrap(); // Verify paused state assert!(controller.state().is_paused(), "Should be paused"); // Seek while paused controller.seek(45.0).unwrap(); assert_eq!(controller.position(), 45.0, "Position should update while paused"); // Verify still paused after seeking assert!(controller.state().is_paused(), "Should still be paused after seeking"); } #[test] fn test_seek_while_playing() { let controller = PlayerController::default(); // Create and play a single item let item = create_test_items(1).into_iter().next().unwrap(); controller.play_item(item).unwrap(); // Ensure playing controller.play().unwrap(); // Verify playing state assert!(controller.state().is_playing(), "Should be playing"); // Seek while playing controller.seek(20.0).unwrap(); assert_eq!(controller.position(), 20.0, "Position should update while playing"); // Verify still playing after seeking assert!(controller.state().is_playing(), "Should still be playing after seeking"); } #[test] fn test_multiple_sequential_seeks() { let controller = PlayerController::default(); let item = create_test_items(1).into_iter().next().unwrap(); controller.play_item(item).unwrap(); // Perform multiple seeks in sequence let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0]; for pos in positions { controller.seek(pos).unwrap(); assert_eq!(controller.position(), pos, "Position should match after seeking to {}", pos); } } #[test] fn test_seek_to_zero() { let controller = PlayerController::default(); let item = create_test_items(1).into_iter().next().unwrap(); controller.play_item(item).unwrap(); // Seek forward controller.seek(60.0).unwrap(); assert_eq!(controller.position(), 60.0); // Seek back to zero controller.seek(0.0).unwrap(); assert_eq!(controller.position(), 0.0, "Should be able to seek to position 0"); } // Autoplay decision tests #[tokio::test] async fn test_audio_with_next_advances() { let controller = PlayerController::default(); // Create queue with 2 audio items let items = create_test_items(2); controller.play_queue(items, 0).unwrap(); // Clear the NewTrackLoaded reason set by play_queue to simulate natural track end controller.take_end_reason(); // Simulate first track ending naturally let decision = controller.on_playback_ended().await.unwrap(); // Should decide to advance to next assert!( matches!(decision, AutoplayDecision::AdvanceToNext), "Expected AdvanceToNext decision when queue has next item" ); } #[tokio::test] async fn test_audio_at_end_stops() { let controller = PlayerController::default(); // Create queue with 2 items, start at last one let items = create_test_items(2); controller.play_queue(items, 1).unwrap(); // Clear the NewTrackLoaded reason to simulate natural track end controller.take_end_reason(); // Simulate last track ending naturally let decision = controller.on_playback_ended().await.unwrap(); // Should decide to stop (no more items) assert!( matches!(decision, AutoplayDecision::Stop), "Expected Stop decision when at end of queue without repeat" ); } #[tokio::test] async fn test_sleep_timer_end_of_track() { let controller = PlayerController::default(); // Create queue with next items let items = create_test_items(3); controller.play_queue(items, 0).unwrap(); // Clear the NewTrackLoaded reason to simulate natural track end controller.take_end_reason(); // Set sleep timer to end of track { let mut timer = controller.sleep_timer.lock().unwrap(); timer.mode = SleepTimerMode::EndOfTrack; } // Simulate track ending naturally let decision = controller.on_playback_ended().await.unwrap(); // Should stop despite having next items assert!( matches!(decision, AutoplayDecision::Stop), "Expected Stop decision when sleep timer is EndOfTrack" ); // Verify timer was cancelled { let timer = controller.sleep_timer.lock().unwrap(); assert!( matches!(timer.mode, SleepTimerMode::Off), "Sleep timer should be cancelled after EndOfTrack" ); } } #[tokio::test] async fn test_empty_queue_stops() { let controller = PlayerController::default(); // Don't set up any queue let decision = controller.on_playback_ended().await.unwrap(); // Should stop (no current item) assert!( matches!(decision, AutoplayDecision::Stop), "Expected Stop decision when queue is empty" ); } #[tokio::test] async fn test_repeat_all_advances_at_end() { let controller = PlayerController::default(); // Create queue with 2 items, enable repeat all let items = create_test_items(2); controller.play_queue(items, 1).unwrap(); // Start at last item controller.cycle_repeat(); // Enable repeat all // Clear the NewTrackLoaded reason to simulate natural track end controller.take_end_reason(); // Simulate last track ending naturally let decision = controller.on_playback_ended().await.unwrap(); // Should advance (will wrap to beginning due to repeat all) assert!( matches!(decision, AutoplayDecision::AdvanceToNext), "Expected AdvanceToNext decision at end of queue with repeat all" ); } #[tokio::test] async fn test_repeat_one_advances() { let controller = PlayerController::default(); // Create queue with 2 items let items = create_test_items(2); controller.play_queue(items, 0).unwrap(); // Enable repeat one controller.cycle_repeat(); // Once for all controller.cycle_repeat(); // Twice for one // Clear the NewTrackLoaded reason to simulate natural track end controller.take_end_reason(); // Simulate track ending naturally let decision = controller.on_playback_ended().await.unwrap(); // Should advance (which repeats the same track) assert!( matches!(decision, AutoplayDecision::AdvanceToNext), "Expected AdvanceToNext decision with repeat one (repeats same track)" ); } // EndReason state machine tests #[test] fn test_load_and_play_sets_new_track_loaded() { let controller = PlayerController::default(); let item = create_test_items(1).into_iter().next().unwrap(); // End reason should be None initially assert!(controller.take_end_reason().is_none()); // Load and play should set NewTrackLoaded controller.load_and_play(&item).unwrap(); // Verify end reason was set let reason = controller.take_end_reason(); assert_eq!(reason, Some(EndReason::NewTrackLoaded)); } #[test] fn test_stop_sets_user_stop() { let controller = PlayerController::default(); let item = create_test_items(1).into_iter().next().unwrap(); // Play an item first controller.play_item(item).unwrap(); // Clear any end reason from load_and_play controller.take_end_reason(); // Stop should set UserStop controller.stop().unwrap(); // Verify end reason was set let reason = controller.take_end_reason(); assert_eq!(reason, Some(EndReason::UserStop)); } #[tokio::test] async fn test_on_playback_ended_with_new_track_loaded_stops() { let controller = PlayerController::default(); // Create queue with 2 items let items = create_test_items(2); controller.play_queue(items, 0).unwrap(); // Manually set end reason to NewTrackLoaded controller.set_end_reason(EndReason::NewTrackLoaded); // Call on_playback_ended let decision = controller.on_playback_ended().await.unwrap(); // Should stop without advancing assert!( matches!(decision, AutoplayDecision::Stop), "Expected Stop decision when EndReason is NewTrackLoaded" ); } #[tokio::test] async fn test_on_playback_ended_with_user_stop_stops() { let controller = PlayerController::default(); // Create queue with 2 items let items = create_test_items(2); controller.play_queue(items, 0).unwrap(); // Manually set end reason to UserStop controller.set_end_reason(EndReason::UserStop); // Call on_playback_ended let decision = controller.on_playback_ended().await.unwrap(); // Should stop without advancing assert!( matches!(decision, AutoplayDecision::Stop), "Expected Stop decision when EndReason is UserStop" ); } #[tokio::test] async fn test_on_playback_ended_natural_end_advances() { let controller = PlayerController::default(); // Create queue with 2 items let items = create_test_items(2); controller.play_queue(items, 0).unwrap(); // Clear the NewTrackLoaded reason to simulate natural track end controller.take_end_reason(); // Call on_playback_ended (no end reason set = natural end) let decision = controller.on_playback_ended().await.unwrap(); // Should advance to next (natural end with next track available) assert!( matches!(decision, AutoplayDecision::AdvanceToNext), "Expected AdvanceToNext decision when track ends naturally with next track available" ); } #[tokio::test] async fn test_on_playback_ended_with_user_skip_stops() { let controller = PlayerController::default(); // Create queue with 2 items let items = create_test_items(2); controller.play_queue(items, 0).unwrap(); // Set end reason to UserSkip controller.set_end_reason(EndReason::UserSkip); // Call on_playback_ended let decision = controller.on_playback_ended().await.unwrap(); // Should stop without advancing (skip already handled) assert!( matches!(decision, AutoplayDecision::Stop), "Expected Stop decision when EndReason is UserSkip" ); } #[tokio::test] async fn test_on_playback_ended_with_error_stops() { let controller = PlayerController::default(); // Create queue with 2 items let items = create_test_items(2); controller.play_queue(items, 0).unwrap(); // Set end reason to Error controller.set_end_reason(EndReason::Error); // Call on_playback_ended let decision = controller.on_playback_ended().await.unwrap(); // Should stop without advancing assert!( matches!(decision, AutoplayDecision::Stop), "Expected Stop decision when EndReason is Error" ); } #[tokio::test] async fn test_take_end_reason_clears_state() { let controller = PlayerController::default(); // Set a reason controller.set_end_reason(EndReason::NewTrackLoaded); // Take it once let reason = controller.take_end_reason(); assert_eq!(reason, Some(EndReason::NewTrackLoaded)); // Take it again - should be None let reason = controller.take_end_reason(); assert!(reason.is_none(), "take_end_reason should clear the state"); } }