A regression I introduced in DR-246 and did not catch, because the capability was declared once for "native engines" as though being native were the property that mattered. It is not. Speaking HLS is. ExoPlayer is a full HLS client: like hls.js it seeks within the VOD playlist it was handed and lets the server catch up. mpv's HLS demuxer will not make the server produce segments from a new offset, so it has to re-open the stream. Grouping them together declared false for both, so on Android a transcoded seek began re-opening the stream where it previously seeked in place — the same class of defect DR-238 was about, reintroduced on the platform I had not exercised. Capabilities::native() is gone, replaced by mpv() and exoplayer(), and the composition root chooses per platform through engine_capabilities(). Treating a category as a proxy for an ability is precisely the inference this design removes; a helper named after the category invited it straight back in. Not yet verified on a device. The conformance cases run against JellyTauPlayer in isolation and do not cover a transcoded seek, PiP, background audio or the media session — none of which have been exercised since the controller port.
4824 lines
184 KiB
Rust
4824 lines
184 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 background_policy;
|
|
#[cfg(any(test, feature = "conformance"))]
|
|
pub mod conformance;
|
|
pub mod events;
|
|
#[cfg(any(test, feature = "conformance"))]
|
|
pub mod fake_player;
|
|
#[cfg(test)]
|
|
mod fake_player_conformance;
|
|
pub mod legacy_player;
|
|
pub mod media;
|
|
pub mod media_player;
|
|
#[cfg(target_os = "linux")]
|
|
pub mod mpv_player;
|
|
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;
|
|
|
|
/// Whether this process renders video natively — one answer, three consumers
|
|
/// (UR-080 / DR-231, DR-235).
|
|
pub mod native_video;
|
|
|
|
/// mpv's render API into a framebuffer we own (UR-080 / DR-231, IR-033).
|
|
///
|
|
/// Deliberately *not* GTK-gated beyond the platform that currently builds it:
|
|
/// everything here is the portable half, and Windows reuses it unchanged behind
|
|
/// its own surface.
|
|
#[cfg(target_os = "linux")]
|
|
pub mod mpv_render;
|
|
|
|
/// The native video surface mpv renders into (UR-080 / DR-231).
|
|
///
|
|
/// Linux-gated because the *surface* is GTK. Everything around it — the render
|
|
/// context, its lifetime, frame pacing, the device profile — is not.
|
|
#[cfg(target_os = "linux")]
|
|
pub mod video_surface;
|
|
|
|
// 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
|
|
use crate::repository::stream_selection::StreamSelection;
|
|
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
|
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
|
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
|
pub use legacy_player::LegacyPlayer;
|
|
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
|
|
pub use media_player::{MediaPlayer, OpenRequest, Phase};
|
|
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,
|
|
};
|
|
|
|
/// Where the player's playback reports go.
|
|
///
|
|
/// The controller's side of reporting is "send this, don't make me wait": a slow
|
|
/// or failing sync must never stall playback, so every send is fire-and-forget.
|
|
/// Production wires this to [`PlaybackReporter`] (local DB, server sync, offline
|
|
/// queueing); tests capture the operations instead of standing up a database and
|
|
/// an HTTP client, which is what let the missing reports below be written as
|
|
/// failing tests rather than found on a device.
|
|
///
|
|
/// TRACES: UR-025 | DR-179
|
|
pub trait PlaybackReportSink: Send + Sync {
|
|
/// Deliver `operation`. Must not block the caller.
|
|
fn send(&self, operation: PlaybackOperation);
|
|
}
|
|
|
|
/// The production sink: hands each operation to the `PlaybackReporter`.
|
|
///
|
|
/// Reports originate on whatever thread playback ended or ticked on — including
|
|
/// JNI callbacks with no Tokio runtime attached — so the spawn falls back to a
|
|
/// throwaway runtime on its own thread rather than assuming one is current.
|
|
struct ReporterSink {
|
|
reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
|
}
|
|
|
|
impl PlaybackReportSink for ReporterSink {
|
|
fn send(&self, operation: PlaybackOperation) {
|
|
let reporter = self.reporter.clone();
|
|
let task = async move {
|
|
let guard = reporter.lock().await;
|
|
let Some(reporter) = guard.as_ref() else {
|
|
warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
|
|
return;
|
|
};
|
|
// `report` decides local-vs-server and queues for sync itself.
|
|
if let Err(e) = reporter.report(operation, true).await {
|
|
log::error!("[PlayerController] Failed to report playback: {}", e);
|
|
}
|
|
};
|
|
|
|
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
|
handle.spawn(task);
|
|
} else {
|
|
std::thread::spawn(move || match tokio::runtime::Runtime::new() {
|
|
Ok(rt) => rt.block_on(task),
|
|
Err(e) => log::error!(
|
|
"[PlayerController] No runtime available to report playback: {}",
|
|
e
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The position to report when a stream ends naturally.
|
|
///
|
|
/// The item's runtime when we know it, because the point of the report is to say
|
|
/// the episode *finished* and Jellyfin decides that by percentage — the last
|
|
/// position actually observed can be seconds short, and on a handoff whose ticks
|
|
/// stopped early it can be nowhere near the end. Without a runtime the best
|
|
/// available answer is where playback got to.
|
|
///
|
|
/// TRACES: UR-025, UR-040 | DR-179 | UT-179
|
|
fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
|
|
match runtime {
|
|
Some(runtime) if runtime > 0.0 => runtime,
|
|
_ => last_position.max(0.0),
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// TRACES: UR-006 | IR-006
|
|
#[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.
|
|
///
|
|
/// No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see
|
|
/// IR-005, which is still Planned.
|
|
///
|
|
/// TRACES: UR-006 | IR-006
|
|
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;
|
|
use crate::utils::conversions::seconds_to_ticks;
|
|
|
|
/// Central player controller that coordinates playback
|
|
pub struct PlayerController {
|
|
/// The engine. One contract, so the controller stops branching on which
|
|
/// platform it is running on — see docs/specs/media-player-controller.md.
|
|
backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
|
|
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>>>,
|
|
|
|
// Where playback reports go. Swappable so tests can assert on what the
|
|
// player tells Jellyfin. See `PlaybackReportSink`.
|
|
reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
|
|
|
|
// Bounds progress reports to one per item per 30s. Position ticks arrive
|
|
// four times a second; the server needs a resume point, not a firehose.
|
|
position_throttler: Arc<EventThrottler>,
|
|
|
|
// End reason tracking for autoplay decision making
|
|
end_reason: Arc<Mutex<Option<EndReason>>>,
|
|
|
|
// Auto-play episode counter (session-based, resets on manual play)
|
|
autoplay_episode_count: Arc<Mutex<u32>>,
|
|
|
|
// Base offset (seconds) of the active background-audio handoff.
|
|
//
|
|
// The audio-only stream is requested with `StartTimeTicks` = the position the
|
|
// video was handed off at, so the server makes that point the stream's zero
|
|
// and the native player reports position RELATIVE to it. Adding this base back
|
|
// yields the absolute position to resume the video at on the way out.
|
|
//
|
|
// Lives on the controller (not beside the command) because the queue and this
|
|
// offset describe the same stream: whenever the controller loads a different
|
|
// one — notably the backend-driven advance to the next episode — the base has
|
|
// to move with it.
|
|
//
|
|
// TRACES: UR-040 | DR-052
|
|
background_audio_base: Arc<Mutex<f64>>,
|
|
|
|
// True while a background-audio handoff owns playback: the native audio
|
|
// player is the real player and the webview <video> has been torn down.
|
|
//
|
|
// The teardown is what makes this necessary. It fires a DOM `pause` that the
|
|
// frontend reports like any other, which would otherwise leave the controller
|
|
// believing webview media is still active — aiming lockscreen transport at an
|
|
// element that no longer exists (see `is_html5_active`).
|
|
//
|
|
// TRACES: UR-040 | DR-052, DR-097
|
|
background_audio_active: Arc<Mutex<bool>>,
|
|
|
|
// Budget for re-opening a stream that ended short of the item's runtime.
|
|
//
|
|
// A resume re-requests the same URL, so a server that is genuinely gone would
|
|
// otherwise end → resume → end without limit. The tracker only bounds retries
|
|
// that make no progress; a resume that plays on refills it.
|
|
//
|
|
// TRACES: UR-040 | DR-129
|
|
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
|
|
|
|
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
|
//
|
|
// Webview-rendered media is played by an element the native backend cannot
|
|
// reach, so the backend's own state() says nothing about it. Tracking the
|
|
// REPORTED state here is what lets transport (play/pause/toggle) be decided
|
|
// in Rust for that media instead of the frontend reading `el.paused` off the
|
|
// DOM — a value that flips transiently while buffering/seeking and caused
|
|
// competing intents to take opposing actions. `None` means no webview media
|
|
// is active and the native backend is authoritative. See DR-097.
|
|
html5_playing: Arc<Mutex<Option<bool>>>,
|
|
|
|
// Last position/duration reported by webview-rendered media.
|
|
//
|
|
// On the webview path the `<video>` element IS the player: nothing is loaded
|
|
// into the native backend, so `backend.position()` is a permanent 0. Those
|
|
// reports used to be re-emitted to the frontend and then dropped, which is
|
|
// why every position the *backend* sent to Jellyfin — including the stop
|
|
// report that sets the resume point — was zero, overwriting the correct one
|
|
// the frontend had just sent. Storing them here makes
|
|
// `absolute_position()` answer for both rendering paths.
|
|
//
|
|
// TRACES: UR-005, UR-025 | DR-178
|
|
reported_time: Arc<Mutex<stream_end::ObservedTime>>,
|
|
}
|
|
|
|
impl PlayerController {
|
|
pub fn new(
|
|
backend: Box<dyn MediaPlayer>,
|
|
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
|
position_throttler: Arc<EventThrottler>,
|
|
) -> Self {
|
|
let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
|
|
reporter: playback_reporter.clone(),
|
|
});
|
|
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,
|
|
reports: Arc::new(Mutex::new(reports)),
|
|
position_throttler,
|
|
end_reason: Arc::new(Mutex::new(None)),
|
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
|
background_audio_base: Arc::new(Mutex::new(0.0)),
|
|
background_audio_active: Arc::new(Mutex::new(false)),
|
|
stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
|
|
html5_playing: Arc::new(Mutex::new(None)),
|
|
reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
|
|
};
|
|
|
|
// 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();
|
|
// A different item is current; the last one's reported position must not
|
|
// be reported against it. This path is how webview-rendered video is
|
|
// queued (no backend load at all), so it is exactly where a stale
|
|
// reading would otherwise survive.
|
|
// TRACES: UR-005 | DR-178
|
|
self.clear_reported_time();
|
|
|
|
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);
|
|
|
|
// Loading into the native backend IS the statement that native renders
|
|
// this item, so transport authority returns to it.
|
|
//
|
|
// `html5_playing` is written only by the webview element's own reports
|
|
// and cleared only when it reports "stopped"/"idle". An element that
|
|
// went away without that final report — or webview-rendered music
|
|
// earlier in the same process — left `is_html5_active()` true, and then
|
|
// every play/pause intent was emitted as a ControlCommand at an element
|
|
// that no longer existed instead of reaching the backend. On Android's
|
|
// native video path that is a pause button that does nothing, from the
|
|
// surface tap and the control bar alike, while seek and skip keep
|
|
// working because they decide elsewhere. Whether it happened at all
|
|
// depended on what had played before, which is what made it look
|
|
// intermittent.
|
|
//
|
|
// The webview re-establishes its own authority the moment an element
|
|
// reports again, so nothing is lost on the HTML5 path: this is the same
|
|
// "element is gone" semantics as the "stopped"/"idle" report, applied at
|
|
// the point where we can know it directly.
|
|
//
|
|
// TRACES: UR-005, UR-003 | DR-193
|
|
*self.html5_playing.lock_safe() = None;
|
|
|
|
let mut backend = self.backend.lock_safe();
|
|
// One operation: the engine is handed the item and where to begin, so
|
|
// there is no window between them for a position to be lost in.
|
|
backend.open(OpenRequest::new(
|
|
item.clone(),
|
|
StreamSelection::for_queued_item(
|
|
item.playable_url(),
|
|
item.transport,
|
|
item.needs_transcoding,
|
|
),
|
|
))?;
|
|
drop(backend);
|
|
|
|
// A different item is loading; the last one's reported position must not
|
|
// be attributed to it.
|
|
self.clear_reported_time();
|
|
|
|
// Report playback start using PlaybackReporter (dual sync: local DB + server)
|
|
if let Some(jellyfin_id) = item.jellyfin_id() {
|
|
// 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
|
|
};
|
|
|
|
// Where this stream actually begins. Zero for an ordinary load, but a
|
|
// background-audio handoff loads a stream whose zero is the handoff
|
|
// point — telling the server the session started at 0:00 there both
|
|
// misreports the session and, being a position, competes with the
|
|
// real one.
|
|
let position = self.absolute_position();
|
|
|
|
log::info!(
|
|
"[PlayerController] Reporting playback start: {} @ {:.1}s",
|
|
jellyfin_id,
|
|
position
|
|
);
|
|
self.report(PlaybackOperation::Start {
|
|
item_id: jellyfin_id.to_string(),
|
|
position_ticks: seconds_to_ticks(position),
|
|
context,
|
|
});
|
|
}
|
|
|
|
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.snapshot().phase.is_active() {
|
|
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()))
|
|
};
|
|
|
|
// Read across every rendering path BEFORE stopping: the backend zeroes
|
|
// its position on stop, and the element that was reporting is gone.
|
|
let position = self.absolute_position();
|
|
|
|
let mut backend = self.backend.lock_safe();
|
|
backend.close()?;
|
|
drop(backend);
|
|
self.clear_reported_time();
|
|
|
|
if let Some(jellyfin_id) = jellyfin_id {
|
|
self.report_stopped_at(jellyfin_id, position);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Tell Jellyfin playback stopped at `position`, unless that position is
|
|
/// zero.
|
|
///
|
|
/// Jellyfin stores the reported position as the resume point, so a zero is
|
|
/// not a harmless no-op — it is an instruction to forget where the viewer
|
|
/// was. And it is never *information*: nobody watched zero seconds of
|
|
/// anything, so every zero this app ever sent came from asking a player that
|
|
/// was not rendering the media (webview video, or a handoff whose first tick
|
|
/// had not landed). On a device trace, 14 of 14 stop reports in 35 minutes
|
|
/// were zeroes, one of them 40s after the frontend had correctly reported
|
|
/// 15:22 for the same episode.
|
|
///
|
|
/// TRACES: UR-025, UR-005 | DR-179 | UT-178
|
|
fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
|
|
if position <= 0.0 {
|
|
debug!(
|
|
"[PlayerController] Withholding zero-position stop report for {} \
|
|
(nothing played; reporting it would clear the resume point)",
|
|
jellyfin_id
|
|
);
|
|
return;
|
|
}
|
|
|
|
log::info!(
|
|
"[PlayerController] Reporting playback stopped: {} @ {:.1}s",
|
|
jellyfin_id,
|
|
position
|
|
);
|
|
self.report(PlaybackOperation::Stopped {
|
|
item_id: jellyfin_id,
|
|
position_ticks: seconds_to_ticks(position),
|
|
});
|
|
}
|
|
|
|
/// 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.snapshot().position.as_secs_f64() > 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, **on the player's own timeline**.
|
|
///
|
|
/// During a background-audio handoff that timeline is relative to the handoff
|
|
/// point, so this is not the call a lockscreen scrub or a UI seek wants — use
|
|
/// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
|
|
/// timeline and is what every caller outside the player itself means.
|
|
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
|
let mut backend = self.backend.lock_safe();
|
|
backend.seek(Duration::from_secs_f64(position.max(0.0)))
|
|
}
|
|
|
|
/// Seek to an **absolute** position on the item's own timeline.
|
|
///
|
|
/// This is the boundary every outside seek comes through — the UI, the
|
|
/// lockscreen scrubber, a headset gesture — because all of them are looking
|
|
/// at the whole episode, not at whatever fragment of it the player happens to
|
|
/// be streaming.
|
|
///
|
|
/// Outside a background-audio handoff the two timelines are the same and this
|
|
/// is an ordinary seek. Inside one they differ by the handoff base, and the
|
|
/// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
|
|
/// transcode with no length, so ExoPlayer either refuses or clamps — and a
|
|
/// clamped seek lands at stream zero, which is the handoff point. That is the
|
|
/// "jumps back to where I locked the screen" symptom. Honouring the seek means
|
|
/// re-opening the URL at the new position, which is exactly what the
|
|
/// truncation recovery already does, so it shares `resume_stream_at`.
|
|
///
|
|
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
|
|
pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
|
|
// Only a *streamed* handoff needs the rebuild. A downloaded file seeks
|
|
// like any other file — and `resume_stream_at` refuses a non-remote
|
|
// source, so sending one through here fails the seek outright.
|
|
// TRACES: UR-071 | DR-180 | UT-181
|
|
let rebuild = self.is_background_audio_active() && {
|
|
let queue = self.queue.lock_safe();
|
|
queue
|
|
.current()
|
|
.map(|item| {
|
|
Self::is_audio_only_video(item)
|
|
&& matches!(item.source, MediaSource::Remote { .. })
|
|
})
|
|
.unwrap_or(false)
|
|
};
|
|
|
|
if rebuild {
|
|
return self.resume_stream_at(position.max(0.0)).await;
|
|
}
|
|
|
|
self.seek(position).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// 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.select_audio_track(Some(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.select_subtitle_track(stream_index)
|
|
}
|
|
|
|
/// Get current state
|
|
pub fn state(&self) -> PlayerState {
|
|
let phase = self.backend.lock_safe().snapshot().phase;
|
|
let media = self.queue.lock_safe().current().cloned();
|
|
match (phase, media) {
|
|
(Phase::Playing, Some(media)) => PlayerState::Playing {
|
|
media,
|
|
position: self.position(),
|
|
duration: self.duration().unwrap_or(0.0),
|
|
},
|
|
(Phase::Paused, Some(media)) => PlayerState::Paused {
|
|
media,
|
|
position: self.position(),
|
|
duration: self.duration().unwrap_or(0.0),
|
|
},
|
|
(Phase::Opening, Some(media)) => PlayerState::Loading { media },
|
|
(Phase::Failed(error), media) => PlayerState::Error { media, error },
|
|
// Ready without an item, or anything terminal, reads as idle: the
|
|
// queue is what says whether there is something to resume.
|
|
_ => PlayerState::Idle,
|
|
}
|
|
}
|
|
|
|
/// What the engine currently rendering can do.
|
|
///
|
|
/// TRACES: UR-081 | DR-246
|
|
pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
|
|
self.backend.lock_safe().capabilities()
|
|
}
|
|
|
|
/// Get current position
|
|
pub fn position(&self) -> f64 {
|
|
self.backend.lock_safe().snapshot().position.as_secs_f64()
|
|
}
|
|
|
|
/// The position on the **item's own timeline**, whatever is rendering it.
|
|
///
|
|
/// This is what every outbound position must be taken from — the resume point
|
|
/// sent to Jellyfin, the point the video reloads at when a handoff ends, the
|
|
/// truncation comparison. `position()` alone answers for exactly one of the
|
|
/// three ways this app plays media, and reads 0 for the other two:
|
|
///
|
|
/// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
|
|
/// backend, so its position is a permanent 0. The element's own reports are
|
|
/// the only reading there is.
|
|
/// - **Background-audio handoff**: the audio-only stream's zero is the
|
|
/// handoff point, and the base is added at the native tick boundary
|
|
/// (DR-159) — so before the first tick lands, nothing has applied it.
|
|
/// Flooring at the base is exact rather than approximate: the stream cannot
|
|
/// physically be behind its own starting point.
|
|
/// - **Native playback**: the backend is authoritative and both other terms
|
|
/// are zero, so the max is its own value.
|
|
///
|
|
/// Returning to the foreground during that pre-first-tick window is what
|
|
/// restarted an episode from 0:00 and wiped its server-side resume point.
|
|
///
|
|
/// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
|
|
pub fn absolute_position(&self) -> f64 {
|
|
let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
|
|
let reported = self.reported_time.lock_safe().last_position();
|
|
let base = if self.is_background_audio_active() {
|
|
*self.background_audio_base.lock_safe()
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
native.max(reported).max(base)
|
|
}
|
|
|
|
/// The duration last reported by webview-rendered media, if any.
|
|
///
|
|
/// TRACES: UR-005 | DR-178 | UT-177
|
|
pub fn observed_duration(&self) -> Option<f64> {
|
|
self.reported_time.lock_safe().last_duration()
|
|
}
|
|
|
|
/// Forget what webview-rendered media reported.
|
|
///
|
|
/// Called wherever that element stops being the player — it was torn down,
|
|
/// a handoff took over, or a different item is loading. A stale position
|
|
/// outliving its element would be reported against whatever plays next.
|
|
///
|
|
/// TRACES: UR-005 | DR-178 | UT-177
|
|
fn clear_reported_time(&self) {
|
|
self.reported_time.lock_safe().reset();
|
|
}
|
|
|
|
/// Replace the sink playback reports go to. Tests capture; production wires
|
|
/// the `PlaybackReporter` at construction and never swaps it.
|
|
///
|
|
/// TRACES: UR-025 | DR-179
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
|
|
*self.reports.lock_safe() = sink;
|
|
}
|
|
|
|
/// Send a playback report. Fire-and-forget by contract, so callers can do
|
|
/// this while holding nothing and waiting for nothing.
|
|
///
|
|
/// TRACES: UR-025 | DR-179
|
|
fn report(&self, operation: PlaybackOperation) {
|
|
let sink = self.reports.lock_safe().clone();
|
|
sink.send(operation);
|
|
}
|
|
|
|
/// The Jellyfin id of whatever is currently queued, if it has one.
|
|
fn current_jellyfin_id(&self) -> Option<String> {
|
|
let queue = self.queue.lock_safe();
|
|
queue
|
|
.current()
|
|
.and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
|
|
}
|
|
|
|
/// Get duration.
|
|
///
|
|
/// Falls back to what webview-rendered media reported for the same reason
|
|
/// [`absolute_position`](Self::absolute_position) does: on that path nothing
|
|
/// is loaded into the native backend, so its duration is `None` and the
|
|
/// element's report is the only one there is.
|
|
///
|
|
/// TRACES: UR-005 | DR-178
|
|
pub fn duration(&self) -> Option<f64> {
|
|
self.backend
|
|
.lock_safe()
|
|
.snapshot()
|
|
.duration
|
|
.map(|d| d.as_secs_f64())
|
|
.or_else(|| self.observed_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().snapshot().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().close() {
|
|
error!("[SleepTimer] Failed to stop playback: {}", e);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Emit update event
|
|
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
|
mode: timer.mode.clone(),
|
|
remaining_seconds: timer.remaining_seconds,
|
|
});
|
|
}
|
|
}
|
|
drop(timer);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Emit sleep timer changed event to frontend
|
|
fn emit_sleep_timer_changed(&self) {
|
|
let timer = self.sleep_timer.lock_safe().clone();
|
|
|
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
|
mode: timer.mode,
|
|
remaining_seconds: timer.remaining_seconds,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Emit queue changed event to frontend
|
|
pub fn emit_queue_changed(&self) {
|
|
let queue = self.queue.lock_safe();
|
|
|
|
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
|
|
queue.items().len(), queue.current_index());
|
|
|
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::QueueChanged {
|
|
items: queue.items().to_vec(),
|
|
current_index: queue.current_index(),
|
|
shuffle: queue.is_shuffle(),
|
|
repeat: queue.repeat_mode(),
|
|
has_next: queue.has_next(),
|
|
has_previous: queue.has_previous(),
|
|
});
|
|
} else {
|
|
warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
|
|
}
|
|
}
|
|
|
|
// ===== HTML5 video report methods =====
|
|
//
|
|
// On platforms where video is rendered in the webview (Linux WebKitGTK
|
|
// HTML5 <video>), the real player lives outside the native backend, so it
|
|
// cannot emit PlayerStatusEvents itself. The frontend HTML5 adapter reports
|
|
// DOM events here, and these methods re-emit them through the SAME event
|
|
// pipeline the native backends use. This keeps the frontend's player store
|
|
// fed from one place (playerEvents.ts) in both native and HTML5 modes, so
|
|
// the Rust controller stays the single source of truth for player state.
|
|
|
|
/// Report an HTML5 <video> state change (playing/paused/loading/stopped).
|
|
///
|
|
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
|
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
|
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
|
// A background-audio handoff has already moved playback to the native
|
|
// player and torn the element down; anything it still reports describes
|
|
// a video that is no longer playing. Dropping it keeps the UI on the
|
|
// audio that IS playing and leaves transport with the native backend.
|
|
if self.is_background_audio_active() {
|
|
debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
|
|
return;
|
|
}
|
|
// Track it: this is the authoritative play/pause state for
|
|
// webview-rendered media, and what transport decisions read (DR-097).
|
|
// "stopped"/"idle" mean the element is gone, so hand authority back to
|
|
// the native backend — otherwise music playback would keep emitting
|
|
// ControlCommands at a element that no longer exists.
|
|
let element_gone = {
|
|
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,
|
|
};
|
|
tracked.is_none()
|
|
};
|
|
// Its last position goes with it: whatever plays next is loaded into the
|
|
// native backend, and a stale reading would be reported against that.
|
|
if element_gone {
|
|
self.clear_reported_time();
|
|
}
|
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
|
|
}
|
|
}
|
|
|
|
/// Report an HTML5 <video> position tick.
|
|
///
|
|
/// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
|
|
/// position updates (the adapter is expected to throttle to ~250ms like MPV).
|
|
pub fn report_html5_position(&self, position: f64, duration: f64) {
|
|
// Stale by definition during a handoff — the native player's ticks are
|
|
// the real position. See `report_html5_state`.
|
|
if self.is_background_audio_active() {
|
|
return;
|
|
}
|
|
// The element is the player on this path, so this tick is the position —
|
|
// for the resume point, the stop report and everything else that asks.
|
|
self.reported_time.lock_safe().record(position, duration);
|
|
|
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
|
|
}
|
|
|
|
self.report_progress_throttled(position);
|
|
}
|
|
|
|
/// Send a throttled progress report to Jellyfin.
|
|
///
|
|
/// Progress is what makes a position survive anything other than a clean
|
|
/// exit — a crash, a swipe-away, a battery death — and lets another device
|
|
/// resume mid-episode. Webview-rendered media reported none: the frontend
|
|
/// service writes progress to the local DB only, and Rust had no position
|
|
/// for it to report. A device trace covering 35 minutes of playback hit
|
|
/// `/Sessions/Playing/Progress` exactly zero times.
|
|
///
|
|
/// The throttler is the one the controller already owned for this purpose
|
|
/// (30s per item), so ticks arriving four times a second cost one request
|
|
/// per half-minute.
|
|
///
|
|
/// TRACES: UR-005, UR-025 | DR-179 | UT-180
|
|
fn report_progress_throttled(&self, position: f64) {
|
|
if position <= 0.0 {
|
|
return;
|
|
}
|
|
let Some(item_id) = self.current_jellyfin_id() else {
|
|
return;
|
|
};
|
|
if !self.position_throttler.should_report(&item_id) {
|
|
return;
|
|
}
|
|
|
|
self.report(PlaybackOperation::Progress {
|
|
item_id: item_id.clone(),
|
|
position_ticks: seconds_to_ticks(position),
|
|
// Ticks only arrive while the element is playing; a pause is carried
|
|
// by the state report, not by a position that stopped moving.
|
|
is_paused: false,
|
|
});
|
|
self.position_throttler.mark_reported(&item_id);
|
|
}
|
|
|
|
/// Report that the HTML5 <video> element finished loading and knows its
|
|
/// duration. Mirrors the native `MediaLoaded` event.
|
|
pub fn report_html5_media_loaded(&self, duration: f64) {
|
|
// See `report_html5_state` — the element is not the player right now.
|
|
if self.is_background_audio_active() {
|
|
return;
|
|
}
|
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
|
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
|
}
|
|
}
|
|
|
|
// ===== Autoplay Methods =====
|
|
|
|
/// Get autoplay settings
|
|
pub fn autoplay_settings(&self) -> AutoplaySettings {
|
|
self.autoplay_settings.lock_safe().clone()
|
|
}
|
|
|
|
/// Set autoplay settings (in-memory only, persistence handled by command layer)
|
|
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
|
|
let validated = settings.with_validated_countdown();
|
|
*self.autoplay_settings.lock_safe() = validated;
|
|
}
|
|
|
|
/// Cancel active autoplay countdown
|
|
pub fn cancel_autoplay_countdown(&self) {
|
|
if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
|
|
*cancel_flag.lock_safe() = true;
|
|
}
|
|
}
|
|
|
|
/// Handle playback ended event - decides what to do next
|
|
///
|
|
/// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
|
|
/// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
|
|
pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
|
|
// A truncated stream is not an end at all, so this is decided BEFORE the
|
|
// end-reason gate below — which returns early for the `NewTrackLoaded`
|
|
// that every load sets, and would therefore swallow the whole question on
|
|
// Android's JNI callback: the one call guaranteed to run while the app is
|
|
// backgrounded and the webview cannot echo anything back.
|
|
if let Some(position) = self.truncated_stream_resume_position() {
|
|
return Ok(AutoplayDecision::ResumeStream { position });
|
|
}
|
|
|
|
// Check why playback ended
|
|
let end_reason = self.take_end_reason();
|
|
|
|
debug!(
|
|
"[PlayerController] on_playback_ended: end_reason={:?}",
|
|
end_reason
|
|
);
|
|
|
|
// Only proceed with autoplay logic if track finished naturally
|
|
match end_reason {
|
|
None | Some(EndReason::Finished) => {
|
|
// Track ended naturally, proceed with autoplay logic
|
|
debug!("[PlayerController] Track finished naturally, checking autoplay");
|
|
}
|
|
Some(EndReason::NewTrackLoaded) => {
|
|
// User loaded a new track, don't autoplay
|
|
debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
Some(EndReason::UserStop) => {
|
|
// User stopped playback, don't autoplay
|
|
debug!("[PlayerController] UserStop - stopping without autoplay");
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
Some(EndReason::UserSkip) => {
|
|
// User skipped, already handled by next/previous
|
|
debug!("[PlayerController] UserSkip - stopping without autoplay");
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
Some(EndReason::Error) => {
|
|
// Playback error, don't autoplay
|
|
debug!("[PlayerController] Error - stopping without autoplay");
|
|
return Ok(AutoplayDecision::Stop);
|
|
}
|
|
}
|
|
|
|
let current_item = {
|
|
let queue = self.queue.lock_safe();
|
|
queue.current().cloned()
|
|
};
|
|
|
|
let Some(current) = current_item else {
|
|
return Ok(AutoplayDecision::Stop);
|
|
};
|
|
|
|
// The item is genuinely finished (a truncated stream returned above), so
|
|
// report it before anything advances — after an advance the queue's
|
|
// current item is the *next* episode and this one is unreachable.
|
|
self.report_completion(¤t);
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
/// Report an item that just finished as stopped at its runtime, so Jellyfin
|
|
/// marks it played.
|
|
///
|
|
/// Jellyfin decides "watched" from the `PlaybackStopped` report and its
|
|
/// position — no report, no completion, however much of the episode was
|
|
/// actually heard. In the foreground the frontend sends one when the
|
|
/// `<video>` ends. In background audio-only mode there is nobody: the webview
|
|
/// is suspended and its element was torn down at the handoff, while the
|
|
/// backend drove the advance to the next episode and said nothing about the
|
|
/// one that ended. An episode listened to end-to-end on the lockscreen
|
|
/// therefore never counted, and (before DR-179) was often reset to 0 by the
|
|
/// stop report that followed.
|
|
///
|
|
/// Scoped to the audio-only handoff — the case the frontend provably cannot
|
|
/// report — so foreground playback keeps its single existing report rather
|
|
/// than gaining a second one. Music tracks ending natively remain
|
|
/// unreported; that is the same gap through a different door and wants its
|
|
/// own change.
|
|
///
|
|
/// TRACES: UR-040, UR-025 | DR-179 | UT-179
|
|
fn report_completion(&self, item: &MediaItem) {
|
|
if !Self::is_audio_only_video(item) {
|
|
return;
|
|
}
|
|
let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
|
|
return;
|
|
};
|
|
|
|
let position = completion_report_position(item.duration, self.absolute_position());
|
|
log::info!(
|
|
"[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
|
|
jellyfin_id,
|
|
position
|
|
);
|
|
self.report_stopped_at(jellyfin_id, position);
|
|
}
|
|
|
|
/// Record the base offset of a background-audio handoff (the position the
|
|
/// video was handed off at, which is the audio stream's zero).
|
|
///
|
|
/// TRACES: UR-040 | DR-052
|
|
pub fn set_background_audio_base(&self, seconds: f64) {
|
|
*self.background_audio_base.lock_safe() = seconds.max(0.0);
|
|
}
|
|
|
|
/// Enter a background-audio handoff at `position` (the video's position, and
|
|
/// therefore the audio stream's zero).
|
|
///
|
|
/// Hands transport authority to the native audio player: the webview
|
|
/// `<video>` is about to be torn down, so its last reports — including the
|
|
/// `pause` the teardown itself fires — must not keep it looking like the
|
|
/// player. Without this the lockscreen pause emitted a ControlCommand at a
|
|
/// dead element and the audio played straight through it.
|
|
///
|
|
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
|
pub fn enter_background_audio(&self, position: f64) {
|
|
self.set_background_audio_base(position);
|
|
*self.background_audio_active.lock_safe() = true;
|
|
*self.html5_playing.lock_safe() = None;
|
|
// The element is being torn down; its last position describes a video
|
|
// that is no longer playing, and the base describes the one that is.
|
|
self.clear_reported_time();
|
|
}
|
|
|
|
/// Leave a background-audio handoff, returning the base offset to add to the
|
|
/// native player's relative position.
|
|
///
|
|
/// The webview `<video>` becomes the player again once it reloads, so its
|
|
/// reports are honoured from here on.
|
|
///
|
|
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
|
pub fn exit_background_audio(&self) -> f64 {
|
|
*self.background_audio_active.lock_safe() = false;
|
|
self.take_background_audio_base()
|
|
}
|
|
|
|
/// True while the native audio player owns playback via a background-audio
|
|
/// handoff.
|
|
///
|
|
/// TRACES: UR-040 | DR-052
|
|
pub fn is_background_audio_active(&self) -> bool {
|
|
*self.background_audio_active.lock_safe()
|
|
}
|
|
|
|
/// Read and clear the background-audio base offset.
|
|
///
|
|
/// TRACES: UR-040 | DR-052
|
|
pub fn take_background_audio_base(&self) -> f64 {
|
|
let mut base = self.background_audio_base.lock_safe();
|
|
std::mem::replace(&mut *base, 0.0)
|
|
}
|
|
|
|
/// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
|
|
///
|
|
/// Single place both end-of-playback dispatchers agree on: the Android JNI
|
|
/// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
|
|
/// (`player_on_playback_ended`). They used to each carry their own copy of
|
|
/// this branch, and the command's copy was missing the background-audio case
|
|
/// entirely — so an audio-only episode ending while backgrounded only ever
|
|
/// started a countdown that nothing could act on.
|
|
///
|
|
/// TRACES: UR-040, UR-023 | DR-052
|
|
pub async fn auto_advance_to_next_episode(
|
|
&self,
|
|
next_episode: crate::repository::types::MediaItem,
|
|
countdown_seconds: u32,
|
|
) {
|
|
// Background audio-only episode: the countdown only emits ticks — the
|
|
// advance itself is a `goto('/player/<id>')` in the webview, which cannot
|
|
// start audio while the app is backgrounded. Load the next episode's
|
|
// audio-only stream here instead, or playback stalls at the boundary.
|
|
if self.current_is_audio_episode() {
|
|
info!(
|
|
"[PlayerController] Background audio episode — advancing to {} in backend",
|
|
next_episode.id
|
|
);
|
|
match self
|
|
.advance_to_next_episode_audio_only(&next_episode.id)
|
|
.await
|
|
{
|
|
Ok(()) => self.emit_queue_changed(),
|
|
Err(e) => {
|
|
error!(
|
|
"[PlayerController] Background audio advance failed: {} — stopping",
|
|
e
|
|
);
|
|
if let Some(emitter) = self.event_emitter() {
|
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Foreground: the frontend drives the advance off the countdown ticks.
|
|
self.start_autoplay_countdown(next_episode, countdown_seconds);
|
|
}
|
|
|
|
/// A video item played through the native *audio* path — i.e. the background
|
|
/// audio-only handoff, the only place a length-less progressive transcode is
|
|
/// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
|
|
fn is_audio_only_video(item: &MediaItem) -> bool {
|
|
stream_end::is_audio_only_video(item)
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
// The Android position tick shifts by the handoff base before anything
|
|
// sees the value, so adding it again here would double-count it
|
|
// (DR-159) — `absolute_position` floors at the base instead, which is
|
|
// what a stream that died before its first tick needs. (DR-178)
|
|
let absolute = self.absolute_position();
|
|
|
|
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
|
|
};
|
|
// Already absolute — see claim_stream_resume. (DR-159)
|
|
let absolute = self.position().max(0.0);
|
|
|
|
// Only spend a resume attempt once the runtime says this really was cut
|
|
// short — a genuine end must stay a genuine end.
|
|
if !stream_end::is_truncated_end(
|
|
absolute,
|
|
item_duration,
|
|
stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
self.claim_stream_resume().map(|(position, _)| position)
|
|
}
|
|
|
|
/// Where to re-open the current stream after a *recoverable* playback error,
|
|
/// plus how many seconds to wait first.
|
|
///
|
|
/// The media was decoding fine a moment ago, so a mid-playback failure on a
|
|
/// server stream is the network — and stopping the player (the previous
|
|
/// behaviour, via the frontend's error handler) turns a hiccup into "playback
|
|
/// just died". Applies to every streamed item, not only the audio-only
|
|
/// handoff: music and video reach here instead of the truncation path because
|
|
/// their streams declare a length, so a cut connection surfaces as an error
|
|
/// rather than a phantom end.
|
|
///
|
|
/// The wait grows with the attempt number so a short outage has time to
|
|
/// clear, and the shared budget stops the retries when it doesn't.
|
|
///
|
|
/// Called from the Android error callback, which decides in-process, and from
|
|
/// `player_recover_stream`, which is how the same decision reaches the
|
|
/// backends whose event thread has no controller to call — MPV is built
|
|
/// before the controller exists, so on Linux the error is emitted, echoed by
|
|
/// the frontend, and decided here.
|
|
///
|
|
/// TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117
|
|
pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
|
|
self.claim_stream_resume()
|
|
.map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
|
|
}
|
|
|
|
/// Re-open the current stream at `position` after the network cut it short.
|
|
///
|
|
/// Single place every dispatcher agrees on, for the same reason
|
|
/// `auto_advance_to_next_episode` is: the Android JNI callbacks and the
|
|
/// frontend-invoked command must not disagree about what a failed stream
|
|
/// means. None of them emits `PlaybackEnded` for this, so nothing downstream
|
|
/// clears the queue or tears the session down — from the outside this is a
|
|
/// buffering hiccup, which is what it actually was.
|
|
///
|
|
/// Reloads the item **in place** rather than through `play_item`, which
|
|
/// replaces the queue with a single item: recovering a track that way would
|
|
/// throw away the rest of the album, turning a network blip into lost state.
|
|
///
|
|
/// Two shapes of stream, two ways back to `position`:
|
|
///
|
|
/// - The audio-only handoff's `/Audio/{id}/universal` transcode is chunked
|
|
/// with no length, so it cannot be seeked. Its URL is rewritten to start at
|
|
/// the position instead — edited, not rebuilt from the repository, since it
|
|
/// already carries the user's audio track and media source and recovering
|
|
/// from a network failure must not itself need a network round-trip.
|
|
/// - Everything else (a static file with byte ranges, an HLS playlist)
|
|
/// declares its whole timeline, so re-preparing the URL it already has and
|
|
/// seeking lands in the right place — and leaves any transcode session
|
|
/// behind it alone.
|
|
///
|
|
/// TRACES: UR-040, UR-004 | DR-129 | UT-117
|
|
pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
|
|
let current = {
|
|
let queue = self.queue.lock_safe();
|
|
queue.current().cloned()
|
|
}
|
|
.ok_or_else(|| "No current item to resume".to_string())?;
|
|
|
|
let MediaSource::Remote { stream_url, .. } = ¤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 {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
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(LegacyPlayer::new(
|
|
NullBackend::new(),
|
|
crate::player::media_player::Capabilities::mpv(),
|
|
)),
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// Captures what the controller reports to Jellyfin, so tests can assert on
|
|
/// the operations themselves rather than on a database and an HTTP client.
|
|
struct CapturingReports {
|
|
operations: std::sync::Mutex<Vec<PlaybackOperation>>,
|
|
}
|
|
|
|
impl CapturingReports {
|
|
fn new() -> Self {
|
|
Self {
|
|
operations: std::sync::Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Every `Stopped` report as `(item_id, position_seconds)`.
|
|
fn stops(&self) -> Vec<(String, f64)> {
|
|
self.operations
|
|
.lock_safe()
|
|
.iter()
|
|
.filter_map(|op| match op {
|
|
PlaybackOperation::Stopped {
|
|
item_id,
|
|
position_ticks,
|
|
} => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every `Progress` report as `(item_id, position_seconds)`.
|
|
fn progress(&self) -> Vec<(String, f64)> {
|
|
self.operations
|
|
.lock_safe()
|
|
.iter()
|
|
.filter_map(|op| match op {
|
|
PlaybackOperation::Progress {
|
|
item_id,
|
|
position_ticks,
|
|
..
|
|
} => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl PlaybackReportSink for CapturingReports {
|
|
fn send(&self, operation: PlaybackOperation) {
|
|
self.operations.lock_safe().push(operation);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_report_html5_state_emits_state_changed() {
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
|
|
|
let events = emitter.events();
|
|
assert_eq!(events.len(), 1);
|
|
match &events[0] {
|
|
PlayerStatusEvent::StateChanged { state, media_id } => {
|
|
assert_eq!(state, "playing");
|
|
assert_eq!(media_id.as_deref(), Some("item-1"));
|
|
}
|
|
other => panic!("expected StateChanged, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_report_html5_position_emits_position_update() {
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
controller.report_html5_position(12.5, 300.0);
|
|
|
|
let events = emitter.events();
|
|
assert_eq!(events.len(), 1);
|
|
match &events[0] {
|
|
PlayerStatusEvent::PositionUpdate { position, duration } => {
|
|
assert_eq!(*position, 12.5);
|
|
assert_eq!(*duration, 300.0);
|
|
}
|
|
other => panic!("expected PositionUpdate, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_report_html5_media_loaded_emits_media_loaded() {
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
controller.report_html5_media_loaded(420.0);
|
|
|
|
let events = emitter.events();
|
|
assert_eq!(events.len(), 1);
|
|
match &events[0] {
|
|
PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
|
|
other => panic!("expected MediaLoaded, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
// ===== HTML5 transport authority (DR-097) =====
|
|
//
|
|
// Webview-rendered video is played by an element the native backend cannot
|
|
// reach, so transport for it must be decided from the state the element
|
|
// REPORTS and executed by emitting a ControlCommand. Previously the frontend
|
|
// decided play-vs-pause itself by reading `el.paused` off the DOM, which
|
|
// flips transiently while buffering/seeking — two intents ~150ms apart read
|
|
// different values, took opposing actions, and self-sustained a pause loop.
|
|
|
|
#[test]
|
|
fn test_html5_state_is_tracked_from_reports() {
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
// No HTML5 media reported yet: the native backend stays authoritative.
|
|
assert!(!controller.is_html5_active());
|
|
|
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
|
assert!(controller.is_html5_active());
|
|
assert!(controller.html5_is_playing());
|
|
|
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
|
assert!(controller.is_html5_active());
|
|
assert!(!controller.html5_is_playing());
|
|
}
|
|
|
|
#[test]
|
|
fn test_html5_toggle_from_paused_emits_play_control() {
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
|
|
|
controller.toggle_playback().unwrap();
|
|
|
|
let controls: Vec<_> = emitter
|
|
.events()
|
|
.into_iter()
|
|
.filter_map(|e| match e {
|
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(controls, vec!["play".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_html5_toggle_from_playing_emits_pause_control() {
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
|
|
|
controller.toggle_playback().unwrap();
|
|
|
|
let controls: Vec<_> = emitter
|
|
.events()
|
|
.into_iter()
|
|
.filter_map(|e| match e {
|
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(controls, vec!["pause".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
|
|
// The loop signature: two intents in quick succession must NOT both
|
|
// resolve the same way, and must not produce opposing actions from a
|
|
// stale read. Rust's own tracked state makes the sequence deterministic
|
|
// as long as the element reports back between intents.
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
|
|
|
controller.toggle_playback().unwrap();
|
|
// Element confirms the pause it was told to do.
|
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
|
controller.toggle_playback().unwrap();
|
|
|
|
let controls: Vec<_> = emitter
|
|
.events()
|
|
.into_iter()
|
|
.filter_map(|e| match e {
|
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_html5_play_and_pause_emit_control_commands() {
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
|
|
|
|
controller.play().unwrap();
|
|
controller.pause().unwrap();
|
|
|
|
let controls: Vec<_> = emitter
|
|
.events()
|
|
.into_iter()
|
|
.filter_map(|e| match e {
|
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_background_audio_handoff_moves_transport_to_native_backend() {
|
|
// Lockscreen pause while playing a video's audio in the background.
|
|
//
|
|
// The handoff tears the WebView <video> down AFTER native audio starts,
|
|
// and that teardown fires a DOM `pause` the frontend dutifully reports.
|
|
// That report used to leave `html5_playing = Some(false)`, so transport
|
|
// kept being aimed at an element that no longer exists: the lockscreen
|
|
// pause emitted a ControlCommand into the void and the audio played on.
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
// Video was playing in the webview.
|
|
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
|
|
assert!(controller.is_html5_active());
|
|
|
|
// Hand off to the native audio player, then tear the element down.
|
|
controller.enter_background_audio(1200.0);
|
|
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
|
|
|
|
assert!(
|
|
!controller.is_html5_active(),
|
|
"native audio owns transport during a background-audio handoff"
|
|
);
|
|
|
|
controller.pause().unwrap();
|
|
let controls: Vec<_> = emitter
|
|
.events()
|
|
.into_iter()
|
|
.filter_map(|e| match e {
|
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert!(
|
|
controls.is_empty(),
|
|
"pause must drive the native backend, not a torn-down element: {:?}",
|
|
controls
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_background_audio_handoff_suppresses_stale_element_events() {
|
|
// The dying element's pause/position reports describe the video, not the
|
|
// audio now playing — re-emitting them flips the UI to paused and yanks
|
|
// the position backwards while native audio keeps going.
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
controller.enter_background_audio(1200.0);
|
|
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
|
|
controller.report_html5_position(1200.0, 2400.0);
|
|
|
|
assert!(
|
|
emitter.events().is_empty(),
|
|
"stale webview reports must not reach the event pipeline: {:?}",
|
|
emitter.events()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_exit_background_audio_returns_transport_to_the_webview() {
|
|
// Back in the foreground the <video> is the player again, so its reports
|
|
// must be honoured — and the base offset still comes back for the resume.
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
controller.enter_background_audio(1200.0);
|
|
assert_eq!(controller.exit_background_audio(), 1200.0);
|
|
|
|
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
|
|
assert!(controller.is_html5_active());
|
|
assert!(controller.html5_is_playing());
|
|
}
|
|
|
|
#[test]
|
|
fn test_html5_stopped_report_releases_transport_to_native_backend() {
|
|
// When webview video goes away, transport must fall back to the native
|
|
// backend (music playback must not keep emitting ControlCommands).
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
|
assert!(controller.is_html5_active());
|
|
|
|
controller.report_html5_state("stopped".to_string(), None);
|
|
assert!(!controller.is_html5_active());
|
|
}
|
|
|
|
#[test]
|
|
fn test_html5_transport_emits_exactly_one_control_per_intent() {
|
|
// Guards against a double-drive on platforms where the *backend* is also
|
|
// webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
|
|
// must replace the backend call, not run in addition to it.
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
|
|
|
|
controller.pause().unwrap();
|
|
|
|
let controls = emitter
|
|
.events()
|
|
.into_iter()
|
|
.filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
|
|
.count();
|
|
assert_eq!(controls, 1, "one intent must produce exactly one control");
|
|
}
|
|
|
|
#[test]
|
|
fn test_controller_volume_default() {
|
|
let controller = PlayerController::default();
|
|
assert_eq!(controller.volume(), 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_controller_set_volume() {
|
|
let controller = PlayerController::default();
|
|
controller.set_volume(0.5).unwrap();
|
|
assert_eq!(controller.volume(), 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_controller_muted_default() {
|
|
let controller = PlayerController::default();
|
|
assert!(!controller.muted());
|
|
}
|
|
|
|
#[test]
|
|
fn test_controller_volume_delegates_to_backend() {
|
|
let controller = PlayerController::default();
|
|
|
|
// Set volume through controller
|
|
controller.set_volume(0.75).unwrap();
|
|
|
|
// Verify it's reflected in both controller.volume() and backend
|
|
assert_eq!(controller.volume(), 0.75);
|
|
}
|
|
|
|
fn create_test_items(count: usize) -> Vec<MediaItem> {
|
|
(0..count)
|
|
.map(|i| MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
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)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_native_load_returns_transport_authority_to_the_backend() {
|
|
// Play/pause did nothing on the Android native video path, from the
|
|
// on-screen tap AND from the control-bar button, while seek and skip
|
|
// worked — those take a different decision path.
|
|
//
|
|
// `html5_playing` is written only by the webview element's own reports
|
|
// and cleared only when it reports "stopped"/"idle" (or on a
|
|
// background-audio handoff). A previous element that went away without
|
|
// that final report — or webview-rendered music earlier in the same
|
|
// process — therefore left `is_html5_active()` true, and every transport
|
|
// intent was emitted as a ControlCommand at an element that no longer
|
|
// existed. Nothing reached ExoPlayer. It looked intermittent because it
|
|
// depends entirely on what played before.
|
|
//
|
|
// Loading into the native backend IS the statement that native renders
|
|
// this item, so it hands authority back — the same "element is gone"
|
|
// semantics the "stopped"/"idle" report already has.
|
|
//
|
|
// TRACES: UR-005, UR-003 | DR-193
|
|
let controller = PlayerController::default();
|
|
let emitter = Arc::new(CapturingEmitter::new());
|
|
controller.set_event_emitter(emitter.clone());
|
|
|
|
// A webview element reported itself playing and never said "stopped".
|
|
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
|
|
assert!(controller.is_html5_active());
|
|
|
|
// Now a native item loads — Android video through ExoPlayer.
|
|
let item = create_test_items(1).into_iter().next().unwrap();
|
|
controller.play_item(item).unwrap();
|
|
|
|
assert!(
|
|
!controller.is_html5_active(),
|
|
"loading into the native backend hands transport back to it"
|
|
);
|
|
|
|
// The toggle must reach the backend, not be emitted at a dead element.
|
|
controller.toggle_playback().unwrap();
|
|
let controls: Vec<_> = emitter
|
|
.events()
|
|
.into_iter()
|
|
.filter_map(|e| match e {
|
|
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert!(
|
|
controls.is_empty(),
|
|
"transport went to a webview element that is not rendering: {controls:?}"
|
|
);
|
|
}
|
|
|
|
// 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>,
|
|
_: Option<&str>,
|
|
) -> String {
|
|
unimplemented!()
|
|
}
|
|
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_favorites(
|
|
&self,
|
|
_: repo_types::SearchScope,
|
|
_: Option<repo_types::GetItemsOptions>,
|
|
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_person(
|
|
&self,
|
|
_: &str,
|
|
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_items_by_person(
|
|
&self,
|
|
_: &str,
|
|
_: Option<repo_types::GetItemsOptions>,
|
|
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_similar_items(
|
|
&self,
|
|
_: &str,
|
|
_: Option<usize>,
|
|
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn create_playlist(
|
|
&self,
|
|
_: &str,
|
|
_: &[String],
|
|
) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn get_playlist_items(
|
|
&self,
|
|
_: &str,
|
|
) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn add_to_playlist(
|
|
&self,
|
|
_: &str,
|
|
_: &[String],
|
|
) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn remove_from_playlist(
|
|
&self,
|
|
_: &str,
|
|
_: &[String],
|
|
) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
async fn move_playlist_item(
|
|
&self,
|
|
_: &str,
|
|
_: &str,
|
|
_: u32,
|
|
) -> Result<(), repo_types::RepoError> {
|
|
unimplemented!()
|
|
}
|
|
}
|
|
|
|
/// Video (HTML5/Linux) path: ending mid-season must produce the
|
|
/// next-episode popup with auto-advance.
|
|
#[tokio::test]
|
|
async fn test_video_playback_ended_offers_next_episode() {
|
|
let controller = PlayerController::default();
|
|
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
|
|
|
|
let decision = controller
|
|
.on_video_playback_ended("ep2", repo)
|
|
.await
|
|
.expect("decision should succeed");
|
|
|
|
match decision {
|
|
AutoplayDecision::ShowNextEpisodePopup {
|
|
current_episode,
|
|
next_episode,
|
|
auto_advance,
|
|
..
|
|
} => {
|
|
assert_eq!(current_episode.id, "ep2");
|
|
assert_eq!(next_episode.id, "ep3");
|
|
assert!(auto_advance, "default settings should auto-advance");
|
|
}
|
|
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
/// Last episode of the season: no popup, stop.
|
|
#[tokio::test]
|
|
async fn test_video_playback_ended_last_episode_stops() {
|
|
let controller = PlayerController::default();
|
|
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
|
|
|
|
let decision = controller
|
|
.on_video_playback_ended("ep3", repo)
|
|
.await
|
|
.expect("decision should succeed");
|
|
|
|
assert!(matches!(decision, AutoplayDecision::Stop));
|
|
}
|
|
|
|
/// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
|
|
/// so the controller-level repository (wired up in `repository_create`)
|
|
/// must be used for the next-episode lookup. Regression test for episode
|
|
/// autoplay never triggering on Android because no repository was set.
|
|
#[tokio::test]
|
|
async fn test_playback_ended_uses_controller_repository_for_episodes() {
|
|
let controller = PlayerController::default();
|
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
|
|
|
// Queue holds the episode that just finished playing
|
|
let episode = MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
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 {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
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 {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
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 {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// The same episode handed off from a **downloaded file** — the handoff's
|
|
/// other source, which starts at the episode's own zero rather than at the
|
|
/// handoff point.
|
|
fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
|
|
MediaItem {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
source: MediaSource::Local {
|
|
file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
|
|
jellyfin_item_id: Some("ep2".to_string()),
|
|
},
|
|
..audio_only_episode(runtime_seconds)
|
|
}
|
|
}
|
|
|
|
/// A file seeks like a file. The rebuild path exists because a chunked
|
|
/// length-less transcode cannot honour a seek, which is not true of local
|
|
/// media — and `resume_stream_at` refuses a non-remote source outright, so
|
|
/// routing a lockscreen scrub through it fails the seek instead of doing it.
|
|
///
|
|
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
|
|
#[tokio::test]
|
|
async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
|
|
let controller = PlayerController::default();
|
|
controller
|
|
.play_queue(vec![local_audio_only_episode(1500.0)], 0)
|
|
.unwrap();
|
|
// A downloaded handoff claims no base: the file's zero is the episode's.
|
|
controller.enter_background_audio(0.0);
|
|
|
|
controller.seek_absolute(900.0).await.unwrap();
|
|
|
|
assert_eq!(controller.position(), 900.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
|
|
),
|
|
}
|
|
}
|
|
|
|
/// A seek arriving during a background-audio handoff is **absolute** — the
|
|
/// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
|
|
/// 25:00 of the episode, not 25:00 into the handoff stream.
|
|
///
|
|
/// The handoff stream cannot be seeked at all (a chunked, length-less
|
|
/// transcode), so honouring it means re-opening the URL at the new position,
|
|
/// exactly as the truncation recovery does. Passing the number through to
|
|
/// ExoPlayer instead — which is what used to happen — asked a stream that
|
|
/// cannot seek to jump past its own end, and a clamped seek lands at stream
|
|
/// zero: the handoff point.
|
|
///
|
|
/// TRACES: UR-040, UR-005 | DR-159 | UT-155
|
|
#[tokio::test]
|
|
async fn test_seek_during_handoff_reopens_the_stream_at_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 20 minutes in, so the stream's zero is 1200s.
|
|
controller.enter_background_audio(1200.0);
|
|
|
|
// The viewer scrubs the lockscreen to 25:00 absolute.
|
|
controller.seek_absolute(1490.0).await.unwrap();
|
|
|
|
let url = {
|
|
let queue = controller.queue();
|
|
let queue = queue.lock_safe();
|
|
match &queue.current().unwrap().source {
|
|
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
|
other => panic!("expected a remote source, got {:?}", other),
|
|
}
|
|
};
|
|
assert!(
|
|
url.contains(&format!(
|
|
"StartTimeTicks={}",
|
|
(1490.0 * 10_000_000.0) as i64
|
|
)),
|
|
"the stream must be re-opened at the absolute position; got {}",
|
|
url
|
|
);
|
|
|
|
assert_eq!(
|
|
*controller.background_audio_base.lock_safe(),
|
|
1490.0,
|
|
"the re-opened stream's zero is the position it was opened at, or \
|
|
every later reading is off by the difference"
|
|
);
|
|
}
|
|
|
|
/// Outside a handoff there is no base and nothing to re-open: an absolute
|
|
/// seek is just a seek, and must not be turned into a stream rebuild.
|
|
///
|
|
/// TRACES: UR-005 | DR-159 | UT-155
|
|
#[tokio::test]
|
|
async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
|
|
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_absolute(300.0).await.unwrap();
|
|
|
|
assert_eq!(controller.position(), 300.0);
|
|
assert_eq!(
|
|
*controller.background_audio_base.lock_safe(),
|
|
0.0,
|
|
"an ordinary seek must not invent a handoff base"
|
|
);
|
|
}
|
|
|
|
// ===== Position authority and reporting (DR-178, DR-179) =====
|
|
//
|
|
// Every position that leaves the app — the resume point Jellyfin stores, the
|
|
// point the video reloads at on the way back from a handoff, the truncation
|
|
// maths — is read off the controller. The device trace showed all of them
|
|
// reading 0: the native backend is not the player on the webview path, and
|
|
// during a handoff its base is only applied once ExoPlayer has ticked, which
|
|
// it has not while the audio-only transcode is still opening.
|
|
|
|
/// Returning to the foreground before the audio-only stream has started
|
|
/// playing hands back the handoff's own starting point, never zero.
|
|
///
|
|
/// Observed on device: locked at 18.4s, unlocked 3.5s later with ExoPlayer
|
|
/// still `IDLE`, `player_exit_background_audio` returned `0.0`, and the video
|
|
/// reloaded with `StartTimeTicks=0` — the episode restarted from the
|
|
/// beginning, and the `Stopped` report that followed wiped the server's
|
|
/// resume point too.
|
|
///
|
|
/// TRACES: UR-040 | DR-178 | UT-176
|
|
#[test]
|
|
fn test_absolute_position_floors_at_the_handoff_base() {
|
|
let controller = PlayerController::default();
|
|
controller.enter_background_audio(18.4);
|
|
|
|
// No tick has landed, so nothing has applied the base yet.
|
|
assert_eq!(controller.position(), 0.0);
|
|
assert_eq!(
|
|
controller.absolute_position(),
|
|
18.4,
|
|
"the audio stream's zero IS the handoff point, so the position can \
|
|
never legitimately read below it"
|
|
);
|
|
}
|
|
|
|
/// Once ticks are flowing the base has already been applied at the native
|
|
/// boundary (DR-159), so flooring must not add it a second time.
|
|
///
|
|
/// TRACES: UR-040 | DR-178 | UT-176
|
|
#[test]
|
|
fn test_absolute_position_does_not_double_count_the_handoff_base() {
|
|
let controller = PlayerController::default();
|
|
controller.enter_background_audio(18.4);
|
|
|
|
// What the real backend reports after a tick: already absolute.
|
|
controller.seek(120.0).unwrap();
|
|
|
|
assert_eq!(controller.absolute_position(), 120.0);
|
|
}
|
|
|
|
/// On the webview path the `<video>` element is the player and the native
|
|
/// backend holds nothing, so the position it reports is the only one there
|
|
/// is. It used to be re-emitted to the frontend and then dropped, leaving
|
|
/// every backend-side report at 0.
|
|
///
|
|
/// TRACES: UR-005, UR-025 | DR-178 | UT-177
|
|
#[test]
|
|
fn test_webview_position_reports_become_the_controllers_position() {
|
|
let controller = PlayerController::default();
|
|
|
|
controller.report_html5_position(253.4, 2640.0);
|
|
|
|
assert_eq!(controller.absolute_position(), 253.4);
|
|
assert_eq!(controller.observed_duration(), Some(2640.0));
|
|
}
|
|
|
|
/// A torn-down element's last position must not outlive it: the next thing
|
|
/// to play is loaded into the native backend, and a stale 253s would be
|
|
/// reported against it.
|
|
///
|
|
/// TRACES: UR-005 | DR-178 | UT-177
|
|
#[test]
|
|
fn test_webview_teardown_clears_the_observed_position() {
|
|
let controller = PlayerController::default();
|
|
controller.report_html5_position(253.4, 2640.0);
|
|
|
|
controller.report_html5_state("stopped".to_string(), None);
|
|
|
|
assert_eq!(controller.absolute_position(), 0.0);
|
|
}
|
|
|
|
/// Entering a handoff tears the element down, so its position stops being
|
|
/// the answer at that exact moment — the native audio player's does.
|
|
///
|
|
/// TRACES: UR-040 | DR-178 | UT-177
|
|
#[test]
|
|
fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
|
|
let controller = PlayerController::default();
|
|
controller.report_html5_position(253.4, 2640.0);
|
|
|
|
controller.enter_background_audio(18.4);
|
|
|
|
assert_eq!(
|
|
controller.absolute_position(),
|
|
18.4,
|
|
"the video element is gone; only the handoff base describes the \
|
|
stream that is now playing"
|
|
);
|
|
}
|
|
|
|
/// Nobody ever watched zero seconds of anything. A `Stopped` at 0 carries no
|
|
/// information and Jellyfin stores it as the resume point, so the only thing
|
|
/// it can do is destroy one — which is what the device trace caught it doing
|
|
/// 14 times in 35 minutes, including 40s after the frontend had correctly
|
|
/// reported 922s for the same episode.
|
|
///
|
|
/// TRACES: UR-025 | DR-179 | UT-178
|
|
#[tokio::test]
|
|
async fn test_a_stop_at_zero_is_never_reported() {
|
|
let controller = PlayerController::default();
|
|
let reports = Arc::new(CapturingReports::new());
|
|
controller.set_report_sink(reports.clone());
|
|
controller
|
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
|
.unwrap();
|
|
|
|
// Nothing ever played: the backend is at 0 and no element reported in.
|
|
controller.stop().unwrap();
|
|
|
|
assert!(
|
|
reports.stops().is_empty(),
|
|
"a zero-position stop must be withheld, not sent; got {:?}",
|
|
reports.stops()
|
|
);
|
|
}
|
|
|
|
/// A real position is still reported, so withholding zero cannot be
|
|
/// mistaken for withholding everything — from either rendering path.
|
|
///
|
|
/// The webview half is the one that was broken: the element reports 253s, the
|
|
/// native backend holds nothing, and the stop report went out as 0 and
|
|
/// overwrote the resume point the frontend had just written correctly.
|
|
///
|
|
/// TRACES: UR-025 | DR-178, DR-179 | UT-178
|
|
#[tokio::test]
|
|
async fn test_a_stop_reports_the_position_actually_reached() {
|
|
// Webview-rendered: the element is the only thing that knows.
|
|
let webview = PlayerController::default();
|
|
let webview_reports = Arc::new(CapturingReports::new());
|
|
webview.set_report_sink(webview_reports.clone());
|
|
webview
|
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
|
.unwrap();
|
|
webview.report_html5_position(253.0, 1500.0);
|
|
|
|
webview.stop().unwrap();
|
|
|
|
assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
|
|
|
|
// Natively rendered: the backend is authoritative and still is.
|
|
let native = PlayerController::default();
|
|
let native_reports = Arc::new(CapturingReports::new());
|
|
native.set_report_sink(native_reports.clone());
|
|
native
|
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
|
.unwrap();
|
|
native.seek(253.0).unwrap();
|
|
|
|
native.stop().unwrap();
|
|
|
|
assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
|
|
}
|
|
|
|
/// An episode listened to end-to-end on the lockscreen must count as
|
|
/// watched. Jellyfin decides that on the `PlaybackStopped` report — no
|
|
/// report, no completion — and in background audio-only mode there is
|
|
/// nobody else to send one: the webview is suspended and its `<video>` was
|
|
/// torn down at the handoff, so the frontend's end-of-playback reporting
|
|
/// cannot run. The backend advanced to the next episode and said nothing
|
|
/// about the one that finished.
|
|
///
|
|
/// TRACES: UR-040, UR-025 | DR-179 | UT-179
|
|
#[tokio::test]
|
|
async fn test_a_finished_audio_only_episode_is_reported_complete() {
|
|
let controller = PlayerController::default();
|
|
let reports = Arc::new(CapturingReports::new());
|
|
controller.set_report_sink(reports.clone());
|
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
|
controller
|
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
|
.unwrap();
|
|
// Played out to the end of the 25-minute episode.
|
|
controller.seek(1499.0).unwrap();
|
|
controller.take_end_reason();
|
|
|
|
controller.on_playback_ended().await.unwrap();
|
|
|
|
assert_eq!(
|
|
reports.stops(),
|
|
vec![("ep2".to_string(), 1500.0)],
|
|
"the finished episode must be reported stopped at its runtime, or \
|
|
Jellyfin's ≥90% rule never marks it played"
|
|
);
|
|
}
|
|
|
|
/// The completion report is for ends that are really ends. A truncated
|
|
/// stream is about to be re-opened and the episode is nowhere near over, so
|
|
/// reporting it stopped would tell Jellyfin the opposite of the truth.
|
|
///
|
|
/// TRACES: UR-040, UR-025 | DR-179 | UT-179
|
|
#[tokio::test]
|
|
async fn test_a_truncated_stream_reports_no_completion() {
|
|
let controller = PlayerController::default();
|
|
let reports = Arc::new(CapturingReports::new());
|
|
controller.set_report_sink(reports.clone());
|
|
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
|
controller
|
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
|
.unwrap();
|
|
controller.seek(600.0).unwrap();
|
|
controller.take_end_reason();
|
|
|
|
let decision = controller.on_playback_ended().await.unwrap();
|
|
|
|
assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
|
|
assert!(
|
|
reports.stops().is_empty(),
|
|
"a dropped connection is not a finished episode; got {:?}",
|
|
reports.stops()
|
|
);
|
|
}
|
|
|
|
/// Position ticks reach Jellyfin while playback is still going, so closing
|
|
/// the app — or losing it to a crash — cannot cost the whole session. The
|
|
/// device trace requested `/Sessions/Playing/Progress` exactly zero times in
|
|
/// 35 minutes: the frontend service writes progress to the local DB only,
|
|
/// and nothing on the Rust side reported it for webview-rendered media.
|
|
///
|
|
/// TRACES: UR-005, UR-025 | DR-179 | UT-180
|
|
#[tokio::test]
|
|
async fn test_webview_position_ticks_report_progress_to_the_server() {
|
|
let controller = PlayerController::default();
|
|
let reports = Arc::new(CapturingReports::new());
|
|
controller.set_report_sink(reports.clone());
|
|
controller
|
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
|
.unwrap();
|
|
|
|
controller.report_html5_position(253.4, 1500.0);
|
|
|
|
assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
|
|
}
|
|
|
|
/// Ticks arrive four times a second; reports must not. The throttler the
|
|
/// controller already owns bounds them to one per item per 30s.
|
|
///
|
|
/// TRACES: UR-005 | DR-179 | UT-180
|
|
#[tokio::test]
|
|
async fn test_progress_reports_are_throttled_not_sent_per_tick() {
|
|
let controller = PlayerController::default();
|
|
let reports = Arc::new(CapturingReports::new());
|
|
controller.set_report_sink(reports.clone());
|
|
controller
|
|
.play_queue(vec![audio_only_episode(1500.0)], 0)
|
|
.unwrap();
|
|
|
|
for tick in 0..12 {
|
|
controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
|
|
}
|
|
|
|
assert_eq!(
|
|
reports.progress().len(),
|
|
1,
|
|
"twelve ticks inside one throttle window are one report"
|
|
);
|
|
}
|
|
|
|
/// The truncation check compares the position against the item's runtime, so
|
|
/// both must be on the same timeline.
|
|
///
|
|
/// They now are by construction: the Android position tick shifts by the
|
|
/// handoff base before anything sees the value, so what the player reports is
|
|
/// already a position on the episode. The base is therefore *not* added here —
|
|
/// doing so would double-count it and make the last minute of a handoff look
|
|
/// like a truncation. What the mock backend holds is what the real one would
|
|
/// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
|
|
#[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, so
|
|
// the player reports 24:56 of the episode.
|
|
controller.set_background_audio_base(1440.0);
|
|
controller.seek(1496.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 {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
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 {
|
|
// Audio and direct-URL items never negotiate a transport.
|
|
transport: None,
|
|
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));
|
|
}
|
|
}
|