Files
jellytau/src-tauri/src/player/mod.rs
T
dtourolle 6a712c46cb fix(player): send subtitle tracks to ExoPlayer on Android (UR-020)
Selecting a subtitle on Android did nothing. The Kotlin side has been
complete for a long time — JellyTauPlayer.load() parses a subtitles JSON
array into MediaItem.SubtitleConfigurations and setSubtitleTrack() drives a
TrackSelectionOverride — but nothing ever reached it.

VideoPlayer built the list and then threw it away: it resolved every
subtitle stream's URL into a subtitleTracks array and the
commands.playerPlayItem({...}) call two lines below passed only streamUrl,
title, id, videoCodec and needsTranscoding. PlayItemRequest had no subtitle
field to put them in, so create_media_item hardcoded subtitles: vec![],
android/mod.rs serialized "[]" across JNI, and every MediaItem reached
ExoPlayer with zero SubtitleConfigurations. A later set_subtitle_track then
found no text track groups and logged "Invalid subtitle track index".

PlayItemRequest now carries the tracks (defaulted, so the background-audio
handoff and next-episode callers are unchanged) and create_media_item
threads them onto the MediaItem.

Serialization: SubtitleTrack is reused verbatim rather than given an
IPC-specific twin, and deliberately keeps snake_case. The same struct feeds
two consumers that both spell mime_type — the JNI JSON that
JellyTauPlayer.load() reads with optString("mime_type"), and the generated
binding the frontend types against. camelCasing it would not fail the build
or the IPC; Kotlin would silently fall back to its default MIME type for
every track. UT-146 asserts the exact serialized keys so a future
rename_all cannot pass unnoticed.

