Search's instant leg read only downloaded items, so with no downloads it returned nothing and every keystroke fell through to a full Recursive=true server query. It now reads the whole synced catalog through the same availability CTE get_items uses, gated on the same include_catalog_browse flag so search and browse cannot diverge. (UR-065, DR-108) Also fixes three defects found while confirming that: - items_fts grew by a full duplicate index every catalog pass. INSERT OR REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement took a fresh rowid and inserted a second entry. Now a real upsert, with migration 021 rebuilding existing indexes. (DR-110) - DELETE FROM items existed nowhere, so server-side deletions never propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl because items.parent_id cascades. (DR-110) - The index omitted MusicArtist, Playlist and People, which search groups results by. Adds them plus people_fts (migration 022). (DR-111) Re-indexing moves from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog and a restart no longer forces a crawl regardless of freshness. (DR-109, IR-030) Downloads gain a lifetime tier. Eviction selected every completed row by age with no download_source filter, so hitting the storage limit deleted the oldest download -- typically one saved deliberately for offline -- to make room for a precached track. It now reclaims only 'auto' rows, and expired ones are reclaimed first, before live cache is evicted. (DR-126, DR-127) Downloaded video and audio-only handoffs now play from disk instead of streaming; the video path had never consulted downloads at all. No transcode is involved: MPV runs video=no and ExoPlayer has no surface for an Audio item. (DR-123 in part, DR-128) FTS queries are built as quoted phrases so apostrophes, hyphens and slashes are data rather than operator syntax, and the item-type filter is bound rather than interpolated. Specs: docs/specs/catalog-index-search.md, docs/specs/read-through-media-cache.md Includes concurrently-developed favourites browsing and background-audio stream-end handling; the two workstreams share offline.rs, lib.rs and online.rs, so no subset of files builds independently.
3782 lines
141 KiB
Rust
3782 lines
141 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};
|
|
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>>,
|
|
|
|
// 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)),
|
|
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>) {
|
|
// 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) {
|
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
|
|
}
|
|
}
|
|
|
|
/// Report that the HTML5 <video> element finished loading and knows its
|
|
/// duration. Mirrors the native `MediaLoaded` event.
|
|
pub fn report_html5_media_loaded(&self, duration: f64) {
|
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
|
}
|
|
}
|
|
|
|
// ===== Autoplay Methods =====
|
|
|
|
/// Get autoplay settings
|
|
pub fn autoplay_settings(&self) -> AutoplaySettings {
|
|
self.autoplay_settings.lock_safe().clone()
|
|
}
|
|
|
|
/// Set autoplay settings (in-memory only, persistence handled by command layer)
|
|
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
|
|
let validated = settings.with_validated_countdown();
|
|
*self.autoplay_settings.lock_safe() = validated;
|
|
}
|
|
|
|
/// Cancel active autoplay countdown
|
|
pub fn cancel_autoplay_countdown(&self) {
|
|
if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
|
|
*cancel_flag.lock_safe() = true;
|
|
}
|
|
}
|
|
|
|
/// Handle playback ended event - decides what to do next
|
|
///
|
|
/// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
|
|
/// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
|
|
pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
|
|
// 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(¤t).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(¤t).await {
|
|
let repo = self.repository.lock_safe().clone();
|
|
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.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);
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Only *called* from the Android error callback (`#[cfg(android)]`), but
|
|
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
|
|
///
|
|
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
|
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
|
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, .. } = ¤t.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(¤t) {
|
|
self.load_and_play(¤t).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 ¤t_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_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 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 ¤t.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 ¤t.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));
|
|
}
|
|
}
|