Duncan Tourolle 1f6977cd01
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
Playback fix
2026-07-02 18:13:55 +02:00

2192 lines
82 KiB
Rust

// 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 seek;
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 seek::{determine_video_seek_strategy, VideoSeekStrategy};
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,
};
/// Metadata for the lockscreen / media notification.
///
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
/// the local ExoPlayer is idle and so can't supply now-playing info. The session
/// poller fills this in from the remote Jellyfin session and pushes it to the
/// notification so the lockscreen stays in sync while casting.
#[derive(Debug, Clone)]
pub struct LockscreenMetadata {
pub title: String,
pub artist: String,
pub album: Option<String>,
/// Track duration in milliseconds.
pub duration_ms: i64,
/// Current playback position in milliseconds.
pub position_ms: i64,
pub is_playing: bool,
}
/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
/// session poller can call it unconditionally and stay platform-agnostic.
pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
#[cfg(target_os = "android")]
{
return android::update_lockscreen_metadata(_meta);
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
use crate::utils::lock::MutexSafe;
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<Mutex<Box<dyn PlayerBackend>>>,
queue: Arc<Mutex<QueueManager>>,
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
muted: bool,
// Sleep timer state
sleep_timer: Arc<Mutex<SleepTimerState>>,
// Autoplay settings
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
// Repository for fetching next episodes
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
// Event emitter for notifications
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
// Countdown cancellation handle
countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
// Playback reporting (dual sync: local DB + server)
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
// 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<EventThrottler>,
// End reason tracking for autoplay decision making
end_reason: Arc<Mutex<Option<EndReason>>>,
// Auto-play episode counter (session-based, resets on manual play)
autoplay_episode_count: Arc<Mutex<u32>>,
}
impl PlayerController {
pub fn new(
backend: Box<dyn PlayerBackend>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
) -> 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<JellyfinClient>) {
let mut jellyfin = self.jellyfin_client.lock_safe();
*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<Mutex<Option<JellyfinClient>>> {
self.jellyfin_client.clone()
}
/// Configure the media repository used for next-episode lookups.
///
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
/// repository handle (unlike the Linux HTML5 path, which passes one per
/// call), so the controller needs a repository of its own or episode
/// autoplay silently decides Stop.
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
*self.repository.lock_safe() = Some(repo);
}
/// Configure the playback reporter for dual sync (local DB + server).
/// Called from `player_configure_jellyfin` on login/restore/reauth.
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
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<TokioMutex<Option<PlaybackReporter>>> {
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<EventThrottler> {
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_safe() = Some(reason);
}
/// Get and clear the current end reason
fn take_end_reason(&self) -> Option<EndReason> {
self.end_reason.lock_safe().take()
}
/// Increment autoplay episode counter. Returns true if limit is reached.
fn increment_autoplay_count(&self) -> bool {
let max = self.autoplay_settings.lock_safe().max_episodes;
if max == 0 {
// Unlimited
return false;
}
let mut count = self.autoplay_episode_count.lock_safe();
*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_safe();
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_safe();
queue.set_queue(vec![item.clone()], 0);
}
// Load and play the item
self.load_and_play(&item)?;
Ok(())
}
/// Set the current queue item without loading it into the playback backend.
///
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
#[cfg(target_os = "linux")]
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0);
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_safe();
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<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
self.play_queue_from(items, start_index, None)
}
/// Set the queue and start playing from the specified index, optionally
/// resuming the starting track at `start_position` (seconds).
///
/// The seek happens immediately after load so the backend never audibly
/// starts at 0 and there's no race against a fixed delay. Used when taking
/// over playback from a remote session.
pub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError> {
debug!(
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
items.len(),
start_index,
start_position
);
// Reset autoplay counter on manual queue start
self.reset_autoplay_count();
{
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
}
// Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?;
// Resume from the requested position. Seeking right after load (while
// the backend lock is no longer held) avoids the start-at-0-then-jump
// race that a delayed frontend seek suffers from.
if let Some(position) = start_position {
if position > 0.5 {
self.seek(position)?;
}
}
}
Ok(())
}
/// Replace the queue without starting local playback.
///
/// Used when we're controlling a remote session: the tracks play on the
/// remote device, but we keep the local queue in sync so the UI reflects
/// what's playing and a later transfer-to-local has the queue to resume.
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_queue (no local playback): {} items, index {}",
items.len(),
start_index
);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
Ok(())
}
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
let mut backend = self.backend.lock_safe();
backend.play()
}
/// Pause playback
pub fn pause(&self) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.pause()
}
/// Toggle play/pause
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
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_safe();
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
};
let position_ticks = {
let backend = self.backend.lock_safe();
(backend.position() * 10_000_000.0) as i64
};
let mut backend = self.backend.lock_safe();
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_safe();
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_safe();
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_safe();
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_safe();
backend.seek(position)
}
/// Set volume (0.0 - 1.0)
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
self.backend.lock_safe().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_safe();
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<i32>) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe();
backend.set_subtitle_track(stream_index)
}
/// Get current state
pub fn state(&self) -> PlayerState {
self.backend.lock_safe().state()
}
/// Get current position
pub fn position(&self) -> f64 {
self.backend.lock_safe().position()
}
/// Get duration
pub fn duration(&self) -> Option<f64> {
self.backend.lock_safe().duration()
}
/// Get queue reference
pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
self.queue.clone()
}
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
/// sleep timer fires or the queue ends with repeat off). Pair with
/// `emit_queue_changed` so the frontend hides the mini player.
pub fn clear_queue(&self) {
self.queue.lock_safe().clear();
}
/// Toggle shuffle
pub fn toggle_shuffle(&self) {
self.queue.lock_safe().toggle_shuffle();
}
/// Cycle repeat mode
pub fn cycle_repeat(&self) {
self.queue.lock_safe().cycle_repeat();
}
/// Check if shuffle is enabled
pub fn is_shuffle(&self) -> bool {
self.queue.lock_safe().is_shuffle()
}
/// Get repeat mode
pub fn repeat_mode(&self) -> RepeatMode {
self.queue.lock_safe().repeat_mode()
}
/// Get current volume (0.0 - 1.0)
pub fn volume(&self) -> f32 {
self.backend.lock_safe().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_safe().set_audio_settings(settings)
}
/// Get current audio settings
pub fn audio_settings(&self) -> AudioSettings {
self.backend.lock_safe().audio_settings()
}
// ===== Sleep Timer Methods =====
/// Set the event emitter for notifications
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
let mut event_emitter = self.event_emitter.lock_safe();
*event_emitter = Some(emitter);
}
/// Get the event emitter
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
self.event_emitter.lock_safe().clone()
}
/// Get sleep timer state
pub fn sleep_timer_state(&self) -> SleepTimerState {
self.sleep_timer.lock_safe().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_safe();
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_safe();
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_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: SleepTimerMode::Off,
remaining_seconds: 0,
});
// Tell the frontend playback must stop: HTML5 video
// (Linux) plays outside the backend, so stopping the
// backend below doesn't reach it.
emitter.emit(PlayerStatusEvent::SleepTimerExpired);
}
drop(timer);
// Stop the backend
if let Err(e) = backend.lock_safe().stop() {
error!("[SleepTimer] Failed to stop playback: {}", e);
}
continue;
}
// Emit update event
if let Some(emitter) = event_emitter.lock_safe().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_safe().clone();
if let Some(emitter) = self.event_emitter.lock_safe().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_safe();
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_safe().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!");
}
}
// ===== HTML5 video report methods =====
//
// On platforms where video is rendered in the webview (Linux WebKitGTK
// HTML5 <video>), the real player lives outside the native backend, so it
// cannot emit PlayerStatusEvents itself. The frontend HTML5 adapter reports
// DOM events here, and these methods re-emit them through the SAME event
// pipeline the native backends use. This keeps the frontend's player store
// fed from one place (playerEvents.ts) in both native and HTML5 modes, so
// the Rust controller stays the single source of truth for player state.
/// Report an HTML5 <video> state change (playing/paused/loading/stopped).
///
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
}
}
/// Report an HTML5 <video> position tick.
///
/// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
/// position updates (the adapter is expected to throttle to ~250ms like MPV).
pub fn report_html5_position(&self, position: f64, duration: f64) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
}
}
/// Report that the HTML5 <video> element finished loading and knows its
/// duration. Mirrors the native `MediaLoaded` event.
pub fn report_html5_media_loaded(&self, duration: f64) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
}
// ===== Autoplay Methods =====
/// Get autoplay settings
pub fn autoplay_settings(&self) -> AutoplaySettings {
self.autoplay_settings.lock_safe().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_safe() = validated;
}
/// Cancel active autoplay countdown
pub fn cancel_autoplay_countdown(&self) {
if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
*cancel_flag.lock_safe() = 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<AutoplayDecision, String> {
// 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_safe();
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_safe();
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_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
}
SleepTimerMode::EndOfTrack => {
// Stop at end of track
self.sleep_timer.lock_safe().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(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock_safe().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(&current).await {
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
// Degrade lookup failures to Stop: playback already ended, and
// surfacing an error here just kills autoplay silently upstream.
match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", jellyfin_id, e);
None
}
}
} else {
warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
None
};
if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock_safe().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_safe();
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<dyn crate::repository::MediaRepository>,
) -> Result<AutoplayDecision, String> {
// 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);
}
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
// Check sleep timer state
let timer_mode = {
let timer = self.sleep_timer.lock_safe();
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_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
}
SleepTimerMode::EndOfTrack => {
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
SleepTimerMode::Episodes { .. } => {
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
self.emit_sleep_timer_changed();
if should_stop {
return Ok(AutoplayDecision::Stop);
}
}
_ => {}
}
// Fetch next episode for the video that just ended. Degrade lookup
// failures to Stop: playback already ended, and propagating an error
// here just kills autoplay silently upstream.
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
None
}
};
if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock_safe().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<dyn crate::repository::MediaRepository>,
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, 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 &current_repo_item.season_id {
Some(sid) => sid.clone(),
None => {
log::info!("[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));
log::info!("[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];
log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
return Ok(Some((current_repo_item, next.clone())));
} else {
log::info!("[PlayerController] Current episode is the last in the season");
}
} else {
log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
}
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_safe() = 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_safe() {
log::info!("[PlayerController] Autoplay countdown cancelled");
return;
}
remaining -= 1;
// Emit countdown tick event
if let Some(emitter) = event_emitter.lock_safe().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 emitter that captures events for asserting the HTML5 report methods
/// re-emit through the normal PlayerStatusEvent pipeline.
struct CapturingEmitter {
events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
}
impl CapturingEmitter {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(Vec::new()),
}
}
fn events(&self) -> Vec<PlayerStatusEvent> {
self.events.lock_safe().clone()
}
}
impl PlayerEventEmitter for CapturingEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock_safe().push(event);
}
}
#[test]
fn test_report_html5_state_emits_state_changed() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
let events = emitter.events();
assert_eq!(events.len(), 1);
match &events[0] {
PlayerStatusEvent::StateChanged { state, media_id } => {
assert_eq!(state, "playing");
assert_eq!(media_id.as_deref(), Some("item-1"));
}
other => panic!("expected StateChanged, got {:?}", other),
}
}
#[test]
fn test_report_html5_position_emits_position_update() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_position(12.5, 300.0);
let events = emitter.events();
assert_eq!(events.len(), 1);
match &events[0] {
PlayerStatusEvent::PositionUpdate { position, duration } => {
assert_eq!(*position, 12.5);
assert_eq!(*duration, 300.0);
}
other => panic!("expected PositionUpdate, got {:?}", other),
}
}
#[test]
fn test_report_html5_media_loaded_emits_media_loaded() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_media_loaded(420.0);
let events = emitter.events();
assert_eq!(events.len(), 1);
match &events[0] {
PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
other => panic!("expected MediaLoaded, got {:?}", other),
}
}
#[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<MediaItem> {
(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_safe();
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_safe();
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_safe();
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_safe();
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_safe();
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_safe();
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_safe();
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_safe();
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_safe();
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);
}
}
/// Resuming a queue at a position seeks the starting track immediately.
/// Regression guard for taking over a remote session: the local player must
/// pick up where the remote left off, not restart from 0.
#[test]
fn test_play_queue_from_resumes_at_position() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue_from(items, 1, Some(42.5)).unwrap();
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
}
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
}
/// A None / near-zero start position starts the track from the beginning.
#[test]
fn test_play_queue_from_without_position_starts_at_zero() {
let controller = PlayerController::default();
controller
.play_queue_from(create_test_items(2), 0, None)
.unwrap();
assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
controller
.play_queue_from(create_test_items(2), 0, Some(0.2))
.unwrap();
assert_eq!(
controller.position(),
0.0,
"Sub-threshold resume position is ignored (starts at 0)"
);
}
#[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_safe();
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_safe();
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");
}
// ===== Next-episode autoplay decision tests =====
use crate::repository::types as repo_types;
/// Mock repository serving a single season of episodes for next-episode
/// lookup tests. Only `get_item` and `get_items` are used by
/// `fetch_next_episode_for_item`; everything else is unreachable.
struct MockEpisodeRepo {
episodes: Vec<repo_types::MediaItem>,
}
impl MockEpisodeRepo {
fn season(count: usize) -> Self {
let episodes = (1..=count)
.map(|i| {
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
item.name = format!("Episode {}", i);
item
})
.collect();
Self { episodes }
}
}
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
repo_types::MediaItem {
id: id.to_string(),
name: format!("Episode {}", index),
item_type: "Episode".to_string(),
is_folder: false,
server_id: "server".to_string(),
parent_id: Some("season1".to_string()),
library_id: None,
overview: None,
genres: None,
runtime_ticks: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: None,
artist_items: None,
index_number: Some(index),
series_id: Some("series1".to_string()),
series_name: Some("Test Series".to_string()),
season_id: Some("season1".to_string()),
season_name: Some("Season 1".to_string()),
parent_index_number: Some(1),
user_data: None,
media_streams: None,
media_sources: None,
people: None,
}
}
#[async_trait::async_trait]
impl crate::repository::MediaRepository for MockEpisodeRepo {
async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
unimplemented!()
}
async fn get_items(
&self,
parent_id: &str,
_options: Option<repo_types::GetItemsOptions>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
assert_eq!(parent_id, "season1", "episode lookup must query the season");
Ok(repo_types::SearchResult {
items: self.episodes.clone(),
total_record_count: self.episodes.len(),
})
}
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
self.episodes
.iter()
.find(|e| e.id == item_id)
.cloned()
.ok_or(repo_types::RepoError::NotFound {
message: format!("{} not found", item_id),
})
}
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_items(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_next_up_episodes(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_recently_played_audio(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_rediscover_albums(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_movies(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_genres(&self, _: Option<&str>) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
unimplemented!()
}
async fn search(&self, _: &str, _: Option<repo_types::SearchOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_playback_info(&self, _: &str) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _: &str) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_progress(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_stopped(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
fn get_image_url(&self, _: &str, _: repo_types::ImageType, _: Option<repo_types::ImageOptions>) -> String {
unimplemented!()
}
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
unimplemented!()
}
fn get_video_download_url(&self, _: &str, _: &str, _: Option<&str>) -> String {
unimplemented!()
}
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
unimplemented!()
}
async fn get_items_by_person(&self, _: &str, _: Option<repo_types::GetItemsOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_similar_items(&self, _: &str, _: Option<usize>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn create_playlist(&self, _: &str, _: &[String]) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _: &str) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _: &str, _: &str, _: u32) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
}
/// Video (HTML5/Linux) path: ending mid-season must produce the
/// next-episode popup with auto-advance.
#[tokio::test]
async fn test_video_playback_ended_offers_next_episode() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
let decision = controller
.on_video_playback_ended("ep2", repo)
.await
.expect("decision should succeed");
match decision {
AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
auto_advance,
..
} => {
assert_eq!(current_episode.id, "ep2");
assert_eq!(next_episode.id, "ep3");
assert!(auto_advance, "default settings should auto-advance");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// Last episode of the season: no popup, stop.
#[tokio::test]
async fn test_video_playback_ended_last_episode_stops() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
let decision = controller
.on_video_playback_ended("ep3", repo)
.await
.expect("decision should succeed");
assert!(matches!(decision, AutoplayDecision::Stop));
}
/// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
/// so the controller-level repository (wired up in `repository_create`)
/// must be used for the next-episode lookup. Regression test for episode
/// autoplay never triggering on Android because no repository was set.
#[tokio::test]
async fn test_playback_ended_uses_controller_repository_for_episodes() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Queue holds the episode that just finished playing
let episode = MediaItem {
media_type: MediaType::Video,
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Clear the NewTrackLoaded reason to simulate natural track end
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "ep2");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
async fn test_playback_ended_without_repository_stops() {
let controller = PlayerController::default();
let episode = MediaItem {
media_type: MediaType::Video,
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(matches!(decision, AutoplayDecision::Stop));
}
}