The index mapping was NOT already correct. setSubtitleTrack(n) indexes
ExoPlayer's filtered text track groups, i.e. the position of the sideloaded
configuration — but the menu passed its own {#each} row number, which counts
every subtitle *stream*, including ones whose URL failed to resolve and were
therefore never sideloaded. One failed URL and every track below it selected
the wrong subtitle. The position is now looked up in the exact array that
was sent (nativeSubtitleArrayIndex), and a stream that was never sent maps
to "off" rather than to a guessed position.

The resolution loop also reuses resolveSubtitleTracks() from the Linux fix
instead of duplicating it, which fans the URL requests out in parallel
rather than awaiting them one per stream before playback can start. The
awaits are safe where they sit: the native-mode pitfall is about Svelte
lifecycle calls after an await, and nothing is registered here — the
background-audio subscriptions above still run synchronously.

No Kotlin change was needed.

Tests (UT-145, UT-146, UT-147) were written first and failed: PlayItemRequest
had no subtitles field to compile against, nativeSubtitleTracks and
nativeSubtitleArrayIndex did not exist, and the playerPlayItem call carried
no subtitles key.

TRACES: UR-020 | IR-016, JA-008 | UT-145, UT-146, UT-147
2026-08-11 20:03:19 +02:00

3930 lines
148 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;
pub mod stream_end;
#[cfg(test)]
mod mpv_backend_test;
// Platform-specific backends
#[cfg(target_os = "android")]
pub mod android;
#[cfg(target_os = "linux")]
pub mod mpv_backend;
// Platforms with no native audio backend (e.g. Windows) render audio-only
// playback through a webview <audio> element, mirroring how all video renders.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub mod webview_audio_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, SubtitleTrack};
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(not(any(target_os = "linux", target_os = "android")))]
pub use webview_audio_backend::WebviewAudioBackend;
#[cfg(target_os = "android")]
pub use android::{
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
};
/// Seconds added per attempt before retrying a stream that failed with an error.
///
/// Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that
/// covers roughly a quarter-minute of outage across the retry budget without
/// leaving the user staring at a dead notification when the network is truly gone.
/// Only *read* by the Android error callback (`#[cfg(android)]`), but compiled
/// and unit-tested on the host, hence `allow(dead_code)` off-Android.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
/// 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)]
// Fields are read only by the Android MediaSession bridge; on other platforms
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
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(())
}
}
/// Set the base offset (seconds) added to positions reported to the Android
/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
/// relative and must be shifted back to absolute to match the full duration.
/// Pass 0.0 to clear on exit. No-op off Android.
pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
#[cfg(target_os = "android")]
{
return android::set_position_offset(_offset_seconds);
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
use crate::jellyfin::JellyfinClient;
use crate::playback_reporting::{
EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
};
use crate::repository::MediaRepository;
use crate::settings::AudioSettings;
/// 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>>,
// Base offset (seconds) of the active background-audio handoff.
//
// The audio-only stream is requested with `StartTimeTicks` = the position the
// video was handed off at, so the server makes that point the stream's zero
// and the native player reports position RELATIVE to it. Adding this base back
// yields the absolute position to resume the video at on the way out.
//
// Lives on the controller (not beside the command) because the queue and this
// offset describe the same stream: whenever the controller loads a different
// one — notably the backend-driven advance to the next episode — the base has
// to move with it.
//
// TRACES: UR-040 | DR-052
background_audio_base: Arc<Mutex<f64>>,
// True while a background-audio handoff owns playback: the native audio
// player is the real player and the webview <video> has been torn down.
//
// The teardown is what makes this necessary. It fires a DOM `pause` that the
// frontend reports like any other, which would otherwise leave the controller
// believing webview media is still active — aiming lockscreen transport at an
// element that no longer exists (see `is_html5_active`).
//
// TRACES: UR-040 | DR-052, DR-097
background_audio_active: Arc<Mutex<bool>>,
// Budget for re-opening a stream that ended short of the item's runtime.
//
// A resume re-requests the same URL, so a server that is genuinely gone would
// otherwise end → resume → end without limit. The tracker only bounds retries
// that make no progress; a resume that plays on refills it.
//
// TRACES: UR-040 | DR-129
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
//
// Webview-rendered media is played by an element the native backend cannot
// reach, so the backend's own state() says nothing about it. Tracking the
// REPORTED state here is what lets transport (play/pause/toggle) be decided
// in Rust for that media instead of the frontend reading `el.paused` off the
// DOM — a value that flips transiently while buffering/seeking and caused
// competing intents to take opposing actions. `None` means no webview media
// is active and the native backend is authoritative. See DR-097.
html5_playing: Arc<Mutex<Option<bool>>>,
}
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)),
background_audio_base: Arc::new(Mutex::new(0.0)),
background_audio_active: Arc::new(Mutex::new(false)),
stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
html5_playing: Arc::new(Mutex::new(None)),
};
// 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()
}
/// Read the end reason WITHOUT consuming it.
///
/// `take_end_reason` has an owner: on Android the JNI ended-callback consumes
/// the `NewTrackLoaded` every load sets, and the frontend's echoed call is the
/// one that sees `None` and decides. The truncated-stream check runs in both
/// calls and must not disturb that hand-off, so it peeks.
fn peek_end_reason(&self) -> Option<EndReason> {
*self.end_reason.lock_safe()
}
/// Record that playback is being stopped by an expiring sleep timer.
///
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
/// Android), which lands in `on_playback_ended`. Without an end reason that
/// reads as a natural finish and autoplay advances — defeating the timer.
/// `UserStop` is the honest label: the stop was user-initiated, just via the
/// timer they set rather than the stop button.
///
/// Takes the shared slot rather than `&self` so the sleep-timer thread —
/// which owns clones, not the controller — records it the same way.
///
/// TRACES: UR-023, UR-026 | DR-029
fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
*end_reason.lock_safe() = Some(EndReason::UserStop);
}
/// 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(())
}
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
/// player, so transport must be routed to it rather than the native backend.
///
/// TRACES: UR-005 | DR-097
pub fn is_html5_active(&self) -> bool {
self.html5_playing.lock_safe().is_some()
}
/// Whether the webview element last reported itself as playing. Meaningless
/// unless [`Self::is_html5_active`] is true.
///
/// TRACES: UR-005 | DR-097
pub fn html5_is_playing(&self) -> bool {
self.html5_playing.lock_safe().unwrap_or(false)
}
/// Send a transport intent to the webview element that is rendering media.
fn emit_html5_control(&self, action: &str) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::ControlCommand {
action: action.to_string(),
position: None,
});
}
}
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
// Webview-rendered media: the native backend isn't playing it, so drive
// the element via a ControlCommand instead (DR-097).
if self.is_html5_active() {
self.emit_html5_control("play");
return Ok(());
}
let mut backend = self.backend.lock_safe();
backend.play()
}
/// Pause playback
pub fn pause(&self) -> Result<(), PlayerError> {
if self.is_html5_active() {
self.emit_html5_control("pause");
return Ok(());
}
let mut backend = self.backend.lock_safe();
backend.pause()
}
/// Toggle play/pause.
///
/// The decision is made HERE, from authoritative state — the reported webview
/// state for HTML5-rendered media, or the native backend's state otherwise.
/// The frontend must never decide this from the DOM (see DR-097).
///
/// TRACES: UR-005 | DR-097
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
if self.is_html5_active() {
let action = if self.html5_is_playing() {
"pause"
} else {
"play"
};
self.emit_html5_control(action);
return Ok(());
}
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()
}
/// True when the current item is a TV episode being played in audio-only
/// (background) mode — i.e. an `item_type == "Episode"` item loaded as
/// `MediaType::Audio`. Used to decide whether the backend must drive the
/// next-episode advance itself (the frontend is suspended in the background).
///
/// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn current_is_audio_episode(&self) -> bool {
self.queue
.lock_safe()
.current()
.map(|item| {
item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
})
.unwrap_or(false)
}
/// 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();
let end_reason = self.end_reason.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();
// Mark the stop *before* it reaches the backend. Stopping
// makes the native player fire its ended callback, and
// cancelling the timer above means on_playback_ended can no
// longer tell this apart from a natural end — without this
// it would show the next-episode popup / autoplay right
// after the sleep timer fired.
Self::note_sleep_timer_stop(&end_reason);
// 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>) {
// A background-audio handoff has already moved playback to the native
// player and torn the element down; anything it still reports describes
// a video that is no longer playing. Dropping it keeps the UI on the
// audio that IS playing and leaves transport with the native backend.
if self.is_background_audio_active() {
debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
return;
}
// Track it: this is the authoritative play/pause state for
// webview-rendered media, and what transport decisions read (DR-097).
// "stopped"/"idle" mean the element is gone, so hand authority back to
// the native backend — otherwise music playback would keep emitting
// ControlCommands at a element that no longer exists.
{
let mut tracked = self.html5_playing.lock_safe();
*tracked = match state.as_str() {
"playing" => Some(true),
// "loading" counts as active-but-not-playing so a toggle during
// load resolves to "play" rather than falling through to the
// native backend.
"paused" | "loading" => Some(false),
// "stopped"/"idle": element is gone, native backend resumes authority.
_ => None,
};
}
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) {
// Stale by definition during a handoff — the native player's ticks are
// the real position. See `report_html5_state`.
if self.is_background_audio_active() {
return;
}
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) {
// See `report_html5_state` — the element is not the player right now.
if self.is_background_audio_active() {
return;
}
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> {
// A truncated stream is not an end at all, so this is decided BEFORE the
// end-reason gate below — which returns early for the `NewTrackLoaded`
// that every load sets, and would therefore swallow the whole question on
// Android's JNI callback: the one call guaranteed to run while the app is
// backgrounded and the webview cannot echo anything back.
if let Some(position) = self.truncated_stream_resume_position() {
return Ok(AutoplayDecision::ResumeStream { position });
}
// 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). Note an
// episode played in background-audio mode is MediaType::Audio, so
// rely on is_episode_item (which checks item_type) rather than the
// media_type alone.
let is_episode = 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 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 episode items sit in the
// backend queue — including background-audio mode, where the episode is a
// MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
if 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)
}
}
/// Record the base offset of a background-audio handoff (the position the
/// video was handed off at, which is the audio stream's zero).
///
/// TRACES: UR-040 | DR-052
pub fn set_background_audio_base(&self, seconds: f64) {
*self.background_audio_base.lock_safe() = seconds.max(0.0);
}
/// Enter a background-audio handoff at `position` (the video's position, and
/// therefore the audio stream's zero).
///
/// Hands transport authority to the native audio player: the webview
/// `<video>` is about to be torn down, so its last reports — including the
/// `pause` the teardown itself fires — must not keep it looking like the
/// player. Without this the lockscreen pause emitted a ControlCommand at a
/// dead element and the audio played straight through it.
///
/// TRACES: UR-040, UR-005 | DR-052, DR-097
pub fn enter_background_audio(&self, position: f64) {
self.set_background_audio_base(position);
*self.background_audio_active.lock_safe() = true;
*self.html5_playing.lock_safe() = None;
}
/// Leave a background-audio handoff, returning the base offset to add to the
/// native player's relative position.
///
/// The webview `<video>` becomes the player again once it reloads, so its
/// reports are honoured from here on.
///
/// TRACES: UR-040, UR-005 | DR-052, DR-097
pub fn exit_background_audio(&self) -> f64 {
*self.background_audio_active.lock_safe() = false;
self.take_background_audio_base()
}
/// True while the native audio player owns playback via a background-audio
/// handoff.
///
/// TRACES: UR-040 | DR-052
pub fn is_background_audio_active(&self) -> bool {
*self.background_audio_active.lock_safe()
}
/// Read and clear the background-audio base offset.
///
/// TRACES: UR-040 | DR-052
pub fn take_background_audio_base(&self) -> f64 {
let mut base = self.background_audio_base.lock_safe();
std::mem::replace(&mut *base, 0.0)
}
/// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
///
/// Single place both end-of-playback dispatchers agree on: the Android JNI
/// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
/// (`player_on_playback_ended`). They used to each carry their own copy of
/// this branch, and the command's copy was missing the background-audio case
/// entirely — so an audio-only episode ending while backgrounded only ever
/// started a countdown that nothing could act on.
///
/// TRACES: UR-040, UR-023 | DR-052
pub async fn auto_advance_to_next_episode(
&self,
next_episode: crate::repository::types::MediaItem,
countdown_seconds: u32,
) {
// Background audio-only episode: the countdown only emits ticks — the
// advance itself is a `goto('/player/<id>')` in the webview, which cannot
// start audio while the app is backgrounded. Load the next episode's
// audio-only stream here instead, or playback stalls at the boundary.
if self.current_is_audio_episode() {
info!(
"[PlayerController] Background audio episode — advancing to {} in backend",
next_episode.id
);
match self
.advance_to_next_episode_audio_only(&next_episode.id)
.await
{
Ok(()) => self.emit_queue_changed(),
Err(e) => {
error!(
"[PlayerController] Background audio advance failed: {} — stopping",
e
);
if let Some(emitter) = self.event_emitter() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
return;
}
// Foreground: the frontend drives the advance off the countdown ticks.
self.start_autoplay_countdown(next_episode, countdown_seconds);
}
/// A video item played through the native *audio* path — i.e. the background
/// audio-only handoff, the only place a length-less progressive transcode is
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
fn is_audio_only_video(item: &MediaItem) -> bool {
item.media_type == MediaType::Audio
&& matches!(item.item_type.as_deref(), Some("Episode") | Some("Movie"))
}
/// Claim a resume attempt for the current stream, returning the absolute
/// position to re-open at and the 1-based attempt number. `None` when the
/// current item cannot meaningfully be re-requested, or when retrying at this
/// position has stopped helping.
///
/// Only `Remote` sources qualify. A downloaded file cannot fail because of
/// the network, so re-opening one would paper over a real read error; a
/// `DirectUrl` is a plugin's endpoint with no Jellyfin item behind it.
///
/// The player's position is relative to the stream's own zero (the handoff
/// URL's `StartTimeTicks`), so the base is added back to get an absolute one.
/// It is zero for everything else, where positions are already absolute.
///
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
fn claim_stream_resume(&self) -> Option<(f64, u32)> {
let current = {
let queue = self.queue.lock_safe();
queue.current().cloned()
}?;
if !matches!(current.source, MediaSource::Remote { .. }) {
return None;
}
let base = *self.background_audio_base.lock_safe();
let absolute = (base + self.position()).max(0.0);
match self.stream_resume.lock_safe().allow_attempt(absolute) {
Some(attempt) => Some((absolute, attempt)),
None => {
warn!(
"[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
current.id, absolute
);
None
}
}
}
/// The absolute position to re-open the current stream at, when the reported
/// end was really a dropped connection — `None` when the end looks genuine,
/// when this is not an audio-only handoff, or when retrying has stopped
/// helping.
///
/// TRACES: UR-040 | DR-129 | UT-117
fn truncated_stream_resume_position(&self) -> Option<f64> {
// An explicit user intent already explains the end; never resume over it.
if matches!(
self.peek_end_reason(),
Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
) {
return None;
}
// One lock at a time — `position()` reaches into the backend, and nesting
// that inside the queue lock would invent a lock order nothing else here
// takes.
let item_duration = {
let queue = self.queue.lock_safe();
let current = queue.current()?;
if !Self::is_audio_only_video(current) {
return None;
}
current.duration
};
let base = *self.background_audio_base.lock_safe();
let absolute = (base + self.position()).max(0.0);
// Only spend a resume attempt once the runtime says this really was cut
// short — a genuine end must stay a genuine end.
if !stream_end::is_truncated_end(
absolute,
item_duration,
stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
) {
return None;
}
self.claim_stream_resume().map(|(position, _)| position)
}
/// Where to re-open the current stream after a *recoverable* playback error,
/// plus how many seconds to wait first.
///
/// The media was decoding fine a moment ago, so a mid-playback failure on a
/// server stream is the network — and stopping the player (the previous
/// behaviour, via the frontend's error handler) turns a hiccup into "playback
/// just died". Applies to every streamed item, not only the audio-only
/// handoff: music and video reach here instead of the truncation path because
/// their streams declare a length, so a cut connection surfaces as an error
/// rather than a phantom end.
///
/// The wait grows with the attempt number so a short outage has time to
/// clear, and the shared budget stops the retries when it doesn't.
///
/// Called from the Android error callback, which decides in-process, and from
/// `player_recover_stream`, which is how the same decision reaches the
/// backends whose event thread has no controller to call — MPV is built
/// before the controller exists, so on Linux the error is emitted, echoed by
/// the frontend, and decided here.
///
/// TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117
pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
self.claim_stream_resume()
.map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
}
/// Re-open the current stream at `position` after the network cut it short.
///
/// Single place every dispatcher agrees on, for the same reason
/// `auto_advance_to_next_episode` is: the Android JNI callbacks and the
/// frontend-invoked command must not disagree about what a failed stream
/// means. None of them emits `PlaybackEnded` for this, so nothing downstream
/// clears the queue or tears the session down — from the outside this is a
/// buffering hiccup, which is what it actually was.
///
/// Reloads the item **in place** rather than through `play_item`, which
/// replaces the queue with a single item: recovering a track that way would
/// throw away the rest of the album, turning a network blip into lost state.
///
/// Two shapes of stream, two ways back to `position`:
///
/// - The audio-only handoff's `/Audio/{id}/universal` transcode is chunked
/// with no length, so it cannot be seeked. Its URL is rewritten to start at
/// the position instead — edited, not rebuilt from the repository, since it
/// already carries the user's audio track and media source and recovering
/// from a network failure must not itself need a network round-trip.
/// - Everything else (a static file with byte ranges, an HLS playlist)
/// declares its whole timeline, so re-preparing the URL it already has and
/// seeking lands in the right place — and leaves any transcode session
/// behind it alone.
///
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
let current = {
let queue = self.queue.lock_safe();
queue.current().cloned()
}
.ok_or_else(|| "No current item to resume".to_string())?;
let MediaSource::Remote { stream_url, .. } = &current.source else {
return Err(format!(
"Cannot resume a non-remote source for {}",
current.id
));
};
info!(
"[PlayerController] Stream for {} failed — re-opening at {:.1}s",
current.id, position
);
if !Self::is_audio_only_video(&current) {
self.load_and_play(&current).map_err(|e| e.to_string())?;
if position > 0.5 {
self.seek(position).map_err(|e| e.to_string())?;
}
return Ok(());
}
let restarted_url = stream_end::with_start_time(stream_url, position);
{
let queue_arc = self.queue.clone();
let mut queue = queue_arc.lock_safe();
if !queue.update_current_stream_url(restarted_url) {
return Err(format!("Failed to update stream URL for {}", current.id));
}
}
let resumed = {
let queue = self.queue.lock_safe();
queue.current().cloned()
}
.ok_or_else(|| "Current item vanished mid-resume".to_string())?;
// The re-opened stream's timeline starts at `position` (StartTimeTicks),
// so that is its zero: the exit-to-foreground maths and the lockscreen
// scrubber both read absolute positions off this base.
self.set_background_audio_base(position);
let _ = set_lockscreen_position_offset(position.max(0.0));
self.load_and_play(&resumed).map_err(|e| e.to_string())
}
/// Advance to the next episode while playing audio-only in the background.
///
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
/// which is unavailable when the app is backgrounded and the WebView is
/// suspended. This drives the advance entirely in the backend: build the next
/// episode's *audio-only* stream URL and load it into the native audio player,
/// so playback continues without any frontend involvement (UR-040).
///
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
///
/// Reached through `auto_advance_to_next_episode`, which gates it on
/// `current_is_audio_episode()` — only ever true after a background-audio
/// handoff (Android), but compiled and unit-tested on every platform.
/// TRACES: UR-040, UR-023 | DR-052
pub async fn advance_to_next_episode_audio_only(
&self,
next_episode_id: &str,
) -> Result<(), String> {
let repo = self
.repository
.lock_safe()
.clone()
.ok_or_else(|| "No repository for background episode advance".to_string())?;
// Details for session metadata (title/series/artwork) and the stream URL.
let next = repo
.get_item(next_episode_id)
.await
.map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
// Audio-only transcode from the start of the episode (no resume offset —
// a freshly-started next episode always plays from the beginning).
let stream_url = repo
.get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
.await
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
let media_item = MediaItem {
id: next.id.clone(),
title: next.name.clone(),
name: Some(next.name.clone()),
artist: next.series_name.clone(),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: next.primary_image_tag.clone(),
image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
// Preserve episode identity so the NEXT end-of-track also advances.
item_type: Some("Episode".to_string()),
playlist_id: None,
duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url,
jellyfin_item_id: next.id.clone(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: next.series_id.clone(),
server_id: Some(next.server_id.clone()),
};
// The previous episode's handoff base described the stream we are leaving.
// This one is built without StartTimeTicks, so its timeline is already
// absolute: clear the base (used to resolve the resume position on the way
// back to the foreground) and the lockscreen scrubber's matching shift.
self.set_background_audio_base(0.0);
let _ = set_lockscreen_position_offset(0.0);
// Different stream entirely: whatever was stuck about the last one is not
// this one's problem.
self.stream_resume.lock_safe().reset();
self.play_item(media_item).map_err(|e| e.to_string())
}
/// 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).
///
/// An explicit `item_type == "Episode"` wins so that a TV episode handed off
/// to the audio path for background playback (UR-040) is still recognised as
/// an episode — otherwise autoplay would fall through to the queue-based
/// audio path, find nothing next, and stop at the episode boundary. When the
/// type is unknown we fall back to the historical heuristic (video == episode).
async fn is_episode_item(&self, item: &MediaItem) -> bool {
match item.item_type.as_deref() {
Some("Episode") => true,
Some(_) => item.media_type == MediaType::Video,
None => 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),
}
}
// ===== HTML5 transport authority (DR-097) =====
//
// Webview-rendered video is played by an element the native backend cannot
// reach, so transport for it must be decided from the state the element
// REPORTS and executed by emitting a ControlCommand. Previously the frontend
// decided play-vs-pause itself by reading `el.paused` off the DOM, which
// flips transiently while buffering/seeking — two intents ~150ms apart read
// different values, took opposing actions, and self-sustained a pause loop.
#[test]
fn test_html5_state_is_tracked_from_reports() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
// No HTML5 media reported yet: the native backend stays authoritative.
assert!(!controller.is_html5_active());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
assert!(controller.html5_is_playing());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
assert!(!controller.html5_is_playing());
}
#[test]
fn test_html5_toggle_from_paused_emits_play_control() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["play".to_string()]);
}
#[test]
fn test_html5_toggle_from_playing_emits_pause_control() {
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()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["pause".to_string()]);
}
#[test]
fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
// The loop signature: two intents in quick succession must NOT both
// resolve the same way, and must not produce opposing actions from a
// stale read. Rust's own tracked state makes the sequence deterministic
// as long as the element reports back between intents.
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()));
controller.toggle_playback().unwrap();
// Element confirms the pause it was told to do.
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
}
#[test]
fn test_html5_play_and_pause_emit_control_commands() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.play().unwrap();
controller.pause().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
}
#[test]
fn test_background_audio_handoff_moves_transport_to_native_backend() {
// Lockscreen pause while playing a video's audio in the background.
//
// The handoff tears the WebView <video> down AFTER native audio starts,
// and that teardown fires a DOM `pause` the frontend dutifully reports.
// That report used to leave `html5_playing = Some(false)`, so transport
// kept being aimed at an element that no longer exists: the lockscreen
// pause emitted a ControlCommand into the void and the audio played on.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
// Video was playing in the webview.
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
assert!(controller.is_html5_active());
// Hand off to the native audio player, then tear the element down.
controller.enter_background_audio(1200.0);
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
assert!(
!controller.is_html5_active(),
"native audio owns transport during a background-audio handoff"
);
controller.pause().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert!(
controls.is_empty(),
"pause must drive the native backend, not a torn-down element: {:?}",
controls
);
}
#[test]
fn test_background_audio_handoff_suppresses_stale_element_events() {
// The dying element's pause/position reports describe the video, not the
// audio now playing — re-emitting them flips the UI to paused and yanks
// the position backwards while native audio keeps going.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.enter_background_audio(1200.0);
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
controller.report_html5_position(1200.0, 2400.0);
assert!(
emitter.events().is_empty(),
"stale webview reports must not reach the event pipeline: {:?}",
emitter.events()
);
}
#[test]
fn test_exit_background_audio_returns_transport_to_the_webview() {
// Back in the foreground the <video> is the player again, so its reports
// must be honoured — and the base offset still comes back for the resume.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.enter_background_audio(1200.0);
assert_eq!(controller.exit_background_audio(), 1200.0);
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
assert!(controller.is_html5_active());
assert!(controller.html5_is_playing());
}
#[test]
fn test_html5_stopped_report_releases_transport_to_native_backend() {
// When webview video goes away, transport must fall back to the native
// backend (music playback must not keep emitting ControlCommands).
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()));
assert!(controller.is_html5_active());
controller.report_html5_state("stopped".to_string(), None);
assert!(!controller.is_html5_active());
}
#[test]
fn test_html5_transport_emits_exactly_one_control_per_intent() {
// Guards against a double-drive on platforms where the *backend* is also
// webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
// must replace the backend call, not run in addition to it.
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()));
controller.pause().unwrap();
let controls = emitter
.events()
.into_iter()
.filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
.count();
assert_eq!(controls, 1, "one intent must produce exactly one control");
}
#[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,
image_id: 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"
);
}
}
/// A time-based sleep timer that fires mid-episode must not let the ended
/// callback fall through to autoplay.
///
/// The timer thread stops the backend directly, which makes ExoPlayer emit
/// its ended callback. That callback races the thread's own `timer.cancel()`:
/// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
/// so the timer branch is skipped and the episode path runs — showing a
/// next-episode popup (or advancing) after the user's sleep timer expired.
#[tokio::test]
async fn test_expired_time_sleep_timer_stops_without_autoplay() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue(items, 0).unwrap();
controller.take_end_reason();
// Arm a time-based timer that is already due, then let the real timer
// thread (started in the constructor, 1s tick) observe the expiry and
// run its stop path. Driving the actual thread is the point: the bug was
// that this path stopped the backend without recording an end reason.
let now = chrono::Utc::now().timestamp_millis();
controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
// Wait for the timer thread to process the expiry (tick is 1s).
for _ in 0..40 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if !controller.sleep_timer.lock_safe().is_active() {
break;
}
}
assert!(
!controller.sleep_timer.lock_safe().is_active(),
"Timer thread should have expired and cancelled the sleep timer"
);
// The backend stop above makes the native player fire its ended callback.
let decision = controller.on_playback_ended().await.unwrap();
assert!(
matches!(decision, AutoplayDecision::Stop),
"Expected Stop after an expired time-based sleep timer, got {:?}",
decision
);
}
#[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(),
kind: crate::domain::MediaKind::Episode,
is_folder: false,
server_id: "server".to_string(),
parent_id: Some("season1".to_string()),
library_id: None,
overview: None,
genres: None,
runtime_ticks: None,
duration_ms: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
image_id: 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_audio_only_stream_url_for_video(
&self,
item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, repo_types::RepoError> {
Ok(format!("http://example.com/{}-audio.mp3", item_id))
}
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_favorites(
&self,
_: repo_types::SearchScope,
_: Option<repo_types::GetItemsOptions>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn mark_played(&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),
}
}
/// Background audio-only mode (UR-040): a video episode is handed off to the
/// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
/// playing while the app is backgrounded. When that audio track ends, autoplay
/// must STILL recognise it as an episode and offer the next one — otherwise
/// playback just pauses at the episode boundary (the reported bug). The item
/// carries its episode identity via `item_type: "Episode"` + `series_id`.
#[tokio::test]
async fn test_playback_ended_background_audio_episode_advances() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
let episode = MediaItem {
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio, // audio-only handoff, not Video
series_id: Some("series1".to_string()),
duration: Some(180.0),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Played through to the end — a natural finish, not a stream cut short.
controller.seek(180.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, "ep3");
}
other => panic!(
"background-audio episode end must advance to the next episode, got {:?}",
other
),
}
}
/// The backend-driven advance (used when backgrounded) must load the next
/// episode as an AUDIO item carrying its episode identity, so the *following*
/// end-of-track also advances rather than stopping.
#[tokio::test]
async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.advance_to_next_episode_audio_only("ep2")
.await
.expect("advance should succeed");
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should be loaded");
assert_eq!(current.id, "ep2");
assert_eq!(current.media_type, MediaType::Audio);
assert_eq!(current.item_type.as_deref(), Some("Episode"));
assert_eq!(current.series_id.as_deref(), Some("series1"));
// Uses the audio-only URL, not a video stream.
match &current.source {
MediaSource::Remote { stream_url, .. } => {
assert!(
stream_url.contains("audio"),
"expected audio-only URL, got {}",
stream_url
);
}
other => panic!("expected Remote source, got {:?}", other),
}
// The controller now considers itself mid background-audio episode, so the
// next end-of-track will advance again rather than stop.
assert!(controller.current_is_audio_episode());
}
/// The handoff base offset describes ONE stream: the audio-only URL built
/// with `StartTimeTicks` = the position the video was handed off at, whose
/// timeline therefore starts at that point. The next episode is loaded from
/// its own beginning, so its timeline is already absolute and the base must
/// be cleared — otherwise returning to the foreground resolves the resume
/// position as `old_base + position_in_new_episode` and the video jumps to a
/// point that has nothing to do with what was playing.
#[tokio::test]
async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Handed off 20 minutes into the previous episode.
controller.set_background_audio_base(1200.0);
controller
.advance_to_next_episode_audio_only("ep2")
.await
.expect("advance should succeed");
assert_eq!(
controller.take_background_audio_base(),
0.0,
"the next episode starts at its own zero, so the previous handoff \
base must not survive the advance"
);
}
/// A background audio-only episode must advance IN THE BACKEND when the
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
/// countdown the frontend is supposed to act on.
///
/// The countdown only emits CountdownTick events; the actual advance is a
/// `goto('/player/<id>')` in the webview. While the app is backgrounded that
/// navigation cannot start audio, so playback stalls at the episode boundary
/// with ExoPlayer parked in STATE_ENDED — and any later play intent
/// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
/// start, which is what surfaces to the user as "the episode randomly
/// restarted".
#[tokio::test]
async fn test_auto_advance_background_audio_episode_advances_in_backend() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Currently playing: ep2 handed off to audio-only background playback.
let episode = MediaItem {
id: "ep2".to_string(),
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio,
series_id: Some("series1".to_string()),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2-audio.mp3".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
let next = make_repo_episode("ep3", 3);
controller.auto_advance_to_next_episode(next, 10).await;
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should still be loaded");
assert_eq!(
current.id, "ep3",
"background audio-only episode must advance in the backend, not wait \
for a frontend navigation that cannot happen while backgrounded"
);
assert_eq!(current.media_type, MediaType::Audio);
assert!(controller.current_is_audio_episode());
}
/// Build the audio-only episode the background handoff loads: a video item
/// played through the native audio path, with a known runtime and a stream
/// URL carrying the handoff position.
fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
MediaItem {
id: "ep2".to_string(),
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio,
series_id: Some("series1".to_string()),
duration: Some(runtime_seconds),
source: MediaSource::Remote {
stream_url:
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
.to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
}
}
/// A flaky connection truncates the progressive mp3 transcode that carries
/// background audio-only playback. ExoPlayer sees end-of-input on a stream
/// with no reliable length, so it reports STATE_ENDED ten minutes into a
/// twenty-five minute episode — indistinguishable, to the player, from the
/// real end.
///
/// Treating that as "the episode finished" is what the user experiences as
/// the episode randomly restarting: playback parks in STATE_ENDED and the
/// next play intent (lockscreen, notification, Bluetooth reconnect) seeks an
/// ended player to position 0 before playing. The runtime we already know
/// says the stream died early, so the decision must be to resume it.
#[tokio::test]
async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
// The connection dropped 10 minutes into a 25-minute episode.
controller.seek(600.0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ResumeStream { position } => {
assert_eq!(position, 600.0, "must resume where the stream died");
}
other => panic!(
"a stream that ended 15 minutes short of the runtime must resume, \
not run end-of-episode logic; got {:?}",
other
),
}
}
/// The handoff stream's timeline starts at the handoff position, so the
/// player reports a *relative* position. The runtime it is compared against
/// is absolute — the base has to be added back, or every handoff looks like a
/// truncation.
#[tokio::test]
async fn test_truncated_check_uses_the_absolute_position() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
// Handed off at 24:00; the stream then played its last 56 seconds out.
controller.set_background_audio_base(1440.0);
controller.seek(56.0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(
matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
"24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
decision
);
}
/// The resume re-opens the same URL, so a server that is actually gone would
/// otherwise end → resume → end forever. After the budget runs out the
/// decision falls back to normal end-of-item handling.
#[tokio::test]
async fn test_repeated_truncation_at_the_same_position_gives_up() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
controller.seek(600.0).unwrap();
for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(
matches!(decision, AutoplayDecision::ResumeStream { .. }),
"attempt {} should still resume, got {:?}",
attempt,
decision
);
}
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(
!matches!(decision, AutoplayDecision::ResumeStream { .. }),
"a stream stuck at the same position must stop retrying, got {:?}",
decision
);
}
/// Ordinary music is not covered: its streams are not the length-less
/// progressive transcode this guards, and a short track legitimately ends
/// well before a stale duration would suggest.
#[tokio::test]
async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
let controller = PlayerController::default();
let mut items = create_test_items(2);
items[0].duration = Some(1500.0);
controller.play_queue(items, 0).unwrap();
controller.seek(60.0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(
matches!(decision, AutoplayDecision::AdvanceToNext),
"plain queue audio must keep advancing, got {:?}",
decision
);
}
/// Music and video stream from URLs that declare their own length (a static
/// file with byte ranges, an HLS playlist), so a truncation reaches the
/// player as an *error* rather than a phantom end. It is the same network
/// failure, and the same recovery applies — the previous behaviour turned it
/// into `playerStop()` and silence.
#[tokio::test]
async fn test_recoverable_error_resumes_a_music_track() {
let controller = PlayerController::default();
let mut items = create_test_items(3);
for item in &mut items {
item.source = MediaSource::Remote {
stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
jellyfin_item_id: item.id.clone(),
};
}
controller.play_queue(items, 1).unwrap();
controller.seek(45.0).unwrap();
let (position, _) = controller
.recoverable_error_resume()
.expect("a streamed music track must be resumable after a network error");
assert_eq!(position, 45.0);
}
/// The resume must reload the failed track IN PLACE. `play_item` replaces the
/// whole queue with a single item, so recovering a track that way would throw
/// away the rest of the album — turning a network blip into lost state.
#[tokio::test]
async fn test_resume_keeps_the_rest_of_the_queue() {
let controller = PlayerController::default();
let mut items = create_test_items(3);
for item in &mut items {
item.source = MediaSource::Remote {
stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
jellyfin_item_id: item.id.clone(),
};
}
controller.play_queue(items, 1).unwrap();
controller
.resume_stream_at(45.0)
.await
.expect("resume should succeed");
let queue = controller.queue.lock_safe();
assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
assert_eq!(queue.current_index(), Some(1), "still on the same track");
assert_eq!(queue.current().unwrap().id, "item_1");
}
/// A seekable stream is re-opened by re-preparing the URL it already has and
/// seeking — its timeline is intact, and rewriting the URL would restart a
/// transcode session for no reason.
#[tokio::test]
async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
let controller = PlayerController::default();
let mut items = create_test_items(1);
items[0].source = MediaSource::Remote {
stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
jellyfin_item_id: "item_0".to_string(),
};
controller.play_queue(items, 0).unwrap();
controller.resume_stream_at(45.0).await.unwrap();
match &controller.queue.lock_safe().current().unwrap().source {
MediaSource::Remote { stream_url, .. } => {
assert_eq!(
stream_url, "http://s/Audio/item_0/stream?Static=true",
"a seekable stream's URL must be left alone"
);
}
other => panic!("expected Remote source, got {:?}", other),
}
assert_eq!(
controller.position(),
45.0,
"and it must land at the position"
);
}
/// Downloaded media cannot fail from the network, and re-opening a local file
/// would paper over a real read error.
#[tokio::test]
async fn test_recoverable_error_ignores_local_media() {
let controller = PlayerController::default();
let mut items = create_test_items(1);
items[0].source = MediaSource::Local {
file_path: "/music/track.flac".into(),
jellyfin_item_id: Some("item_0".to_string()),
};
controller.play_queue(items, 0).unwrap();
controller.seek(45.0).unwrap();
assert!(controller.recoverable_error_resume().is_none());
}
/// A recoverable error during background audio-only playback is the network,
/// not the media — the previous behaviour (surface it, frontend stops the
/// player) turned a hiccup into silence. Retrying must also back off, or the
/// three attempts are spent inside a second and the outage outlives them.
#[tokio::test]
async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
let controller = PlayerController::default();
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
controller.seek(600.0).unwrap();
let mut waits = Vec::new();
for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
let (position, delay) = controller
.recoverable_error_resume()
.unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
assert_eq!(position, 600.0);
waits.push(delay);
}
assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
assert!(
controller.recoverable_error_resume().is_none(),
"a stream that keeps failing at the same spot must surface the error"
);
}
/// Plugin/channel `DirectUrl` sources are somebody else's endpoint with no
/// Jellyfin item behind them, so the resume has nothing to re-request.
#[tokio::test]
async fn test_recoverable_error_ignores_direct_url_playback() {
let controller = PlayerController::default();
controller.play_queue(create_test_items(2), 0).unwrap();
assert!(controller.recoverable_error_resume().is_none());
}
/// Re-opening the stream must land where it died and keep playing, with the
/// handoff base moved to the new stream's zero so returning to the
/// foreground still resolves an absolute position.
#[tokio::test]
async fn test_resume_truncated_stream_reloads_at_position() {
let controller = PlayerController::default();
controller
.play_queue(vec![audio_only_episode(1500.0)], 0)
.unwrap();
controller.set_background_audio_base(0.0);
controller
.resume_stream_at(600.0)
.await
.expect("resume should succeed");
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("the same item should still be loaded");
assert_eq!(current.id, "ep2", "resume must not change the item");
match &current.source {
MediaSource::Remote { stream_url, .. } => {
assert!(
stream_url.contains("StartTimeTicks=6000000000"),
"stream must re-open at 600s, got {}",
stream_url
);
assert!(
stream_url.contains("AudioStreamIndex=2"),
"the selected audio track must survive the resume, got {}",
stream_url
);
}
other => panic!("expected Remote source, got {:?}", other),
}
assert_eq!(
controller.take_background_audio_base(),
600.0,
"the re-opened stream's zero is the resume position"
);
}
/// Foreground video playback keeps the countdown-driven advance: the frontend
/// owns the navigation there, so the backend must NOT load the next episode
/// itself (that would race the page transition and double-start playback).
#[tokio::test]
async fn test_auto_advance_foreground_video_episode_uses_countdown() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
let episode = MediaItem {
id: "ep2".to_string(),
item_type: Some("Episode".to_string()),
media_type: MediaType::Video,
series_id: Some("series1".to_string()),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2.m3u8".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
let next = make_repo_episode("ep3", 3);
controller.auto_advance_to_next_episode(next, 10).await;
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should still be loaded");
assert_eq!(
current.id, "ep2",
"foreground video advance is frontend-driven; the backend must not \
swap the queue item out from under it"
);
}
/// 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));
}
}