Skip to main content

#
jellytau_lib/player/
mod.rs

    1// Player module - Complete playback control system
    2// TRACES: UR-003, UR-004, UR-005, UR-019, UR-023, UR-026 |
    3//         IR-003, IR-004, IR-006, IR-008 |
    4//         DR-001, DR-004, DR-005, DR-009, DR-028, DR-029, DR-047
    5pub mod autoplay;
    6pub mod backend;
    7pub mod background_policy;
    8#[cfg(any(test, feature = "conformance"))]
    9pub mod conformance;
    10pub mod events;
    11#[cfg(any(test, feature = "conformance"))]
    12pub mod fake_player;
    13#[cfg(test)]
    14mod fake_player_conformance;
    15pub mod legacy_player;
    16pub mod media;
    17pub mod media_player;
    18#[cfg(target_os = "linux")]
    19pub mod mpv_player;
    20pub mod queue;
    21pub mod seek;
    22pub mod session;
    23pub mod sleep_timer;
    24pub mod state;
    25pub mod stream_end;
    26pub mod track_switch;
    27
    28#[cfg(test)]
    29mod mpv_backend_test;
    30
    31// Platform-specific backends
    32#[cfg(target_os = "android")]
    33pub mod android;
    34
    35#[cfg(target_os = "linux")]
    36pub mod mpv_backend;
    37
    38/// Whether this process renders video natively — one answer, three consumers
    39/// (UR-080 / DR-231, DR-235).
    40pub mod native_video;
    41
    42/// mpv's render API into a framebuffer we own (UR-080 / DR-231, IR-033).
    43///
    44/// Deliberately *not* GTK-gated beyond the platform that currently builds it:
    45/// everything here is the portable half, and Windows reuses it unchanged behind
    46/// its own surface.
    47#[cfg(target_os = "linux")]
    48pub mod mpv_render;
    49
    50/// The native video surface mpv renders into (UR-080 / DR-231).
    51///
    52/// Linux-gated because the *surface* is GTK. Everything around it — the render
    53/// context, its lifetime, frame pacing, the device profile — is not.
    54#[cfg(target_os = "linux")]
    55pub mod video_surface;
    56
    57// Platforms with no native audio backend (e.g. Windows) render audio-only
    58// playback through a webview <audio> element, mirroring how all video renders.
    59#[cfg(not(any(target_os = "linux", target_os = "android")))]
    60pub mod webview_audio_backend;
    61
    62// Re-export commonly used types
    63use crate::repository::stream_selection::StreamSelection;
    64pub use autoplay::{AutoplayDecision, AutoplaySettings};
    65pub use backend::{NullBackend, PlayerBackend, PlayerError};
    66pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
    67pub use legacy_player::LegacyPlayer;
    68pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
    69pub use media_player::{MediaPlayer, OpenRequest, Phase};
    70pub use queue::{QueueManager, RepeatMode};
    71pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
    72pub use session::{MediaSessionManager, MediaSessionType};
    73pub use sleep_timer::{SleepTimerMode, SleepTimerState};
    74pub use state::{EndReason, PlayerState};
    75pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchStrategy};
    76
    77// Re-export platform-specific backends
    78#[cfg(target_os = "android")]
    79pub use android::ExoPlayerBackend;
    80
    81#[cfg(target_os = "linux")]
    82pub use mpv_backend::MpvBackend;
    83
    84#[cfg(not(any(target_os = "linux", target_os = "android")))]
    85pub use webview_audio_backend::WebviewAudioBackend;
    86
    87#[cfg(target_os = "android")]
    88pub use android::{
    89    disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
    90    set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
    91};
    92
    93/// Where the player's playback reports go.
    94///
    95/// The controller's side of reporting is "send this, don't make me wait": a slow
    96/// or failing sync must never stall playback, so every send is fire-and-forget.
    97/// Production wires this to [`PlaybackReporter`] (local DB, server sync, offline
    98/// queueing); tests capture the operations instead of standing up a database and
    99/// an HTTP client, which is what let the missing reports below be written as
    100/// failing tests rather than found on a device.
    101///
    102/// TRACES: UR-025 | DR-179
    103pub trait PlaybackReportSink: Send + Sync {
    104    /// Deliver `operation`. Must not block the caller.
    105    fn send(&self, operation: PlaybackOperation);
    106}
    107
    108/// The production sink: hands each operation to the `PlaybackReporter`.
    109///
    110/// Reports originate on whatever thread playback ended or ticked on — including
    111/// JNI callbacks with no Tokio runtime attached — so the spawn falls back to a
    112/// throwaway runtime on its own thread rather than assuming one is current.
    113struct ReporterSink {
    114    reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
    115}
    116
    117impl PlaybackReportSink for ReporterSink {
    118    fn send(&self, operation: PlaybackOperation) {
    119        let reporter = self.reporter.clone();
    120        let task = async move {
    121            let guard = reporter.lock().await;
    122            let Some(reporter) = guard.as_ref() else {
    123                warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
    124                return;
    125            };
    126            // `report` decides local-vs-server and queues for sync itself.
    127            if let Err(e) = reporter.report(operation, true).await {
    128                log::error!("[PlayerController] Failed to report playback: {}", e);
    129            }
    130        };
    131
    132        if let Ok(handle) = tokio::runtime::Handle::try_current() {
    133            handle.spawn(task);
    134        } else {
    135            std::thread::spawn(move || match tokio::runtime::Runtime::new() {
    136                Ok(rt) => rt.block_on(task),
    137                Err(e) => log::error!(
    138                    "[PlayerController] No runtime available to report playback: {}",
    139                    e
    140                ),
    141            });
    142        }
    143    }
    144}
    145
    146/// The position to report when a stream ends naturally.
    147///
    148/// The item's runtime when we know it, because the point of the report is to say
    149/// the episode *finished* and Jellyfin decides that by percentage — the last
    150/// position actually observed can be seconds short, and on a handoff whose ticks
    151/// stopped early it can be nowhere near the end. Without a runtime the best
    152/// available answer is where playback got to.
    153///
    154/// TRACES: UR-025, UR-040 | DR-179 | UT-179
    155fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
    156    match runtime {
    157        Some(runtime) if runtime > 0.0 => runtime,
    158        _ => last_position.max(0.0),
    159    }
    160}
    161
    162/// Seconds added per attempt before retrying a stream that failed with an error.
    163///
    164/// Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that
    165/// covers roughly a quarter-minute of outage across the retry budget without
    166/// leaving the user staring at a dead notification when the network is truly gone.
    167/// Only *read* by the Android error callback (`#[cfg(android)]`), but compiled
    168/// and unit-tested on the host, hence `allow(dead_code)` off-Android.
    169#[cfg_attr(not(target_os = "android"), allow(dead_code))]
    170const RESUME_BACKOFF_STEP_SECS: u64 = 2;
    171
    172/// Metadata for the lockscreen / media notification.
    173///
    174/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
    175/// the local ExoPlayer is idle and so can't supply now-playing info. The session
    176/// poller fills this in from the remote Jellyfin session and pushes it to the
    177/// notification so the lockscreen stays in sync while casting.
    178///
    179/// TRACES: UR-006 | IR-006
    180#[derive(Debug, Clone)]
    181// Fields are read only by the Android MediaSession bridge; on other platforms
    182// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
    183#[cfg_attr(not(target_os = "android"), allow(dead_code))]
    184pub struct LockscreenMetadata {
    185    pub title: String,
    186    pub artist: String,
    187    pub album: Option<String>,
    188    /// Track duration in milliseconds.
    189    pub duration_ms: i64,
    190    /// Current playback position in milliseconds.
    191    pub position_ms: i64,
    192    pub is_playing: bool,
    193}
    194
    195/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
    196/// session poller can call it unconditionally and stay platform-agnostic.
    197///
    198/// No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see
    199/// IR-005, which is still Planned.
    200///
    201/// TRACES: UR-006 | IR-006
    202pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
    203    #[cfg(target_os = "android")]
    204    {
    205        return android::update_lockscreen_metadata(_meta);
    206    }
    207    #[cfg(not(target_os = "android"))]
    208    {
    209        Ok(())
    210    }
    211}
    212
    213/// Set the base offset (seconds) added to positions reported to the Android
    214/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
    215/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
    216/// relative and must be shifted back to absolute to match the full duration.
    217/// Pass 0.0 to clear on exit. No-op off Android.
    218pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
    219    #[cfg(target_os = "android")]
    220    {
    221        return android::set_position_offset(_offset_seconds);
    222    }
    223    #[cfg(not(target_os = "android"))]
    224    {
    225        Ok(())
    226    }
    227}
    228
    229use crate::utils::lock::MutexSafe;
    230use log::{debug, error, info, warn};
    231use std::sync::{Arc, Mutex};
    232use std::time::Duration;
    233use tokio::sync::Mutex as TokioMutex;
    234
    235use crate::jellyfin::JellyfinClient;
    236use crate::playback_reporting::{
    237    EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
    238};
    239use crate::repository::MediaRepository;
    240use crate::settings::AudioSettings;
    241use crate::utils::conversions::seconds_to_ticks;
    242
    243/// Central player controller that coordinates playback
    244pub struct PlayerController {
    245    /// The engine. One contract, so the controller stops branching on which
    246    /// platform it is running on — see docs/specs/media-player-controller.md.
    247    backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
    248    queue: Arc<Mutex<QueueManager>>,
    249    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
    250    muted: bool,
    251
    252    // Sleep timer state
    253    sleep_timer: Arc<Mutex<SleepTimerState>>,
    254
    255    // Autoplay settings
    256    autoplay_settings: Arc<Mutex<AutoplaySettings>>,
    257
    258    // Repository for fetching next episodes
    259    repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
    260
    261    // Event emitter for notifications
    262    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
    263
    264    // Countdown cancellation handle
    265    countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
    266
    267    // Playback reporting (dual sync: local DB + server)
    268    playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
    269
    270    // Where playback reports go. Swappable so tests can assert on what the
    271    // player tells Jellyfin. See `PlaybackReportSink`.
    272    reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
    273
    274    // Bounds progress reports to one per item per 30s. Position ticks arrive
    275    // four times a second; the server needs a resume point, not a firehose.
    276    position_throttler: Arc<EventThrottler>,
    277
    278    // End reason tracking for autoplay decision making
    279    end_reason: Arc<Mutex<Option<EndReason>>>,
    280
    281    // Auto-play episode counter (session-based, resets on manual play)
    282    autoplay_episode_count: Arc<Mutex<u32>>,
    283
    284    // Base offset (seconds) of the active background-audio handoff.
    285    //
    286    // The audio-only stream is requested with `StartTimeTicks` = the position the
    287    // video was handed off at, so the server makes that point the stream's zero
    288    // and the native player reports position RELATIVE to it. Adding this base back
    289    // yields the absolute position to resume the video at on the way out.
    290    //
    291    // Lives on the controller (not beside the command) because the queue and this
    292    // offset describe the same stream: whenever the controller loads a different
    293    // one — notably the backend-driven advance to the next episode — the base has
    294    // to move with it.
    295    //
    296    // TRACES: UR-040 | DR-052
    297    background_audio_base: Arc<Mutex<f64>>,
    298
    299    // True while a background-audio handoff owns playback: the native audio
    300    // player is the real player and the webview <video> has been torn down.
    301    //
    302    // The teardown is what makes this necessary. It fires a DOM `pause` that the
    303    // frontend reports like any other, which would otherwise leave the controller
    304    // believing webview media is still active — aiming lockscreen transport at an
    305    // element that no longer exists (see `is_html5_active`).
    306    //
    307    // TRACES: UR-040 | DR-052, DR-097
    308    background_audio_active: Arc<Mutex<bool>>,
    309
    310    // Budget for re-opening a stream that ended short of the item's runtime.
    311    //
    312    // A resume re-requests the same URL, so a server that is genuinely gone would
    313    // otherwise end → resume → end without limit. The tracker only bounds retries
    314    // that make no progress; a resume that plays on refills it.
    315    //
    316    // TRACES: UR-040 | DR-129
    317    stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
    318
    319    // Last state reported by a webview-rendered HTML5 <video>/<audio> element.
    320    //
    321    // Webview-rendered media is played by an element the native backend cannot
    322    // reach, so the backend's own state() says nothing about it. Tracking the
    323    // REPORTED state here is what lets transport (play/pause/toggle) be decided
    324    // in Rust for that media instead of the frontend reading `el.paused` off the
    325    // DOM — a value that flips transiently while buffering/seeking and caused
    326    // competing intents to take opposing actions. `None` means no webview media
    327    // is active and the native backend is authoritative. See DR-097.
    328    html5_playing: Arc<Mutex<Option<bool>>>,
    329
    330    // Last position/duration reported by webview-rendered media.
    331    //
    332    // On the webview path the `<video>` element IS the player: nothing is loaded
    333    // into the native backend, so `backend.position()` is a permanent 0. Those
    334    // reports used to be re-emitted to the frontend and then dropped, which is
    335    // why every position the *backend* sent to Jellyfin — including the stop
    336    // report that sets the resume point — was zero, overwriting the correct one
    337    // the frontend had just sent. Storing them here makes
    338    // `absolute_position()` answer for both rendering paths.
    339    //
    340    // TRACES: UR-005, UR-025 | DR-178
    341    reported_time: Arc<Mutex<stream_end::ObservedTime>>,
    342}
    343
    344impl PlayerController {
    345    pub fn new(
    346        backend: Box<dyn MediaPlayer>,
    347        playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
    348        position_throttler: Arc<EventThrottler>,
    349    ) -> Self {
    350        let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
    351            reporter: playback_reporter.clone(),
    352        });
    353        let controller = Self {
    354            backend: Arc::new(Mutex::new(backend)),
    355            queue: Arc::new(Mutex::new(QueueManager::new())),
    356            jellyfin_client: Arc::new(Mutex::new(None)),
    357            muted: false,
    358            sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
    359            autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
    360            repository: Arc::new(Mutex::new(None)),
    361            event_emitter: Arc::new(Mutex::new(None)),
    362            countdown_cancel: Arc::new(Mutex::new(None)),
    363            playback_reporter,
    364            reports: Arc::new(Mutex::new(reports)),
    365            position_throttler,
    366            end_reason: Arc::new(Mutex::new(None)),
    367            autoplay_episode_count: Arc::new(Mutex::new(0)),
    368            background_audio_base: Arc::new(Mutex::new(0.0)),
    369            background_audio_active: Arc::new(Mutex::new(false)),
    370            stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
    371            html5_playing: Arc::new(Mutex::new(None)),
    372            reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
    373        };
    374
    375        // Start background timer thread for sleep timer countdown
    376        controller.start_timer_thread();
    377
    378        controller
    379    }
    380
    381    /// Configure the Jellyfin API client for automatic playback reporting
    382    pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
    383        let mut jellyfin = self.jellyfin_client.lock_safe();
    384        *jellyfin = client;
    385        log::info!(
    386            "[PlayerController] Jellyfin client configured: {}",
    387            jellyfin.is_some()
    388        );
    389    }
    390
    391    /// Get a reference to the Jellyfin client (for remote session control)
    392    pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
    393        self.jellyfin_client.clone()
    394    }
    395
    396    /// Configure the media repository used for next-episode lookups.
    397    ///
    398    /// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
    399    /// repository handle (unlike the Linux HTML5 path, which passes one per
    400    /// call), so the controller needs a repository of its own or episode
    401    /// autoplay silently decides Stop.
    402    pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
    403        *self.repository.lock_safe() = Some(repo);
    404    }
    405
    406    /// Configure the playback reporter for dual sync (local DB + server).
    407    /// Called from `player_configure_jellyfin` on login/restore/reauth.
    408    pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
    409        let mut reporter_guard = self.playback_reporter.lock().await;
    410        *reporter_guard = reporter;
    411        log::info!(
    412            "[PlayerController] Playback reporter configured: {}",
    413            reporter_guard.is_some()
    414        );
    415    }
    416
    417    /// Get a reference to the playback reporter (for backend position updates)
    418    /// Will be used when position update hooks are added to backends
    419    #[allow(dead_code)]
    420    pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
    421        self.playback_reporter.clone()
    422    }
    423
    424    /// Get a reference to the position throttler (for backend position updates)
    425    /// Will be used when position update hooks are added to backends
    426    #[allow(dead_code)]
    427    pub fn position_throttler(&self) -> Arc<EventThrottler> {
    428        self.position_throttler.clone()
    429    }
    430
    431    /// Set the end reason for the next playback end event
    432    fn set_end_reason(&self, reason: EndReason) {
    433        log::debug!("[PlayerController] Setting end reason: {:?}", reason);
    434        *self.end_reason.lock_safe() = Some(reason);
    435    }
    436
    437    /// Get and clear the current end reason
    438    fn take_end_reason(&self) -> Option<EndReason> {
    439        self.end_reason.lock_safe().take()
    440    }
    441
    442    /// Read the end reason WITHOUT consuming it.
    443    ///
    444    /// `take_end_reason` has an owner: on Android the JNI ended-callback consumes
    445    /// the `NewTrackLoaded` every load sets, and the frontend's echoed call is the
    446    /// one that sees `None` and decides. The truncated-stream check runs in both
    447    /// calls and must not disturb that hand-off, so it peeks.
    448    fn peek_end_reason(&self) -> Option<EndReason> {
    449        *self.end_reason.lock_safe()
    450    }
    451
    452    /// Record that playback is being stopped by an expiring sleep timer.
    453    ///
    454    /// Stopping the backend makes it fire its ended callback (ExoPlayer does on
    455    /// Android), which lands in `on_playback_ended`. Without an end reason that
    456    /// reads as a natural finish and autoplay advances — defeating the timer.
    457    /// `UserStop` is the honest label: the stop was user-initiated, just via the
    458    /// timer they set rather than the stop button.
    459    ///
    460    /// Takes the shared slot rather than `&self` so the sleep-timer thread —
    461    /// which owns clones, not the controller — records it the same way.
    462    ///
    463    /// TRACES: UR-023, UR-026 | DR-029
    464    fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
    465        log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
    466        *end_reason.lock_safe() = Some(EndReason::UserStop);
    467    }
    468
    469    /// Increment autoplay episode counter. Returns true if limit is reached.
    470    fn increment_autoplay_count(&self) -> bool {
    471        let max = self.autoplay_settings.lock_safe().max_episodes;
    472
    473        if max == 0 {
    474            // Unlimited
    475            return false;
    476        }
    477
    478        let mut count = self.autoplay_episode_count.lock_safe();
    479        *count += 1;
    480        debug!(
    481            "[PlayerController] Autoplay episode count: {}/{}",
    482            *count, max
    483        );
    484
    485        *count >= max
    486    }
    487
    488    /// Reset autoplay episode counter (called on manual play actions)
    489    fn reset_autoplay_count(&self) {
    490        let mut count = self.autoplay_episode_count.lock_safe();
    491        if *count > 0 {
    492            debug!(
    493                "[PlayerController] Resetting autoplay episode counter (was {})",
    494                *count
    495            );
    496        }
    497        *count = 0;
    498    }
    499
    500    /// Load and play a single item (also sets the queue to contain only this item)
    501    pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
    502        debug!("[PlayerController] play_item: {}", item.title);
    503
    504        // Reset autoplay counter on manual play
    505        self.reset_autoplay_count();
    506
    507        // Update queue with this single item
    508        {
    509            let mut queue = self.queue.lock_safe();
    510            queue.set_queue(vec![item.clone()], 0);
    511        }
    512
    513        // Load and play the item
    514        self.load_and_play(&item)?;
    515
    516        Ok(())
    517    }
    518
    519    /// Set the current queue item without loading it into the playback backend.
    520    ///
    521    /// Used on platforms where video is rendered outside the native backend
    522    /// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
    523    /// item, but MPV must not start a redundant decode for it.
    524    ///
    525    /// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
    526    /// a runtime question — "does this renderer draw the picture?" — so the
    527    /// `else` arm is compiled on every platform even where it never runs. The
    528    /// gate outliving its caller broke the Android build outright, which went
    529    /// unnoticed because nothing built for Android afterwards.
    530    pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
    531        debug!(
    532            "[PlayerController] set_current_item (no backend load): {}",
    533            item.title
    534        );
    535
    536        self.reset_autoplay_count();
    537        // A different item is current; the last one's reported position must not
    538        // be reported against it. This path is how webview-rendered video is
    539        // queued (no backend load at all), so it is exactly where a stale
    540        // reading would otherwise survive.
    541        // TRACES: UR-005 | DR-178
    542        self.clear_reported_time();
    543
    544        let mut queue = self.queue.lock_safe();
    545        queue.set_queue(vec![item], 0);
    546
    547        Ok(())
    548    }
    549
    550    /// Load and play an item without modifying the queue
    551    /// Use this when the queue is already set up and you just want to play a specific item from it
    552    pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
    553        debug!("[PlayerController] load_and_play: {}", item.title);
    554
    555        // Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
    556        self.set_end_reason(EndReason::NewTrackLoaded);
    557
    558        // Loading into the native backend IS the statement that native renders
    559        // this item, so transport authority returns to it.
    560        //
    561        // `html5_playing` is written only by the webview element's own reports
    562        // and cleared only when it reports "stopped"/"idle". An element that
    563        // went away without that final report — or webview-rendered music
    564        // earlier in the same process — left `is_html5_active()` true, and then
    565        // every play/pause intent was emitted as a ControlCommand at an element
    566        // that no longer existed instead of reaching the backend. On Android's
    567        // native video path that is a pause button that does nothing, from the
    568        // surface tap and the control bar alike, while seek and skip keep
    569        // working because they decide elsewhere. Whether it happened at all
    570        // depended on what had played before, which is what made it look
    571        // intermittent.
    572        //
    573        // The webview re-establishes its own authority the moment an element
    574        // reports again, so nothing is lost on the HTML5 path: this is the same
    575        // "element is gone" semantics as the "stopped"/"idle" report, applied at
    576        // the point where we can know it directly.
    577        //
    578        // TRACES: UR-005, UR-003 | DR-193
    579        *self.html5_playing.lock_safe() = None;
    580
    581        let mut backend = self.backend.lock_safe();
    582        // One operation: the engine is handed the item and where to begin, so
    583        // there is no window between them for a position to be lost in.
    584        backend.open(OpenRequest::new(
    585            item.clone(),
    586            StreamSelection::for_queued_item(
    587                item.playback_url(),
    588                item.transport,
    589                item.needs_transcoding,
    590            ),
    591        ))?;
    592        drop(backend);
    593
    594        // A different item is loading; the last one's reported position must not
    595        // be attributed to it.
    596        self.clear_reported_time();
    597
    598        // Report playback start using PlaybackReporter (dual sync: local DB + server)
    599        if let Some(jellyfin_id) = item.jellyfin_id() {
    600            // Build playback context from item metadata
    601            let context = if item.album_id.is_some() {
    602                Some(PlaybackContext {
    603                    context_type: "container".to_string(),
    604                    context_id: item.album_id.clone(),
    605                })
    606            } else {
    607                None
    608            };
    609
    610            // Where this stream actually begins. Zero for an ordinary load, but a
    611            // background-audio handoff loads a stream whose zero is the handoff
    612            // point — telling the server the session started at 0:00 there both
    613            // misreports the session and, being a position, competes with the
    614            // real one.
    615            let position = self.absolute_position();
    616
    617            log::info!(
    618                "[PlayerController] Reporting playback start: {} @ {:.1}s",
    619                jellyfin_id,
    620                position
    621            );
    622            self.report(PlaybackOperation::Start {
    623                item_id: jellyfin_id.to_string(),
    624                position_ticks: seconds_to_ticks(position),
    625                context,
    626            });
    627        }
    628
    629        Ok(())
    630    }
    631
    632    /// Set the queue and start playing from the specified index
    633    pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
    634        self.play_queue_from(items, start_index, None)
    635    }
    636
    637    /// Set the queue and start playing from the specified index, optionally
    638    /// resuming the starting track at `start_position` (seconds).
    639    ///
    640    /// The seek happens immediately after load so the backend never audibly
    641    /// starts at 0 and there's no race against a fixed delay. Used when taking
    642    /// over playback from a remote session.
    643    pub fn play_queue_from(
    644        &self,
    645        items: Vec<MediaItem>,
    646        start_index: usize,
    647        start_position: Option<f64>,
    648    ) -> Result<(), PlayerError> {
    649        debug!(
    650            "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
    651            items.len(),
    652            start_index,
    653            start_position
    654        );
    655
    656        // Reset autoplay counter on manual queue start
    657        self.reset_autoplay_count();
    658
    659        {
    660            let mut queue = self.queue.lock_safe();
    661            queue.set_queue(items, start_index);
    662        }
    663
    664        // Play the current item (without modifying the queue we just set)
    665        if let Some(item) = self.queue.lock_safe().current().cloned() {
    666            self.load_and_play(&item)?;
    667
    668            // Resume from the requested position. Seeking right after load (while
    669            // the backend lock is no longer held) avoids the start-at-0-then-jump
    670            // race that a delayed frontend seek suffers from.
    671            if let Some(position) = start_position {
    672                if position > 0.5 {
    673                    self.seek(position)?;
    674                }
    675            }
    676        }
    677
    678        Ok(())
    679    }
    680
    681    /// Replace the queue without starting local playback.
    682    ///
    683    /// Used when we're controlling a remote session: the tracks play on the
    684    /// remote device, but we keep the local queue in sync so the UI reflects
    685    /// what's playing and a later transfer-to-local has the queue to resume.
    686    pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
    687        debug!(
    688            "[PlayerController] set_queue (no local playback): {} items, index {}",
    689            items.len(),
    690            start_index
    691        );
    692        self.reset_autoplay_count();
    693        let mut queue = self.queue.lock_safe();
    694        queue.set_queue(items, start_index);
    695        Ok(())
    696    }
    697
    698    /// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
    699    /// player, so transport must be routed to it rather than the native backend.
    700    ///
    701    /// TRACES: UR-005 | DR-097
    702    pub fn is_html5_active(&self) -> bool {
    703        self.html5_playing.lock_safe().is_some()
    704    }
    705
    706    /// Whether the webview element last reported itself as playing. Meaningless
    707    /// unless [`Self::is_html5_active`] is true.
    708    ///
    709    /// TRACES: UR-005 | DR-097
    710    pub fn html5_is_playing(&self) -> bool {
    711        self.html5_playing.lock_safe().unwrap_or(false)
    712    }
    713
    714    /// Send a transport intent to the webview element that is rendering media.
    715    fn emit_html5_control(&self, action: &str) {
    716        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
    717            emitter.emit(PlayerStatusEvent::ControlCommand {
    718                action: action.to_string(),
    719                position: None,
    720            });
    721        }
    722    }
    723
    724    /// Play/resume playback
    725    pub fn play(&self) -> Result<(), PlayerError> {
    726        debug!("[PlayerController] play");
    727        // Webview-rendered media: the native backend isn't playing it, so drive
    728        // the element via a ControlCommand instead (DR-097).
    729        if self.is_html5_active() {
    730            self.emit_html5_control("play");
    731            return Ok(());
    732        }
    733        let mut backend = self.backend.lock_safe();
    734        backend.play()
    735    }
    736
    737    /// Pause playback
    738    pub fn pause(&self) -> Result<(), PlayerError> {
    739        if self.is_html5_active() {
    740            self.emit_html5_control("pause");
    741            return Ok(());
    742        }
    743        let mut backend = self.backend.lock_safe();
    744        backend.pause()
    745    }
    746
    747    /// Toggle play/pause.
    748    ///
    749    /// The decision is made HERE, from authoritative state — the reported webview
    750    /// state for HTML5-rendered media, or the native backend's state otherwise.
    751    /// The frontend must never decide this from the DOM (see DR-097).
    752    ///
    753    /// TRACES: UR-005 | DR-097
    754    pub fn toggle_playback(&self) -> Result<(), PlayerError> {
    755        if self.is_html5_active() {
    756            let action = if self.html5_is_playing() {
    757                "pause"
    758            } else {
    759                "play"
    760            };
    761            self.emit_html5_control(action);
    762            return Ok(());
    763        }
    764        let mut backend = self.backend.lock_safe();
    765        if backend.snapshot().phase.is_active() {
    766            backend.pause()
    767        } else {
    768            backend.play()
    769        }
    770    }
    771
    772    /// Stop playback
    773    pub fn stop(&self) -> Result<(), PlayerError> {
    774        // Set end reason to UserStop to prevent autoplay
    775        self.set_end_reason(EndReason::UserStop);
    776
    777        // Get current playback info before stopping
    778        let jellyfin_id = {
    779            let queue = self.queue.lock_safe();
    780            queue
    781                .current()
    782                .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
    783        };
    784
    785        // Read across every rendering path BEFORE stopping: the backend zeroes
    786        // its position on stop, and the element that was reporting is gone.
    787        let position = self.absolute_position();
    788
    789        let mut backend = self.backend.lock_safe();
    790        backend.close()?;
    791        drop(backend);
    792        self.clear_reported_time();
    793
    794        // Stopping means *nothing is playing*, from any renderer — not "the
    795        // thing we currently believe owns playback has been asked to stop".
    796        //
    797        // A background-audio handoff swaps which renderer that is, and the swap
    798        // is bookkeeping that can be mid-flight: `exit_background_audio` marks
    799        // the webview element the player again the moment it is called, while
    800        // the element has not reloaded yet. A stop aimed at what the flags say
    801        // is playing therefore misses the audio stream that actually is, and it
    802        // resurfaces in the mini player as an audio track.
    803        //
    804        // Clearing the handoff here is the other half of that: a stop that
    805        // leaves the base offset and the active flag behind lets the next
    806        // position read be interpreted against a handoff that no longer exists.
    807        //
    808        // TRACES: UR-040, UR-005 | DR-250
    809        if self.is_background_audio_active() {
    810            debug!("[PlayerController] stop: clearing an active background-audio handoff");
    811        }
    812        *self.background_audio_active.lock_safe() = false;
    813        self.set_background_audio_base(0.0);
    814        *self.html5_playing.lock_safe() = None;
    815
    816        if let Some(jellyfin_id) = jellyfin_id {
    817            self.report_stopped_at(jellyfin_id, position);
    818        }
    819
    820        Ok(())
    821    }
    822
    823    /// Tell Jellyfin playback stopped at `position`, unless that position is
    824    /// zero.
    825    ///
    826    /// Jellyfin stores the reported position as the resume point, so a zero is
    827    /// not a harmless no-op — it is an instruction to forget where the viewer
    828    /// was. And it is never *information*: nobody watched zero seconds of
    829    /// anything, so every zero this app ever sent came from asking a player that
    830    /// was not rendering the media (webview video, or a handoff whose first tick
    831    /// had not landed). On a device trace, 14 of 14 stop reports in 35 minutes
    832    /// were zeroes, one of them 40s after the frontend had correctly reported
    833    /// 15:22 for the same episode.
    834    ///
    835    /// TRACES: UR-025, UR-005 | DR-179 | UT-178
    836    fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
    837        if position <= 0.0 {
    838            debug!(
    839                "[PlayerController] Withholding zero-position stop report for {} \
    840                 (nothing played; reporting it would clear the resume point)",
    841                jellyfin_id
    842            );
    843            return;
    844        }
    845
    846        log::info!(
    847            "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
    848            jellyfin_id,
    849            position
    850        );
    851        self.report(PlaybackOperation::Stopped {
    852            item_id: jellyfin_id,
    853            position_ticks: seconds_to_ticks(position),
    854        });
    855    }
    856
    857    /// Skip to next track
    858    ///
    859    /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
    860    /// from triggering when the current track's EndFile event fires
    861    pub fn next(&self) -> Result<(), PlayerError> {
    862        // Reset autoplay counter on manual skip
    863        self.reset_autoplay_count();
    864
    865        let next_item = {
    866            let mut queue = self.queue.lock_safe();
    867            queue.next().cloned()
    868        };
    869
    870        debug!(
    871            "[PlayerController] next: {:?}",
    872            next_item.as_ref().map(|i| &i.title)
    873        );
    874
    875        if let Some(item) = next_item {
    876            self.load_and_play(&item)
    877        } else {
    878            debug!("[PlayerController] No next item, stopping");
    879            self.stop()
    880        }
    881    }
    882
    883    /// Skip to previous track
    884    ///
    885    /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
    886    /// from triggering when the current track's EndFile event fires
    887    pub fn previous(&self) -> Result<(), PlayerError> {
    888        // Reset autoplay counter on manual skip
    889        self.reset_autoplay_count();
    890        // If we're more than 3 seconds in, restart current track
    891        {
    892            let backend = self.backend.lock_safe();
    893            if backend.snapshot().position.as_secs_f64() > 3.0 {
    894                debug!("[PlayerController] previous: restarting current track (position > 3s)");
    895                drop(backend);
    896                return self.seek(0.0);
    897            }
    898        }
    899
    900        let prev_item = {
    901            let mut queue = self.queue.lock_safe();
    902            queue.previous().cloned()
    903        };
    904
    905        debug!(
    906            "[PlayerController] previous: {:?}",
    907            prev_item.as_ref().map(|i| &i.title)
    908        );
    909
    910        if let Some(item) = prev_item {
    911            self.load_and_play(&item)
    912        } else {
    913            self.seek(0.0)
    914        }
    915    }
    916
    917    /// Seek to a position in seconds, **on the player's own timeline**.
    918    ///
    919    /// During a background-audio handoff that timeline is relative to the handoff
    920    /// point, so this is not the call a lockscreen scrub or a UI seek wants — use
    921    /// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
    922    /// timeline and is what every caller outside the player itself means.
    923    pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
    924        let mut backend = self.backend.lock_safe();
    925        backend.seek(Duration::from_secs_f64(position.max(0.0)))
    926    }
    927
    928    /// Seek to an **absolute** position on the item's own timeline.
    929    ///
    930    /// This is the boundary every outside seek comes through — the UI, the
    931    /// lockscreen scrubber, a headset gesture — because all of them are looking
    932    /// at the whole episode, not at whatever fragment of it the player happens to
    933    /// be streaming.
    934    ///
    935    /// Outside a background-audio handoff the two timelines are the same and this
    936    /// is an ordinary seek. Inside one they differ by the handoff base, and the
    937    /// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
    938    /// transcode with no length, so ExoPlayer either refuses or clamps — and a
    939    /// clamped seek lands at stream zero, which is the handoff point. That is the
    940    /// "jumps back to where I locked the screen" symptom. Honouring the seek means
    941    /// re-opening the URL at the new position, which is exactly what the
    942    /// truncation recovery already does, so it shares `resume_stream_at`.
    943    ///
    944    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
    945    pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
    946        // Only a *streamed* handoff needs the rebuild. A downloaded file seeks
    947        // like any other file — and `resume_stream_at` refuses a non-remote
    948        // source, so sending one through here fails the seek outright.
    949        // TRACES: UR-071 | DR-180 | UT-181
    950        let rebuild = self.is_background_audio_active() && {
    951            let queue = self.queue.lock_safe();
    952            queue
    953                .current()
    954                .map(|item| {
    955                    Self::is_audio_only_video(item)
    956                        && matches!(item.source, MediaSource::Remote { .. })
    957                })
    958                .unwrap_or(false)
    959        };
    960
    961        if rebuild {
    962            return self.resume_stream_at(position.max(0.0)).await;
    963        }
    964
    965        self.seek(position).map_err(|e| e.to_string())
    966    }
    967
    968    /// Set volume (0.0 - 1.0)
    969    pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
    970        self.backend.lock_safe().set_volume(volume)
    971    }
    972
    973    /// Set the active audio track by stream index
    974    pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
    975        let mut backend = self.backend.lock_safe();
    976        backend.select_audio_track(Some(stream_index))
    977    }
    978
    979    /// Set the active subtitle track by stream index (None to disable subtitles)
    980    pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
    981        let mut backend = self.backend.lock_safe();
    982        backend.select_subtitle_track(stream_index)
    983    }
    984
    985    /// Get current state
    986    pub fn state(&self) -> PlayerState {
    987        let phase = self.backend.lock_safe().snapshot().phase;
    988        let media = self.queue.lock_safe().current().cloned();
    989        match (phase, media) {
    990            (Phase::Playing, Some(media)) => PlayerState::Playing {
    991                media,
    992                position: self.position(),
    993                duration: self.duration().unwrap_or(0.0),
    994            },
    995            (Phase::Paused, Some(media)) => PlayerState::Paused {
    996                media,
    997                position: self.position(),
    998                duration: self.duration().unwrap_or(0.0),
    999            },
    1000            (Phase::Opening, Some(media)) => PlayerState::Loading { media },
    1001            (Phase::Failed(error), media) => PlayerState::Error { media, error },
    1002            // Ready without an item, or anything terminal, reads as idle: the
    1003            // queue is what says whether there is something to resume.
    1004            _ => PlayerState::Idle,
    1005        }
    1006    }
    1007
    1008    /// What the engine currently rendering can do.
    1009    ///
    1010    /// TRACES: UR-081 | DR-246
    1011    pub fn capabilities(&self) -> crate::player::media_player::Capabilities {
    1012        self.backend.lock_safe().capabilities()
    1013    }
    1014
    1015    /// Get current position
    1016    pub fn position(&self) -> f64 {
    1017        self.backend.lock_safe().snapshot().position.as_secs_f64()
    1018    }
    1019
    1020    /// The position on the **item's own timeline**, whatever is rendering it.
    1021    ///
    1022    /// This is what every outbound position must be taken from — the resume point
    1023    /// sent to Jellyfin, the point the video reloads at when a handoff ends, the
    1024    /// truncation comparison. `position()` alone answers for exactly one of the
    1025    /// three ways this app plays media, and reads 0 for the other two:
    1026    ///
    1027    /// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
    1028    ///   backend, so its position is a permanent 0. The element's own reports are
    1029    ///   the only reading there is.
    1030    /// - **Background-audio handoff**: the audio-only stream's zero is the
    1031    ///   handoff point, and the base is added at the native tick boundary
    1032    ///   (DR-159) — so before the first tick lands, nothing has applied it.
    1033    ///   Flooring at the base is exact rather than approximate: the stream cannot
    1034    ///   physically be behind its own starting point.
    1035    /// - **Native playback**: the backend is authoritative and both other terms
    1036    ///   are zero, so the max is its own value.
    1037    ///
    1038    /// Returning to the foreground during that pre-first-tick window is what
    1039    /// restarted an episode from 0:00 and wiped its server-side resume point.
    1040    ///
    1041    /// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
    1042    pub fn absolute_position(&self) -> f64 {
    1043        let native = self.backend.lock_safe().snapshot().position.as_secs_f64();
    1044        let reported = self.reported_time.lock_safe().last_position();
    1045        let base = if self.is_background_audio_active() {
    1046            *self.background_audio_base.lock_safe()
    1047        } else {
    1048            0.0
    1049        };
    1050
    1051        native.max(reported).max(base)
    1052    }
    1053
    1054    /// The duration last reported by webview-rendered media, if any.
    1055    ///
    1056    /// TRACES: UR-005 | DR-178 | UT-177
    1057    pub fn observed_duration(&self) -> Option<f64> {
    1058        self.reported_time.lock_safe().last_duration()
    1059    }
    1060
    1061    /// Forget what webview-rendered media reported.
    1062    ///
    1063    /// Called wherever that element stops being the player — it was torn down,
    1064    /// a handoff took over, or a different item is loading. A stale position
    1065    /// outliving its element would be reported against whatever plays next.
    1066    ///
    1067    /// TRACES: UR-005 | DR-178 | UT-177
    1068    fn clear_reported_time(&self) {
    1069        self.reported_time.lock_safe().reset();
    1070    }
    1071
    1072    /// Replace the sink playback reports go to. Tests capture; production wires
    1073    /// the `PlaybackReporter` at construction and never swaps it.
    1074    ///
    1075    /// TRACES: UR-025 | DR-179
    1076    #[cfg_attr(not(test), allow(dead_code))]
    1077    pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
    1078        *self.reports.lock_safe() = sink;
    1079    }
    1080
    1081    /// Send a playback report. Fire-and-forget by contract, so callers can do
    1082    /// this while holding nothing and waiting for nothing.
    1083    ///
    1084    /// TRACES: UR-025 | DR-179
    1085    fn report(&self, operation: PlaybackOperation) {
    1086        let sink = self.reports.lock_safe().clone();
    1087        sink.send(operation);
    1088    }
    1089
    1090    /// The Jellyfin id of whatever is currently queued, if it has one.
    1091    fn current_jellyfin_id(&self) -> Option<String> {
    1092        let queue = self.queue.lock_safe();
    1093        queue
    1094            .current()
    1095            .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
    1096    }
    1097
    1098    /// Get duration.
    1099    ///
    1100    /// Falls back to what webview-rendered media reported for the same reason
    1101    /// [`absolute_position`](Self::absolute_position) does: on that path nothing
    1102    /// is loaded into the native backend, so its duration is `None` and the
    1103    /// element's report is the only one there is.
    1104    ///
    1105    /// TRACES: UR-005 | DR-178
    1106    pub fn duration(&self) -> Option<f64> {
    1107        // Zero is not a duration, it is an engine saying it does not know yet.
    1108        //
    1109        // ExoPlayer reports `C.TIME_UNSET` until it has resolved one, and
    1110        // `JellyTauPlayer.getDuration()` maps that to `0.0` — so the engine
    1111        // answers `Some(0.0)`, every "unknown duration" fallback below is
    1112        // skipped, and the seek bar is left with no scale. That presents as
    1113        // scrubbing being broken rather than as a duration that never arrived.
    1114        //
    1115        // The item usually knows: the catalog carried a runtime long before
    1116        // anything started decoding.
    1117        //
    1118        // TRACES: UR-005, UR-040 | DR-251
    1119        let usable = |d: f64| (d > 0.0).then_some(d);
    1120
    1121        self.backend
    1122            .lock_safe()
    1123            .snapshot()
    1124            .duration
    1125            .map(|d| d.as_secs_f64())
    1126            .and_then(usable)
    1127            .or_else(|| self.observed_duration().and_then(usable))
    1128            .or_else(|| {
    1129                self.queue
    1130                    .lock_safe()
    1131                    .current()
    1132                    .and_then(|item| item.duration)
    1133                    .and_then(usable)
    1134            })
    1135    }
    1136
    1137    /// Get queue reference
    1138    pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
    1139        self.queue.clone()
    1140    }
    1141
    1142    /// True when the current item is a TV episode being played in audio-only
    1143    /// (background) mode — i.e. an `item_type == "Episode"` item loaded as
    1144    /// `MediaType::Audio`. Used to decide whether the backend must drive the
    1145    /// next-episode advance itself (the frontend is suspended in the background).
    1146    ///
    1147    /// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
    1148    /// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
    1149    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
    1150    pub fn current_is_audio_episode(&self) -> bool {
    1151        self.queue
    1152            .lock_safe()
    1153            .current()
    1154            .map(|item| {
    1155                item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
    1156            })
    1157            .unwrap_or(false)
    1158    }
    1159
    1160    /// Clear the queue entirely (used when playback genuinely stops, e.g. the
    1161    /// sleep timer fires or the queue ends with repeat off). Pair with
    1162    /// `emit_queue_changed` so the frontend hides the mini player.
    1163    pub fn clear_queue(&self) {
    1164        self.queue.lock_safe().clear();
    1165    }
    1166
    1167    /// Toggle shuffle
    1168    pub fn toggle_shuffle(&self) {
    1169        self.queue.lock_safe().toggle_shuffle();
    1170    }
    1171
    1172    /// Cycle repeat mode
    1173    pub fn cycle_repeat(&self) {
    1174        self.queue.lock_safe().cycle_repeat();
    1175    }
    1176
    1177    /// Check if shuffle is enabled
    1178    pub fn is_shuffle(&self) -> bool {
    1179        self.queue.lock_safe().is_shuffle()
    1180    }
    1181
    1182    /// Get repeat mode
    1183    pub fn repeat_mode(&self) -> RepeatMode {
    1184        self.queue.lock_safe().repeat_mode()
    1185    }
    1186
    1187    /// Get current volume (0.0 - 1.0)
    1188    pub fn volume(&self) -> f32 {
    1189        self.backend.lock_safe().snapshot().volume
    1190    }
    1191
    1192    /// Check if muted
    1193    pub fn muted(&self) -> bool {
    1194        self.muted
    1195    }
    1196
    1197    /// Set audio settings (crossfade, gapless, normalization)
    1198    pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
    1199        self.backend.lock_safe().set_audio_settings(settings)
    1200    }
    1201
    1202    /// Get current audio settings
    1203    pub fn audio_settings(&self) -> AudioSettings {
    1204        self.backend.lock_safe().audio_settings()
    1205    }
    1206
    1207    // ===== Sleep Timer Methods =====
    1208
    1209    /// Set the event emitter for notifications
    1210    pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
    1211        let mut event_emitter = self.event_emitter.lock_safe();
    1212        *event_emitter = Some(emitter);
    1213    }
    1214
    1215    /// Get the event emitter
    1216    pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
    1217        self.event_emitter.lock_safe().clone()
    1218    }
    1219
    1220    /// Get sleep timer state
    1221    pub fn sleep_timer_state(&self) -> SleepTimerState {
    1222        self.sleep_timer.lock_safe().clone()
    1223    }
    1224
    1225    /// Set sleep timer mode (in-memory only, not persisted)
    1226    pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
    1227        let mut timer = self.sleep_timer.lock_safe();
    1228        timer.mode = mode.clone();
    1229        if let SleepTimerMode::Time { end_time } = mode {
    1230            let now = chrono::Utc::now().timestamp_millis();
    1231            timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
    1232        } else {
    1233            timer.remaining_seconds = 0;
    1234        }
    1235        drop(timer);
    1236
    1237        // Emit event to frontend for display update
    1238        self.emit_sleep_timer_changed();
    1239    }
    1240
    1241    /// Cancel sleep timer
    1242    pub fn cancel_sleep_timer(&self) {
    1243        self.set_sleep_timer(SleepTimerMode::Off);
    1244    }
    1245
    1246    /// Start background timer thread for sleep timer countdown updates
    1247    fn start_timer_thread(&self) {
    1248        let sleep_timer = self.sleep_timer.clone();
    1249        let event_emitter = self.event_emitter.clone();
    1250        let backend = self.backend.clone();
    1251        let end_reason = self.end_reason.clone();
    1252
    1253        std::thread::spawn(move || {
    1254            loop {
    1255                std::thread::sleep(Duration::from_secs(1));
    1256
    1257                let mut timer = sleep_timer.lock_safe();
    1258                if timer.is_active() {
    1259                    timer.update_remaining_seconds();
    1260
    1261                    // Time-based timer expired: stop playback
    1262                    if matches!(timer.mode, SleepTimerMode::Time { .. })
    1263                        && timer.remaining_seconds == 0
    1264                    {
    1265                        debug!("[SleepTimer] Time-based timer expired, stopping playback");
    1266                        timer.cancel();
    1267
    1268                        // Mark the stop *before* it reaches the backend. Stopping
    1269                        // makes the native player fire its ended callback, and
    1270                        // cancelling the timer above means on_playback_ended can no
    1271                        // longer tell this apart from a natural end — without this
    1272                        // it would show the next-episode popup / autoplay right
    1273                        // after the sleep timer fired.
    1274                        Self::note_sleep_timer_stop(&end_reason);
    1275
    1276                        // Emit cancelled state
    1277                        if let Some(emitter) = event_emitter.lock_safe().as_ref() {
    1278                            emitter.emit(PlayerStatusEvent::SleepTimerChanged {
    1279                                mode: SleepTimerMode::Off,
    1280                                remaining_seconds: 0,
    1281                            });
    1282                            // Tell the frontend playback must stop: HTML5 video
    1283                            // (Linux) plays outside the backend, so stopping the
    1284                            // backend below doesn't reach it.
    1285                            emitter.emit(PlayerStatusEvent::SleepTimerExpired);
    1286                        }
    1287                        drop(timer);
    1288
    1289                        // Stop the backend
    1290                        if let Err(e) = backend.lock_safe().close() {
    1291                            error!("[SleepTimer] Failed to stop playback: {}", e);
    1292                        }
    1293                        continue;
    1294                    }
    1295
    1296                    // Emit update event
    1297                    if let Some(emitter) = event_emitter.lock_safe().as_ref() {
    1298                        emitter.emit(PlayerStatusEvent::SleepTimerChanged {
    1299                            mode: timer.mode.clone(),
    1300                            remaining_seconds: timer.remaining_seconds,
    1301                        });
    1302                    }
    1303                }
    1304                drop(timer);
    1305            }
    1306        });
    1307    }
    1308
    1309    /// Emit sleep timer changed event to frontend
    1310    fn emit_sleep_timer_changed(&self) {
    1311        let timer = self.sleep_timer.lock_safe().clone();
    1312
    1313        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
    1314            emitter.emit(PlayerStatusEvent::SleepTimerChanged {
    1315                mode: timer.mode,
    1316                remaining_seconds: timer.remaining_seconds,
    1317            });
    1318        }
    1319    }
    1320
    1321    /// Emit queue changed event to frontend
    1322    pub fn emit_queue_changed(&self) {
    1323        let queue = self.queue.lock_safe();
    1324
    1325        debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
    1326            queue.items().len(), queue.current_index());
    1327
    1328        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
    1329            emitter.emit(PlayerStatusEvent::QueueChanged {
    1330                items: queue.items().to_vec(),
    1331                current_index: queue.current_index(),
    1332                shuffle: queue.is_shuffle(),
    1333                repeat: queue.repeat_mode(),
    1334                has_next: queue.has_next(),
    1335                has_previous: queue.has_previous(),
    1336            });
    1337        } else {
    1338            warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
    1339        }
    1340    }
    1341
    1342    // ===== HTML5 video report methods =====
    1343    //
    1344    // On platforms where video is rendered in the webview (Linux WebKitGTK
    1345    // HTML5 <video>), the real player lives outside the native backend, so it
    1346    // cannot emit PlayerStatusEvents itself. The frontend HTML5 adapter reports
    1347    // DOM events here, and these methods re-emit them through the SAME event
    1348    // pipeline the native backends use. This keeps the frontend's player store
    1349    // fed from one place (playerEvents.ts) in both native and HTML5 modes, so
    1350    // the Rust controller stays the single source of truth for player state.
    1351
    1352    /// Report an HTML5 <video> state change (playing/paused/loading/stopped).
    1353    ///
    1354    /// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
    1355    /// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
    1356    pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
    1357        // A background-audio handoff has already moved playback to the native
    1358        // player and torn the element down; anything it still reports describes
    1359        // a video that is no longer playing. Dropping it keeps the UI on the
    1360        // audio that IS playing and leaves transport with the native backend.
    1361        if self.is_background_audio_active() {
    1362            debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
    1363            return;
    1364        }
    1365        // Track it: this is the authoritative play/pause state for
    1366        // webview-rendered media, and what transport decisions read (DR-097).
    1367        // "stopped"/"idle" mean the element is gone, so hand authority back to
    1368        // the native backend — otherwise music playback would keep emitting
    1369        // ControlCommands at a element that no longer exists.
    1370        let element_gone = {
    1371            let mut tracked = self.html5_playing.lock_safe();
    1372            *tracked = match state.as_str() {
    1373                "playing" => Some(true),
    1374                // "loading" counts as active-but-not-playing so a toggle during
    1375                // load resolves to "play" rather than falling through to the
    1376                // native backend.
    1377                "paused" | "loading" => Some(false),
    1378                // "stopped"/"idle": element is gone, native backend resumes authority.
    1379                _ => None,
    1380            };
    1381            tracked.is_none()
    1382        };
    1383        // Its last position goes with it: whatever plays next is loaded into the
    1384        // native backend, and a stale reading would be reported against that.
    1385        if element_gone {
    1386            self.clear_reported_time();
    1387        }
    1388        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
    1389            emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
    1390        }
    1391    }
    1392
    1393    /// Report an HTML5 <video> position tick.
    1394    ///
    1395    /// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
    1396    /// position updates (the adapter is expected to throttle to ~250ms like MPV).
    1397    pub fn report_html5_position(&self, position: f64, duration: f64) {
    1398        // Stale by definition during a handoff — the native player's ticks are
    1399        // the real position. See `report_html5_state`.
    1400        if self.is_background_audio_active() {
    1401            return;
    1402        }
    1403        // The element is the player on this path, so this tick is the position —
    1404        // for the resume point, the stop report and everything else that asks.
    1405        self.reported_time.lock_safe().record(position, duration);
    1406
    1407        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
    1408            emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
    1409        }
    1410
    1411        self.report_progress_throttled(position);
    1412    }
    1413
    1414    /// Send a throttled progress report to Jellyfin.
    1415    ///
    1416    /// Progress is what makes a position survive anything other than a clean
    1417    /// exit — a crash, a swipe-away, a battery death — and lets another device
    1418    /// resume mid-episode. Webview-rendered media reported none: the frontend
    1419    /// service writes progress to the local DB only, and Rust had no position
    1420    /// for it to report. A device trace covering 35 minutes of playback hit
    1421    /// `/Sessions/Playing/Progress` exactly zero times.
    1422    ///
    1423    /// The throttler is the one the controller already owned for this purpose
    1424    /// (30s per item), so ticks arriving four times a second cost one request
    1425    /// per half-minute.
    1426    ///
    1427    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
    1428    fn report_progress_throttled(&self, position: f64) {
    1429        if position <= 0.0 {
    1430            return;
    1431        }
    1432        let Some(item_id) = self.current_jellyfin_id() else {
    1433            return;
    1434        };
    1435        if !self.position_throttler.should_report(&item_id) {
    1436            return;
    1437        }
    1438
    1439        self.report(PlaybackOperation::Progress {
    1440            item_id: item_id.clone(),
    1441            position_ticks: seconds_to_ticks(position),
    1442            // Ticks only arrive while the element is playing; a pause is carried
    1443            // by the state report, not by a position that stopped moving.
    1444            is_paused: false,
    1445        });
    1446        self.position_throttler.mark_reported(&item_id);
    1447    }
    1448
    1449    /// Report that the HTML5 <video> element finished loading and knows its
    1450    /// duration. Mirrors the native `MediaLoaded` event.
    1451    pub fn report_html5_media_loaded(&self, duration: f64) {
    1452        // See `report_html5_state` — the element is not the player right now.
    1453        if self.is_background_audio_active() {
    1454            return;
    1455        }
    1456        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
    1457            emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
    1458        }
    1459    }
    1460
    1461    // ===== Autoplay Methods =====
    1462
    1463    /// Get autoplay settings
    1464    pub fn autoplay_settings(&self) -> AutoplaySettings {
    1465        self.autoplay_settings.lock_safe().clone()
    1466    }
    1467
    1468    /// Set autoplay settings (in-memory only, persistence handled by command layer)
    1469    pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
    1470        let validated = settings.with_validated_countdown();
    1471        *self.autoplay_settings.lock_safe() = validated;
    1472    }
    1473
    1474    /// Cancel active autoplay countdown
    1475    pub fn cancel_autoplay_countdown(&self) {
    1476        if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
    1477            *cancel_flag.lock_safe() = true;
    1478        }
    1479    }
    1480
    1481    /// Handle playback ended event - decides what to do next
    1482    ///
    1483    /// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
    1484    /// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
    1485    pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
    1486        // A truncated stream is not an end at all, so this is decided BEFORE the
    1487        // end-reason gate below — which returns early for the `NewTrackLoaded`
    1488        // that every load sets, and would therefore swallow the whole question on
    1489        // Android's JNI callback: the one call guaranteed to run while the app is
    1490        // backgrounded and the webview cannot echo anything back.
    1491        if let Some(position) = self.truncated_stream_resume_position() {
    1492            return Ok(AutoplayDecision::ResumeStream { position });
    1493        }
    1494
    1495        // Check why playback ended
    1496        let end_reason = self.take_end_reason();
    1497
    1498        debug!(
    1499            "[PlayerController] on_playback_ended: end_reason={:?}",
    1500            end_reason
    1501        );
    1502
    1503        // Only proceed with autoplay logic if track finished naturally
    1504        match end_reason {
    1505            None | Some(EndReason::Finished) => {
    1506                // Track ended naturally, proceed with autoplay logic
    1507                debug!("[PlayerController] Track finished naturally, checking autoplay");
    1508            }
    1509            Some(EndReason::NewTrackLoaded) => {
    1510                // User loaded a new track, don't autoplay
    1511                debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
    1512                return Ok(AutoplayDecision::Stop);
    1513            }
    1514            Some(EndReason::UserStop) => {
    1515                // User stopped playback, don't autoplay
    1516                debug!("[PlayerController] UserStop - stopping without autoplay");
    1517                return Ok(AutoplayDecision::Stop);
    1518            }
    1519            Some(EndReason::UserSkip) => {
    1520                // User skipped, already handled by next/previous
    1521                debug!("[PlayerController] UserSkip - stopping without autoplay");
    1522                return Ok(AutoplayDecision::Stop);
    1523            }
    1524            Some(EndReason::Error) => {
    1525                // Playback error, don't autoplay
    1526                debug!("[PlayerController] Error - stopping without autoplay");
    1527                return Ok(AutoplayDecision::Stop);
    1528            }
    1529        }
    1530
    1531        let current_item = {
    1532            let queue = self.queue.lock_safe();
    1533            queue.current().cloned()
    1534        };
    1535
    1536        let Some(current) = current_item else {
    1537            return Ok(AutoplayDecision::Stop);
    1538        };
    1539
    1540        // The item is genuinely finished (a truncated stream returned above), so
    1541        // report it before anything advances — after an advance the queue's
    1542        // current item is the *next* episode and this one is unreachable.
    1543        self.report_completion(&current);
    1544
    1545        // Check sleep timer state
    1546        let timer_mode = {
    1547            let timer = self.sleep_timer.lock_safe();
    1548            timer.mode.clone()
    1549        };
    1550
    1551        match &timer_mode {
    1552            SleepTimerMode::Time { end_time } => {
    1553                // If time has expired, stop instead of playing next
    1554                let now = chrono::Utc::now().timestamp_millis();
    1555                if now >= *end_time {
    1556                    debug!("[PlayerController] Time-based sleep timer expired at track boundary");
    1557                    self.sleep_timer.lock_safe().cancel();
    1558                    self.emit_sleep_timer_changed();
    1559                    return Ok(AutoplayDecision::Stop);
    1560                }
    1561            }
    1562            SleepTimerMode::EndOfTrack => {
    1563                // Stop at end of track
    1564                self.sleep_timer.lock_safe().cancel();
    1565                self.emit_sleep_timer_changed();
    1566                return Ok(AutoplayDecision::Stop);
    1567            }
    1568            SleepTimerMode::Episodes { .. } => {
    1569                // Only count TV episodes (not audio tracks or movies). Note an
    1570                // episode played in background-audio mode is MediaType::Audio, so
    1571                // rely on is_episode_item (which checks item_type) rather than the
    1572                // media_type alone.
    1573                let is_episode = self.is_episode_item(&current).await;
    1574
    1575                if is_episode {
    1576                    let should_stop = self.sleep_timer.lock_safe().decrement_episode();
    1577                    self.emit_sleep_timer_changed();
    1578
    1579                    if should_stop {
    1580                        return Ok(AutoplayDecision::Stop);
    1581                    }
    1582                }
    1583            }
    1584            _ => {
    1585                // No action needed for other modes
    1586            }
    1587        }
    1588
    1589        // For episodes, fetch next episode and show popup.
    1590        // Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
    1591        // It's here for the Android ExoPlayer path where episode items sit in the
    1592        // backend queue — including background-audio mode, where the episode is a
    1593        // MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
    1594        if self.is_episode_item(&current).await {
    1595            let repo = self.repository.lock_safe().clone();
    1596            let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
    1597            let next_ep_result = if let Some(repo) = &repo {
    1598                // Degrade lookup failures to Stop: playback already ended, and
    1599                // surfacing an error here just kills autoplay silently upstream.
    1600                match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
    1601                    Ok(next) => next,
    1602                    Err(e) => {
    1603                        warn!(
    1604                            "[PlayerController] Next-episode lookup failed for {}: {}",
    1605                            jellyfin_id, e
    1606                        );
    1607                        None
    1608                    }
    1609                }
    1610            } else {
    1611                warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
    1612                None
    1613            };
    1614            if let Some(next_ep) = next_ep_result {
    1615                let settings = self.autoplay_settings.lock_safe().clone();
    1616
    1617                // Check if auto-play episode limit is reached
    1618                let limit_reached = self.increment_autoplay_count();
    1619                if limit_reached {
    1620                    debug!(
    1621                        "[PlayerController] Auto-play episode limit reached ({} episodes)",
    1622                        settings.max_episodes
    1623                    );
    1624                }
    1625
    1626                return Ok(AutoplayDecision::ShowNextEpisodePopup {
    1627                    current_episode: next_ep.0, // Repository MediaItem
    1628                    next_episode: next_ep.1,
    1629                    countdown_seconds: settings.countdown_seconds,
    1630                    auto_advance: settings.enabled && !limit_reached,
    1631                });
    1632            }
    1633            // No next episode found
    1634            return Ok(AutoplayDecision::Stop);
    1635        }
    1636
    1637        // For audio/movies, check if there's a next track in the queue
    1638        let has_next = {
    1639            let queue = self.queue.lock_safe();
    1640            queue.has_next()
    1641        };
    1642
    1643        if has_next {
    1644            // Advance to next track
    1645            Ok(AutoplayDecision::AdvanceToNext)
    1646        } else {
    1647            // End of queue
    1648            Ok(AutoplayDecision::Stop)
    1649        }
    1650    }
    1651
    1652    /// Report an item that just finished as stopped at its runtime, so Jellyfin
    1653    /// marks it played.
    1654    ///
    1655    /// Jellyfin decides "watched" from the `PlaybackStopped` report and its
    1656    /// position — no report, no completion, however much of the episode was
    1657    /// actually heard. In the foreground the frontend sends one when the
    1658    /// `<video>` ends. In background audio-only mode there is nobody: the webview
    1659    /// is suspended and its element was torn down at the handoff, while the
    1660    /// backend drove the advance to the next episode and said nothing about the
    1661    /// one that ended. An episode listened to end-to-end on the lockscreen
    1662    /// therefore never counted, and (before DR-179) was often reset to 0 by the
    1663    /// stop report that followed.
    1664    ///
    1665    /// Scoped to the audio-only handoff — the case the frontend provably cannot
    1666    /// report — so foreground playback keeps its single existing report rather
    1667    /// than gaining a second one. Music tracks ending natively remain
    1668    /// unreported; that is the same gap through a different door and wants its
    1669    /// own change.
    1670    ///
    1671    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
    1672    fn report_completion(&self, item: &MediaItem) {
    1673        if !Self::is_audio_only_video(item) {
    1674            return;
    1675        }
    1676        let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
    1677            return;
    1678        };
    1679
    1680        let position = completion_report_position(item.duration, self.absolute_position());
    1681        log::info!(
    1682            "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
    1683            jellyfin_id,
    1684            position
    1685        );
    1686        self.report_stopped_at(jellyfin_id, position);
    1687    }
    1688
    1689    /// Record the base offset of a background-audio handoff (the position the
    1690    /// video was handed off at, which is the audio stream's zero).
    1691    ///
    1692    /// TRACES: UR-040 | DR-052
    1693    pub fn set_background_audio_base(&self, seconds: f64) {
    1694        *self.background_audio_base.lock_safe() = seconds.max(0.0);
    1695    }
    1696
    1697    /// Enter a background-audio handoff at `position` (the video's position, and
    1698    /// therefore the audio stream's zero).
    1699    ///
    1700    /// Hands transport authority to the native audio player: the webview
    1701    /// `<video>` is about to be torn down, so its last reports — including the
    1702    /// `pause` the teardown itself fires — must not keep it looking like the
    1703    /// player. Without this the lockscreen pause emitted a ControlCommand at a
    1704    /// dead element and the audio played straight through it.
    1705    ///
    1706    /// TRACES: UR-040, UR-005 | DR-052, DR-097
    1707    pub fn enter_background_audio(&self, position: f64) {
    1708        self.set_background_audio_base(position);
    1709        *self.background_audio_active.lock_safe() = true;
    1710        *self.html5_playing.lock_safe() = None;
    1711        // The element is being torn down; its last position describes a video
    1712        // that is no longer playing, and the base describes the one that is.
    1713        self.clear_reported_time();
    1714    }
    1715
    1716    /// Leave a background-audio handoff, returning the base offset to add to the
    1717    /// native player's relative position.
    1718    ///
    1719    /// The webview `<video>` becomes the player again once it reloads, so its
    1720    /// reports are honoured from here on.
    1721    ///
    1722    /// TRACES: UR-040, UR-005 | DR-052, DR-097
    1723    pub fn exit_background_audio(&self) -> f64 {
    1724        *self.background_audio_active.lock_safe() = false;
    1725        self.take_background_audio_base()
    1726    }
    1727
    1728    /// True while the native audio player owns playback via a background-audio
    1729    /// handoff.
    1730    ///
    1731    /// TRACES: UR-040 | DR-052
    1732    pub fn is_background_audio_active(&self) -> bool {
    1733        *self.background_audio_active.lock_safe()
    1734    }
    1735
    1736    /// Read and clear the background-audio base offset.
    1737    ///
    1738    /// TRACES: UR-040 | DR-052
    1739    pub fn take_background_audio_base(&self) -> f64 {
    1740        let mut base = self.background_audio_base.lock_safe();
    1741        std::mem::replace(&mut *base, 0.0)
    1742    }
    1743
    1744    /// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
    1745    ///
    1746    /// Single place both end-of-playback dispatchers agree on: the Android JNI
    1747    /// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
    1748    /// (`player_on_playback_ended`). They used to each carry their own copy of
    1749    /// this branch, and the command's copy was missing the background-audio case
    1750    /// entirely — so an audio-only episode ending while backgrounded only ever
    1751    /// started a countdown that nothing could act on.
    1752    ///
    1753    /// TRACES: UR-040, UR-023 | DR-052
    1754    pub async fn auto_advance_to_next_episode(
    1755        &self,
    1756        next_episode: crate::repository::types::MediaItem,
    1757        countdown_seconds: u32,
    1758    ) {
    1759        // Background audio-only episode: the countdown only emits ticks — the
    1760        // advance itself is a `goto('/player/<id>')` in the webview, which cannot
    1761        // start audio while the app is backgrounded. Load the next episode's
    1762        // audio-only stream here instead, or playback stalls at the boundary.
    1763        if self.current_is_audio_episode() {
    1764            info!(
    1765                "[PlayerController] Background audio episode — advancing to {} in backend",
    1766                next_episode.id
    1767            );
    1768            match self
    1769                .advance_to_next_episode_audio_only(&next_episode.id)
    1770                .await
    1771            {
    1772                Ok(()) => self.emit_queue_changed(),
    1773                Err(e) => {
    1774                    error!(
    1775                        "[PlayerController] Background audio advance failed: {} — stopping",
    1776                        e
    1777                    );
    1778                    if let Some(emitter) = self.event_emitter() {
    1779                        emitter.emit(PlayerStatusEvent::PlaybackEnded);
    1780                    }
    1781                }
    1782            }
    1783            return;
    1784        }
    1785
    1786        // Foreground: the frontend drives the advance off the countdown ticks.
    1787        self.start_autoplay_countdown(next_episode, countdown_seconds);
    1788    }
    1789
    1790    /// A video item played through the native *audio* path — i.e. the background
    1791    /// audio-only handoff, the only place a length-less progressive transcode is
    1792    /// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
    1793    fn is_audio_only_video(item: &MediaItem) -> bool {
    1794        stream_end::is_audio_only_video(item)
    1795    }
    1796
    1797    /// Claim a resume attempt for the current stream, returning the absolute
    1798    /// position to re-open at and the 1-based attempt number. `None` when the
    1799    /// current item cannot meaningfully be re-requested, or when retrying at this
    1800    /// position has stopped helping.
    1801    ///
    1802    /// Only `Remote` sources qualify. A downloaded file cannot fail because of
    1803    /// the network, so re-opening one would paper over a real read error; a
    1804    /// `DirectUrl` is a plugin's endpoint with no Jellyfin item behind it.
    1805    ///
    1806    /// The player's position is relative to the stream's own zero (the handoff
    1807    /// URL's `StartTimeTicks`), so the base is added back to get an absolute one.
    1808    /// It is zero for everything else, where positions are already absolute.
    1809    ///
    1810    /// TRACES: UR-040, UR-004 | DR-129 | UT-117
    1811    fn claim_stream_resume(&self) -> Option<(f64, u32)> {
    1812        let current = {
    1813            let queue = self.queue.lock_safe();
    1814            queue.current().cloned()
    1815        }?;
    1816        if !matches!(current.source, MediaSource::Remote { .. }) {
    1817            return None;
    1818        }
    1819
    1820        // The Android position tick shifts by the handoff base before anything
    1821        // sees the value, so adding it again here would double-count it
    1822        // (DR-159) — `absolute_position` floors at the base instead, which is
    1823        // what a stream that died before its first tick needs. (DR-178)
    1824        let absolute = self.absolute_position();
    1825
    1826        match self.stream_resume.lock_safe().allow_attempt(absolute) {
    1827            Some(attempt) => Some((absolute, attempt)),
    1828            None => {
    1829                warn!(
    1830                    "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
    1831                    current.id, absolute
    1832                );
    1833                None
    1834            }
    1835        }
    1836    }
    1837
    1838    /// The absolute position to re-open the current stream at, when the reported
    1839    /// end was really a dropped connection — `None` when the end looks genuine,
    1840    /// when this is not an audio-only handoff, or when retrying has stopped
    1841    /// helping.
    1842    ///
    1843    /// TRACES: UR-040 | DR-129 | UT-117
    1844    fn truncated_stream_resume_position(&self) -> Option<f64> {
    1845        // An explicit user intent already explains the end; never resume over it.
    1846        if matches!(
    1847            self.peek_end_reason(),
    1848            Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
    1849        ) {
    1850            return None;
    1851        }
    1852
    1853        // One lock at a time — `position()` reaches into the backend, and nesting
    1854        // that inside the queue lock would invent a lock order nothing else here
    1855        // takes.
    1856        let item_duration = {
    1857            let queue = self.queue.lock_safe();
    1858            let current = queue.current()?;
    1859            if !Self::is_audio_only_video(current) {
    1860                return None;
    1861            }
    1862            current.duration
    1863        };
    1864        // Already absolute — see claim_stream_resume. (DR-159)
    1865        let absolute = self.position().max(0.0);
    1866
    1867        // Only spend a resume attempt once the runtime says this really was cut
    1868        // short — a genuine end must stay a genuine end.
    1869        if !stream_end::is_truncated_end(
    1870            absolute,
    1871            item_duration,
    1872            stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
    1873        ) {
    1874            return None;
    1875        }
    1876
    1877        self.claim_stream_resume().map(|(position, _)| position)
    1878    }
    1879
    1880    /// Where to re-open the current stream after a *recoverable* playback error,
    1881    /// plus how many seconds to wait first.
    1882    ///
    1883    /// The media was decoding fine a moment ago, so a mid-playback failure on a
    1884    /// server stream is the network — and stopping the player (the previous
    1885    /// behaviour, via the frontend's error handler) turns a hiccup into "playback
    1886    /// just died". Applies to every streamed item, not only the audio-only
    1887    /// handoff: music and video reach here instead of the truncation path because
    1888    /// their streams declare a length, so a cut connection surfaces as an error
    1889    /// rather than a phantom end.
    1890    ///
    1891    /// The wait grows with the attempt number so a short outage has time to
    1892    /// clear, and the shared budget stops the retries when it doesn't.
    1893    ///
    1894    /// Called from the Android error callback, which decides in-process, and from
    1895    /// `player_recover_stream`, which is how the same decision reaches the
    1896    /// backends whose event thread has no controller to call — MPV is built
    1897    /// before the controller exists, so on Linux the error is emitted, echoed by
    1898    /// the frontend, and decided here.
    1899    ///
    1900    /// TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117
    1901    pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
    1902        self.claim_stream_resume()
    1903            .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
    1904    }
    1905
    1906    /// Re-open the current stream at `position` after the network cut it short.
    1907    ///
    1908    /// Single place every dispatcher agrees on, for the same reason
    1909    /// `auto_advance_to_next_episode` is: the Android JNI callbacks and the
    1910    /// frontend-invoked command must not disagree about what a failed stream
    1911    /// means. None of them emits `PlaybackEnded` for this, so nothing downstream
    1912    /// clears the queue or tears the session down — from the outside this is a
    1913    /// buffering hiccup, which is what it actually was.
    1914    ///
    1915    /// Reloads the item **in place** rather than through `play_item`, which
    1916    /// replaces the queue with a single item: recovering a track that way would
    1917    /// throw away the rest of the album, turning a network blip into lost state.
    1918    ///
    1919    /// Two shapes of stream, two ways back to `position`:
    1920    ///
    1921    /// - The audio-only handoff's `/Audio/{id}/universal` transcode is chunked
    1922    ///   with no length, so it cannot be seeked. Its URL is rewritten to start at
    1923    ///   the position instead — edited, not rebuilt from the repository, since it
    1924    ///   already carries the user's audio track and media source and recovering
    1925    ///   from a network failure must not itself need a network round-trip.
    1926    /// - Everything else (a static file with byte ranges, an HLS playlist)
    1927    ///   declares its whole timeline, so re-preparing the URL it already has and
    1928    ///   seeking lands in the right place — and leaves any transcode session
    1929    ///   behind it alone.
    1930    ///
    1931    /// TRACES: UR-040, UR-004 | DR-129 | UT-117
    1932    pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
    1933        let current = {
    1934            let queue = self.queue.lock_safe();
    1935            queue.current().cloned()
    1936        }
    1937        .ok_or_else(|| "No current item to resume".to_string())?;
    1938
    1939        let MediaSource::Remote { stream_url, .. } = &current.source else {
    1940            return Err(format!(
    1941                "Cannot resume a non-remote source for {}",
    1942                current.id
    1943            ));
    1944        };
    1945
    1946        info!(
    1947            "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
    1948            current.id, position
    1949        );
    1950
    1951        if !Self::is_audio_only_video(&current) {
    1952            self.load_and_play(&current).map_err(|e| e.to_string())?;
    1953            if position > 0.5 {
    1954                self.seek(position).map_err(|e| e.to_string())?;
    1955            }
    1956            return Ok(());
    1957        }
    1958
    1959        let restarted_url = stream_end::with_start_time(stream_url, position);
    1960        {
    1961            let queue_arc = self.queue.clone();
    1962            let mut queue = queue_arc.lock_safe();
    1963            if !queue.update_current_stream_url(restarted_url) {
    1964                return Err(format!("Failed to update stream URL for {}", current.id));
    1965            }
    1966        }
    1967        let resumed = {
    1968            let queue = self.queue.lock_safe();
    1969            queue.current().cloned()
    1970        }
    1971        .ok_or_else(|| "Current item vanished mid-resume".to_string())?;
    1972
    1973        // The re-opened stream's timeline starts at `position` (StartTimeTicks),
    1974        // so that is its zero: the exit-to-foreground maths and the lockscreen
    1975        // scrubber both read absolute positions off this base.
    1976        self.set_background_audio_base(position);
    1977        let _ = set_lockscreen_position_offset(position.max(0.0));
    1978
    1979        self.load_and_play(&resumed).map_err(|e| e.to_string())
    1980    }
    1981
    1982    /// Advance to the next episode while playing audio-only in the background.
    1983    ///
    1984    /// The normal autoplay-next path navigates the frontend to `/player/<id>`,
    1985    /// which is unavailable when the app is backgrounded and the WebView is
    1986    /// suspended. This drives the advance entirely in the backend: build the next
    1987    /// episode's *audio-only* stream URL and load it into the native audio player,
    1988    /// so playback continues without any frontend involvement (UR-040).
    1989    ///
    1990    /// `next_episode_id` is the Jellyfin item ID of the episode to play next.
    1991    ///
    1992    /// Reached through `auto_advance_to_next_episode`, which gates it on
    1993    /// `current_is_audio_episode()` — only ever true after a background-audio
    1994    /// handoff (Android), but compiled and unit-tested on every platform.
    1995    /// TRACES: UR-040, UR-023 | DR-052
    1996    pub async fn advance_to_next_episode_audio_only(
    1997        &self,
    1998        next_episode_id: &str,
    1999    ) -> Result<(), String> {
    2000        // A new episode is a new playback, so a ceiling chosen for the previous
    2001        // one does not carry into it. Every advance the frontend drives goes
    2002        // through `player_play_item` and is cleared there; this one loads the
    2003        // next episode in Rust and would otherwise keep the old cap forever,
    2004        // with nothing in the UI saying why. Cleared before the URL is built,
    2005        // since that is what reads it.
    2006        // TRACES: UR-074 | DR-254
    2007        crate::repository::online::clear_playback_quality_override();
    2008
    2009        let repo = self
    2010            .repository
    2011            .lock_safe()
    2012            .clone()
    2013            .ok_or_else(|| "No repository for background episode advance".to_string())?;
    2014
    2015        // Details for session metadata (title/series/artwork) and the stream URL.
    2016        let next = repo
    2017            .get_item(next_episode_id)
    2018            .await
    2019            .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
    2020
    2021        // Audio-only transcode from the start of the episode (no resume offset —
    2022        // a freshly-started next episode always plays from the beginning).
    2023        let stream_url = repo
    2024            .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
    2025            .await
    2026            .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
    2027
    2028        let media_item = MediaItem {
    2029            // Audio and direct-URL items never negotiate a transport.
    2030            transport: None,
    2031            id: next.id.clone(),
    2032            title: next.name.clone(),
    2033            name: Some(next.name.clone()),
    2034            artist: next.series_name.clone(),
    2035            album: None,
    2036            album_name: None,
    2037            album_id: None,
    2038            artist_items: None,
    2039            artists: None,
    2040            primary_image_tag: next.primary_image_tag.clone(),
    2041            image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
    2042            // Preserve episode identity so the NEXT end-of-track also advances.
    2043            item_type: Some("Episode".to_string()),
    2044            playlist_id: None,
    2045            duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
    2046            artwork_url: None,
    2047            media_type: MediaType::Audio,
    2048            source: MediaSource::Remote {
    2049                stream_url,
    2050                jellyfin_item_id: next.id.clone(),
    2051            },
    2052            video_codec: None,
    2053            needs_transcoding: false,
    2054            video_width: None,
    2055            video_height: None,
    2056            subtitles: vec![],
    2057            series_id: next.series_id.clone(),
    2058            server_id: Some(next.server_id.clone()),
    2059        };
    2060
    2061        // The previous episode's handoff base described the stream we are leaving.
    2062        // This one is built without StartTimeTicks, so its timeline is already
    2063        // absolute: clear the base (used to resolve the resume position on the way
    2064        // back to the foreground) and the lockscreen scrubber's matching shift.
    2065        self.set_background_audio_base(0.0);
    2066        let _ = set_lockscreen_position_offset(0.0);
    2067        // Different stream entirely: whatever was stuck about the last one is not
    2068        // this one's problem.
    2069        self.stream_resume.lock_safe().reset();
    2070
    2071        self.play_item(media_item).map_err(|e| e.to_string())
    2072    }
    2073
    2074    /// Handle video playback ended from HTML5 video element.
    2075    ///
    2076    /// HTML5 video plays independently of the Rust backend, so the backend
    2077    /// queue has no knowledge of the video item. This method bypasses the
    2078    /// queue lookup and end_reason check, using the provided Jellyfin item ID
    2079    /// to look up the item and check for next episodes.
    2080    pub async fn on_video_playback_ended(
    2081        &self,
    2082        item_id: &str,
    2083        repo: Arc<dyn crate::repository::MediaRepository>,
    2084    ) -> Result<AutoplayDecision, String> {
    2085        // Clear any stale end_reason (e.g., UserStop from stopping audio before video)
    2086        let stale_reason = self.take_end_reason();
    2087        if stale_reason.is_some() {
    2088            debug!(
    2089                "[PlayerController] Cleared stale end_reason for video: {:?}",
    2090                stale_reason
    2091            );
    2092        }
    2093
    2094        log::info!(
    2095            "[PlayerController] on_video_playback_ended: item_id={}",
    2096            item_id
    2097        );
    2098
    2099        // Check sleep timer state
    2100        let timer_mode = {
    2101            let timer = self.sleep_timer.lock_safe();
    2102            timer.mode.clone()
    2103        };
    2104
    2105        match &timer_mode {
    2106            SleepTimerMode::Time { end_time } => {
    2107                let now = chrono::Utc::now().timestamp_millis();
    2108                if now >= *end_time {
    2109                    debug!("[PlayerController] Time-based sleep timer expired at video end");
    2110                    self.sleep_timer.lock_safe().cancel();
    2111                    self.emit_sleep_timer_changed();
    2112                    return Ok(AutoplayDecision::Stop);
    2113                }
    2114            }
    2115            SleepTimerMode::EndOfTrack => {
    2116                self.sleep_timer.lock_safe().cancel();
    2117                self.emit_sleep_timer_changed();
    2118                return Ok(AutoplayDecision::Stop);
    2119            }
    2120            SleepTimerMode::Episodes { .. } => {
    2121                let should_stop = self.sleep_timer.lock_safe().decrement_episode();
    2122                self.emit_sleep_timer_changed();
    2123                if should_stop {
    2124                    return Ok(AutoplayDecision::Stop);
    2125                }
    2126            }
    2127            _ => {}
    2128        }
    2129
    2130        // Fetch next episode for the video that just ended. Degrade lookup
    2131        // failures to Stop: playback already ended, and propagating an error
    2132        // here just kills autoplay silently upstream.
    2133        let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
    2134            Ok(next) => next,
    2135            Err(e) => {
    2136                warn!(
    2137                    "[PlayerController] Next-episode lookup failed for {}: {}",
    2138                    item_id, e
    2139                );
    2140                None
    2141            }
    2142        };
    2143        if let Some(next_ep) = next_ep_result {
    2144            let settings = self.autoplay_settings.lock_safe().clone();
    2145
    2146            let limit_reached = self.increment_autoplay_count();
    2147            if limit_reached {
    2148                debug!(
    2149                    "[PlayerController] Auto-play episode limit reached ({} episodes)",
    2150                    settings.max_episodes
    2151                );
    2152            }
    2153
    2154            return Ok(AutoplayDecision::ShowNextEpisodePopup {
    2155                current_episode: next_ep.0,
    2156                next_episode: next_ep.1,
    2157                countdown_seconds: settings.countdown_seconds,
    2158                auto_advance: settings.enabled && !limit_reached,
    2159            });
    2160        }
    2161
    2162        // No next episode found
    2163        debug!("[PlayerController] No next episode found for {}", item_id);
    2164        Ok(AutoplayDecision::Stop)
    2165    }
    2166
    2167    /// Check if a media item is an episode (has Jellyfin ID to query).
    2168    ///
    2169    /// An explicit `item_type == "Episode"` wins so that a TV episode handed off
    2170    /// to the audio path for background playback (UR-040) is still recognised as
    2171    /// an episode — otherwise autoplay would fall through to the queue-based
    2172    /// audio path, find nothing next, and stop at the episode boundary. When the
    2173    /// type is unknown we fall back to the historical heuristic (video == episode).
    2174    async fn is_episode_item(&self, item: &MediaItem) -> bool {
    2175        match item.item_type.as_deref() {
    2176            Some("Episode") => true,
    2177            Some(_) => item.media_type == MediaType::Video,
    2178            None => item.media_type == MediaType::Video,
    2179        }
    2180    }
    2181
    2182    /// Fetch next episode for a series by looking up the season's episodes
    2183    /// sorted by index number and picking the one after the current episode.
    2184    ///
    2185    /// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
    2186    /// (which relies on watch history that may not be updated yet due to
    2187    /// the async nature of playback progress reporting).
    2188    async fn fetch_next_episode_for_item(
    2189        &self,
    2190        item_id: &str,
    2191        repo: &Arc<dyn crate::repository::MediaRepository>,
    2192    ) -> Result<
    2193        Option<(
    2194            crate::repository::types::MediaItem,
    2195            crate::repository::types::MediaItem,
    2196        )>,
    2197        String,
    2198    > {
    2199        use crate::repository::types::GetItemsOptions;
    2200
    2201        // Get the current item details from repository
    2202        let current_repo_item = repo
    2203            .get_item(item_id)
    2204            .await
    2205            .map_err(|e| format!("Failed to get current item: {}", e))?;
    2206
    2207        // Need season_id to fetch sibling episodes
    2208        let season_id = match &current_repo_item.season_id {
    2209            Some(sid) => sid.clone(),
    2210            None => {
    2211                log::info!(
    2212                    "[PlayerController] Current item has no season_id, cannot find next episode"
    2213                );
    2214                return Ok(None);
    2215            }
    2216        };
    2217
    2218        // Fetch all episodes in the season sorted by episode number
    2219        let options = GetItemsOptions {
    2220            sort_by: Some("IndexNumber".to_string()),
    2221            sort_order: Some("Ascending".to_string()),
    2222            limit: Some(500),
    2223            include_item_types: Some(vec!["Episode".to_string()]),
    2224            ..Default::default()
    2225        };
    2226
    2227        let result = repo
    2228            .get_items(&season_id, Some(options))
    2229            .await
    2230            .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
    2231
    2232        // Sort client-side by index_number to ensure correct ordering
    2233        // (offline repo ignores sort_by and sorts by sort_name instead)
    2234        let mut episodes = result.items;
    2235        episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
    2236        log::info!(
    2237            "[PlayerController] Season has {} episodes, looking for next after {}",
    2238            episodes.len(),
    2239            current_repo_item.id
    2240        );
    2241
    2242        // Find the current episode by ID and return the next one
    2243        if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
    2244            if current_idx + 1 < episodes.len() {
    2245                let next = &episodes[current_idx + 1];
    2246                log::info!(
    2247                    "[PlayerController] Found next episode: {} (index {})",
    2248                    next.name,
    2249                    current_idx + 1
    2250                );
    2251                return Ok(Some((current_repo_item, next.clone())));
    2252            } else {
    2253                log::info!("[PlayerController] Current episode is the last in the season");
    2254                if let Some(next) = self
    2255                    .first_episode_of_next_season(&current_repo_item, repo)
    2256                    .await
    2257                {
    2258                    return Ok(Some((current_repo_item, next)));
    2259                }
    2260            }
    2261        } else {
    2262            log::info!(
    2263                "[PlayerController] Current episode not found in season episodes (ids: {:?})",
    2264                episodes
    2265                    .iter()
    2266                    .map(|e| e.id.as_str())
    2267                    .take(20)
    2268                    .collect::<Vec<_>>()
    2269            );
    2270        }
    2271
    2272        Ok(None)
    2273    }
    2274
    2275    /// The first episode of the season after this one, if the series has one.
    2276    ///
    2277    /// A season boundary is not the end of a series, and stopping there is felt
    2278    /// most sharply on the background-audio path (UR-040): the screen is locked,
    2279    /// nothing shows a "next" button, and playback simply stops mid-binge. Every
    2280    /// other autoplay entry point shares this lookup, so foreground video and
    2281    /// the Android native path cross the boundary too.
    2282    ///
    2283    /// Lookup failures degrade to `None` rather than an error: the episode has
    2284    /// already finished, and the caller's only alternative is to stop anyway.
    2285    ///
    2286    /// TRACES: UR-023, UR-040 | DR-263 | UT-238
    2287    async fn first_episode_of_next_season(
    2288        &self,
    2289        current: &crate::repository::types::MediaItem,
    2290        repo: &Arc<dyn crate::repository::MediaRepository>,
    2291    ) -> Option<crate::repository::types::MediaItem> {
    2292        use crate::repository::types::GetItemsOptions;
    2293
    2294        let series_id = current.series_id.as_deref()?;
    2295        let season_id = current.season_id.as_deref()?;
    2296
    2297        let season_options = GetItemsOptions {
    2298            sort_by: Some("IndexNumber".to_string()),
    2299            sort_order: Some("Ascending".to_string()),
    2300            limit: Some(500),
    2301            include_item_types: Some(vec!["Season".to_string()]),
    2302            ..Default::default()
    2303        };
    2304        let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
    2305            Ok(result) => result.items,
    2306            Err(e) => {
    2307                log::warn!(
    2308                    "[PlayerController] Season lookup failed for series {}: {}",
    2309                    series_id,
    2310                    e
    2311                );
    2312                return None;
    2313            }
    2314        };
    2315        // Same client-side sort as the episode list: the offline repository
    2316        // ignores sort_by and orders by sort_name instead.
    2317        seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
    2318
    2319        let current_idx = seasons.iter().position(|s| s.id == season_id)?;
    2320
    2321        for season in &seasons[current_idx + 1..] {
    2322            // Never roll into Specials. Jellyfin numbers them 0, so they sort
    2323            // ahead of season 1 and are normally unreachable from here -- but a
    2324            // server that leaves the index unset sorts them last, right where
    2325            // this walk would otherwise land.
    2326            if season.index_number == Some(0) {
    2327                continue;
    2328            }
    2329
    2330            let episode_options = GetItemsOptions {
    2331                sort_by: Some("IndexNumber".to_string()),
    2332                sort_order: Some("Ascending".to_string()),
    2333                limit: Some(500),
    2334                include_item_types: Some(vec!["Episode".to_string()]),
    2335                ..Default::default()
    2336            };
    2337            let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
    2338                Ok(result) => result.items,
    2339                Err(e) => {
    2340                    log::warn!(
    2341                        "[PlayerController] Episode lookup failed for season {}: {}",
    2342                        season.id,
    2343                        e
    2344                    );
    2345                    return None;
    2346                }
    2347            };
    2348            episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
    2349
    2350            // An empty season is a gap in the series, not the end of it.
    2351            if let Some(first) = episodes.into_iter().next() {
    2352                log::info!(
    2353                    "[PlayerController] Rolling over to {} of {}: {}",
    2354                    first.name,
    2355                    season.name,
    2356                    first.id
    2357                );
    2358                return Some(first);
    2359            }
    2360        }
    2361
    2362        log::info!("[PlayerController] No further season to roll over into");
    2363        None
    2364    }
    2365
    2366    /// Start autoplay countdown thread
    2367    pub fn start_autoplay_countdown(
    2368        &self,
    2369        _next_item: crate::repository::types::MediaItem,
    2370        countdown_seconds: u32,
    2371    ) {
    2372        // Create cancellation flag
    2373        let cancel_flag = Arc::new(Mutex::new(false));
    2374        *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
    2375
    2376        let event_emitter = self.event_emitter.clone();
    2377
    2378        std::thread::spawn(move || {
    2379            let mut remaining = countdown_seconds;
    2380
    2381            while remaining > 0 {
    2382                std::thread::sleep(Duration::from_secs(1));
    2383
    2384                // Check cancellation
    2385                if *cancel_flag.lock_safe() {
    2386                    log::info!("[PlayerController] Autoplay countdown cancelled");
    2387                    return;
    2388                }
    2389
    2390                remaining -= 1;
    2391
    2392                // Emit countdown tick event
    2393                if let Some(emitter) = event_emitter.lock_safe().as_ref() {
    2394                    emitter.emit(PlayerStatusEvent::CountdownTick {
    2395                        remaining_seconds: remaining,
    2396                    });
    2397                }
    2398            }
    2399
    2400            // Countdown finished (final tick at 0 was already emitted inside the loop)
    2401            log::info!("[PlayerController] Autoplay countdown finished");
    2402        });
    2403    }
    2404}
    2405
    2406impl Default for PlayerController {
    2407    fn default() -> Self {
    2408        let playback_reporter = Arc::new(TokioMutex::new(None));
    2409        let position_throttler = Arc::new(EventThrottler::new());
    2410        Self::new(
    2411            Box::new(LegacyPlayer::new(
    2412                NullBackend::new(),
    2413                crate::player::media_player::Capabilities::mpv(),
    2414            )),
    2415            playback_reporter,
    2416            position_throttler,
    2417        )
    2418    }
    2419}
    2420
    2421#[cfg(test)]
    2422mod tests {
    2423
    2424    /// Advancing to the next episode drops a per-playback quality override.
    2425    ///
    2426    /// The override is process-wide and describes *one* playback: a viewer who
    2427    /// drops to 720p for a struggling episode has said nothing about the next
    2428    /// one. `player_play_item`, `player_play_queue` and `player_play_tracks`
    2429    /// all clear it, so every advance the frontend drives is covered — but the
    2430    /// background audio-only advance loads the next episode in Rust and skips
    2431    /// all three, so every later episode stayed capped at the old quality with
    2432    /// nothing in the UI saying so.
    2433    ///
    2434    /// A wiring assertion, like UT-218 and UT-225: the call site is what
    2435    /// matters, and reaching it at runtime needs a repository, a server and a
    2436    /// live player.
    2437    ///
    2438    /// TRACES: UR-074 | DR-254 | UT-226
    2439    #[test]
    2440    fn test_background_episode_advance_clears_the_quality_override() {
    2441        let src = include_str!("mod.rs");
    2442        let start = src
    2443            .find("fn advance_to_next_episode_audio_only")
    2444            .expect("advance_to_next_episode_audio_only not found");
    2445        let rest = &src[start..];
    2446        let end = rest.find("\n    pub ").unwrap_or(rest.len());
    2447        let body = &rest[..end];
    2448
    2449        assert!(
    2450            body.contains("clear_playback_quality_override"),
    2451            "the background episode advance does not clear the per-playback \
    2452             quality override, so a ceiling chosen for one episode silently \
    2453             caps every episode after it"
    2454        );
    2455    }
    2456
    2457    /// Stopping clears a background-audio handoff.
    2458    ///
    2459    /// This was verified by listening to a tablet, which is not a test. The
    2460    /// handoff swaps which renderer owns playback, and the swap is bookkeeping:
    2461    /// leaving the base offset and the active flag behind after a stop lets a
    2462    /// later position read be interpreted against a handoff that no longer
    2463    /// exists, and left the film playing on as an audio track in the mini
    2464    /// player.
    2465    ///
    2466    /// TRACES: UR-040, UR-005 | DR-250 | UT-224
    2467    #[test]
    2468    fn test_stop_clears_an_active_background_audio_handoff() {
    2469        let controller = PlayerController::default();
    2470        let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
    2471        {
    2472            let queue_arc = controller.queue();
    2473            let mut queue = queue_arc.lock_safe();
    2474            queue.set_queue(vec![item], 0);
    2475        }
    2476
    2477        controller.enter_background_audio(557.5);
    2478        assert!(
    2479            controller.is_background_audio_active(),
    2480            "precondition: the handoff is active"
    2481        );
    2482
    2483        controller.stop().expect("stop failed");
    2484
    2485        assert!(
    2486            !controller.is_background_audio_active(),
    2487            "a stop must not leave a handoff behind for the next position read"
    2488        );
    2489        assert_eq!(
    2490            *controller.background_audio_base.lock_safe(),
    2491            0.0,
    2492            "the handoff base must be cleared with it"
    2493        );
    2494    }
    2495
    2496    /// A duration the engine does not know must fall back to the one the item
    2497    /// carries, and zero must count as "does not know".
    2498    ///
    2499    /// ExoPlayer reports `C.TIME_UNSET` for a duration it has not resolved;
    2500    /// `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answers
    2501    /// `Some(0.0)` rather than `None` and every "unknown duration" fallback is
    2502    /// skipped. The seek bar then has no scale, which presents as scrubbing
    2503    /// being dead rather than as a missing duration.
    2504    ///
    2505    /// TRACES: UR-005, UR-040 | DR-251 | UT-221
    2506    #[test]
    2507    fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
    2508        let controller = PlayerController::default();
    2509        let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
    2510        item.duration = Some(1800.0);
    2511
    2512        {
    2513            let queue_arc = controller.queue();
    2514            let mut queue = queue_arc.lock_safe();
    2515            queue.set_queue(vec![item], 0);
    2516        }
    2517
    2518        assert_eq!(
    2519            controller.duration(),
    2520            Some(1800.0),
    2521            "an engine that cannot report a duration should not erase the one the item carries"
    2522        );
    2523    }
    2524    use super::*;
    2525
    2526    /// Test emitter that captures events for asserting the HTML5 report methods
    2527    /// re-emit through the normal PlayerStatusEvent pipeline.
    2528    struct CapturingEmitter {
    2529        events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
    2530    }
    2531
    2532    impl CapturingEmitter {
    2533        fn new() -> Self {
    2534            Self {
    2535                events: std::sync::Mutex::new(Vec::new()),
    2536            }
    2537        }
    2538        fn events(&self) -> Vec<PlayerStatusEvent> {
    2539            self.events.lock_safe().clone()
    2540        }
    2541    }
    2542
    2543    impl PlayerEventEmitter for CapturingEmitter {
    2544        fn emit(&self, event: PlayerStatusEvent) {
    2545            self.events.lock_safe().push(event);
    2546        }
    2547    }
    2548
    2549    /// Captures what the controller reports to Jellyfin, so tests can assert on
    2550    /// the operations themselves rather than on a database and an HTTP client.
    2551    struct CapturingReports {
    2552        operations: std::sync::Mutex<Vec<PlaybackOperation>>,
    2553    }
    2554
    2555    impl CapturingReports {
    2556        fn new() -> Self {
    2557            Self {
    2558                operations: std::sync::Mutex::new(Vec::new()),
    2559            }
    2560        }
    2561
    2562        /// Every `Stopped` report as `(item_id, position_seconds)`.
    2563        fn stops(&self) -> Vec<(String, f64)> {
    2564            self.operations
    2565                .lock_safe()
    2566                .iter()
    2567                .filter_map(|op| match op {
    2568                    PlaybackOperation::Stopped {
    2569                        item_id,
    2570                        position_ticks,
    2571                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
    2572                    _ => None,
    2573                })
    2574                .collect()
    2575        }
    2576
    2577        /// Every `Progress` report as `(item_id, position_seconds)`.
    2578        fn progress(&self) -> Vec<(String, f64)> {
    2579            self.operations
    2580                .lock_safe()
    2581                .iter()
    2582                .filter_map(|op| match op {
    2583                    PlaybackOperation::Progress {
    2584                        item_id,
    2585                        position_ticks,
    2586                        ..
    2587                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
    2588                    _ => None,
    2589                })
    2590                .collect()
    2591        }
    2592    }
    2593
    2594    impl PlaybackReportSink for CapturingReports {
    2595        fn send(&self, operation: PlaybackOperation) {
    2596            self.operations.lock_safe().push(operation);
    2597        }
    2598    }
    2599
    2600    #[test]
    2601    fn test_report_html5_state_emits_state_changed() {
    2602        let controller = PlayerController::default();
    2603        let emitter = Arc::new(CapturingEmitter::new());
    2604        controller.set_event_emitter(emitter.clone());
    2605
    2606        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
    2607
    2608        let events = emitter.events();
    2609        assert_eq!(events.len(), 1);
    2610        match &events[0] {
    2611            PlayerStatusEvent::StateChanged { state, media_id } => {
    2612                assert_eq!(state, "playing");
    2613                assert_eq!(media_id.as_deref(), Some("item-1"));
    2614            }
    2615            other => panic!("expected StateChanged, got {:?}", other),
    2616        }
    2617    }
    2618
    2619    #[test]
    2620    fn test_report_html5_position_emits_position_update() {
    2621        let controller = PlayerController::default();
    2622        let emitter = Arc::new(CapturingEmitter::new());
    2623        controller.set_event_emitter(emitter.clone());
    2624
    2625        controller.report_html5_position(12.5, 300.0);
    2626
    2627        let events = emitter.events();
    2628        assert_eq!(events.len(), 1);
    2629        match &events[0] {
    2630            PlayerStatusEvent::PositionUpdate { position, duration } => {
    2631                assert_eq!(*position, 12.5);
    2632                assert_eq!(*duration, 300.0);
    2633            }
    2634            other => panic!("expected PositionUpdate, got {:?}", other),
    2635        }
    2636    }
    2637
    2638    #[test]
    2639    fn test_report_html5_media_loaded_emits_media_loaded() {
    2640        let controller = PlayerController::default();
    2641        let emitter = Arc::new(CapturingEmitter::new());
    2642        controller.set_event_emitter(emitter.clone());
    2643
    2644        controller.report_html5_media_loaded(420.0);
    2645
    2646        let events = emitter.events();
    2647        assert_eq!(events.len(), 1);
    2648        match &events[0] {
    2649            PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
    2650            other => panic!("expected MediaLoaded, got {:?}", other),
    2651        }
    2652    }
    2653
    2654    // ===== HTML5 transport authority (DR-097) =====
    2655    //
    2656    // Webview-rendered video is played by an element the native backend cannot
    2657    // reach, so transport for it must be decided from the state the element
    2658    // REPORTS and executed by emitting a ControlCommand. Previously the frontend
    2659    // decided play-vs-pause itself by reading `el.paused` off the DOM, which
    2660    // flips transiently while buffering/seeking — two intents ~150ms apart read
    2661    // different values, took opposing actions, and self-sustained a pause loop.
    2662
    2663    #[test]
    2664    fn test_html5_state_is_tracked_from_reports() {
    2665        let controller = PlayerController::default();
    2666        let emitter = Arc::new(CapturingEmitter::new());
    2667        controller.set_event_emitter(emitter.clone());
    2668
    2669        // No HTML5 media reported yet: the native backend stays authoritative.
    2670        assert!(!controller.is_html5_active());
    2671
    2672        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
    2673        assert!(controller.is_html5_active());
    2674        assert!(controller.html5_is_playing());
    2675
    2676        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
    2677        assert!(controller.is_html5_active());
    2678        assert!(!controller.html5_is_playing());
    2679    }
    2680
    2681    #[test]
    2682    fn test_html5_toggle_from_paused_emits_play_control() {
    2683        let controller = PlayerController::default();
    2684        let emitter = Arc::new(CapturingEmitter::new());
    2685        controller.set_event_emitter(emitter.clone());
    2686        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
    2687
    2688        controller.toggle_playback().unwrap();
    2689
    2690        let controls: Vec<_> = emitter
    2691            .events()
    2692            .into_iter()
    2693            .filter_map(|e| match e {
    2694                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
    2695                _ => None,
    2696            })
    2697            .collect();
    2698        assert_eq!(controls, vec!["play".to_string()]);
    2699    }
    2700
    2701    #[test]
    2702    fn test_html5_toggle_from_playing_emits_pause_control() {
    2703        let controller = PlayerController::default();
    2704        let emitter = Arc::new(CapturingEmitter::new());
    2705        controller.set_event_emitter(emitter.clone());
    2706        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
    2707
    2708        controller.toggle_playback().unwrap();
    2709
    2710        let controls: Vec<_> = emitter
    2711            .events()
    2712            .into_iter()
    2713            .filter_map(|e| match e {
    2714                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
    2715                _ => None,
    2716            })
    2717            .collect();
    2718        assert_eq!(controls, vec!["pause".to_string()]);
    2719    }
    2720
    2721    #[test]
    2722    fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
    2723        // The loop signature: two intents in quick succession must NOT both
    2724        // resolve the same way, and must not produce opposing actions from a
    2725        // stale read. Rust's own tracked state makes the sequence deterministic
    2726        // as long as the element reports back between intents.
    2727        let controller = PlayerController::default();
    2728        let emitter = Arc::new(CapturingEmitter::new());
    2729        controller.set_event_emitter(emitter.clone());
    2730        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
    2731
    2732        controller.toggle_playback().unwrap();
    2733        // Element confirms the pause it was told to do.
    2734        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
    2735        controller.toggle_playback().unwrap();
    2736
    2737        let controls: Vec<_> = emitter
    2738            .events()
    2739            .into_iter()
    2740            .filter_map(|e| match e {
    2741                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
    2742                _ => None,
    2743            })
    2744            .collect();
    2745        assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
    2746    }
    2747
    2748    #[test]
    2749    fn test_html5_play_and_pause_emit_control_commands() {
    2750        let controller = PlayerController::default();
    2751        let emitter = Arc::new(CapturingEmitter::new());
    2752        controller.set_event_emitter(emitter.clone());
    2753        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
    2754
    2755        controller.play().unwrap();
    2756        controller.pause().unwrap();
    2757
    2758        let controls: Vec<_> = emitter
    2759            .events()
    2760            .into_iter()
    2761            .filter_map(|e| match e {
    2762                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
    2763                _ => None,
    2764            })
    2765            .collect();
    2766        assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
    2767    }
    2768
    2769    #[test]
    2770    fn test_background_audio_handoff_moves_transport_to_native_backend() {
    2771        // Lockscreen pause while playing a video's audio in the background.
    2772        //
    2773        // The handoff tears the WebView <video> down AFTER native audio starts,
    2774        // and that teardown fires a DOM `pause` the frontend dutifully reports.
    2775        // That report used to leave `html5_playing = Some(false)`, so transport
    2776        // kept being aimed at an element that no longer exists: the lockscreen
    2777        // pause emitted a ControlCommand into the void and the audio played on.
    2778        let controller = PlayerController::default();
    2779        let emitter = Arc::new(CapturingEmitter::new());
    2780        controller.set_event_emitter(emitter.clone());
    2781
    2782        // Video was playing in the webview.
    2783        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
    2784        assert!(controller.is_html5_active());
    2785
    2786        // Hand off to the native audio player, then tear the element down.
    2787        controller.enter_background_audio(1200.0);
    2788        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
    2789
    2790        assert!(
    2791            !controller.is_html5_active(),
    2792            "native audio owns transport during a background-audio handoff"
    2793        );
    2794
    2795        controller.pause().unwrap();
    2796        let controls: Vec<_> = emitter
    2797            .events()
    2798            .into_iter()
    2799            .filter_map(|e| match e {
    2800                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
    2801                _ => None,
    2802            })
    2803            .collect();
    2804        assert!(
    2805            controls.is_empty(),
    2806            "pause must drive the native backend, not a torn-down element: {:?}",
    2807            controls
    2808        );
    2809    }
    2810
    2811    #[test]
    2812    fn test_background_audio_handoff_suppresses_stale_element_events() {
    2813        // The dying element's pause/position reports describe the video, not the
    2814        // audio now playing — re-emitting them flips the UI to paused and yanks
    2815        // the position backwards while native audio keeps going.
    2816        let controller = PlayerController::default();
    2817        let emitter = Arc::new(CapturingEmitter::new());
    2818        controller.set_event_emitter(emitter.clone());
    2819
    2820        controller.enter_background_audio(1200.0);
    2821        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
    2822        controller.report_html5_position(1200.0, 2400.0);
    2823
    2824        assert!(
    2825            emitter.events().is_empty(),
    2826            "stale webview reports must not reach the event pipeline: {:?}",
    2827            emitter.events()
    2828        );
    2829    }
    2830
    2831    #[test]
    2832    fn test_exit_background_audio_returns_transport_to_the_webview() {
    2833        // Back in the foreground the <video> is the player again, so its reports
    2834        // must be honoured — and the base offset still comes back for the resume.
    2835        let controller = PlayerController::default();
    2836        let emitter = Arc::new(CapturingEmitter::new());
    2837        controller.set_event_emitter(emitter.clone());
    2838
    2839        controller.enter_background_audio(1200.0);
    2840        assert_eq!(controller.exit_background_audio(), 1200.0);
    2841
    2842        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
    2843        assert!(controller.is_html5_active());
    2844        assert!(controller.html5_is_playing());
    2845    }
    2846
    2847    #[test]
    2848    fn test_html5_stopped_report_releases_transport_to_native_backend() {
    2849        // When webview video goes away, transport must fall back to the native
    2850        // backend (music playback must not keep emitting ControlCommands).
    2851        let controller = PlayerController::default();
    2852        let emitter = Arc::new(CapturingEmitter::new());
    2853        controller.set_event_emitter(emitter.clone());
    2854
    2855        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
    2856        assert!(controller.is_html5_active());
    2857
    2858        controller.report_html5_state("stopped".to_string(), None);
    2859        assert!(!controller.is_html5_active());
    2860    }
    2861
    2862    #[test]
    2863    fn test_html5_transport_emits_exactly_one_control_per_intent() {
    2864        // Guards against a double-drive on platforms where the *backend* is also
    2865        // webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
    2866        // must replace the backend call, not run in addition to it.
    2867        let controller = PlayerController::default();
    2868        let emitter = Arc::new(CapturingEmitter::new());
    2869        controller.set_event_emitter(emitter.clone());
    2870        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
    2871
    2872        controller.pause().unwrap();
    2873
    2874        let controls = emitter
    2875            .events()
    2876            .into_iter()
    2877            .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
    2878            .count();
    2879        assert_eq!(controls, 1, "one intent must produce exactly one control");
    2880    }
    2881
    2882    #[test]
    2883    fn test_controller_volume_default() {
    2884        let controller = PlayerController::default();
    2885        assert_eq!(controller.volume(), 1.0);
    2886    }
    2887
    2888    #[test]
    2889    fn test_controller_set_volume() {
    2890        let controller = PlayerController::default();
    2891        controller.set_volume(0.5).unwrap();
    2892        assert_eq!(controller.volume(), 0.5);
    2893    }
    2894
    2895    #[test]
    2896    fn test_controller_muted_default() {
    2897        let controller = PlayerController::default();
    2898        assert!(!controller.muted());
    2899    }
    2900
    2901    #[test]
    2902    fn test_controller_volume_delegates_to_backend() {
    2903        let controller = PlayerController::default();
    2904
    2905        // Set volume through controller
    2906        controller.set_volume(0.75).unwrap();
    2907
    2908        // Verify it's reflected in both controller.volume() and backend
    2909        assert_eq!(controller.volume(), 0.75);
    2910    }
    2911
    2912    fn create_test_items(count: usize) -> Vec<MediaItem> {
    2913        (0..count)
    2914            .map(|i| MediaItem {
    2915                // Audio and direct-URL items never negotiate a transport.
    2916                transport: None,
    2917                id: format!("item_{}", i),
    2918                title: format!("Track {}", i + 1),
    2919                name: Some(format!("Track {}", i + 1)),
    2920                artist: Some("Test Artist".to_string()),
    2921                album: Some("Test Album".to_string()),
    2922                album_name: Some("Test Album".to_string()),
    2923                album_id: None,
    2924                artist_items: None,
    2925                artists: Some(vec!["Test Artist".to_string()]),
    2926                primary_image_tag: None,
    2927                image_id: None,
    2928                item_type: Some("Audio".to_string()),
    2929                playlist_id: None,
    2930                duration: Some(180.0),
    2931                artwork_url: None,
    2932                media_type: MediaType::Audio,
    2933                source: MediaSource::DirectUrl {
    2934                    url: format!("http://example.com/track_{}.mp3", i),
    2935                },
    2936                video_codec: None,
    2937                needs_transcoding: false,
    2938                video_width: None,
    2939                video_height: None,
    2940                subtitles: vec![],
    2941                series_id: None,
    2942                server_id: None,
    2943            })
    2944            .collect()
    2945    }
    2946
    2947    #[test]
    2948    fn test_skip_preserves_queue() {
    2949        let controller = PlayerController::default();
    2950
    2951        // Create a queue with 5 items
    2952        let items = create_test_items(5);
    2953        let items_clone = items.clone();
    2954
    2955        // Play the queue starting at index 0
    2956        controller.play_queue(items, 0).unwrap();
    2957
    2958        // Verify initial state
    2959        {
    2960            let queue = controller.queue();
    2961            let queue_lock = queue.lock_safe();
    2962            assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
    2963            assert_eq!(
    2964                queue_lock.current_index(),
    2965                Some(0),
    2966                "Should start at index 0"
    2967            );
    2968            assert_eq!(
    2969                queue_lock.current().unwrap().id,
    2970                "item_0",
    2971                "Current item should be item_0"
    2972            );
    2973        }
    2974
    2975        // Skip to next track
    2976        controller.next().unwrap();
    2977
    2978        // Verify queue is intact and index advanced
    2979        {
    2980            let queue = controller.queue();
    2981            let queue_lock = queue.lock_safe();
    2982            assert_eq!(
    2983                queue_lock.items().len(),
    2984                5,
    2985                "Queue should still have 5 items after skip"
    2986            );
    2987            assert_eq!(
    2988                queue_lock.current_index(),
    2989                Some(1),
    2990                "Index should advance to 1"
    2991            );
    2992            assert_eq!(
    2993                queue_lock.current().unwrap().id,
    2994                "item_1",
    2995                "Current item should be item_1"
    2996            );
    2997
    2998            // Verify all original items are still present
    2999            let current_items = queue_lock.items();
    3000            for (i, original) in items_clone.iter().enumerate() {
    3001                assert_eq!(
    3002                    current_items[i].id, original.id,
    3003                    "Item {} should still be in queue",
    3004                    i
    3005                );
    3006                assert_eq!(
    3007                    current_items[i].title, original.title,
    3008                    "Item {} title should be unchanged",
    3009                    i
    3010                );
    3011            }
    3012        }
    3013
    3014        // Skip again
    3015        controller.next().unwrap();
    3016
    3017        // Verify queue still intact and index advanced again
    3018        {
    3019            let queue = controller.queue();
    3020            let queue_lock = queue.lock_safe();
    3021            assert_eq!(
    3022                queue_lock.items().len(),
    3023                5,
    3024                "Queue should still have 5 items after second skip"
    3025            );
    3026            assert_eq!(
    3027                queue_lock.current_index(),
    3028                Some(2),
    3029                "Index should advance to 2"
    3030            );
    3031            assert_eq!(
    3032                queue_lock.current().unwrap().id,
    3033                "item_2",
    3034                "Current item should be item_2"
    3035            );
    3036        }
    3037
    3038        // Skip multiple times to reach the end
    3039        controller.next().unwrap(); // -> item_3
    3040        controller.next().unwrap(); // -> item_4
    3041
    3042        // Verify we're at the last item
    3043        {
    3044            let queue = controller.queue();
    3045            let queue_lock = queue.lock_safe();
    3046            assert_eq!(
    3047                queue_lock.items().len(),
    3048                5,
    3049                "Queue should still have 5 items at end"
    3050            );
    3051            assert_eq!(
    3052                queue_lock.current_index(),
    3053                Some(4),
    3054                "Index should be at last item (4)"
    3055            );
    3056            assert_eq!(
    3057                queue_lock.current().unwrap().id,
    3058                "item_4",
    3059                "Current item should be item_4"
    3060            );
    3061        }
    3062    }
    3063
    3064    #[test]
    3065    fn test_skip_at_end_without_repeat() {
    3066        let controller = PlayerController::default();
    3067
    3068        // Create a queue with 3 items
    3069        let items = create_test_items(3);
    3070        controller.play_queue(items, 0).unwrap();
    3071
    3072        // Skip to last item
    3073        controller.next().unwrap(); // -> item_1
    3074        controller.next().unwrap(); // -> item_2
    3075
    3076        // Verify we're at the last item
    3077        {
    3078            let queue = controller.queue();
    3079            let queue_lock = queue.lock_safe();
    3080            assert_eq!(
    3081                queue_lock.current_index(),
    3082                Some(2),
    3083                "Should be at last item"
    3084            );
    3085        }
    3086
    3087        // Try to skip past the end (without repeat mode)
    3088        // This should succeed but stop playback while preserving the queue
    3089        controller.next().unwrap();
    3090
    3091        // Verify queue is still intact
    3092        {
    3093            let queue = controller.queue();
    3094            let queue_lock = queue.lock_safe();
    3095            assert_eq!(
    3096                queue_lock.items().len(),
    3097                3,
    3098                "Queue should still have 3 items after skip at end"
    3099            );
    3100            // When we skip past the end, the queue index should stay at the last item
    3101            // or become None (depending on implementation)
    3102            // The key is the queue items themselves should be preserved
    3103        }
    3104    }
    3105
    3106    #[test]
    3107    fn test_skip_with_repeat_all() {
    3108        let controller = PlayerController::default();
    3109
    3110        // Create a queue with 3 items
    3111        let items = create_test_items(3);
    3112        controller.play_queue(items, 0).unwrap();
    3113
    3114        // Enable repeat all
    3115        controller.cycle_repeat();
    3116
    3117        // Skip to last item
    3118        controller.next().unwrap(); // -> item_1
    3119        controller.next().unwrap(); // -> item_2
    3120
    3121        // Skip again - should wrap to beginning
    3122        controller.next().unwrap();
    3123
    3124        // Verify we wrapped to the first item
    3125        {
    3126            let queue = controller.queue();
    3127            let queue_lock = queue.lock_safe();
    3128            assert_eq!(
    3129                queue_lock.items().len(),
    3130                3,
    3131                "Queue should still have 3 items"
    3132            );
    3133            assert_eq!(
    3134                queue_lock.current_index(),
    3135                Some(0),
    3136                "Should wrap to index 0"
    3137            );
    3138            assert_eq!(
    3139                queue_lock.current().unwrap().id,
    3140                "item_0",
    3141                "Should be back at item_0"
    3142            );
    3143        }
    3144    }
    3145
    3146    #[test]
    3147    fn test_previous_preserves_queue() {
    3148        let controller = PlayerController::default();
    3149
    3150        // Create a queue with 5 items, start at item 3
    3151        let items = create_test_items(5);
    3152        let items_clone = items.clone();
    3153        controller.play_queue(items, 3).unwrap();
    3154
    3155        // Verify starting position
    3156        {
    3157            let queue = controller.queue();
    3158            let queue_lock = queue.lock_safe();
    3159            assert_eq!(
    3160                queue_lock.current_index(),
    3161                Some(3),
    3162                "Should start at index 3"
    3163            );
    3164        }
    3165
    3166        // Go to previous track
    3167        controller.previous().unwrap();
    3168
    3169        // Verify queue is intact and index moved back
    3170        {
    3171            let queue = controller.queue();
    3172            let queue_lock = queue.lock_safe();
    3173            assert_eq!(
    3174                queue_lock.items().len(),
    3175                5,
    3176                "Queue should still have 5 items after previous"
    3177            );
    3178            assert_eq!(
    3179                queue_lock.current_index(),
    3180                Some(2),
    3181                "Index should move to 2"
    3182            );
    3183            assert_eq!(
    3184                queue_lock.current().unwrap().id,
    3185                "item_2",
    3186                "Current item should be item_2"
    3187            );
    3188
    3189            // Verify all original items are still present
    3190            let current_items = queue_lock.items();
    3191            for (i, original) in items_clone.iter().enumerate() {
    3192                assert_eq!(
    3193                    current_items[i].id, original.id,
    3194                    "Item {} should still be in queue",
    3195                    i
    3196                );
    3197            }
    3198        }
    3199    }
    3200
    3201    #[test]
    3202    fn test_seek_updates_position() {
    3203        let controller = PlayerController::default();
    3204
    3205        // Create and play a single item
    3206        let item = create_test_items(1).into_iter().next().unwrap();
    3207        controller.play_item(item).unwrap();
    3208
    3209        // Verify initial position
    3210        assert_eq!(controller.position(), 0.0, "Initial position should be 0");
    3211
    3212        // Seek to 30 seconds
    3213        controller.seek(30.0).unwrap();
    3214        assert_eq!(
    3215            controller.position(),
    3216            30.0,
    3217            "Position should be 30 after seeking"
    3218        );
    3219
    3220        // Seek to 60 seconds
    3221        controller.seek(60.0).unwrap();
    3222        assert_eq!(
    3223            controller.position(),
    3224            60.0,
    3225            "Position should be 60 after seeking"
    3226        );
    3227
    3228        // Seek backward to 15 seconds
    3229        controller.seek(15.0).unwrap();
    3230        assert_eq!(
    3231            controller.position(),
    3232            15.0,
    3233            "Position should be 15 after seeking backward"
    3234        );
    3235    }
    3236
    3237    #[test]
    3238    fn test_seek_while_paused() {
    3239        let controller = PlayerController::default();
    3240
    3241        // Create and play a single item
    3242        let item = create_test_items(1).into_iter().next().unwrap();
    3243        controller.play_item(item).unwrap();
    3244
    3245        // Pause playback
    3246        controller.pause().unwrap();
    3247
    3248        // Verify paused state
    3249        assert!(controller.state().is_paused(), "Should be paused");
    3250
    3251        // Seek while paused
    3252        controller.seek(45.0).unwrap();
    3253        assert_eq!(
    3254            controller.position(),
    3255            45.0,
    3256            "Position should update while paused"
    3257        );
    3258
    3259        // Verify still paused after seeking
    3260        assert!(
    3261            controller.state().is_paused(),
    3262            "Should still be paused after seeking"
    3263        );
    3264    }
    3265
    3266    #[test]
    3267    fn test_seek_while_playing() {
    3268        let controller = PlayerController::default();
    3269
    3270        // Create and play a single item
    3271        let item = create_test_items(1).into_iter().next().unwrap();
    3272        controller.play_item(item).unwrap();
    3273
    3274        // Ensure playing
    3275        controller.play().unwrap();
    3276
    3277        // Verify playing state
    3278        assert!(controller.state().is_playing(), "Should be playing");
    3279
    3280        // Seek while playing
    3281        controller.seek(20.0).unwrap();
    3282        assert_eq!(
    3283            controller.position(),
    3284            20.0,
    3285            "Position should update while playing"
    3286        );
    3287
    3288        // Verify still playing after seeking
    3289        assert!(
    3290            controller.state().is_playing(),
    3291            "Should still be playing after seeking"
    3292        );
    3293    }
    3294
    3295    #[test]
    3296    fn test_multiple_sequential_seeks() {
    3297        let controller = PlayerController::default();
    3298
    3299        let item = create_test_items(1).into_iter().next().unwrap();
    3300        controller.play_item(item).unwrap();
    3301
    3302        // Perform multiple seeks in sequence
    3303        let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
    3304
    3305        for pos in positions {
    3306            controller.seek(pos).unwrap();
    3307            assert_eq!(
    3308                controller.position(),
    3309                pos,
    3310                "Position should match after seeking to {}",
    3311                pos
    3312            );
    3313        }
    3314    }
    3315
    3316    /// Resuming a queue at a position seeks the starting track immediately.
    3317    /// Regression guard for taking over a remote session: the local player must
    3318    /// pick up where the remote left off, not restart from 0.
    3319    #[test]
    3320    fn test_play_queue_from_resumes_at_position() {
    3321        let controller = PlayerController::default();
    3322        let items = create_test_items(3);
    3323
    3324        controller.play_queue_from(items, 1, Some(42.5)).unwrap();
    3325
    3326        {
    3327            let queue = controller.queue();
    3328            let queue_lock = queue.lock_safe();
    3329            assert_eq!(
    3330                queue_lock.current_index(),
    3331                Some(1),
    3332                "Should start at index 1"
    3333            );
    3334        }
    3335        assert_eq!(
    3336            controller.position(),
    3337            42.5,
    3338            "Should resume at the requested position"
    3339        );
    3340    }
    3341
    3342    /// A None / near-zero start position starts the track from the beginning.
    3343    #[test]
    3344    fn test_play_queue_from_without_position_starts_at_zero() {
    3345        let controller = PlayerController::default();
    3346
    3347        controller
    3348            .play_queue_from(create_test_items(2), 0, None)
    3349            .unwrap();
    3350        assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
    3351
    3352        controller
    3353            .play_queue_from(create_test_items(2), 0, Some(0.2))
    3354            .unwrap();
    3355        assert_eq!(
    3356            controller.position(),
    3357            0.0,
    3358            "Sub-threshold resume position is ignored (starts at 0)"
    3359        );
    3360    }
    3361
    3362    #[test]
    3363    fn test_seek_to_zero() {
    3364        let controller = PlayerController::default();
    3365
    3366        let item = create_test_items(1).into_iter().next().unwrap();
    3367        controller.play_item(item).unwrap();
    3368
    3369        // Seek forward
    3370        controller.seek(60.0).unwrap();
    3371        assert_eq!(controller.position(), 60.0);
    3372
    3373        // Seek back to zero
    3374        controller.seek(0.0).unwrap();
    3375        assert_eq!(
    3376            controller.position(),
    3377            0.0,
    3378            "Should be able to seek to position 0"
    3379        );
    3380    }
    3381
    3382    // Autoplay decision tests
    3383    #[tokio::test]
    3384    async fn test_audio_with_next_advances() {
    3385        let controller = PlayerController::default();
    3386
    3387        // Create queue with 2 audio items
    3388        let items = create_test_items(2);
    3389        controller.play_queue(items, 0).unwrap();
    3390
    3391        // Clear the NewTrackLoaded reason set by play_queue to simulate natural track end
    3392        controller.take_end_reason();
    3393
    3394        // Simulate first track ending naturally
    3395        let decision = controller.on_playback_ended().await.unwrap();
    3396
    3397        // Should decide to advance to next
    3398        assert!(
    3399            matches!(decision, AutoplayDecision::AdvanceToNext),
    3400            "Expected AdvanceToNext decision when queue has next item"
    3401        );
    3402    }
    3403
    3404    #[tokio::test]
    3405    async fn test_audio_at_end_stops() {
    3406        let controller = PlayerController::default();
    3407
    3408        // Create queue with 2 items, start at last one
    3409        let items = create_test_items(2);
    3410        controller.play_queue(items, 1).unwrap();
    3411
    3412        // Clear the NewTrackLoaded reason to simulate natural track end
    3413        controller.take_end_reason();
    3414
    3415        // Simulate last track ending naturally
    3416        let decision = controller.on_playback_ended().await.unwrap();
    3417
    3418        // Should decide to stop (no more items)
    3419        assert!(
    3420            matches!(decision, AutoplayDecision::Stop),
    3421            "Expected Stop decision when at end of queue without repeat"
    3422        );
    3423    }
    3424
    3425    #[tokio::test]
    3426    async fn test_sleep_timer_end_of_track() {
    3427        let controller = PlayerController::default();
    3428
    3429        // Create queue with next items
    3430        let items = create_test_items(3);
    3431        controller.play_queue(items, 0).unwrap();
    3432
    3433        // Clear the NewTrackLoaded reason to simulate natural track end
    3434        controller.take_end_reason();
    3435
    3436        // Set sleep timer to end of track
    3437        {
    3438            let mut timer = controller.sleep_timer.lock_safe();
    3439            timer.mode = SleepTimerMode::EndOfTrack;
    3440        }
    3441
    3442        // Simulate track ending naturally
    3443        let decision = controller.on_playback_ended().await.unwrap();
    3444
    3445        // Should stop despite having next items
    3446        assert!(
    3447            matches!(decision, AutoplayDecision::Stop),
    3448            "Expected Stop decision when sleep timer is EndOfTrack"
    3449        );
    3450
    3451        // Verify timer was cancelled
    3452        {
    3453            let timer = controller.sleep_timer.lock_safe();
    3454            assert!(
    3455                matches!(timer.mode, SleepTimerMode::Off),
    3456                "Sleep timer should be cancelled after EndOfTrack"
    3457            );
    3458        }
    3459    }
    3460
    3461    /// A time-based sleep timer that fires mid-episode must not let the ended
    3462    /// callback fall through to autoplay.
    3463    ///
    3464    /// The timer thread stops the backend directly, which makes ExoPlayer emit
    3465    /// its ended callback. That callback races the thread's own `timer.cancel()`:
    3466    /// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
    3467    /// so the timer branch is skipped and the episode path runs — showing a
    3468    /// next-episode popup (or advancing) after the user's sleep timer expired.
    3469    #[tokio::test]
    3470    async fn test_expired_time_sleep_timer_stops_without_autoplay() {
    3471        let controller = PlayerController::default();
    3472
    3473        let items = create_test_items(3);
    3474        controller.play_queue(items, 0).unwrap();
    3475        controller.take_end_reason();
    3476
    3477        // Arm a time-based timer that is already due, then let the real timer
    3478        // thread (started in the constructor, 1s tick) observe the expiry and
    3479        // run its stop path. Driving the actual thread is the point: the bug was
    3480        // that this path stopped the backend without recording an end reason.
    3481        let now = chrono::Utc::now().timestamp_millis();
    3482        controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
    3483
    3484        // Wait for the timer thread to process the expiry (tick is 1s).
    3485        for _ in 0..40 {
    3486            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    3487            if !controller.sleep_timer.lock_safe().is_active() {
    3488                break;
    3489            }
    3490        }
    3491        assert!(
    3492            !controller.sleep_timer.lock_safe().is_active(),
    3493            "Timer thread should have expired and cancelled the sleep timer"
    3494        );
    3495
    3496        // The backend stop above makes the native player fire its ended callback.
    3497        let decision = controller.on_playback_ended().await.unwrap();
    3498
    3499        assert!(
    3500            matches!(decision, AutoplayDecision::Stop),
    3501            "Expected Stop after an expired time-based sleep timer, got {:?}",
    3502            decision
    3503        );
    3504    }
    3505
    3506    #[tokio::test]
    3507    async fn test_empty_queue_stops() {
    3508        let controller = PlayerController::default();
    3509
    3510        // Don't set up any queue
    3511        let decision = controller.on_playback_ended().await.unwrap();
    3512
    3513        // Should stop (no current item)
    3514        assert!(
    3515            matches!(decision, AutoplayDecision::Stop),
    3516            "Expected Stop decision when queue is empty"
    3517        );
    3518    }
    3519
    3520    #[tokio::test]
    3521    async fn test_repeat_all_advances_at_end() {
    3522        let controller = PlayerController::default();
    3523
    3524        // Create queue with 2 items, enable repeat all
    3525        let items = create_test_items(2);
    3526        controller.play_queue(items, 1).unwrap(); // Start at last item
    3527        controller.cycle_repeat(); // Enable repeat all
    3528
    3529        // Clear the NewTrackLoaded reason to simulate natural track end
    3530        controller.take_end_reason();
    3531
    3532        // Simulate last track ending naturally
    3533        let decision = controller.on_playback_ended().await.unwrap();
    3534
    3535        // Should advance (will wrap to beginning due to repeat all)
    3536        assert!(
    3537            matches!(decision, AutoplayDecision::AdvanceToNext),
    3538            "Expected AdvanceToNext decision at end of queue with repeat all"
    3539        );
    3540    }
    3541
    3542    #[tokio::test]
    3543    async fn test_repeat_one_advances() {
    3544        let controller = PlayerController::default();
    3545
    3546        // Create queue with 2 items
    3547        let items = create_test_items(2);
    3548        controller.play_queue(items, 0).unwrap();
    3549
    3550        // Enable repeat one
    3551        controller.cycle_repeat(); // Once for all
    3552        controller.cycle_repeat(); // Twice for one
    3553
    3554        // Clear the NewTrackLoaded reason to simulate natural track end
    3555        controller.take_end_reason();
    3556
    3557        // Simulate track ending naturally
    3558        let decision = controller.on_playback_ended().await.unwrap();
    3559
    3560        // Should advance (which repeats the same track)
    3561        assert!(
    3562            matches!(decision, AutoplayDecision::AdvanceToNext),
    3563            "Expected AdvanceToNext decision with repeat one (repeats same track)"
    3564        );
    3565    }
    3566
    3567    #[test]
    3568    fn test_native_load_returns_transport_authority_to_the_backend() {
    3569        // Play/pause did nothing on the Android native video path, from the
    3570        // on-screen tap AND from the control-bar button, while seek and skip
    3571        // worked — those take a different decision path.
    3572        //
    3573        // `html5_playing` is written only by the webview element's own reports
    3574        // and cleared only when it reports "stopped"/"idle" (or on a
    3575        // background-audio handoff). A previous element that went away without
    3576        // that final report — or webview-rendered music earlier in the same
    3577        // process — therefore left `is_html5_active()` true, and every transport
    3578        // intent was emitted as a ControlCommand at an element that no longer
    3579        // existed. Nothing reached ExoPlayer. It looked intermittent because it
    3580        // depends entirely on what played before.
    3581        //
    3582        // Loading into the native backend IS the statement that native renders
    3583        // this item, so it hands authority back — the same "element is gone"
    3584        // semantics the "stopped"/"idle" report already has.
    3585        //
    3586        // TRACES: UR-005, UR-003 | DR-193
    3587        let controller = PlayerController::default();
    3588        let emitter = Arc::new(CapturingEmitter::new());
    3589        controller.set_event_emitter(emitter.clone());
    3590
    3591        // A webview element reported itself playing and never said "stopped".
    3592        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
    3593        assert!(controller.is_html5_active());
    3594
    3595        // Now a native item loads — Android video through ExoPlayer.
    3596        let item = create_test_items(1).into_iter().next().unwrap();
    3597        controller.play_item(item).unwrap();
    3598
    3599        assert!(
    3600            !controller.is_html5_active(),
    3601            "loading into the native backend hands transport back to it"
    3602        );
    3603
    3604        // The toggle must reach the backend, not be emitted at a dead element.
    3605        controller.toggle_playback().unwrap();
    3606        let controls: Vec<_> = emitter
    3607            .events()
    3608            .into_iter()
    3609            .filter_map(|e| match e {
    3610                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
    3611                _ => None,
    3612            })
    3613            .collect();
    3614        assert!(
    3615            controls.is_empty(),
    3616            "transport went to a webview element that is not rendering: {controls:?}"
    3617        );
    3618    }
    3619
    3620    // EndReason state machine tests
    3621    #[test]
    3622    fn test_load_and_play_sets_new_track_loaded() {
    3623        let controller = PlayerController::default();
    3624        let item = create_test_items(1).into_iter().next().unwrap();
    3625
    3626        // End reason should be None initially
    3627        assert!(controller.take_end_reason().is_none());
    3628
    3629        // Load and play should set NewTrackLoaded
    3630        controller.load_and_play(&item).unwrap();
    3631
    3632        // Verify end reason was set
    3633        let reason = controller.take_end_reason();
    3634        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
    3635    }
    3636
    3637    #[test]
    3638    fn test_stop_sets_user_stop() {
    3639        let controller = PlayerController::default();
    3640        let item = create_test_items(1).into_iter().next().unwrap();
    3641
    3642        // Play an item first
    3643        controller.play_item(item).unwrap();
    3644
    3645        // Clear any end reason from load_and_play
    3646        controller.take_end_reason();
    3647
    3648        // Stop should set UserStop
    3649        controller.stop().unwrap();
    3650
    3651        // Verify end reason was set
    3652        let reason = controller.take_end_reason();
    3653        assert_eq!(reason, Some(EndReason::UserStop));
    3654    }
    3655
    3656    #[tokio::test]
    3657    async fn test_on_playback_ended_with_new_track_loaded_stops() {
    3658        let controller = PlayerController::default();
    3659
    3660        // Create queue with 2 items
    3661        let items = create_test_items(2);
    3662        controller.play_queue(items, 0).unwrap();
    3663
    3664        // Manually set end reason to NewTrackLoaded
    3665        controller.set_end_reason(EndReason::NewTrackLoaded);
    3666
    3667        // Call on_playback_ended
    3668        let decision = controller.on_playback_ended().await.unwrap();
    3669
    3670        // Should stop without advancing
    3671        assert!(
    3672            matches!(decision, AutoplayDecision::Stop),
    3673            "Expected Stop decision when EndReason is NewTrackLoaded"
    3674        );
    3675    }
    3676
    3677    #[tokio::test]
    3678    async fn test_on_playback_ended_with_user_stop_stops() {
    3679        let controller = PlayerController::default();
    3680
    3681        // Create queue with 2 items
    3682        let items = create_test_items(2);
    3683        controller.play_queue(items, 0).unwrap();
    3684
    3685        // Manually set end reason to UserStop
    3686        controller.set_end_reason(EndReason::UserStop);
    3687
    3688        // Call on_playback_ended
    3689        let decision = controller.on_playback_ended().await.unwrap();
    3690
    3691        // Should stop without advancing
    3692        assert!(
    3693            matches!(decision, AutoplayDecision::Stop),
    3694            "Expected Stop decision when EndReason is UserStop"
    3695        );
    3696    }
    3697
    3698    #[tokio::test]
    3699    async fn test_on_playback_ended_natural_end_advances() {
    3700        let controller = PlayerController::default();
    3701
    3702        // Create queue with 2 items
    3703        let items = create_test_items(2);
    3704        controller.play_queue(items, 0).unwrap();
    3705
    3706        // Clear the NewTrackLoaded reason to simulate natural track end
    3707        controller.take_end_reason();
    3708
    3709        // Call on_playback_ended (no end reason set = natural end)
    3710        let decision = controller.on_playback_ended().await.unwrap();
    3711
    3712        // Should advance to next (natural end with next track available)
    3713        assert!(
    3714            matches!(decision, AutoplayDecision::AdvanceToNext),
    3715            "Expected AdvanceToNext decision when track ends naturally with next track available"
    3716        );
    3717    }
    3718
    3719    #[tokio::test]
    3720    async fn test_on_playback_ended_with_user_skip_stops() {
    3721        let controller = PlayerController::default();
    3722
    3723        // Create queue with 2 items
    3724        let items = create_test_items(2);
    3725        controller.play_queue(items, 0).unwrap();
    3726
    3727        // Set end reason to UserSkip
    3728        controller.set_end_reason(EndReason::UserSkip);
    3729
    3730        // Call on_playback_ended
    3731        let decision = controller.on_playback_ended().await.unwrap();
    3732
    3733        // Should stop without advancing (skip already handled)
    3734        assert!(
    3735            matches!(decision, AutoplayDecision::Stop),
    3736            "Expected Stop decision when EndReason is UserSkip"
    3737        );
    3738    }
    3739
    3740    #[tokio::test]
    3741    async fn test_on_playback_ended_with_error_stops() {
    3742        let controller = PlayerController::default();
    3743
    3744        // Create queue with 2 items
    3745        let items = create_test_items(2);
    3746        controller.play_queue(items, 0).unwrap();
    3747
    3748        // Set end reason to Error
    3749        controller.set_end_reason(EndReason::Error);
    3750
    3751        // Call on_playback_ended
    3752        let decision = controller.on_playback_ended().await.unwrap();
    3753
    3754        // Should stop without advancing
    3755        assert!(
    3756            matches!(decision, AutoplayDecision::Stop),
    3757            "Expected Stop decision when EndReason is Error"
    3758        );
    3759    }
    3760
    3761    #[tokio::test]
    3762    async fn test_take_end_reason_clears_state() {
    3763        let controller = PlayerController::default();
    3764
    3765        // Set a reason
    3766        controller.set_end_reason(EndReason::NewTrackLoaded);
    3767
    3768        // Take it once
    3769        let reason = controller.take_end_reason();
    3770        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
    3771
    3772        // Take it again - should be None
    3773        let reason = controller.take_end_reason();
    3774        assert!(reason.is_none(), "take_end_reason should clear the state");
    3775    }
    3776
    3777    // ===== Next-episode autoplay decision tests =====
    3778
    3779    use crate::repository::types as repo_types;
    3780
    3781    /// Mock repository serving a single season of episodes for next-episode
    3782    /// lookup tests. Only `get_item` and `get_items` are used by
    3783    /// `fetch_next_episode_for_item`; everything else is unreachable.
    3784    struct MockEpisodeRepo {
    3785        /// Seasons in the order the series lists them, each with its episodes.
    3786        seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
    3787    }
    3788
    3789    impl MockEpisodeRepo {
    3790        /// A one-season series, whose episodes keep the historical `ep{n}` ids.
    3791        fn season(count: usize) -> Self {
    3792            Self::series(&[count])
    3793        }
    3794
    3795        /// A series whose seasons hold the given episode counts. Season 1 keeps
    3796        /// the `ep{n}` ids the single-season tests use; later seasons get
    3797        /// `s{season}e{n}` so a rollover assertion names the season it landed in.
    3798        fn series(counts: &[usize]) -> Self {
    3799            let seasons = counts
    3800                .iter()
    3801                .enumerate()
    3802                .map(|(s, count)| {
    3803                    let season_number = s as i32 + 1;
    3804                    let episodes = (1..=*count)
    3805                        .map(|i| {
    3806                            let id = if season_number == 1 {
    3807                                format!("ep{}", i)
    3808                            } else {
    3809                                format!("s{}e{}", season_number, i)
    3810                            };
    3811                            make_repo_episode_in(season_number, &id, i as i32)
    3812                        })
    3813                        .collect();
    3814                    (make_repo_season(season_number), episodes)
    3815                })
    3816                .collect();
    3817            Self { seasons }
    3818        }
    3819
    3820        fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
    3821            self.seasons.iter().flat_map(|(_, eps)| eps.iter())
    3822        }
    3823    }
    3824
    3825    fn make_repo_season(index: i32) -> repo_types::MediaItem {
    3826        repo_types::MediaItem {
    3827            id: format!("season{}", index),
    3828            name: format!("Season {}", index),
    3829            item_type: "Season".to_string(),
    3830            kind: crate::domain::MediaKind::Season,
    3831            is_folder: true,
    3832            parent_id: Some("series1".to_string()),
    3833            index_number: Some(index),
    3834            season_id: None,
    3835            season_name: None,
    3836            parent_index_number: None,
    3837            ..make_repo_episode(&format!("season{}", index), index)
    3838        }
    3839    }
    3840
    3841    fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
    3842        make_repo_episode_in(1, id, index)
    3843    }
    3844
    3845    fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
    3846        repo_types::MediaItem {
    3847            id: id.to_string(),
    3848            name: format!("Episode {}", index),
    3849            item_type: "Episode".to_string(),
    3850            kind: crate::domain::MediaKind::Episode,
    3851            is_folder: false,
    3852            server_id: "server".to_string(),
    3853            parent_id: Some(format!("season{}", season_number)),
    3854            library_id: None,
    3855            overview: None,
    3856            genres: None,
    3857            runtime_ticks: None,
    3858            duration_ms: None,
    3859            production_year: None,
    3860            premiere_date: None,
    3861            community_rating: None,
    3862            official_rating: None,
    3863            primary_image_tag: None,
    3864            image_id: None,
    3865            backdrop_image_tags: None,
    3866            parent_backdrop_image_tags: None,
    3867            album_id: None,
    3868            album_name: None,
    3869            album_artist: None,
    3870            artists: None,
    3871            artist_items: None,
    3872            index_number: Some(index),
    3873            series_id: Some("series1".to_string()),
    3874            series_name: Some("Test Series".to_string()),
    3875            season_id: Some(format!("season{}", season_number)),
    3876            season_name: Some(format!("Season {}", season_number)),
    3877            parent_index_number: Some(season_number),
    3878            user_data: None,
    3879            media_streams: None,
    3880            media_sources: None,
    3881            people: None,
    3882        }
    3883    }
    3884
    3885    #[async_trait::async_trait]
    3886    impl crate::repository::MediaRepository for MockEpisodeRepo {
    3887        async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
    3888            unimplemented!()
    3889        }
    3890        async fn get_items(
    3891            &self,
    3892            parent_id: &str,
    3893            _options: Option<repo_types::GetItemsOptions>,
    3894        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
    3895            // The series lists its seasons; a season lists its episodes. Both
    3896            // are real lookups the autoplay path makes -- the second only once
    3897            // the first has told it which season comes next.
    3898            let items = if parent_id == "series1" {
    3899                self.seasons.iter().map(|(s, _)| s.clone()).collect()
    3900            } else {
    3901                self.seasons
    3902                    .iter()
    3903                    .find(|(season, _)| season.id == parent_id)
    3904                    .map(|(_, eps)| eps.clone())
    3905                    .unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
    3906            };
    3907            let total_record_count = items.len();
    3908            Ok(repo_types::SearchResult {
    3909                items,
    3910                total_record_count,
    3911            })
    3912        }
    3913        async fn get_item(
    3914            &self,
    3915            item_id: &str,
    3916        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
    3917            self.all_episodes()
    3918                .find(|e| e.id == item_id)
    3919                .cloned()
    3920                .ok_or(repo_types::RepoError::NotFound {
    3921                    message: format!("{} not found", item_id),
    3922                })
    3923        }
    3924        async fn get_latest_items(
    3925            &self,
    3926            _: &str,
    3927            _: Option<usize>,
    3928        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
    3929            unimplemented!()
    3930        }
    3931        async fn get_resume_items(
    3932            &self,
    3933            _: Option<&str>,
    3934            _: Option<usize>,
    3935        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
    3936            unimplemented!()
    3937        }
    3938        async fn get_next_up_episodes(
    3939            &self,
    3940            _: Option<&str>,
    3941            _: Option<usize>,
    3942        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
    3943            unimplemented!()
    3944        }
    3945        async fn get_recently_played_audio(
    3946            &self,
    3947            _: Option<usize>,
    3948        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
    3949            unimplemented!()
    3950        }
    3951        async fn get_rediscover_albums(
    3952            &self,
    3953            _: Option<&str>,
    3954            _: Option<usize>,
    3955        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
    3956            unimplemented!()
    3957        }
    3958        async fn get_resume_movies(
    3959            &self,
    3960            _: Option<usize>,
    3961        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
    3962            unimplemented!()
    3963        }
    3964        async fn get_genres(
    3965            &self,
    3966            _: Option<&str>,
    3967        ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
    3968            unimplemented!()
    3969        }
    3970        async fn search(
    3971            &self,
    3972            _: &str,
    3973            _: Option<repo_types::SearchOptions>,
    3974        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
    3975            unimplemented!()
    3976        }
    3977        async fn get_playback_info(
    3978            &self,
    3979            _: &str,
    3980        ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
    3981            unimplemented!()
    3982        }
    3983        async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
    3984            unimplemented!()
    3985        }
    3986        async fn get_audio_only_stream_url_for_video(
    3987            &self,
    3988            item_id: &str,
    3989            _media_source_id: Option<&str>,
    3990            _start_time_seconds: Option<f64>,
    3991            _audio_stream_index: Option<i32>,
    3992        ) -> Result<String, repo_types::RepoError> {
    3993            Ok(format!("http://example.com/{}-audio.mp3", item_id))
    3994        }
    3995        async fn get_live_tv_channels(
    3996            &self,
    3997        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
    3998            unimplemented!()
    3999        }
    4000        async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
    4001            unimplemented!()
    4002        }
    4003        async fn open_live_stream(
    4004            &self,
    4005            _: &str,
    4006        ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
    4007            unimplemented!()
    4008        }
    4009        async fn report_playback_start(
    4010            &self,
    4011            _: &str,
    4012            _: i64,
    4013        ) -> Result<(), repo_types::RepoError> {
    4014            unimplemented!()
    4015        }
    4016        async fn report_playback_progress(
    4017            &self,
    4018            _: &str,
    4019            _: i64,
    4020        ) -> Result<(), repo_types::RepoError> {
    4021            unimplemented!()
    4022        }
    4023        async fn report_playback_stopped(
    4024            &self,
    4025            _: &str,
    4026            _: i64,
    4027        ) -> Result<(), repo_types::RepoError> {
    4028            unimplemented!()
    4029        }
    4030        fn get_image_url(
    4031            &self,
    4032            _: &str,
    4033            _: repo_types::ImageType,
    4034            _: Option<repo_types::ImageOptions>,
    4035        ) -> String {
    4036            unimplemented!()
    4037        }
    4038        fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
    4039            unimplemented!()
    4040        }
    4041        fn get_video_download_url(
    4042            &self,
    4043            _: &str,
    4044            _: &str,
    4045            _: Option<&str>,
    4046            _: Option<&str>,
    4047        ) -> String {
    4048            unimplemented!()
    4049        }
    4050        async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
    4051            unimplemented!()
    4052        }
    4053        async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
    4054            unimplemented!()
    4055        }
    4056        async fn get_favorites(
    4057            &self,
    4058            _: repo_types::SearchScope,
    4059            _: Option<repo_types::GetItemsOptions>,
    4060        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
    4061            unimplemented!()
    4062        }
    4063        async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
    4064            unimplemented!()
    4065        }
    4066        async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
    4067            unimplemented!()
    4068        }
    4069        async fn get_person(
    4070            &self,
    4071            _: &str,
    4072        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
    4073            unimplemented!()
    4074        }
    4075        async fn get_items_by_person(
    4076            &self,
    4077            _: &str,
    4078            _: Option<repo_types::GetItemsOptions>,
    4079        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
    4080            unimplemented!()
    4081        }
    4082        async fn get_similar_items(
    4083            &self,
    4084            _: &str,
    4085            _: Option<usize>,
    4086        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
    4087            unimplemented!()
    4088        }
    4089        async fn create_playlist(
    4090            &self,
    4091            _: &str,
    4092            _: &[String],
    4093        ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
    4094            unimplemented!()
    4095        }
    4096        async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
    4097            unimplemented!()
    4098        }
    4099        async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
    4100            unimplemented!()
    4101        }
    4102        async fn get_playlist_items(
    4103            &self,
    4104            _: &str,
    4105        ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
    4106            unimplemented!()
    4107        }
    4108        async fn add_to_playlist(
    4109            &self,
    4110            _: &str,
    4111            _: &[String],
    4112        ) -> Result<(), repo_types::RepoError> {
    4113            unimplemented!()
    4114        }
    4115        async fn remove_from_playlist(
    4116            &self,
    4117            _: &str,
    4118            _: &[String],
    4119        ) -> Result<(), repo_types::RepoError> {
    4120            unimplemented!()
    4121        }
    4122        async fn move_playlist_item(
    4123            &self,
    4124            _: &str,
    4125            _: &str,
    4126            _: u32,
    4127        ) -> Result<(), repo_types::RepoError> {
    4128            unimplemented!()
    4129        }
    4130    }
    4131
    4132    /// Video (HTML5/Linux) path: ending mid-season must produce the
    4133    /// next-episode popup with auto-advance.
    4134    #[tokio::test]
    4135    async fn test_video_playback_ended_offers_next_episode() {
    4136        let controller = PlayerController::default();
    4137        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
    4138
    4139        let decision = controller
    4140            .on_video_playback_ended("ep2", repo)
    4141            .await
    4142            .expect("decision should succeed");
    4143
    4144        match decision {
    4145            AutoplayDecision::ShowNextEpisodePopup {
    4146                current_episode,
    4147                next_episode,
    4148                auto_advance,
    4149                ..
    4150            } => {
    4151                assert_eq!(current_episode.id, "ep2");
    4152                assert_eq!(next_episode.id, "ep3");
    4153                assert!(auto_advance, "default settings should auto-advance");
    4154            }
    4155            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
    4156        }
    4157    }
    4158
    4159    /// A season boundary is not the end of the series. The lookup used to stop
    4160    /// dead at the last episode of a season, which on Android's background-audio
    4161    /// path is felt as playback simply pausing at the end of an episode with the
    4162    /// screen locked and nothing to un-pause it.
    4163    ///
    4164    /// TRACES: UR-023, UR-040 | DR-263 | UT-238
    4165    #[tokio::test]
    4166    async fn test_next_episode_rolls_over_to_the_next_season() {
    4167        let controller = PlayerController::default();
    4168        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
    4169
    4170        let decision = controller
    4171            .on_video_playback_ended("ep2", repo)
    4172            .await
    4173            .expect("decision should succeed");
    4174
    4175        match decision {
    4176            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
    4177                assert_eq!(
    4178                    next_episode.id, "s2e1",
    4179                    "the first episode of the next season follows the last of this one"
    4180                );
    4181            }
    4182            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
    4183        }
    4184    }
    4185
    4186    /// A season with nothing in it is not the end of the series either.
    4187    ///
    4188    /// TRACES: UR-023 | DR-263 | UT-238
    4189    #[tokio::test]
    4190    async fn test_next_episode_skips_an_empty_season() {
    4191        let controller = PlayerController::default();
    4192        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
    4193
    4194        let decision = controller
    4195            .on_video_playback_ended("ep1", repo)
    4196            .await
    4197            .expect("decision should succeed");
    4198
    4199        match decision {
    4200            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
    4201                assert_eq!(next_episode.id, "s3e1");
    4202            }
    4203            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
    4204        }
    4205    }
    4206
    4207    /// The rollover must not out-rank the sleep timer: crossing a season
    4208    /// boundary is still a track boundary, and that is exactly where a timer set
    4209    /// to "end of episode" is supposed to stop.
    4210    ///
    4211    /// TRACES: UR-023, UR-026 | DR-263 | UT-238
    4212    #[tokio::test]
    4213    async fn test_sleep_timer_still_stops_at_a_season_boundary() {
    4214        let controller = PlayerController::default();
    4215        controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
    4216        controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
    4217
    4218        let episode = MediaItem {
    4219            transport: None,
    4220            media_type: MediaType::Audio,
    4221            item_type: Some("Episode".to_string()),
    4222            series_id: Some("series1".to_string()),
    4223            duration: Some(180.0),
    4224            source: MediaSource::Remote {
    4225                stream_url: "http://example.com/ep1.mp3".to_string(),
    4226                jellyfin_item_id: "ep1".to_string(),
    4227            },
    4228            ..create_test_items(1).remove(0)
    4229        };
    4230        controller.play_queue(vec![episode], 0).unwrap();
    4231        // Played through to the end -- a natural finish, not a stream cut short.
    4232        controller.seek(180.0).unwrap();
    4233        controller.take_end_reason();
    4234
    4235        let decision = controller.on_playback_ended().await.unwrap();
    4236
    4237        assert!(
    4238            matches!(decision, AutoplayDecision::Stop),
    4239            "the last episode the timer allows must stop, not roll into season 2 (got {:?})",
    4240            decision
    4241        );
    4242    }
    4243
    4244    /// Last episode of the season: no popup, stop.
    4245    #[tokio::test]
    4246    async fn test_video_playback_ended_last_episode_stops() {
    4247        let controller = PlayerController::default();
    4248        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
    4249
    4250        let decision = controller
    4251            .on_video_playback_ended("ep3", repo)
    4252            .await
    4253            .expect("decision should succeed");
    4254
    4255        assert!(matches!(decision, AutoplayDecision::Stop));
    4256    }
    4257
    4258    /// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
    4259    /// so the controller-level repository (wired up in `repository_create`)
    4260    /// must be used for the next-episode lookup. Regression test for episode
    4261    /// autoplay never triggering on Android because no repository was set.
    4262    #[tokio::test]
    4263    async fn test_playback_ended_uses_controller_repository_for_episodes() {
    4264        let controller = PlayerController::default();
    4265        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4266
    4267        // Queue holds the episode that just finished playing
    4268        let episode = MediaItem {
    4269            // Audio and direct-URL items never negotiate a transport.
    4270            transport: None,
    4271            media_type: MediaType::Video,
    4272            source: MediaSource::Remote {
    4273                stream_url: "http://example.com/ep1.mkv".to_string(),
    4274                jellyfin_item_id: "ep1".to_string(),
    4275            },
    4276            ..create_test_items(1).remove(0)
    4277        };
    4278        controller.play_queue(vec![episode], 0).unwrap();
    4279
    4280        // Clear the NewTrackLoaded reason to simulate natural track end
    4281        controller.take_end_reason();
    4282
    4283        let decision = controller.on_playback_ended().await.unwrap();
    4284
    4285        match decision {
    4286            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
    4287                assert_eq!(next_episode.id, "ep2");
    4288            }
    4289            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
    4290        }
    4291    }
    4292
    4293    /// Background audio-only mode (UR-040): a video episode is handed off to the
    4294    /// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
    4295    /// playing while the app is backgrounded. When that audio track ends, autoplay
    4296    /// must STILL recognise it as an episode and offer the next one — otherwise
    4297    /// playback just pauses at the episode boundary (the reported bug). The item
    4298    /// carries its episode identity via `item_type: "Episode"` + `series_id`.
    4299    #[tokio::test]
    4300    async fn test_playback_ended_background_audio_episode_advances() {
    4301        let controller = PlayerController::default();
    4302        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4303
    4304        // Mirrors what player_enter_background_audio builds: the episode as AUDIO.
    4305        let episode = MediaItem {
    4306            // Audio and direct-URL items never negotiate a transport.
    4307            transport: None,
    4308            item_type: Some("Episode".to_string()),
    4309            media_type: MediaType::Audio, // audio-only handoff, not Video
    4310            series_id: Some("series1".to_string()),
    4311            duration: Some(180.0),
    4312            source: MediaSource::Remote {
    4313                stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
    4314                jellyfin_item_id: "ep2".to_string(),
    4315            },
    4316            ..create_test_items(1).remove(0)
    4317        };
    4318        controller.play_queue(vec![episode], 0).unwrap();
    4319
    4320        // Played through to the end — a natural finish, not a stream cut short.
    4321        controller.seek(180.0).unwrap();
    4322        // Clear the NewTrackLoaded reason to simulate natural track end.
    4323        controller.take_end_reason();
    4324
    4325        let decision = controller.on_playback_ended().await.unwrap();
    4326
    4327        match decision {
    4328            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
    4329                assert_eq!(next_episode.id, "ep3");
    4330            }
    4331            other => panic!(
    4332                "background-audio episode end must advance to the next episode, got {:?}",
    4333                other
    4334            ),
    4335        }
    4336    }
    4337
    4338    /// The backend-driven advance (used when backgrounded) must load the next
    4339    /// episode as an AUDIO item carrying its episode identity, so the *following*
    4340    /// end-of-track also advances rather than stopping.
    4341    #[tokio::test]
    4342    async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
    4343        let controller = PlayerController::default();
    4344        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4345
    4346        controller
    4347            .advance_to_next_episode_audio_only("ep2")
    4348            .await
    4349            .expect("advance should succeed");
    4350
    4351        let current = controller
    4352            .queue
    4353            .lock_safe()
    4354            .current()
    4355            .cloned()
    4356            .expect("an item should be loaded");
    4357        assert_eq!(current.id, "ep2");
    4358        assert_eq!(current.media_type, MediaType::Audio);
    4359        assert_eq!(current.item_type.as_deref(), Some("Episode"));
    4360        assert_eq!(current.series_id.as_deref(), Some("series1"));
    4361        // Uses the audio-only URL, not a video stream.
    4362        match &current.source {
    4363            MediaSource::Remote { stream_url, .. } => {
    4364                assert!(
    4365                    stream_url.contains("audio"),
    4366                    "expected audio-only URL, got {}",
    4367                    stream_url
    4368                );
    4369            }
    4370            other => panic!("expected Remote source, got {:?}", other),
    4371        }
    4372
    4373        // The controller now considers itself mid background-audio episode, so the
    4374        // next end-of-track will advance again rather than stop.
    4375        assert!(controller.current_is_audio_episode());
    4376    }
    4377
    4378    /// The handoff base offset describes ONE stream: the audio-only URL built
    4379    /// with `StartTimeTicks` = the position the video was handed off at, whose
    4380    /// timeline therefore starts at that point. The next episode is loaded from
    4381    /// its own beginning, so its timeline is already absolute and the base must
    4382    /// be cleared — otherwise returning to the foreground resolves the resume
    4383    /// position as `old_base + position_in_new_episode` and the video jumps to a
    4384    /// point that has nothing to do with what was playing.
    4385    #[tokio::test]
    4386    async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
    4387        let controller = PlayerController::default();
    4388        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4389
    4390        // Handed off 20 minutes into the previous episode.
    4391        controller.set_background_audio_base(1200.0);
    4392
    4393        controller
    4394            .advance_to_next_episode_audio_only("ep2")
    4395            .await
    4396            .expect("advance should succeed");
    4397
    4398        assert_eq!(
    4399            controller.take_background_audio_base(),
    4400            0.0,
    4401            "the next episode starts at its own zero, so the previous handoff \
    4402             base must not survive the advance"
    4403        );
    4404    }
    4405
    4406    /// A background audio-only episode must advance IN THE BACKEND when the
    4407    /// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
    4408    /// countdown the frontend is supposed to act on.
    4409    ///
    4410    /// The countdown only emits CountdownTick events; the actual advance is a
    4411    /// `goto('/player/<id>')` in the webview. While the app is backgrounded that
    4412    /// navigation cannot start audio, so playback stalls at the episode boundary
    4413    /// with ExoPlayer parked in STATE_ENDED — and any later play intent
    4414    /// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
    4415    /// start, which is what surfaces to the user as "the episode randomly
    4416    /// restarted".
    4417    #[tokio::test]
    4418    async fn test_auto_advance_background_audio_episode_advances_in_backend() {
    4419        let controller = PlayerController::default();
    4420        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4421
    4422        // Currently playing: ep2 handed off to audio-only background playback.
    4423        let episode = MediaItem {
    4424            // Audio and direct-URL items never negotiate a transport.
    4425            transport: None,
    4426            id: "ep2".to_string(),
    4427            item_type: Some("Episode".to_string()),
    4428            media_type: MediaType::Audio,
    4429            series_id: Some("series1".to_string()),
    4430            source: MediaSource::Remote {
    4431                stream_url: "http://example.com/ep2-audio.mp3".to_string(),
    4432                jellyfin_item_id: "ep2".to_string(),
    4433            },
    4434            ..create_test_items(1).remove(0)
    4435        };
    4436        controller.play_queue(vec![episode], 0).unwrap();
    4437
    4438        let next = make_repo_episode("ep3", 3);
    4439        controller.auto_advance_to_next_episode(next, 10).await;
    4440
    4441        let current = controller
    4442            .queue
    4443            .lock_safe()
    4444            .current()
    4445            .cloned()
    4446            .expect("an item should still be loaded");
    4447        assert_eq!(
    4448            current.id, "ep3",
    4449            "background audio-only episode must advance in the backend, not wait \
    4450             for a frontend navigation that cannot happen while backgrounded"
    4451        );
    4452        assert_eq!(current.media_type, MediaType::Audio);
    4453        assert!(controller.current_is_audio_episode());
    4454    }
    4455
    4456    /// Build the audio-only episode the background handoff loads: a video item
    4457    /// played through the native audio path, with a known runtime and a stream
    4458    /// URL carrying the handoff position.
    4459    fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
    4460        MediaItem {
    4461            // Audio and direct-URL items never negotiate a transport.
    4462            transport: None,
    4463            id: "ep2".to_string(),
    4464            item_type: Some("Episode".to_string()),
    4465            media_type: MediaType::Audio,
    4466            series_id: Some("series1".to_string()),
    4467            duration: Some(runtime_seconds),
    4468            source: MediaSource::Remote {
    4469                stream_url:
    4470                    "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
    4471                        .to_string(),
    4472                jellyfin_item_id: "ep2".to_string(),
    4473            },
    4474            ..create_test_items(1).remove(0)
    4475        }
    4476    }
    4477
    4478    /// The same episode handed off from a **downloaded file** — the handoff's
    4479    /// other source, which starts at the episode's own zero rather than at the
    4480    /// handoff point.
    4481    fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
    4482        MediaItem {
    4483            // Audio and direct-URL items never negotiate a transport.
    4484            transport: None,
    4485            source: MediaSource::Local {
    4486                file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
    4487                jellyfin_item_id: Some("ep2".to_string()),
    4488            },
    4489            ..audio_only_episode(runtime_seconds)
    4490        }
    4491    }
    4492
    4493    /// A file seeks like a file. The rebuild path exists because a chunked
    4494    /// length-less transcode cannot honour a seek, which is not true of local
    4495    /// media — and `resume_stream_at` refuses a non-remote source outright, so
    4496    /// routing a lockscreen scrub through it fails the seek instead of doing it.
    4497    ///
    4498    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
    4499    #[tokio::test]
    4500    async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
    4501        let controller = PlayerController::default();
    4502        controller
    4503            .play_queue(vec![local_audio_only_episode(1500.0)], 0)
    4504            .unwrap();
    4505        // A downloaded handoff claims no base: the file's zero is the episode's.
    4506        controller.enter_background_audio(0.0);
    4507
    4508        controller.seek_absolute(900.0).await.unwrap();
    4509
    4510        assert_eq!(controller.position(), 900.0);
    4511    }
    4512
    4513    /// A flaky connection truncates the progressive mp3 transcode that carries
    4514    /// background audio-only playback. ExoPlayer sees end-of-input on a stream
    4515    /// with no reliable length, so it reports STATE_ENDED ten minutes into a
    4516    /// twenty-five minute episode — indistinguishable, to the player, from the
    4517    /// real end.
    4518    ///
    4519    /// Treating that as "the episode finished" is what the user experiences as
    4520    /// the episode randomly restarting: playback parks in STATE_ENDED and the
    4521    /// next play intent (lockscreen, notification, Bluetooth reconnect) seeks an
    4522    /// ended player to position 0 before playing. The runtime we already know
    4523    /// says the stream died early, so the decision must be to resume it.
    4524    #[tokio::test]
    4525    async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
    4526        let controller = PlayerController::default();
    4527        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4528
    4529        controller
    4530            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4531            .unwrap();
    4532        // The connection dropped 10 minutes into a 25-minute episode.
    4533        controller.seek(600.0).unwrap();
    4534        controller.take_end_reason();
    4535
    4536        let decision = controller.on_playback_ended().await.unwrap();
    4537
    4538        match decision {
    4539            AutoplayDecision::ResumeStream { position } => {
    4540                assert_eq!(position, 600.0, "must resume where the stream died");
    4541            }
    4542            other => panic!(
    4543                "a stream that ended 15 minutes short of the runtime must resume, \
    4544                 not run end-of-episode logic; got {:?}",
    4545                other
    4546            ),
    4547        }
    4548    }
    4549
    4550    /// A seek arriving during a background-audio handoff is **absolute** — the
    4551    /// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
    4552    /// 25:00 of the episode, not 25:00 into the handoff stream.
    4553    ///
    4554    /// The handoff stream cannot be seeked at all (a chunked, length-less
    4555    /// transcode), so honouring it means re-opening the URL at the new position,
    4556    /// exactly as the truncation recovery does. Passing the number through to
    4557    /// ExoPlayer instead — which is what used to happen — asked a stream that
    4558    /// cannot seek to jump past its own end, and a clamped seek lands at stream
    4559    /// zero: the handoff point.
    4560    ///
    4561    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
    4562    #[tokio::test]
    4563    async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
    4564        let controller = PlayerController::default();
    4565        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4566        controller
    4567            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4568            .unwrap();
    4569
    4570        // Handed off 20 minutes in, so the stream's zero is 1200s.
    4571        controller.enter_background_audio(1200.0);
    4572
    4573        // The viewer scrubs the lockscreen to 25:00 absolute.
    4574        controller.seek_absolute(1490.0).await.unwrap();
    4575
    4576        let url = {
    4577            let queue = controller.queue();
    4578            let queue = queue.lock_safe();
    4579            match &queue.current().unwrap().source {
    4580                MediaSource::Remote { stream_url, .. } => stream_url.clone(),
    4581                other => panic!("expected a remote source, got {:?}", other),
    4582            }
    4583        };
    4584        assert!(
    4585            url.contains(&format!(
    4586                "StartTimeTicks={}",
    4587                (1490.0 * 10_000_000.0) as i64
    4588            )),
    4589            "the stream must be re-opened at the absolute position; got {}",
    4590            url
    4591        );
    4592
    4593        assert_eq!(
    4594            *controller.background_audio_base.lock_safe(),
    4595            1490.0,
    4596            "the re-opened stream's zero is the position it was opened at, or \
    4597             every later reading is off by the difference"
    4598        );
    4599    }
    4600
    4601    /// Outside a handoff there is no base and nothing to re-open: an absolute
    4602    /// seek is just a seek, and must not be turned into a stream rebuild.
    4603    ///
    4604    /// TRACES: UR-005 | DR-159 | UT-155
    4605    #[tokio::test]
    4606    async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
    4607        let controller = PlayerController::default();
    4608        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4609        controller
    4610            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4611            .unwrap();
    4612
    4613        controller.seek_absolute(300.0).await.unwrap();
    4614
    4615        assert_eq!(controller.position(), 300.0);
    4616        assert_eq!(
    4617            *controller.background_audio_base.lock_safe(),
    4618            0.0,
    4619            "an ordinary seek must not invent a handoff base"
    4620        );
    4621    }
    4622
    4623    // ===== Position authority and reporting (DR-178, DR-179) =====
    4624    //
    4625    // Every position that leaves the app — the resume point Jellyfin stores, the
    4626    // point the video reloads at on the way back from a handoff, the truncation
    4627    // maths — is read off the controller. The device trace showed all of them
    4628    // reading 0: the native backend is not the player on the webview path, and
    4629    // during a handoff its base is only applied once ExoPlayer has ticked, which
    4630    // it has not while the audio-only transcode is still opening.
    4631
    4632    /// Returning to the foreground before the audio-only stream has started
    4633    /// playing hands back the handoff's own starting point, never zero.
    4634    ///
    4635    /// Observed on device: locked at 18.4s, unlocked 3.5s later with ExoPlayer
    4636    /// still `IDLE`, `player_exit_background_audio` returned `0.0`, and the video
    4637    /// reloaded with `StartTimeTicks=0` — the episode restarted from the
    4638    /// beginning, and the `Stopped` report that followed wiped the server's
    4639    /// resume point too.
    4640    ///
    4641    /// TRACES: UR-040 | DR-178 | UT-176
    4642    #[test]
    4643    fn test_absolute_position_floors_at_the_handoff_base() {
    4644        let controller = PlayerController::default();
    4645        controller.enter_background_audio(18.4);
    4646
    4647        // No tick has landed, so nothing has applied the base yet.
    4648        assert_eq!(controller.position(), 0.0);
    4649        assert_eq!(
    4650            controller.absolute_position(),
    4651            18.4,
    4652            "the audio stream's zero IS the handoff point, so the position can \
    4653             never legitimately read below it"
    4654        );
    4655    }
    4656
    4657    /// Once ticks are flowing the base has already been applied at the native
    4658    /// boundary (DR-159), so flooring must not add it a second time.
    4659    ///
    4660    /// TRACES: UR-040 | DR-178 | UT-176
    4661    #[test]
    4662    fn test_absolute_position_does_not_double_count_the_handoff_base() {
    4663        let controller = PlayerController::default();
    4664        controller.enter_background_audio(18.4);
    4665
    4666        // What the real backend reports after a tick: already absolute.
    4667        controller.seek(120.0).unwrap();
    4668
    4669        assert_eq!(controller.absolute_position(), 120.0);
    4670    }
    4671
    4672    /// On the webview path the `<video>` element is the player and the native
    4673    /// backend holds nothing, so the position it reports is the only one there
    4674    /// is. It used to be re-emitted to the frontend and then dropped, leaving
    4675    /// every backend-side report at 0.
    4676    ///
    4677    /// TRACES: UR-005, UR-025 | DR-178 | UT-177
    4678    #[test]
    4679    fn test_webview_position_reports_become_the_controllers_position() {
    4680        let controller = PlayerController::default();
    4681
    4682        controller.report_html5_position(253.4, 2640.0);
    4683
    4684        assert_eq!(controller.absolute_position(), 253.4);
    4685        assert_eq!(controller.observed_duration(), Some(2640.0));
    4686    }
    4687
    4688    /// A torn-down element's last position must not outlive it: the next thing
    4689    /// to play is loaded into the native backend, and a stale 253s would be
    4690    /// reported against it.
    4691    ///
    4692    /// TRACES: UR-005 | DR-178 | UT-177
    4693    #[test]
    4694    fn test_webview_teardown_clears_the_observed_position() {
    4695        let controller = PlayerController::default();
    4696        controller.report_html5_position(253.4, 2640.0);
    4697
    4698        controller.report_html5_state("stopped".to_string(), None);
    4699
    4700        assert_eq!(controller.absolute_position(), 0.0);
    4701    }
    4702
    4703    /// Entering a handoff tears the element down, so its position stops being
    4704    /// the answer at that exact moment — the native audio player's does.
    4705    ///
    4706    /// TRACES: UR-040 | DR-178 | UT-177
    4707    #[test]
    4708    fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
    4709        let controller = PlayerController::default();
    4710        controller.report_html5_position(253.4, 2640.0);
    4711
    4712        controller.enter_background_audio(18.4);
    4713
    4714        assert_eq!(
    4715            controller.absolute_position(),
    4716            18.4,
    4717            "the video element is gone; only the handoff base describes the \
    4718             stream that is now playing"
    4719        );
    4720    }
    4721
    4722    /// Nobody ever watched zero seconds of anything. A `Stopped` at 0 carries no
    4723    /// information and Jellyfin stores it as the resume point, so the only thing
    4724    /// it can do is destroy one — which is what the device trace caught it doing
    4725    /// 14 times in 35 minutes, including 40s after the frontend had correctly
    4726    /// reported 922s for the same episode.
    4727    ///
    4728    /// TRACES: UR-025 | DR-179 | UT-178
    4729    #[tokio::test]
    4730    async fn test_a_stop_at_zero_is_never_reported() {
    4731        let controller = PlayerController::default();
    4732        let reports = Arc::new(CapturingReports::new());
    4733        controller.set_report_sink(reports.clone());
    4734        controller
    4735            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4736            .unwrap();
    4737
    4738        // Nothing ever played: the backend is at 0 and no element reported in.
    4739        controller.stop().unwrap();
    4740
    4741        assert!(
    4742            reports.stops().is_empty(),
    4743            "a zero-position stop must be withheld, not sent; got {:?}",
    4744            reports.stops()
    4745        );
    4746    }
    4747
    4748    /// A real position is still reported, so withholding zero cannot be
    4749    /// mistaken for withholding everything — from either rendering path.
    4750    ///
    4751    /// The webview half is the one that was broken: the element reports 253s, the
    4752    /// native backend holds nothing, and the stop report went out as 0 and
    4753    /// overwrote the resume point the frontend had just written correctly.
    4754    ///
    4755    /// TRACES: UR-025 | DR-178, DR-179 | UT-178
    4756    #[tokio::test]
    4757    async fn test_a_stop_reports_the_position_actually_reached() {
    4758        // Webview-rendered: the element is the only thing that knows.
    4759        let webview = PlayerController::default();
    4760        let webview_reports = Arc::new(CapturingReports::new());
    4761        webview.set_report_sink(webview_reports.clone());
    4762        webview
    4763            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4764            .unwrap();
    4765        webview.report_html5_position(253.0, 1500.0);
    4766
    4767        webview.stop().unwrap();
    4768
    4769        assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
    4770
    4771        // Natively rendered: the backend is authoritative and still is.
    4772        let native = PlayerController::default();
    4773        let native_reports = Arc::new(CapturingReports::new());
    4774        native.set_report_sink(native_reports.clone());
    4775        native
    4776            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4777            .unwrap();
    4778        native.seek(253.0).unwrap();
    4779
    4780        native.stop().unwrap();
    4781
    4782        assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
    4783    }
    4784
    4785    /// An episode listened to end-to-end on the lockscreen must count as
    4786    /// watched. Jellyfin decides that on the `PlaybackStopped` report — no
    4787    /// report, no completion — and in background audio-only mode there is
    4788    /// nobody else to send one: the webview is suspended and its `<video>` was
    4789    /// torn down at the handoff, so the frontend's end-of-playback reporting
    4790    /// cannot run. The backend advanced to the next episode and said nothing
    4791    /// about the one that finished.
    4792    ///
    4793    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
    4794    #[tokio::test]
    4795    async fn test_a_finished_audio_only_episode_is_reported_complete() {
    4796        let controller = PlayerController::default();
    4797        let reports = Arc::new(CapturingReports::new());
    4798        controller.set_report_sink(reports.clone());
    4799        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4800        controller
    4801            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4802            .unwrap();
    4803        // Played out to the end of the 25-minute episode.
    4804        controller.seek(1499.0).unwrap();
    4805        controller.take_end_reason();
    4806
    4807        controller.on_playback_ended().await.unwrap();
    4808
    4809        assert_eq!(
    4810            reports.stops(),
    4811            vec![("ep2".to_string(), 1500.0)],
    4812            "the finished episode must be reported stopped at its runtime, or \
    4813             Jellyfin's ≥90% rule never marks it played"
    4814        );
    4815    }
    4816
    4817    /// The completion report is for ends that are really ends. A truncated
    4818    /// stream is about to be re-opened and the episode is nowhere near over, so
    4819    /// reporting it stopped would tell Jellyfin the opposite of the truth.
    4820    ///
    4821    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
    4822    #[tokio::test]
    4823    async fn test_a_truncated_stream_reports_no_completion() {
    4824        let controller = PlayerController::default();
    4825        let reports = Arc::new(CapturingReports::new());
    4826        controller.set_report_sink(reports.clone());
    4827        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4828        controller
    4829            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4830            .unwrap();
    4831        controller.seek(600.0).unwrap();
    4832        controller.take_end_reason();
    4833
    4834        let decision = controller.on_playback_ended().await.unwrap();
    4835
    4836        assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
    4837        assert!(
    4838            reports.stops().is_empty(),
    4839            "a dropped connection is not a finished episode; got {:?}",
    4840            reports.stops()
    4841        );
    4842    }
    4843
    4844    /// Position ticks reach Jellyfin while playback is still going, so closing
    4845    /// the app — or losing it to a crash — cannot cost the whole session. The
    4846    /// device trace requested `/Sessions/Playing/Progress` exactly zero times in
    4847    /// 35 minutes: the frontend service writes progress to the local DB only,
    4848    /// and nothing on the Rust side reported it for webview-rendered media.
    4849    ///
    4850    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
    4851    #[tokio::test]
    4852    async fn test_webview_position_ticks_report_progress_to_the_server() {
    4853        let controller = PlayerController::default();
    4854        let reports = Arc::new(CapturingReports::new());
    4855        controller.set_report_sink(reports.clone());
    4856        controller
    4857            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4858            .unwrap();
    4859
    4860        controller.report_html5_position(253.4, 1500.0);
    4861
    4862        assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
    4863    }
    4864
    4865    /// Ticks arrive four times a second; reports must not. The throttler the
    4866    /// controller already owns bounds them to one per item per 30s.
    4867    ///
    4868    /// TRACES: UR-005 | DR-179 | UT-180
    4869    #[tokio::test]
    4870    async fn test_progress_reports_are_throttled_not_sent_per_tick() {
    4871        let controller = PlayerController::default();
    4872        let reports = Arc::new(CapturingReports::new());
    4873        controller.set_report_sink(reports.clone());
    4874        controller
    4875            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4876            .unwrap();
    4877
    4878        for tick in 0..12 {
    4879            controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
    4880        }
    4881
    4882        assert_eq!(
    4883            reports.progress().len(),
    4884            1,
    4885            "twelve ticks inside one throttle window are one report"
    4886        );
    4887    }
    4888
    4889    /// The truncation check compares the position against the item's runtime, so
    4890    /// both must be on the same timeline.
    4891    ///
    4892    /// They now are by construction: the Android position tick shifts by the
    4893    /// handoff base before anything sees the value, so what the player reports is
    4894    /// already a position on the episode. The base is therefore *not* added here —
    4895    /// doing so would double-count it and make the last minute of a handoff look
    4896    /// like a truncation. What the mock backend holds is what the real one would
    4897    /// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
    4898    #[tokio::test]
    4899    async fn test_truncated_check_uses_the_absolute_position() {
    4900        let controller = PlayerController::default();
    4901        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4902
    4903        controller
    4904            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4905            .unwrap();
    4906        // Handed off at 24:00; the stream then played its last 56 seconds out, so
    4907        // the player reports 24:56 of the episode.
    4908        controller.set_background_audio_base(1440.0);
    4909        controller.seek(1496.0).unwrap();
    4910        controller.take_end_reason();
    4911
    4912        let decision = controller.on_playback_ended().await.unwrap();
    4913
    4914        assert!(
    4915            matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
    4916            "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
    4917            decision
    4918        );
    4919    }
    4920
    4921    /// The resume re-opens the same URL, so a server that is actually gone would
    4922    /// otherwise end → resume → end forever. After the budget runs out the
    4923    /// decision falls back to normal end-of-item handling.
    4924    #[tokio::test]
    4925    async fn test_repeated_truncation_at_the_same_position_gives_up() {
    4926        let controller = PlayerController::default();
    4927        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    4928
    4929        controller
    4930            .play_queue(vec![audio_only_episode(1500.0)], 0)
    4931            .unwrap();
    4932        controller.seek(600.0).unwrap();
    4933
    4934        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
    4935            controller.take_end_reason();
    4936            let decision = controller.on_playback_ended().await.unwrap();
    4937            assert!(
    4938                matches!(decision, AutoplayDecision::ResumeStream { .. }),
    4939                "attempt {} should still resume, got {:?}",
    4940                attempt,
    4941                decision
    4942            );
    4943        }
    4944
    4945        controller.take_end_reason();
    4946        let decision = controller.on_playback_ended().await.unwrap();
    4947        assert!(
    4948            !matches!(decision, AutoplayDecision::ResumeStream { .. }),
    4949            "a stream stuck at the same position must stop retrying, got {:?}",
    4950            decision
    4951        );
    4952    }
    4953
    4954    /// Ordinary music is not covered: its streams are not the length-less
    4955    /// progressive transcode this guards, and a short track legitimately ends
    4956    /// well before a stale duration would suggest.
    4957    #[tokio::test]
    4958    async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
    4959        let controller = PlayerController::default();
    4960
    4961        let mut items = create_test_items(2);
    4962        items[0].duration = Some(1500.0);
    4963        controller.play_queue(items, 0).unwrap();
    4964        controller.seek(60.0).unwrap();
    4965        controller.take_end_reason();
    4966
    4967        let decision = controller.on_playback_ended().await.unwrap();
    4968        assert!(
    4969            matches!(decision, AutoplayDecision::AdvanceToNext),
    4970            "plain queue audio must keep advancing, got {:?}",
    4971            decision
    4972        );
    4973    }
    4974
    4975    /// Music and video stream from URLs that declare their own length (a static
    4976    /// file with byte ranges, an HLS playlist), so a truncation reaches the
    4977    /// player as an *error* rather than a phantom end. It is the same network
    4978    /// failure, and the same recovery applies — the previous behaviour turned it
    4979    /// into `playerStop()` and silence.
    4980    #[tokio::test]
    4981    async fn test_recoverable_error_resumes_a_music_track() {
    4982        let controller = PlayerController::default();
    4983
    4984        let mut items = create_test_items(3);
    4985        for item in &mut items {
    4986            item.source = MediaSource::Remote {
    4987                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
    4988                jellyfin_item_id: item.id.clone(),
    4989            };
    4990        }
    4991        controller.play_queue(items, 1).unwrap();
    4992        controller.seek(45.0).unwrap();
    4993
    4994        let (position, _) = controller
    4995            .recoverable_error_resume()
    4996            .expect("a streamed music track must be resumable after a network error");
    4997        assert_eq!(position, 45.0);
    4998    }
    4999
    5000    /// The resume must reload the failed track IN PLACE. `play_item` replaces the
    5001    /// whole queue with a single item, so recovering a track that way would throw
    5002    /// away the rest of the album — turning a network blip into lost state.
    5003    #[tokio::test]
    5004    async fn test_resume_keeps_the_rest_of_the_queue() {
    5005        let controller = PlayerController::default();
    5006
    5007        let mut items = create_test_items(3);
    5008        for item in &mut items {
    5009            item.source = MediaSource::Remote {
    5010                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
    5011                jellyfin_item_id: item.id.clone(),
    5012            };
    5013        }
    5014        controller.play_queue(items, 1).unwrap();
    5015
    5016        controller
    5017            .resume_stream_at(45.0)
    5018            .await
    5019            .expect("resume should succeed");
    5020
    5021        let queue = controller.queue.lock_safe();
    5022        assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
    5023        assert_eq!(queue.current_index(), Some(1), "still on the same track");
    5024        assert_eq!(queue.current().unwrap().id, "item_1");
    5025    }
    5026
    5027    /// A seekable stream is re-opened by re-preparing the URL it already has and
    5028    /// seeking — its timeline is intact, and rewriting the URL would restart a
    5029    /// transcode session for no reason.
    5030    #[tokio::test]
    5031    async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
    5032        let controller = PlayerController::default();
    5033
    5034        let mut items = create_test_items(1);
    5035        items[0].source = MediaSource::Remote {
    5036            stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
    5037            jellyfin_item_id: "item_0".to_string(),
    5038        };
    5039        controller.play_queue(items, 0).unwrap();
    5040
    5041        controller.resume_stream_at(45.0).await.unwrap();
    5042
    5043        match &controller.queue.lock_safe().current().unwrap().source {
    5044            MediaSource::Remote { stream_url, .. } => {
    5045                assert_eq!(
    5046                    stream_url, "http://s/Audio/item_0/stream?Static=true",
    5047                    "a seekable stream's URL must be left alone"
    5048                );
    5049            }
    5050            other => panic!("expected Remote source, got {:?}", other),
    5051        }
    5052        assert_eq!(
    5053            controller.position(),
    5054            45.0,
    5055            "and it must land at the position"
    5056        );
    5057    }
    5058
    5059    /// Downloaded media cannot fail from the network, and re-opening a local file
    5060    /// would paper over a real read error.
    5061    #[tokio::test]
    5062    async fn test_recoverable_error_ignores_local_media() {
    5063        let controller = PlayerController::default();
    5064
    5065        let mut items = create_test_items(1);
    5066        items[0].source = MediaSource::Local {
    5067            file_path: "/music/track.flac".into(),
    5068            jellyfin_item_id: Some("item_0".to_string()),
    5069        };
    5070        controller.play_queue(items, 0).unwrap();
    5071        controller.seek(45.0).unwrap();
    5072
    5073        assert!(controller.recoverable_error_resume().is_none());
    5074    }
    5075
    5076    /// A recoverable error during background audio-only playback is the network,
    5077    /// not the media — the previous behaviour (surface it, frontend stops the
    5078    /// player) turned a hiccup into silence. Retrying must also back off, or the
    5079    /// three attempts are spent inside a second and the outage outlives them.
    5080    #[tokio::test]
    5081    async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
    5082        let controller = PlayerController::default();
    5083
    5084        controller
    5085            .play_queue(vec![audio_only_episode(1500.0)], 0)
    5086            .unwrap();
    5087        controller.seek(600.0).unwrap();
    5088
    5089        let mut waits = Vec::new();
    5090        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
    5091            let (position, delay) = controller
    5092                .recoverable_error_resume()
    5093                .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
    5094            assert_eq!(position, 600.0);
    5095            waits.push(delay);
    5096        }
    5097        assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
    5098        assert!(
    5099            controller.recoverable_error_resume().is_none(),
    5100            "a stream that keeps failing at the same spot must surface the error"
    5101        );
    5102    }
    5103
    5104    /// Plugin/channel `DirectUrl` sources are somebody else's endpoint with no
    5105    /// Jellyfin item behind them, so the resume has nothing to re-request.
    5106    #[tokio::test]
    5107    async fn test_recoverable_error_ignores_direct_url_playback() {
    5108        let controller = PlayerController::default();
    5109        controller.play_queue(create_test_items(2), 0).unwrap();
    5110
    5111        assert!(controller.recoverable_error_resume().is_none());
    5112    }
    5113
    5114    /// Re-opening the stream must land where it died and keep playing, with the
    5115    /// handoff base moved to the new stream's zero so returning to the
    5116    /// foreground still resolves an absolute position.
    5117    #[tokio::test]
    5118    async fn test_resume_truncated_stream_reloads_at_position() {
    5119        let controller = PlayerController::default();
    5120
    5121        controller
    5122            .play_queue(vec![audio_only_episode(1500.0)], 0)
    5123            .unwrap();
    5124        controller.set_background_audio_base(0.0);
    5125
    5126        controller
    5127            .resume_stream_at(600.0)
    5128            .await
    5129            .expect("resume should succeed");
    5130
    5131        let current = controller
    5132            .queue
    5133            .lock_safe()
    5134            .current()
    5135            .cloned()
    5136            .expect("the same item should still be loaded");
    5137        assert_eq!(current.id, "ep2", "resume must not change the item");
    5138        match &current.source {
    5139            MediaSource::Remote { stream_url, .. } => {
    5140                assert!(
    5141                    stream_url.contains("StartTimeTicks=6000000000"),
    5142                    "stream must re-open at 600s, got {}",
    5143                    stream_url
    5144                );
    5145                assert!(
    5146                    stream_url.contains("AudioStreamIndex=2"),
    5147                    "the selected audio track must survive the resume, got {}",
    5148                    stream_url
    5149                );
    5150            }
    5151            other => panic!("expected Remote source, got {:?}", other),
    5152        }
    5153        assert_eq!(
    5154            controller.take_background_audio_base(),
    5155            600.0,
    5156            "the re-opened stream's zero is the resume position"
    5157        );
    5158    }
    5159
    5160    /// Foreground video playback keeps the countdown-driven advance: the frontend
    5161    /// owns the navigation there, so the backend must NOT load the next episode
    5162    /// itself (that would race the page transition and double-start playback).
    5163    #[tokio::test]
    5164    async fn test_auto_advance_foreground_video_episode_uses_countdown() {
    5165        let controller = PlayerController::default();
    5166        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
    5167
    5168        let episode = MediaItem {
    5169            // Audio and direct-URL items never negotiate a transport.
    5170            transport: None,
    5171            id: "ep2".to_string(),
    5172            item_type: Some("Episode".to_string()),
    5173            media_type: MediaType::Video,
    5174            series_id: Some("series1".to_string()),
    5175            source: MediaSource::Remote {
    5176                stream_url: "http://example.com/ep2.m3u8".to_string(),
    5177                jellyfin_item_id: "ep2".to_string(),
    5178            },
    5179            ..create_test_items(1).remove(0)
    5180        };
    5181        controller.play_queue(vec![episode], 0).unwrap();
    5182
    5183        let next = make_repo_episode("ep3", 3);
    5184        controller.auto_advance_to_next_episode(next, 10).await;
    5185
    5186        let current = controller
    5187            .queue
    5188            .lock_safe()
    5189            .current()
    5190            .cloned()
    5191            .expect("an item should still be loaded");
    5192        assert_eq!(
    5193            current.id, "ep2",
    5194            "foreground video advance is frontend-driven; the backend must not \
    5195             swap the queue item out from under it"
    5196        );
    5197    }
    5198
    5199    /// Without a controller repository the Android episode path must still
    5200    /// stop gracefully (previous behavior) rather than error.
    5201    #[tokio::test]
    5202    async fn test_playback_ended_without_repository_stops() {
    5203        let controller = PlayerController::default();
    5204
    5205        let episode = MediaItem {
    5206            // Audio and direct-URL items never negotiate a transport.
    5207            transport: None,
    5208            media_type: MediaType::Video,
    5209            source: MediaSource::Remote {
    5210                stream_url: "http://example.com/ep1.mkv".to_string(),
    5211                jellyfin_item_id: "ep1".to_string(),
    5212            },
    5213            ..create_test_items(1).remove(0)
    5214        };
    5215        controller.play_queue(vec![episode], 0).unwrap();
    5216        controller.take_end_reason();
    5217
    5218        let decision = controller.on_playback_ended().await.unwrap();
    5219        assert!(matches!(decision, AutoplayDecision::Stop));
    5220    }
    5221}