// Player module - Complete playback control system
// TRACES: UR-003, UR-004, UR-005, UR-019, UR-023, UR-026 |
//         IR-003, IR-004, IR-006, IR-008 |
//         DR-001, DR-004, DR-005, DR-009, DR-028, DR-029, DR-047
pub mod autoplay;
pub mod backend;
pub mod events;
pub mod media;
pub mod queue;
pub mod seek;
pub mod session;
pub mod sleep_timer;
pub mod state;
pub mod stream_end;

#[cfg(test)]
mod mpv_backend_test;

// Platform-specific backends
#[cfg(target_os = "android")]
pub mod android;

#[cfg(target_os = "linux")]
pub mod mpv_backend;

// Platforms with no native audio backend (e.g. Windows) render audio-only
// playback through a webview <audio> element, mirroring how all video renders.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub mod webview_audio_backend;

// Re-export commonly used types
pub use autoplay::{AutoplayDecision, AutoplaySettings};
pub use backend::{NullBackend, PlayerBackend, PlayerError};
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
pub use queue::{QueueManager, RepeatMode};
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType};
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
pub use state::{EndReason, PlayerState};

// Re-export platform-specific backends
#[cfg(target_os = "android")]
pub use android::ExoPlayerBackend;

#[cfg(target_os = "linux")]
pub use mpv_backend::MpvBackend;

#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub use webview_audio_backend::WebviewAudioBackend;

#[cfg(target_os = "android")]
pub use android::{
    disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
    set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
};

/// Where the player's playback reports go.
///
/// The controller's side of reporting is "send this, don't make me wait": a slow
/// or failing sync must never stall playback, so every send is fire-and-forget.
/// Production wires this to [`PlaybackReporter`] (local DB, server sync, offline
/// queueing); tests capture the operations instead of standing up a database and
/// an HTTP client, which is what let the missing reports below be written as
/// failing tests rather than found on a device.
///
/// TRACES: UR-025 | DR-179
pub trait PlaybackReportSink: Send + Sync {
    /// Deliver `operation`. Must not block the caller.
    fn send(&self, operation: PlaybackOperation);
}

/// The production sink: hands each operation to the `PlaybackReporter`.
///
/// Reports originate on whatever thread playback ended or ticked on — including
/// JNI callbacks with no Tokio runtime attached — so the spawn falls back to a
/// throwaway runtime on its own thread rather than assuming one is current.
struct ReporterSink {
    reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
}

impl PlaybackReportSink for ReporterSink {
    fn send(&self, operation: PlaybackOperation) {
        let reporter = self.reporter.clone();
        let task = async move {
            let guard = reporter.lock().await;
            let Some(reporter) = guard.as_ref() else {
                warn!("[PlayerController] PlaybackReporter not initialized; dropping report");
                return;
            };
            // `report` decides local-vs-server and queues for sync itself.
            if let Err(e) = reporter.report(operation, true).await {
                log::error!("[PlayerController] Failed to report playback: {}", e);
            }
        };

        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            handle.spawn(task);
        } else {
            std::thread::spawn(move || match tokio::runtime::Runtime::new() {
                Ok(rt) => rt.block_on(task),
                Err(e) => log::error!(
                    "[PlayerController] No runtime available to report playback: {}",
                    e
                ),
            });
        }
    }
}

/// The position to report when a stream ends naturally.
///
/// The item's runtime when we know it, because the point of the report is to say
/// the episode *finished* and Jellyfin decides that by percentage — the last
/// position actually observed can be seconds short, and on a handoff whose ticks
/// stopped early it can be nowhere near the end. Without a runtime the best
/// available answer is where playback got to.
///
/// TRACES: UR-025, UR-040 | DR-179 | UT-179
fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
    match runtime {
        Some(runtime) if runtime > 0.0 => runtime,
        _ => last_position.max(0.0),
    }
}

/// Seconds added per attempt before retrying a stream that failed with an error.
///
/// Attempt 1 waits this long, attempt 2 twice as long, and so on — a spread that
/// covers roughly a quarter-minute of outage across the retry budget without
/// leaving the user staring at a dead notification when the network is truly gone.
/// Only *read* by the Android error callback (`#[cfg(android)]`), but compiled
/// and unit-tested on the host, hence `allow(dead_code)` off-Android.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
const RESUME_BACKOFF_STEP_SECS: u64 = 2;

/// Metadata for the lockscreen / media notification.
///
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
/// the local ExoPlayer is idle and so can't supply now-playing info. The session
/// poller fills this in from the remote Jellyfin session and pushes it to the
/// notification so the lockscreen stays in sync while casting.
///
/// TRACES: UR-006 | IR-006
#[derive(Debug, Clone)]
// Fields are read only by the Android MediaSession bridge; on other platforms
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub struct LockscreenMetadata {
    pub title: String,
    pub artist: String,
    pub album: Option<String>,
    /// Track duration in milliseconds.
    pub duration_ms: i64,
    /// Current playback position in milliseconds.
    pub position_ms: i64,
    pub is_playing: bool,
}

/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
/// session poller can call it unconditionally and stay platform-agnostic.
///
/// No-op on Linux specifically because there is no MPRIS/D-Bus publisher — see
/// IR-005, which is still Planned.
///
/// TRACES: UR-006 | IR-006
pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
    #[cfg(target_os = "android")]
    {
        return android::update_lockscreen_metadata(_meta);
    }
    #[cfg(not(target_os = "android"))]
    {
        Ok(())
    }
}

/// Set the base offset (seconds) added to positions reported to the Android
/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
/// relative and must be shifted back to absolute to match the full duration.
/// Pass 0.0 to clear on exit. No-op off Android.
pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
    #[cfg(target_os = "android")]
    {
        return android::set_position_offset(_offset_seconds);
    }
    #[cfg(not(target_os = "android"))]
    {
        Ok(())
    }
}

use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;

use crate::jellyfin::JellyfinClient;
use crate::playback_reporting::{
    EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
};
use crate::repository::MediaRepository;
use crate::settings::AudioSettings;
use crate::utils::conversions::seconds_to_ticks;

/// Central player controller that coordinates playback
pub struct PlayerController {
    backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
    queue: Arc<Mutex<QueueManager>>,
    jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
    muted: bool,

    // Sleep timer state
    sleep_timer: Arc<Mutex<SleepTimerState>>,

    // Autoplay settings
    autoplay_settings: Arc<Mutex<AutoplaySettings>>,

    // Repository for fetching next episodes
    repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,

    // Event emitter for notifications
    event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,

    // Countdown cancellation handle
    countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,

    // Playback reporting (dual sync: local DB + server)
    playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,

    // Where playback reports go. Swappable so tests can assert on what the
    // player tells Jellyfin. See `PlaybackReportSink`.
    reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,

    // Bounds progress reports to one per item per 30s. Position ticks arrive
    // four times a second; the server needs a resume point, not a firehose.
    position_throttler: Arc<EventThrottler>,

    // End reason tracking for autoplay decision making
    end_reason: Arc<Mutex<Option<EndReason>>>,

    // Auto-play episode counter (session-based, resets on manual play)
    autoplay_episode_count: Arc<Mutex<u32>>,

    // Base offset (seconds) of the active background-audio handoff.
    //
    // The audio-only stream is requested with `StartTimeTicks` = the position the
    // video was handed off at, so the server makes that point the stream's zero
    // and the native player reports position RELATIVE to it. Adding this base back
    // yields the absolute position to resume the video at on the way out.
    //
    // Lives on the controller (not beside the command) because the queue and this
    // offset describe the same stream: whenever the controller loads a different
    // one — notably the backend-driven advance to the next episode — the base has
    // to move with it.
    //
    // TRACES: UR-040 | DR-052
    background_audio_base: Arc<Mutex<f64>>,

    // True while a background-audio handoff owns playback: the native audio
    // player is the real player and the webview <video> has been torn down.
    //
    // The teardown is what makes this necessary. It fires a DOM `pause` that the
    // frontend reports like any other, which would otherwise leave the controller
    // believing webview media is still active — aiming lockscreen transport at an
    // element that no longer exists (see `is_html5_active`).
    //
    // TRACES: UR-040 | DR-052, DR-097
    background_audio_active: Arc<Mutex<bool>>,

    // Budget for re-opening a stream that ended short of the item's runtime.
    //
    // A resume re-requests the same URL, so a server that is genuinely gone would
    // otherwise end → resume → end without limit. The tracker only bounds retries
    // that make no progress; a resume that plays on refills it.
    //
    // TRACES: UR-040 | DR-129
    stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,

    // Last state reported by a webview-rendered HTML5 <video>/<audio> element.
    //
    // Webview-rendered media is played by an element the native backend cannot
    // reach, so the backend's own state() says nothing about it. Tracking the
    // REPORTED state here is what lets transport (play/pause/toggle) be decided
    // in Rust for that media instead of the frontend reading `el.paused` off the
    // DOM — a value that flips transiently while buffering/seeking and caused
    // competing intents to take opposing actions. `None` means no webview media
    // is active and the native backend is authoritative. See DR-097.
    html5_playing: Arc<Mutex<Option<bool>>>,

    // Last position/duration reported by webview-rendered media.
    //
    // On the webview path the `<video>` element IS the player: nothing is loaded
    // into the native backend, so `backend.position()` is a permanent 0. Those
    // reports used to be re-emitted to the frontend and then dropped, which is
    // why every position the *backend* sent to Jellyfin — including the stop
    // report that sets the resume point — was zero, overwriting the correct one
    // the frontend had just sent. Storing them here makes
    // `absolute_position()` answer for both rendering paths.
    //
    // TRACES: UR-005, UR-025 | DR-178
    reported_time: Arc<Mutex<stream_end::ObservedTime>>,
}

impl PlayerController {
    pub fn new(
        backend: Box<dyn PlayerBackend>,
        playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
        position_throttler: Arc<EventThrottler>,
    ) -> Self {
        let reports: Arc<dyn PlaybackReportSink> = Arc::new(ReporterSink {
            reporter: playback_reporter.clone(),
        });
        let controller = Self {
            backend: Arc::new(Mutex::new(backend)),
            queue: Arc::new(Mutex::new(QueueManager::new())),
            jellyfin_client: Arc::new(Mutex::new(None)),
            muted: false,
            sleep_timer: Arc::new(Mutex::new(SleepTimerState::default())),
            autoplay_settings: Arc::new(Mutex::new(AutoplaySettings::default())),
            repository: Arc::new(Mutex::new(None)),
            event_emitter: Arc::new(Mutex::new(None)),
            countdown_cancel: Arc::new(Mutex::new(None)),
            playback_reporter,
            reports: Arc::new(Mutex::new(reports)),
            position_throttler,
            end_reason: Arc::new(Mutex::new(None)),
            autoplay_episode_count: Arc::new(Mutex::new(0)),
            background_audio_base: Arc::new(Mutex::new(0.0)),
            background_audio_active: Arc::new(Mutex::new(false)),
            stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
            html5_playing: Arc::new(Mutex::new(None)),
            reported_time: Arc::new(Mutex::new(stream_end::ObservedTime::default())),
        };

        // Start background timer thread for sleep timer countdown
        controller.start_timer_thread();

        controller
    }

    /// Configure the Jellyfin API client for automatic playback reporting
    pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
        let mut jellyfin = self.jellyfin_client.lock_safe();
        *jellyfin = client;
        log::info!(
            "[PlayerController] Jellyfin client configured: {}",
            jellyfin.is_some()
        );
    }

    /// Get a reference to the Jellyfin client (for remote session control)
    pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> {
        self.jellyfin_client.clone()
    }

    /// Configure the media repository used for next-episode lookups.
    ///
    /// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
    /// repository handle (unlike the Linux HTML5 path, which passes one per
    /// call), so the controller needs a repository of its own or episode
    /// autoplay silently decides Stop.
    pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
        *self.repository.lock_safe() = Some(repo);
    }

    /// Configure the playback reporter for dual sync (local DB + server).
    /// Called from `player_configure_jellyfin` on login/restore/reauth.
    pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
        let mut reporter_guard = self.playback_reporter.lock().await;
        *reporter_guard = reporter;
        log::info!(
            "[PlayerController] Playback reporter configured: {}",
            reporter_guard.is_some()
        );
    }

    /// Get a reference to the playback reporter (for backend position updates)
    /// Will be used when position update hooks are added to backends
    #[allow(dead_code)]
    pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> {
        self.playback_reporter.clone()
    }

    /// Get a reference to the position throttler (for backend position updates)
    /// Will be used when position update hooks are added to backends
    #[allow(dead_code)]
    pub fn position_throttler(&self) -> Arc<EventThrottler> {
        self.position_throttler.clone()
    }

    /// Set the end reason for the next playback end event
    fn set_end_reason(&self, reason: EndReason) {
        log::debug!("[PlayerController] Setting end reason: {:?}", reason);
        *self.end_reason.lock_safe() = Some(reason);
    }

    /// Get and clear the current end reason
    fn take_end_reason(&self) -> Option<EndReason> {
        self.end_reason.lock_safe().take()
    }

    /// Read the end reason WITHOUT consuming it.
    ///
    /// `take_end_reason` has an owner: on Android the JNI ended-callback consumes
    /// the `NewTrackLoaded` every load sets, and the frontend's echoed call is the
    /// one that sees `None` and decides. The truncated-stream check runs in both
    /// calls and must not disturb that hand-off, so it peeks.
    fn peek_end_reason(&self) -> Option<EndReason> {
        *self.end_reason.lock_safe()
    }

    /// Record that playback is being stopped by an expiring sleep timer.
    ///
    /// Stopping the backend makes it fire its ended callback (ExoPlayer does on
    /// Android), which lands in `on_playback_ended`. Without an end reason that
    /// reads as a natural finish and autoplay advances — defeating the timer.
    /// `UserStop` is the honest label: the stop was user-initiated, just via the
    /// timer they set rather than the stop button.
    ///
    /// Takes the shared slot rather than `&self` so the sleep-timer thread —
    /// which owns clones, not the controller — records it the same way.
    ///
    /// TRACES: UR-023, UR-026 | DR-029
    fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
        log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
        *end_reason.lock_safe() = Some(EndReason::UserStop);
    }

    /// Increment autoplay episode counter. Returns true if limit is reached.
    fn increment_autoplay_count(&self) -> bool {
        let max = self.autoplay_settings.lock_safe().max_episodes;

        if max == 0 {
            // Unlimited
            return false;
        }

        let mut count = self.autoplay_episode_count.lock_safe();
        *count += 1;
        debug!(
            "[PlayerController] Autoplay episode count: {}/{}",
            *count, max
        );

        *count >= max
    }

    /// Reset autoplay episode counter (called on manual play actions)
    fn reset_autoplay_count(&self) {
        let mut count = self.autoplay_episode_count.lock_safe();
        if *count > 0 {
            debug!(
                "[PlayerController] Resetting autoplay episode counter (was {})",
                *count
            );
        }
        *count = 0;
    }

    /// Load and play a single item (also sets the queue to contain only this item)
    pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
        debug!("[PlayerController] play_item: {}", item.title);

        // Reset autoplay counter on manual play
        self.reset_autoplay_count();

        // Update queue with this single item
        {
            let mut queue = self.queue.lock_safe();
            queue.set_queue(vec![item.clone()], 0);
        }

        // Load and play the item
        self.load_and_play(&item)?;

        Ok(())
    }

    /// Set the current queue item without loading it into the playback backend.
    ///
    /// Used on platforms where video is rendered outside the native backend
    /// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
    /// item, but MPV must not start a redundant decode for it.
    #[cfg(target_os = "linux")]
    pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
        debug!(
            "[PlayerController] set_current_item (no backend load): {}",
            item.title
        );

        self.reset_autoplay_count();
        // A different item is current; the last one's reported position must not
        // be reported against it. This path is how webview-rendered video is
        // queued (no backend load at all), so it is exactly where a stale
        // reading would otherwise survive.
        // TRACES: UR-005 | DR-178
        self.clear_reported_time();

        let mut queue = self.queue.lock_safe();
        queue.set_queue(vec![item], 0);

        Ok(())
    }

    /// Load and play an item without modifying the queue
    /// Use this when the queue is already set up and you just want to play a specific item from it
    pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
        debug!("[PlayerController] load_and_play: {}", item.title);

        // Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
        self.set_end_reason(EndReason::NewTrackLoaded);

        // Loading into the native backend IS the statement that native renders
        // this item, so transport authority returns to it.
        //
        // `html5_playing` is written only by the webview element's own reports
        // and cleared only when it reports "stopped"/"idle". An element that
        // went away without that final report — or webview-rendered music
        // earlier in the same process — left `is_html5_active()` true, and then
        // every play/pause intent was emitted as a ControlCommand at an element
        // that no longer existed instead of reaching the backend. On Android's
        // native video path that is a pause button that does nothing, from the
        // surface tap and the control bar alike, while seek and skip keep
        // working because they decide elsewhere. Whether it happened at all
        // depended on what had played before, which is what made it look
        // intermittent.
        //
        // The webview re-establishes its own authority the moment an element
        // reports again, so nothing is lost on the HTML5 path: this is the same
        // "element is gone" semantics as the "stopped"/"idle" report, applied at
        // the point where we can know it directly.
        //
        // TRACES: UR-005, UR-003 | DR-193
        *self.html5_playing.lock_safe() = None;

        let mut backend = self.backend.lock_safe();
        backend.load(item)?;
        backend.play()?;
        drop(backend);

        // A different item is loading; the last one's reported position must not
        // be attributed to it.
        self.clear_reported_time();

        // Report playback start using PlaybackReporter (dual sync: local DB + server)
        if let Some(jellyfin_id) = item.jellyfin_id() {
            // Build playback context from item metadata
            let context = if item.album_id.is_some() {
                Some(PlaybackContext {
                    context_type: "container".to_string(),
                    context_id: item.album_id.clone(),
                })
            } else {
                None
            };

            // Where this stream actually begins. Zero for an ordinary load, but a
            // background-audio handoff loads a stream whose zero is the handoff
            // point — telling the server the session started at 0:00 there both
            // misreports the session and, being a position, competes with the
            // real one.
            let position = self.absolute_position();

            log::info!(
                "[PlayerController] Reporting playback start: {} @ {:.1}s",
                jellyfin_id,
                position
            );
            self.report(PlaybackOperation::Start {
                item_id: jellyfin_id.to_string(),
                position_ticks: seconds_to_ticks(position),
                context,
            });
        }

        Ok(())
    }

    /// Set the queue and start playing from the specified index
    pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
        self.play_queue_from(items, start_index, None)
    }

    /// Set the queue and start playing from the specified index, optionally
    /// resuming the starting track at `start_position` (seconds).
    ///
    /// The seek happens immediately after load so the backend never audibly
    /// starts at 0 and there's no race against a fixed delay. Used when taking
    /// over playback from a remote session.
    pub fn play_queue_from(
        &self,
        items: Vec<MediaItem>,
        start_index: usize,
        start_position: Option<f64>,
    ) -> Result<(), PlayerError> {
        debug!(
            "[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
            items.len(),
            start_index,
            start_position
        );

        // Reset autoplay counter on manual queue start
        self.reset_autoplay_count();

        {
            let mut queue = self.queue.lock_safe();
            queue.set_queue(items, start_index);
        }

        // Play the current item (without modifying the queue we just set)
        if let Some(item) = self.queue.lock_safe().current().cloned() {
            self.load_and_play(&item)?;

            // Resume from the requested position. Seeking right after load (while
            // the backend lock is no longer held) avoids the start-at-0-then-jump
            // race that a delayed frontend seek suffers from.
            if let Some(position) = start_position {
                if position > 0.5 {
                    self.seek(position)?;
                }
            }
        }

        Ok(())
    }

    /// Replace the queue without starting local playback.
    ///
    /// Used when we're controlling a remote session: the tracks play on the
    /// remote device, but we keep the local queue in sync so the UI reflects
    /// what's playing and a later transfer-to-local has the queue to resume.
    pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
        debug!(
            "[PlayerController] set_queue (no local playback): {} items, index {}",
            items.len(),
            start_index
        );
        self.reset_autoplay_count();
        let mut queue = self.queue.lock_safe();
        queue.set_queue(items, start_index);
        Ok(())
    }

    /// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
    /// player, so transport must be routed to it rather than the native backend.
    ///
    /// TRACES: UR-005 | DR-097
    pub fn is_html5_active(&self) -> bool {
        self.html5_playing.lock_safe().is_some()
    }

    /// Whether the webview element last reported itself as playing. Meaningless
    /// unless [`Self::is_html5_active`] is true.
    ///
    /// TRACES: UR-005 | DR-097
    pub fn html5_is_playing(&self) -> bool {
        self.html5_playing.lock_safe().unwrap_or(false)
    }

    /// Send a transport intent to the webview element that is rendering media.
    fn emit_html5_control(&self, action: &str) {
        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
            emitter.emit(PlayerStatusEvent::ControlCommand {
                action: action.to_string(),
                position: None,
            });
        }
    }

    /// Play/resume playback
    pub fn play(&self) -> Result<(), PlayerError> {
        debug!("[PlayerController] play");
        // Webview-rendered media: the native backend isn't playing it, so drive
        // the element via a ControlCommand instead (DR-097).
        if self.is_html5_active() {
            self.emit_html5_control("play");
            return Ok(());
        }
        let mut backend = self.backend.lock_safe();
        backend.play()
    }

    /// Pause playback
    pub fn pause(&self) -> Result<(), PlayerError> {
        if self.is_html5_active() {
            self.emit_html5_control("pause");
            return Ok(());
        }
        let mut backend = self.backend.lock_safe();
        backend.pause()
    }

    /// Toggle play/pause.
    ///
    /// The decision is made HERE, from authoritative state — the reported webview
    /// state for HTML5-rendered media, or the native backend's state otherwise.
    /// The frontend must never decide this from the DOM (see DR-097).
    ///
    /// TRACES: UR-005 | DR-097
    pub fn toggle_playback(&self) -> Result<(), PlayerError> {
        if self.is_html5_active() {
            let action = if self.html5_is_playing() {
                "pause"
            } else {
                "play"
            };
            self.emit_html5_control(action);
            return Ok(());
        }
        let mut backend = self.backend.lock_safe();
        if backend.state().is_playing() {
            backend.pause()
        } else {
            backend.play()
        }
    }

    /// Stop playback
    pub fn stop(&self) -> Result<(), PlayerError> {
        // Set end reason to UserStop to prevent autoplay
        self.set_end_reason(EndReason::UserStop);

        // Get current playback info before stopping
        let jellyfin_id = {
            let queue = self.queue.lock_safe();
            queue
                .current()
                .and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
        };

        // Read across every rendering path BEFORE stopping: the backend zeroes
        // its position on stop, and the element that was reporting is gone.
        let position = self.absolute_position();

        let mut backend = self.backend.lock_safe();
        backend.stop()?;
        drop(backend);
        self.clear_reported_time();

        if let Some(jellyfin_id) = jellyfin_id {
            self.report_stopped_at(jellyfin_id, position);
        }

        Ok(())
    }

    /// Tell Jellyfin playback stopped at `position`, unless that position is
    /// zero.
    ///
    /// Jellyfin stores the reported position as the resume point, so a zero is
    /// not a harmless no-op — it is an instruction to forget where the viewer
    /// was. And it is never *information*: nobody watched zero seconds of
    /// anything, so every zero this app ever sent came from asking a player that
    /// was not rendering the media (webview video, or a handoff whose first tick
    /// had not landed). On a device trace, 14 of 14 stop reports in 35 minutes
    /// were zeroes, one of them 40s after the frontend had correctly reported
    /// 15:22 for the same episode.
    ///
    /// TRACES: UR-025, UR-005 | DR-179 | UT-178
    fn report_stopped_at(&self, jellyfin_id: String, position: f64) {
        if position <= 0.0 {
            debug!(
                "[PlayerController] Withholding zero-position stop report for {} \
                 (nothing played; reporting it would clear the resume point)",
                jellyfin_id
            );
            return;
        }

        log::info!(
            "[PlayerController] Reporting playback stopped: {} @ {:.1}s",
            jellyfin_id,
            position
        );
        self.report(PlaybackOperation::Stopped {
            item_id: jellyfin_id,
            position_ticks: seconds_to_ticks(position),
        });
    }

    /// Skip to next track
    ///
    /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
    /// from triggering when the current track's EndFile event fires
    pub fn next(&self) -> Result<(), PlayerError> {
        // Reset autoplay counter on manual skip
        self.reset_autoplay_count();

        let next_item = {
            let mut queue = self.queue.lock_safe();
            queue.next().cloned()
        };

        debug!(
            "[PlayerController] next: {:?}",
            next_item.as_ref().map(|i| &i.title)
        );

        if let Some(item) = next_item {
            self.load_and_play(&item)
        } else {
            debug!("[PlayerController] No next item, stopping");
            self.stop()
        }
    }

    /// Skip to previous track
    ///
    /// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
    /// from triggering when the current track's EndFile event fires
    pub fn previous(&self) -> Result<(), PlayerError> {
        // Reset autoplay counter on manual skip
        self.reset_autoplay_count();
        // If we're more than 3 seconds in, restart current track
        {
            let backend = self.backend.lock_safe();
            if backend.position() > 3.0 {
                debug!("[PlayerController] previous: restarting current track (position > 3s)");
                drop(backend);
                return self.seek(0.0);
            }
        }

        let prev_item = {
            let mut queue = self.queue.lock_safe();
            queue.previous().cloned()
        };

        debug!(
            "[PlayerController] previous: {:?}",
            prev_item.as_ref().map(|i| &i.title)
        );

        if let Some(item) = prev_item {
            self.load_and_play(&item)
        } else {
            self.seek(0.0)
        }
    }

    /// Seek to a position in seconds, **on the player's own timeline**.
    ///
    /// During a background-audio handoff that timeline is relative to the handoff
    /// point, so this is not the call a lockscreen scrub or a UI seek wants — use
    /// [`seek_absolute`](Self::seek_absolute), which speaks the episode's
    /// timeline and is what every caller outside the player itself means.
    pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
        let mut backend = self.backend.lock_safe();
        backend.seek(position)
    }

    /// Seek to an **absolute** position on the item's own timeline.
    ///
    /// This is the boundary every outside seek comes through — the UI, the
    /// lockscreen scrubber, a headset gesture — because all of them are looking
    /// at the whole episode, not at whatever fragment of it the player happens to
    /// be streaming.
    ///
    /// Outside a background-audio handoff the two timelines are the same and this
    /// is an ordinary seek. Inside one they differ by the handoff base, and the
    /// stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked
    /// transcode with no length, so ExoPlayer either refuses or clamps — and a
    /// clamped seek lands at stream zero, which is the handoff point. That is the
    /// "jumps back to where I locked the screen" symptom. Honouring the seek means
    /// re-opening the URL at the new position, which is exactly what the
    /// truncation recovery already does, so it shares `resume_stream_at`.
    ///
    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
    pub async fn seek_absolute(&self, position: f64) -> Result<(), String> {
        // Only a *streamed* handoff needs the rebuild. A downloaded file seeks
        // like any other file — and `resume_stream_at` refuses a non-remote
        // source, so sending one through here fails the seek outright.
        // TRACES: UR-071 | DR-180 | UT-181
        let rebuild = self.is_background_audio_active() && {
            let queue = self.queue.lock_safe();
            queue
                .current()
                .map(|item| {
                    Self::is_audio_only_video(item)
                        && matches!(item.source, MediaSource::Remote { .. })
                })
                .unwrap_or(false)
        };

        if rebuild {
            return self.resume_stream_at(position.max(0.0)).await;
        }

        self.seek(position).map_err(|e| e.to_string())
    }

    /// Set volume (0.0 - 1.0)
    pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
        self.backend.lock_safe().set_volume(volume)
    }

    /// Set the active audio track by stream index
    pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
        let mut backend = self.backend.lock_safe();
        backend.set_audio_track(stream_index)
    }

    /// Set the active subtitle track by stream index (None to disable subtitles)
    pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
        let mut backend = self.backend.lock_safe();
        backend.set_subtitle_track(stream_index)
    }

    /// Get current state
    pub fn state(&self) -> PlayerState {
        self.backend.lock_safe().state()
    }

    /// Get current position
    pub fn position(&self) -> f64 {
        self.backend.lock_safe().position()
    }

    /// The position on the **item's own timeline**, whatever is rendering it.
    ///
    /// This is what every outbound position must be taken from — the resume point
    /// sent to Jellyfin, the point the video reloads at when a handoff ends, the
    /// truncation comparison. `position()` alone answers for exactly one of the
    /// three ways this app plays media, and reads 0 for the other two:
    ///
    /// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
    ///   backend, so its position is a permanent 0. The element's own reports are
    ///   the only reading there is.
    /// - **Background-audio handoff**: the audio-only stream's zero is the
    ///   handoff point, and the base is added at the native tick boundary
    ///   (DR-159) — so before the first tick lands, nothing has applied it.
    ///   Flooring at the base is exact rather than approximate: the stream cannot
    ///   physically be behind its own starting point.
    /// - **Native playback**: the backend is authoritative and both other terms
    ///   are zero, so the max is its own value.
    ///
    /// Returning to the foreground during that pre-first-tick window is what
    /// restarted an episode from 0:00 and wiped its server-side resume point.
    ///
    /// TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177
    pub fn absolute_position(&self) -> f64 {
        let native = self.backend.lock_safe().position().max(0.0);
        let reported = self.reported_time.lock_safe().last_position();
        let base = if self.is_background_audio_active() {
            *self.background_audio_base.lock_safe()
        } else {
            0.0
        };

        native.max(reported).max(base)
    }

    /// The duration last reported by webview-rendered media, if any.
    ///
    /// TRACES: UR-005 | DR-178 | UT-177
    pub fn observed_duration(&self) -> Option<f64> {
        self.reported_time.lock_safe().last_duration()
    }

    /// Forget what webview-rendered media reported.
    ///
    /// Called wherever that element stops being the player — it was torn down,
    /// a handoff took over, or a different item is loading. A stale position
    /// outliving its element would be reported against whatever plays next.
    ///
    /// TRACES: UR-005 | DR-178 | UT-177
    fn clear_reported_time(&self) {
        self.reported_time.lock_safe().reset();
    }

    /// Replace the sink playback reports go to. Tests capture; production wires
    /// the `PlaybackReporter` at construction and never swaps it.
    ///
    /// TRACES: UR-025 | DR-179
    #[cfg_attr(not(test), allow(dead_code))]
    pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>) {
        *self.reports.lock_safe() = sink;
    }

    /// Send a playback report. Fire-and-forget by contract, so callers can do
    /// this while holding nothing and waiting for nothing.
    ///
    /// TRACES: UR-025 | DR-179
    fn report(&self, operation: PlaybackOperation) {
        let sink = self.reports.lock_safe().clone();
        sink.send(operation);
    }

    /// The Jellyfin id of whatever is currently queued, if it has one.
    fn current_jellyfin_id(&self) -> Option<String> {
        let queue = self.queue.lock_safe();
        queue
            .current()
            .and_then(|item| item.jellyfin_id().map(|id| id.to_string()))
    }

    /// Get duration.
    ///
    /// Falls back to what webview-rendered media reported for the same reason
    /// [`absolute_position`](Self::absolute_position) does: on that path nothing
    /// is loaded into the native backend, so its duration is `None` and the
    /// element's report is the only one there is.
    ///
    /// TRACES: UR-005 | DR-178
    pub fn duration(&self) -> Option<f64> {
        self.backend
            .lock_safe()
            .duration()
            .or_else(|| self.observed_duration())
    }

    /// Get queue reference
    pub fn queue(&self) -> Arc<Mutex<QueueManager>> {
        self.queue.clone()
    }

    /// True when the current item is a TV episode being played in audio-only
    /// (background) mode — i.e. an `item_type == "Episode"` item loaded as
    /// `MediaType::Audio`. Used to decide whether the backend must drive the
    /// next-episode advance itself (the frontend is suspended in the background).
    ///
    /// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
    /// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
    pub fn current_is_audio_episode(&self) -> bool {
        self.queue
            .lock_safe()
            .current()
            .map(|item| {
                item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
            })
            .unwrap_or(false)
    }

    /// Clear the queue entirely (used when playback genuinely stops, e.g. the
    /// sleep timer fires or the queue ends with repeat off). Pair with
    /// `emit_queue_changed` so the frontend hides the mini player.
    pub fn clear_queue(&self) {
        self.queue.lock_safe().clear();
    }

    /// Toggle shuffle
    pub fn toggle_shuffle(&self) {
        self.queue.lock_safe().toggle_shuffle();
    }

    /// Cycle repeat mode
    pub fn cycle_repeat(&self) {
        self.queue.lock_safe().cycle_repeat();
    }

    /// Check if shuffle is enabled
    pub fn is_shuffle(&self) -> bool {
        self.queue.lock_safe().is_shuffle()
    }

    /// Get repeat mode
    pub fn repeat_mode(&self) -> RepeatMode {
        self.queue.lock_safe().repeat_mode()
    }

    /// Get current volume (0.0 - 1.0)
    pub fn volume(&self) -> f32 {
        self.backend.lock_safe().volume()
    }

    /// Check if muted
    pub fn muted(&self) -> bool {
        self.muted
    }

    /// Set audio settings (crossfade, gapless, normalization)
    pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
        self.backend.lock_safe().set_audio_settings(settings)
    }

    /// Get current audio settings
    pub fn audio_settings(&self) -> AudioSettings {
        self.backend.lock_safe().audio_settings()
    }

    // ===== Sleep Timer Methods =====

    /// Set the event emitter for notifications
    pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
        let mut event_emitter = self.event_emitter.lock_safe();
        *event_emitter = Some(emitter);
    }

    /// Get the event emitter
    pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
        self.event_emitter.lock_safe().clone()
    }

    /// Get sleep timer state
    pub fn sleep_timer_state(&self) -> SleepTimerState {
        self.sleep_timer.lock_safe().clone()
    }

    /// Set sleep timer mode (in-memory only, not persisted)
    pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
        let mut timer = self.sleep_timer.lock_safe();
        timer.mode = mode.clone();
        if let SleepTimerMode::Time { end_time } = mode {
            let now = chrono::Utc::now().timestamp_millis();
            timer.remaining_seconds = ((end_time - now) / 1000).max(0) as u32;
        } else {
            timer.remaining_seconds = 0;
        }
        drop(timer);

        // Emit event to frontend for display update
        self.emit_sleep_timer_changed();
    }

    /// Cancel sleep timer
    pub fn cancel_sleep_timer(&self) {
        self.set_sleep_timer(SleepTimerMode::Off);
    }

    /// Start background timer thread for sleep timer countdown updates
    fn start_timer_thread(&self) {
        let sleep_timer = self.sleep_timer.clone();
        let event_emitter = self.event_emitter.clone();
        let backend = self.backend.clone();
        let end_reason = self.end_reason.clone();

        std::thread::spawn(move || {
            loop {
                std::thread::sleep(Duration::from_secs(1));

                let mut timer = sleep_timer.lock_safe();
                if timer.is_active() {
                    timer.update_remaining_seconds();

                    // Time-based timer expired: stop playback
                    if matches!(timer.mode, SleepTimerMode::Time { .. })
                        && timer.remaining_seconds == 0
                    {
                        debug!("[SleepTimer] Time-based timer expired, stopping playback");
                        timer.cancel();

                        // Mark the stop *before* it reaches the backend. Stopping
                        // makes the native player fire its ended callback, and
                        // cancelling the timer above means on_playback_ended can no
                        // longer tell this apart from a natural end — without this
                        // it would show the next-episode popup / autoplay right
                        // after the sleep timer fired.
                        Self::note_sleep_timer_stop(&end_reason);

                        // Emit cancelled state
                        if let Some(emitter) = event_emitter.lock_safe().as_ref() {
                            emitter.emit(PlayerStatusEvent::SleepTimerChanged {
                                mode: SleepTimerMode::Off,
                                remaining_seconds: 0,
                            });
                            // Tell the frontend playback must stop: HTML5 video
                            // (Linux) plays outside the backend, so stopping the
                            // backend below doesn't reach it.
                            emitter.emit(PlayerStatusEvent::SleepTimerExpired);
                        }
                        drop(timer);

                        // Stop the backend
                        if let Err(e) = backend.lock_safe().stop() {
                            error!("[SleepTimer] Failed to stop playback: {}", e);
                        }
                        continue;
                    }

                    // Emit update event
                    if let Some(emitter) = event_emitter.lock_safe().as_ref() {
                        emitter.emit(PlayerStatusEvent::SleepTimerChanged {
                            mode: timer.mode.clone(),
                            remaining_seconds: timer.remaining_seconds,
                        });
                    }
                }
                drop(timer);
            }
        });
    }

    /// Emit sleep timer changed event to frontend
    fn emit_sleep_timer_changed(&self) {
        let timer = self.sleep_timer.lock_safe().clone();

        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
            emitter.emit(PlayerStatusEvent::SleepTimerChanged {
                mode: timer.mode,
                remaining_seconds: timer.remaining_seconds,
            });
        }
    }

    /// Emit queue changed event to frontend
    pub fn emit_queue_changed(&self) {
        let queue = self.queue.lock_safe();

        debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
            queue.items().len(), queue.current_index());

        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
            emitter.emit(PlayerStatusEvent::QueueChanged {
                items: queue.items().to_vec(),
                current_index: queue.current_index(),
                shuffle: queue.is_shuffle(),
                repeat: queue.repeat_mode(),
                has_next: queue.has_next(),
                has_previous: queue.has_previous(),
            });
        } else {
            warn!("PlayerController::emit_queue_changed() - WARNING: No event emitter set!");
        }
    }

    // ===== HTML5 video report methods =====
    //
    // On platforms where video is rendered in the webview (Linux WebKitGTK
    // HTML5 <video>), the real player lives outside the native backend, so it
    // cannot emit PlayerStatusEvents itself. The frontend HTML5 adapter reports
    // DOM events here, and these methods re-emit them through the SAME event
    // pipeline the native backends use. This keeps the frontend's player store
    // fed from one place (playerEvents.ts) in both native and HTML5 modes, so
    // the Rust controller stays the single source of truth for player state.

    /// Report an HTML5 <video> state change (playing/paused/loading/stopped).
    ///
    /// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
    /// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
    pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
        // A background-audio handoff has already moved playback to the native
        // player and torn the element down; anything it still reports describes
        // a video that is no longer playing. Dropping it keeps the UI on the
        // audio that IS playing and leaves transport with the native backend.
        if self.is_background_audio_active() {
            debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
            return;
        }
        // Track it: this is the authoritative play/pause state for
        // webview-rendered media, and what transport decisions read (DR-097).
        // "stopped"/"idle" mean the element is gone, so hand authority back to
        // the native backend — otherwise music playback would keep emitting
        // ControlCommands at a element that no longer exists.
        let element_gone = {
            let mut tracked = self.html5_playing.lock_safe();
            *tracked = match state.as_str() {
                "playing" => Some(true),
                // "loading" counts as active-but-not-playing so a toggle during
                // load resolves to "play" rather than falling through to the
                // native backend.
                "paused" | "loading" => Some(false),
                // "stopped"/"idle": element is gone, native backend resumes authority.
                _ => None,
            };
            tracked.is_none()
        };
        // Its last position goes with it: whatever plays next is loaded into the
        // native backend, and a stale reading would be reported against that.
        if element_gone {
            self.clear_reported_time();
        }
        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
            emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
        }
    }

    /// Report an HTML5 <video> position tick.
    ///
    /// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
    /// position updates (the adapter is expected to throttle to ~250ms like MPV).
    pub fn report_html5_position(&self, position: f64, duration: f64) {
        // Stale by definition during a handoff — the native player's ticks are
        // the real position. See `report_html5_state`.
        if self.is_background_audio_active() {
            return;
        }
        // The element is the player on this path, so this tick is the position —
        // for the resume point, the stop report and everything else that asks.
        self.reported_time.lock_safe().record(position, duration);

        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
            emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
        }

        self.report_progress_throttled(position);
    }

    /// Send a throttled progress report to Jellyfin.
    ///
    /// Progress is what makes a position survive anything other than a clean
    /// exit — a crash, a swipe-away, a battery death — and lets another device
    /// resume mid-episode. Webview-rendered media reported none: the frontend
    /// service writes progress to the local DB only, and Rust had no position
    /// for it to report. A device trace covering 35 minutes of playback hit
    /// `/Sessions/Playing/Progress` exactly zero times.
    ///
    /// The throttler is the one the controller already owned for this purpose
    /// (30s per item), so ticks arriving four times a second cost one request
    /// per half-minute.
    ///
    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
    fn report_progress_throttled(&self, position: f64) {
        if position <= 0.0 {
            return;
        }
        let Some(item_id) = self.current_jellyfin_id() else {
            return;
        };
        if !self.position_throttler.should_report(&item_id) {
            return;
        }

        self.report(PlaybackOperation::Progress {
            item_id: item_id.clone(),
            position_ticks: seconds_to_ticks(position),
            // Ticks only arrive while the element is playing; a pause is carried
            // by the state report, not by a position that stopped moving.
            is_paused: false,
        });
        self.position_throttler.mark_reported(&item_id);
    }

    /// Report that the HTML5 <video> element finished loading and knows its
    /// duration. Mirrors the native `MediaLoaded` event.
    pub fn report_html5_media_loaded(&self, duration: f64) {
        // See `report_html5_state` — the element is not the player right now.
        if self.is_background_audio_active() {
            return;
        }
        if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
            emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
        }
    }

    // ===== Autoplay Methods =====

    /// Get autoplay settings
    pub fn autoplay_settings(&self) -> AutoplaySettings {
        self.autoplay_settings.lock_safe().clone()
    }

    /// Set autoplay settings (in-memory only, persistence handled by command layer)
    pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
        let validated = settings.with_validated_countdown();
        *self.autoplay_settings.lock_safe() = validated;
    }

    /// Cancel active autoplay countdown
    pub fn cancel_autoplay_countdown(&self) {
        if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
            *cancel_flag.lock_safe() = true;
        }
    }

    /// Handle playback ended event - decides what to do next
    ///
    /// Only triggers autoplay if the track finished naturally (EndReason::Finished or None).
    /// If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.
    pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String> {
        // A truncated stream is not an end at all, so this is decided BEFORE the
        // end-reason gate below — which returns early for the `NewTrackLoaded`
        // that every load sets, and would therefore swallow the whole question on
        // Android's JNI callback: the one call guaranteed to run while the app is
        // backgrounded and the webview cannot echo anything back.
        if let Some(position) = self.truncated_stream_resume_position() {
            return Ok(AutoplayDecision::ResumeStream { position });
        }

        // Check why playback ended
        let end_reason = self.take_end_reason();

        debug!(
            "[PlayerController] on_playback_ended: end_reason={:?}",
            end_reason
        );

        // Only proceed with autoplay logic if track finished naturally
        match end_reason {
            None | Some(EndReason::Finished) => {
                // Track ended naturally, proceed with autoplay logic
                debug!("[PlayerController] Track finished naturally, checking autoplay");
            }
            Some(EndReason::NewTrackLoaded) => {
                // User loaded a new track, don't autoplay
                debug!("[PlayerController] NewTrackLoaded - stopping without autoplay");
                return Ok(AutoplayDecision::Stop);
            }
            Some(EndReason::UserStop) => {
                // User stopped playback, don't autoplay
                debug!("[PlayerController] UserStop - stopping without autoplay");
                return Ok(AutoplayDecision::Stop);
            }
            Some(EndReason::UserSkip) => {
                // User skipped, already handled by next/previous
                debug!("[PlayerController] UserSkip - stopping without autoplay");
                return Ok(AutoplayDecision::Stop);
            }
            Some(EndReason::Error) => {
                // Playback error, don't autoplay
                debug!("[PlayerController] Error - stopping without autoplay");
                return Ok(AutoplayDecision::Stop);
            }
        }

        let current_item = {
            let queue = self.queue.lock_safe();
            queue.current().cloned()
        };

        let Some(current) = current_item else {
            return Ok(AutoplayDecision::Stop);
        };

        // The item is genuinely finished (a truncated stream returned above), so
        // report it before anything advances — after an advance the queue's
        // current item is the *next* episode and this one is unreachable.
        self.report_completion(&current);

        // Check sleep timer state
        let timer_mode = {
            let timer = self.sleep_timer.lock_safe();
            timer.mode.clone()
        };

        match &timer_mode {
            SleepTimerMode::Time { end_time } => {
                // If time has expired, stop instead of playing next
                let now = chrono::Utc::now().timestamp_millis();
                if now >= *end_time {
                    debug!("[PlayerController] Time-based sleep timer expired at track boundary");
                    self.sleep_timer.lock_safe().cancel();
                    self.emit_sleep_timer_changed();
                    return Ok(AutoplayDecision::Stop);
                }
            }
            SleepTimerMode::EndOfTrack => {
                // Stop at end of track
                self.sleep_timer.lock_safe().cancel();
                self.emit_sleep_timer_changed();
                return Ok(AutoplayDecision::Stop);
            }
            SleepTimerMode::Episodes { .. } => {
                // Only count TV episodes (not audio tracks or movies). Note an
                // episode played in background-audio mode is MediaType::Audio, so
                // rely on is_episode_item (which checks item_type) rather than the
                // media_type alone.
                let is_episode = self.is_episode_item(&current).await;

                if is_episode {
                    let should_stop = self.sleep_timer.lock_safe().decrement_episode();
                    self.emit_sleep_timer_changed();

                    if should_stop {
                        return Ok(AutoplayDecision::Stop);
                    }
                }
            }
            _ => {
                // No action needed for other modes
            }
        }

        // For episodes, fetch next episode and show popup.
        // Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
        // It's here for the Android ExoPlayer path where episode items sit in the
        // backend queue — including background-audio mode, where the episode is a
        // MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
        if self.is_episode_item(&current).await {
            let repo = self.repository.lock_safe().clone();
            let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
            let next_ep_result = if let Some(repo) = &repo {
                // Degrade lookup failures to Stop: playback already ended, and
                // surfacing an error here just kills autoplay silently upstream.
                match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
                    Ok(next) => next,
                    Err(e) => {
                        warn!(
                            "[PlayerController] Next-episode lookup failed for {}: {}",
                            jellyfin_id, e
                        );
                        None
                    }
                }
            } else {
                warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
                None
            };
            if let Some(next_ep) = next_ep_result {
                let settings = self.autoplay_settings.lock_safe().clone();

                // Check if auto-play episode limit is reached
                let limit_reached = self.increment_autoplay_count();
                if limit_reached {
                    debug!(
                        "[PlayerController] Auto-play episode limit reached ({} episodes)",
                        settings.max_episodes
                    );
                }

                return Ok(AutoplayDecision::ShowNextEpisodePopup {
                    current_episode: next_ep.0, // Repository MediaItem
                    next_episode: next_ep.1,
                    countdown_seconds: settings.countdown_seconds,
                    auto_advance: settings.enabled && !limit_reached,
                });
            }
            // No next episode found
            return Ok(AutoplayDecision::Stop);
        }

        // For audio/movies, check if there's a next track in the queue
        let has_next = {
            let queue = self.queue.lock_safe();
            queue.has_next()
        };

        if has_next {
            // Advance to next track
            Ok(AutoplayDecision::AdvanceToNext)
        } else {
            // End of queue
            Ok(AutoplayDecision::Stop)
        }
    }

    /// Report an item that just finished as stopped at its runtime, so Jellyfin
    /// marks it played.
    ///
    /// Jellyfin decides "watched" from the `PlaybackStopped` report and its
    /// position — no report, no completion, however much of the episode was
    /// actually heard. In the foreground the frontend sends one when the
    /// `<video>` ends. In background audio-only mode there is nobody: the webview
    /// is suspended and its element was torn down at the handoff, while the
    /// backend drove the advance to the next episode and said nothing about the
    /// one that ended. An episode listened to end-to-end on the lockscreen
    /// therefore never counted, and (before DR-179) was often reset to 0 by the
    /// stop report that followed.
    ///
    /// Scoped to the audio-only handoff — the case the frontend provably cannot
    /// report — so foreground playback keeps its single existing report rather
    /// than gaining a second one. Music tracks ending natively remain
    /// unreported; that is the same gap through a different door and wants its
    /// own change.
    ///
    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
    fn report_completion(&self, item: &MediaItem) {
        if !Self::is_audio_only_video(item) {
            return;
        }
        let Some(jellyfin_id) = item.jellyfin_id().map(|id| id.to_string()) else {
            return;
        };

        let position = completion_report_position(item.duration, self.absolute_position());
        log::info!(
            "[PlayerController] Audio-only {} finished — reporting complete at {:.1}s",
            jellyfin_id,
            position
        );
        self.report_stopped_at(jellyfin_id, position);
    }

    /// Record the base offset of a background-audio handoff (the position the
    /// video was handed off at, which is the audio stream's zero).
    ///
    /// TRACES: UR-040 | DR-052
    pub fn set_background_audio_base(&self, seconds: f64) {
        *self.background_audio_base.lock_safe() = seconds.max(0.0);
    }

    /// Enter a background-audio handoff at `position` (the video's position, and
    /// therefore the audio stream's zero).
    ///
    /// Hands transport authority to the native audio player: the webview
    /// `<video>` is about to be torn down, so its last reports — including the
    /// `pause` the teardown itself fires — must not keep it looking like the
    /// player. Without this the lockscreen pause emitted a ControlCommand at a
    /// dead element and the audio played straight through it.
    ///
    /// TRACES: UR-040, UR-005 | DR-052, DR-097
    pub fn enter_background_audio(&self, position: f64) {
        self.set_background_audio_base(position);
        *self.background_audio_active.lock_safe() = true;
        *self.html5_playing.lock_safe() = None;
        // The element is being torn down; its last position describes a video
        // that is no longer playing, and the base describes the one that is.
        self.clear_reported_time();
    }

    /// Leave a background-audio handoff, returning the base offset to add to the
    /// native player's relative position.
    ///
    /// The webview `<video>` becomes the player again once it reloads, so its
    /// reports are honoured from here on.
    ///
    /// TRACES: UR-040, UR-005 | DR-052, DR-097
    pub fn exit_background_audio(&self) -> f64 {
        *self.background_audio_active.lock_safe() = false;
        self.take_background_audio_base()
    }

    /// True while the native audio player owns playback via a background-audio
    /// handoff.
    ///
    /// TRACES: UR-040 | DR-052
    pub fn is_background_audio_active(&self) -> bool {
        *self.background_audio_active.lock_safe()
    }

    /// Read and clear the background-audio base offset.
    ///
    /// TRACES: UR-040 | DR-052
    pub fn take_background_audio_base(&self) -> f64 {
        let mut base = self.background_audio_base.lock_safe();
        std::mem::replace(&mut *base, 0.0)
    }

    /// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
    ///
    /// Single place both end-of-playback dispatchers agree on: the Android JNI
    /// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
    /// (`player_on_playback_ended`). They used to each carry their own copy of
    /// this branch, and the command's copy was missing the background-audio case
    /// entirely — so an audio-only episode ending while backgrounded only ever
    /// started a countdown that nothing could act on.
    ///
    /// TRACES: UR-040, UR-023 | DR-052
    pub async fn auto_advance_to_next_episode(
        &self,
        next_episode: crate::repository::types::MediaItem,
        countdown_seconds: u32,
    ) {
        // Background audio-only episode: the countdown only emits ticks — the
        // advance itself is a `goto('/player/<id>')` in the webview, which cannot
        // start audio while the app is backgrounded. Load the next episode's
        // audio-only stream here instead, or playback stalls at the boundary.
        if self.current_is_audio_episode() {
            info!(
                "[PlayerController] Background audio episode — advancing to {} in backend",
                next_episode.id
            );
            match self
                .advance_to_next_episode_audio_only(&next_episode.id)
                .await
            {
                Ok(()) => self.emit_queue_changed(),
                Err(e) => {
                    error!(
                        "[PlayerController] Background audio advance failed: {} — stopping",
                        e
                    );
                    if let Some(emitter) = self.event_emitter() {
                        emitter.emit(PlayerStatusEvent::PlaybackEnded);
                    }
                }
            }
            return;
        }

        // Foreground: the frontend drives the advance off the countdown ticks.
        self.start_autoplay_countdown(next_episode, countdown_seconds);
    }

    /// A video item played through the native *audio* path — i.e. the background
    /// audio-only handoff, the only place a length-less progressive transcode is
    /// used. Jellyfin's item-type taxonomy stays in Rust (CLAUDE.md).
    fn is_audio_only_video(item: &MediaItem) -> bool {
        stream_end::is_audio_only_video(item)
    }

    /// Claim a resume attempt for the current stream, returning the absolute
    /// position to re-open at and the 1-based attempt number. `None` when the
    /// current item cannot meaningfully be re-requested, or when retrying at this
    /// position has stopped helping.
    ///
    /// Only `Remote` sources qualify. A downloaded file cannot fail because of
    /// the network, so re-opening one would paper over a real read error; a
    /// `DirectUrl` is a plugin's endpoint with no Jellyfin item behind it.
    ///
    /// The player's position is relative to the stream's own zero (the handoff
    /// URL's `StartTimeTicks`), so the base is added back to get an absolute one.
    /// It is zero for everything else, where positions are already absolute.
    ///
    /// TRACES: UR-040, UR-004 | DR-129 | UT-117
    fn claim_stream_resume(&self) -> Option<(f64, u32)> {
        let current = {
            let queue = self.queue.lock_safe();
            queue.current().cloned()
        }?;
        if !matches!(current.source, MediaSource::Remote { .. }) {
            return None;
        }

        // The Android position tick shifts by the handoff base before anything
        // sees the value, so adding it again here would double-count it
        // (DR-159) — `absolute_position` floors at the base instead, which is
        // what a stream that died before its first tick needs. (DR-178)
        let absolute = self.absolute_position();

        match self.stream_resume.lock_safe().allow_attempt(absolute) {
            Some(attempt) => Some((absolute, attempt)),
            None => {
                warn!(
                    "[PlayerController] Stream for {} keeps failing at {:.1}s — giving up on resuming",
                    current.id, absolute
                );
                None
            }
        }
    }

    /// The absolute position to re-open the current stream at, when the reported
    /// end was really a dropped connection — `None` when the end looks genuine,
    /// when this is not an audio-only handoff, or when retrying has stopped
    /// helping.
    ///
    /// TRACES: UR-040 | DR-129 | UT-117
    fn truncated_stream_resume_position(&self) -> Option<f64> {
        // An explicit user intent already explains the end; never resume over it.
        if matches!(
            self.peek_end_reason(),
            Some(EndReason::UserStop) | Some(EndReason::UserSkip) | Some(EndReason::Error)
        ) {
            return None;
        }

        // One lock at a time — `position()` reaches into the backend, and nesting
        // that inside the queue lock would invent a lock order nothing else here
        // takes.
        let item_duration = {
            let queue = self.queue.lock_safe();
            let current = queue.current()?;
            if !Self::is_audio_only_video(current) {
                return None;
            }
            current.duration
        };
        // Already absolute — see claim_stream_resume. (DR-159)
        let absolute = self.position().max(0.0);

        // Only spend a resume attempt once the runtime says this really was cut
        // short — a genuine end must stay a genuine end.
        if !stream_end::is_truncated_end(
            absolute,
            item_duration,
            stream_end::TRUNCATED_STREAM_TOLERANCE_SECS,
        ) {
            return None;
        }

        self.claim_stream_resume().map(|(position, _)| position)
    }

    /// Where to re-open the current stream after a *recoverable* playback error,
    /// plus how many seconds to wait first.
    ///
    /// The media was decoding fine a moment ago, so a mid-playback failure on a
    /// server stream is the network — and stopping the player (the previous
    /// behaviour, via the frontend's error handler) turns a hiccup into "playback
    /// just died". Applies to every streamed item, not only the audio-only
    /// handoff: music and video reach here instead of the truncation path because
    /// their streams declare a length, so a cut connection surfaces as an error
    /// rather than a phantom end.
    ///
    /// The wait grows with the attempt number so a short outage has time to
    /// clear, and the shared budget stops the retries when it doesn't.
    ///
    /// Called from the Android error callback, which decides in-process, and from
    /// `player_recover_stream`, which is how the same decision reaches the
    /// backends whose event thread has no controller to call — MPV is built
    /// before the controller exists, so on Linux the error is emitted, echoed by
    /// the frontend, and decided here.
    ///
    /// TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117
    pub fn recoverable_error_resume(&self) -> Option<(f64, u64)> {
        self.claim_stream_resume()
            .map(|(position, attempt)| (position, attempt as u64 * RESUME_BACKOFF_STEP_SECS))
    }

    /// Re-open the current stream at `position` after the network cut it short.
    ///
    /// Single place every dispatcher agrees on, for the same reason
    /// `auto_advance_to_next_episode` is: the Android JNI callbacks and the
    /// frontend-invoked command must not disagree about what a failed stream
    /// means. None of them emits `PlaybackEnded` for this, so nothing downstream
    /// clears the queue or tears the session down — from the outside this is a
    /// buffering hiccup, which is what it actually was.
    ///
    /// Reloads the item **in place** rather than through `play_item`, which
    /// replaces the queue with a single item: recovering a track that way would
    /// throw away the rest of the album, turning a network blip into lost state.
    ///
    /// Two shapes of stream, two ways back to `position`:
    ///
    /// - The audio-only handoff's `/Audio/{id}/universal` transcode is chunked
    ///   with no length, so it cannot be seeked. Its URL is rewritten to start at
    ///   the position instead — edited, not rebuilt from the repository, since it
    ///   already carries the user's audio track and media source and recovering
    ///   from a network failure must not itself need a network round-trip.
    /// - Everything else (a static file with byte ranges, an HLS playlist)
    ///   declares its whole timeline, so re-preparing the URL it already has and
    ///   seeking lands in the right place — and leaves any transcode session
    ///   behind it alone.
    ///
    /// TRACES: UR-040, UR-004 | DR-129 | UT-117
    pub async fn resume_stream_at(&self, position: f64) -> Result<(), String> {
        let current = {
            let queue = self.queue.lock_safe();
            queue.current().cloned()
        }
        .ok_or_else(|| "No current item to resume".to_string())?;

        let MediaSource::Remote { stream_url, .. } = &current.source else {
            return Err(format!(
                "Cannot resume a non-remote source for {}",
                current.id
            ));
        };

        info!(
            "[PlayerController] Stream for {} failed — re-opening at {:.1}s",
            current.id, position
        );

        if !Self::is_audio_only_video(&current) {
            self.load_and_play(&current).map_err(|e| e.to_string())?;
            if position > 0.5 {
                self.seek(position).map_err(|e| e.to_string())?;
            }
            return Ok(());
        }

        let restarted_url = stream_end::with_start_time(stream_url, position);
        {
            let queue_arc = self.queue.clone();
            let mut queue = queue_arc.lock_safe();
            if !queue.update_current_stream_url(restarted_url) {
                return Err(format!("Failed to update stream URL for {}", current.id));
            }
        }
        let resumed = {
            let queue = self.queue.lock_safe();
            queue.current().cloned()
        }
        .ok_or_else(|| "Current item vanished mid-resume".to_string())?;

        // The re-opened stream's timeline starts at `position` (StartTimeTicks),
        // so that is its zero: the exit-to-foreground maths and the lockscreen
        // scrubber both read absolute positions off this base.
        self.set_background_audio_base(position);
        let _ = set_lockscreen_position_offset(position.max(0.0));

        self.load_and_play(&resumed).map_err(|e| e.to_string())
    }

    /// Advance to the next episode while playing audio-only in the background.
    ///
    /// The normal autoplay-next path navigates the frontend to `/player/<id>`,
    /// which is unavailable when the app is backgrounded and the WebView is
    /// suspended. This drives the advance entirely in the backend: build the next
    /// episode's *audio-only* stream URL and load it into the native audio player,
    /// so playback continues without any frontend involvement (UR-040).
    ///
    /// `next_episode_id` is the Jellyfin item ID of the episode to play next.
    ///
    /// Reached through `auto_advance_to_next_episode`, which gates it on
    /// `current_is_audio_episode()` — only ever true after a background-audio
    /// handoff (Android), but compiled and unit-tested on every platform.
    /// TRACES: UR-040, UR-023 | DR-052
    pub async fn advance_to_next_episode_audio_only(
        &self,
        next_episode_id: &str,
    ) -> Result<(), String> {
        let repo = self
            .repository
            .lock_safe()
            .clone()
            .ok_or_else(|| "No repository for background episode advance".to_string())?;

        // Details for session metadata (title/series/artwork) and the stream URL.
        let next = repo
            .get_item(next_episode_id)
            .await
            .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;

        // Audio-only transcode from the start of the episode (no resume offset —
        // a freshly-started next episode always plays from the beginning).
        let stream_url = repo
            .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
            .await
            .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;

        let media_item = MediaItem {
            id: next.id.clone(),
            title: next.name.clone(),
            name: Some(next.name.clone()),
            artist: next.series_name.clone(),
            album: None,
            album_name: None,
            album_id: None,
            artist_items: None,
            artists: None,
            primary_image_tag: next.primary_image_tag.clone(),
            image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
            // Preserve episode identity so the NEXT end-of-track also advances.
            item_type: Some("Episode".to_string()),
            playlist_id: None,
            duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
            artwork_url: None,
            media_type: MediaType::Audio,
            source: MediaSource::Remote {
                stream_url,
                jellyfin_item_id: next.id.clone(),
            },
            video_codec: None,
            needs_transcoding: false,
            video_width: None,
            video_height: None,
            subtitles: vec![],
            series_id: next.series_id.clone(),
            server_id: Some(next.server_id.clone()),
        };

        // The previous episode's handoff base described the stream we are leaving.
        // This one is built without StartTimeTicks, so its timeline is already
        // absolute: clear the base (used to resolve the resume position on the way
        // back to the foreground) and the lockscreen scrubber's matching shift.
        self.set_background_audio_base(0.0);
        let _ = set_lockscreen_position_offset(0.0);
        // Different stream entirely: whatever was stuck about the last one is not
        // this one's problem.
        self.stream_resume.lock_safe().reset();

        self.play_item(media_item).map_err(|e| e.to_string())
    }

    /// Handle video playback ended from HTML5 video element.
    ///
    /// HTML5 video plays independently of the Rust backend, so the backend
    /// queue has no knowledge of the video item. This method bypasses the
    /// queue lookup and end_reason check, using the provided Jellyfin item ID
    /// to look up the item and check for next episodes.
    pub async fn on_video_playback_ended(
        &self,
        item_id: &str,
        repo: Arc<dyn crate::repository::MediaRepository>,
    ) -> Result<AutoplayDecision, String> {
        // Clear any stale end_reason (e.g., UserStop from stopping audio before video)
        let stale_reason = self.take_end_reason();
        if stale_reason.is_some() {
            debug!(
                "[PlayerController] Cleared stale end_reason for video: {:?}",
                stale_reason
            );
        }

        log::info!(
            "[PlayerController] on_video_playback_ended: item_id={}",
            item_id
        );

        // Check sleep timer state
        let timer_mode = {
            let timer = self.sleep_timer.lock_safe();
            timer.mode.clone()
        };

        match &timer_mode {
            SleepTimerMode::Time { end_time } => {
                let now = chrono::Utc::now().timestamp_millis();
                if now >= *end_time {
                    debug!("[PlayerController] Time-based sleep timer expired at video end");
                    self.sleep_timer.lock_safe().cancel();
                    self.emit_sleep_timer_changed();
                    return Ok(AutoplayDecision::Stop);
                }
            }
            SleepTimerMode::EndOfTrack => {
                self.sleep_timer.lock_safe().cancel();
                self.emit_sleep_timer_changed();
                return Ok(AutoplayDecision::Stop);
            }
            SleepTimerMode::Episodes { .. } => {
                let should_stop = self.sleep_timer.lock_safe().decrement_episode();
                self.emit_sleep_timer_changed();
                if should_stop {
                    return Ok(AutoplayDecision::Stop);
                }
            }
            _ => {}
        }

        // Fetch next episode for the video that just ended. Degrade lookup
        // failures to Stop: playback already ended, and propagating an error
        // here just kills autoplay silently upstream.
        let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
            Ok(next) => next,
            Err(e) => {
                warn!(
                    "[PlayerController] Next-episode lookup failed for {}: {}",
                    item_id, e
                );
                None
            }
        };
        if let Some(next_ep) = next_ep_result {
            let settings = self.autoplay_settings.lock_safe().clone();

            let limit_reached = self.increment_autoplay_count();
            if limit_reached {
                debug!(
                    "[PlayerController] Auto-play episode limit reached ({} episodes)",
                    settings.max_episodes
                );
            }

            return Ok(AutoplayDecision::ShowNextEpisodePopup {
                current_episode: next_ep.0,
                next_episode: next_ep.1,
                countdown_seconds: settings.countdown_seconds,
                auto_advance: settings.enabled && !limit_reached,
            });
        }

        // No next episode found
        debug!("[PlayerController] No next episode found for {}", item_id);
        Ok(AutoplayDecision::Stop)
    }

    /// Check if a media item is an episode (has Jellyfin ID to query).
    ///
    /// An explicit `item_type == "Episode"` wins so that a TV episode handed off
    /// to the audio path for background playback (UR-040) is still recognised as
    /// an episode — otherwise autoplay would fall through to the queue-based
    /// audio path, find nothing next, and stop at the episode boundary. When the
    /// type is unknown we fall back to the historical heuristic (video == episode).
    async fn is_episode_item(&self, item: &MediaItem) -> bool {
        match item.item_type.as_deref() {
            Some("Episode") => true,
            Some(_) => item.media_type == MediaType::Video,
            None => item.media_type == MediaType::Video,
        }
    }

    /// Fetch next episode for a series by looking up the season's episodes
    /// sorted by index number and picking the one after the current episode.
    ///
    /// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
    /// (which relies on watch history that may not be updated yet due to
    /// the async nature of playback progress reporting).
    async fn fetch_next_episode_for_item(
        &self,
        item_id: &str,
        repo: &Arc<dyn crate::repository::MediaRepository>,
    ) -> Result<
        Option<(
            crate::repository::types::MediaItem,
            crate::repository::types::MediaItem,
        )>,
        String,
    > {
        use crate::repository::types::GetItemsOptions;

        // Get the current item details from repository
        let current_repo_item = repo
            .get_item(item_id)
            .await
            .map_err(|e| format!("Failed to get current item: {}", e))?;

        // Need season_id to fetch sibling episodes
        let season_id = match &current_repo_item.season_id {
            Some(sid) => sid.clone(),
            None => {
                log::info!(
                    "[PlayerController] Current item has no season_id, cannot find next episode"
                );
                return Ok(None);
            }
        };

        // Fetch all episodes in the season sorted by episode number
        let options = GetItemsOptions {
            sort_by: Some("IndexNumber".to_string()),
            sort_order: Some("Ascending".to_string()),
            limit: Some(500),
            include_item_types: Some(vec!["Episode".to_string()]),
            ..Default::default()
        };

        let result = repo
            .get_items(&season_id, Some(options))
            .await
            .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;

        // Sort client-side by index_number to ensure correct ordering
        // (offline repo ignores sort_by and sorts by sort_name instead)
        let mut episodes = result.items;
        episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
        log::info!(
            "[PlayerController] Season has {} episodes, looking for next after {}",
            episodes.len(),
            current_repo_item.id
        );

        // Find the current episode by ID and return the next one
        if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
            if current_idx + 1 < episodes.len() {
                let next = &episodes[current_idx + 1];
                log::info!(
                    "[PlayerController] Found next episode: {} (index {})",
                    next.name,
                    current_idx + 1
                );
                return Ok(Some((current_repo_item, next.clone())));
            } else {
                log::info!("[PlayerController] Current episode is the last in the season");
            }
        } else {
            log::info!(
                "[PlayerController] Current episode not found in season episodes (ids: {:?})",
                episodes
                    .iter()
                    .map(|e| e.id.as_str())
                    .take(20)
                    .collect::<Vec<_>>()
            );
        }

        Ok(None)
    }

    /// Start autoplay countdown thread
    pub fn start_autoplay_countdown(
        &self,
        _next_item: crate::repository::types::MediaItem,
        countdown_seconds: u32,
    ) {
        // Create cancellation flag
        let cancel_flag = Arc::new(Mutex::new(false));
        *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());

        let event_emitter = self.event_emitter.clone();

        std::thread::spawn(move || {
            let mut remaining = countdown_seconds;

            while remaining > 0 {
                std::thread::sleep(Duration::from_secs(1));

                // Check cancellation
                if *cancel_flag.lock_safe() {
                    log::info!("[PlayerController] Autoplay countdown cancelled");
                    return;
                }

                remaining -= 1;

                // Emit countdown tick event
                if let Some(emitter) = event_emitter.lock_safe().as_ref() {
                    emitter.emit(PlayerStatusEvent::CountdownTick {
                        remaining_seconds: remaining,
                    });
                }
            }

            // Countdown finished (final tick at 0 was already emitted inside the loop)
            log::info!("[PlayerController] Autoplay countdown finished");
        });
    }
}

impl Default for PlayerController {
    fn default() -> Self {
        let playback_reporter = Arc::new(TokioMutex::new(None));
        let position_throttler = Arc::new(EventThrottler::new());
        Self::new(
            Box::new(NullBackend::new()),
            playback_reporter,
            position_throttler,
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Test emitter that captures events for asserting the HTML5 report methods
    /// re-emit through the normal PlayerStatusEvent pipeline.
    struct CapturingEmitter {
        events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
    }

    impl CapturingEmitter {
        fn new() -> Self {
            Self {
                events: std::sync::Mutex::new(Vec::new()),
            }
        }
        fn events(&self) -> Vec<PlayerStatusEvent> {
            self.events.lock_safe().clone()
        }
    }

    impl PlayerEventEmitter for CapturingEmitter {
        fn emit(&self, event: PlayerStatusEvent) {
            self.events.lock_safe().push(event);
        }
    }

    /// Captures what the controller reports to Jellyfin, so tests can assert on
    /// the operations themselves rather than on a database and an HTTP client.
    struct CapturingReports {
        operations: std::sync::Mutex<Vec<PlaybackOperation>>,
    }

    impl CapturingReports {
        fn new() -> Self {
            Self {
                operations: std::sync::Mutex::new(Vec::new()),
            }
        }

        /// Every `Stopped` report as `(item_id, position_seconds)`.
        fn stops(&self) -> Vec<(String, f64)> {
            self.operations
                .lock_safe()
                .iter()
                .filter_map(|op| match op {
                    PlaybackOperation::Stopped {
                        item_id,
                        position_ticks,
                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
                    _ => None,
                })
                .collect()
        }

        /// Every `Progress` report as `(item_id, position_seconds)`.
        fn progress(&self) -> Vec<(String, f64)> {
            self.operations
                .lock_safe()
                .iter()
                .filter_map(|op| match op {
                    PlaybackOperation::Progress {
                        item_id,
                        position_ticks,
                        ..
                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
                    _ => None,
                })
                .collect()
        }
    }

    impl PlaybackReportSink for CapturingReports {
        fn send(&self, operation: PlaybackOperation) {
            self.operations.lock_safe().push(operation);
        }
    }

    #[test]
    fn test_report_html5_state_emits_state_changed() {
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));

        let events = emitter.events();
        assert_eq!(events.len(), 1);
        match &events[0] {
            PlayerStatusEvent::StateChanged { state, media_id } => {
                assert_eq!(state, "playing");
                assert_eq!(media_id.as_deref(), Some("item-1"));
            }
            other => panic!("expected StateChanged, got {:?}", other),
        }
    }

    #[test]
    fn test_report_html5_position_emits_position_update() {
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        controller.report_html5_position(12.5, 300.0);

        let events = emitter.events();
        assert_eq!(events.len(), 1);
        match &events[0] {
            PlayerStatusEvent::PositionUpdate { position, duration } => {
                assert_eq!(*position, 12.5);
                assert_eq!(*duration, 300.0);
            }
            other => panic!("expected PositionUpdate, got {:?}", other),
        }
    }

    #[test]
    fn test_report_html5_media_loaded_emits_media_loaded() {
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        controller.report_html5_media_loaded(420.0);

        let events = emitter.events();
        assert_eq!(events.len(), 1);
        match &events[0] {
            PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
            other => panic!("expected MediaLoaded, got {:?}", other),
        }
    }

    // ===== HTML5 transport authority (DR-097) =====
    //
    // Webview-rendered video is played by an element the native backend cannot
    // reach, so transport for it must be decided from the state the element
    // REPORTS and executed by emitting a ControlCommand. Previously the frontend
    // decided play-vs-pause itself by reading `el.paused` off the DOM, which
    // flips transiently while buffering/seeking — two intents ~150ms apart read
    // different values, took opposing actions, and self-sustained a pause loop.

    #[test]
    fn test_html5_state_is_tracked_from_reports() {
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        // No HTML5 media reported yet: the native backend stays authoritative.
        assert!(!controller.is_html5_active());

        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
        assert!(controller.is_html5_active());
        assert!(controller.html5_is_playing());

        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
        assert!(controller.is_html5_active());
        assert!(!controller.html5_is_playing());
    }

    #[test]
    fn test_html5_toggle_from_paused_emits_play_control() {
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());
        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));

        controller.toggle_playback().unwrap();

        let controls: Vec<_> = emitter
            .events()
            .into_iter()
            .filter_map(|e| match e {
                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
                _ => None,
            })
            .collect();
        assert_eq!(controls, vec!["play".to_string()]);
    }

    #[test]
    fn test_html5_toggle_from_playing_emits_pause_control() {
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());
        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));

        controller.toggle_playback().unwrap();

        let controls: Vec<_> = emitter
            .events()
            .into_iter()
            .filter_map(|e| match e {
                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
                _ => None,
            })
            .collect();
        assert_eq!(controls, vec!["pause".to_string()]);
    }

    #[test]
    fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
        // The loop signature: two intents in quick succession must NOT both
        // resolve the same way, and must not produce opposing actions from a
        // stale read. Rust's own tracked state makes the sequence deterministic
        // as long as the element reports back between intents.
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());
        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));

        controller.toggle_playback().unwrap();
        // Element confirms the pause it was told to do.
        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
        controller.toggle_playback().unwrap();

        let controls: Vec<_> = emitter
            .events()
            .into_iter()
            .filter_map(|e| match e {
                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
                _ => None,
            })
            .collect();
        assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
    }

    #[test]
    fn test_html5_play_and_pause_emit_control_commands() {
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());
        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));

        controller.play().unwrap();
        controller.pause().unwrap();

        let controls: Vec<_> = emitter
            .events()
            .into_iter()
            .filter_map(|e| match e {
                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
                _ => None,
            })
            .collect();
        assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
    }

    #[test]
    fn test_background_audio_handoff_moves_transport_to_native_backend() {
        // Lockscreen pause while playing a video's audio in the background.
        //
        // The handoff tears the WebView <video> down AFTER native audio starts,
        // and that teardown fires a DOM `pause` the frontend dutifully reports.
        // That report used to leave `html5_playing = Some(false)`, so transport
        // kept being aimed at an element that no longer exists: the lockscreen
        // pause emitted a ControlCommand into the void and the audio played on.
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        // Video was playing in the webview.
        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
        assert!(controller.is_html5_active());

        // Hand off to the native audio player, then tear the element down.
        controller.enter_background_audio(1200.0);
        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));

        assert!(
            !controller.is_html5_active(),
            "native audio owns transport during a background-audio handoff"
        );

        controller.pause().unwrap();
        let controls: Vec<_> = emitter
            .events()
            .into_iter()
            .filter_map(|e| match e {
                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
                _ => None,
            })
            .collect();
        assert!(
            controls.is_empty(),
            "pause must drive the native backend, not a torn-down element: {:?}",
            controls
        );
    }

    #[test]
    fn test_background_audio_handoff_suppresses_stale_element_events() {
        // The dying element's pause/position reports describe the video, not the
        // audio now playing — re-emitting them flips the UI to paused and yanks
        // the position backwards while native audio keeps going.
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        controller.enter_background_audio(1200.0);
        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
        controller.report_html5_position(1200.0, 2400.0);

        assert!(
            emitter.events().is_empty(),
            "stale webview reports must not reach the event pipeline: {:?}",
            emitter.events()
        );
    }

    #[test]
    fn test_exit_background_audio_returns_transport_to_the_webview() {
        // Back in the foreground the <video> is the player again, so its reports
        // must be honoured — and the base offset still comes back for the resume.
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        controller.enter_background_audio(1200.0);
        assert_eq!(controller.exit_background_audio(), 1200.0);

        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
        assert!(controller.is_html5_active());
        assert!(controller.html5_is_playing());
    }

    #[test]
    fn test_html5_stopped_report_releases_transport_to_native_backend() {
        // When webview video goes away, transport must fall back to the native
        // backend (music playback must not keep emitting ControlCommands).
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
        assert!(controller.is_html5_active());

        controller.report_html5_state("stopped".to_string(), None);
        assert!(!controller.is_html5_active());
    }

    #[test]
    fn test_html5_transport_emits_exactly_one_control_per_intent() {
        // Guards against a double-drive on platforms where the *backend* is also
        // webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
        // must replace the backend call, not run in addition to it.
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());
        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));

        controller.pause().unwrap();

        let controls = emitter
            .events()
            .into_iter()
            .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
            .count();
        assert_eq!(controls, 1, "one intent must produce exactly one control");
    }

    #[test]
    fn test_controller_volume_default() {
        let controller = PlayerController::default();
        assert_eq!(controller.volume(), 1.0);
    }

    #[test]
    fn test_controller_set_volume() {
        let controller = PlayerController::default();
        controller.set_volume(0.5).unwrap();
        assert_eq!(controller.volume(), 0.5);
    }

    #[test]
    fn test_controller_muted_default() {
        let controller = PlayerController::default();
        assert!(!controller.muted());
    }

    #[test]
    fn test_controller_volume_delegates_to_backend() {
        let controller = PlayerController::default();

        // Set volume through controller
        controller.set_volume(0.75).unwrap();

        // Verify it's reflected in both controller.volume() and backend
        assert_eq!(controller.volume(), 0.75);
    }

    fn create_test_items(count: usize) -> Vec<MediaItem> {
        (0..count)
            .map(|i| MediaItem {
                id: format!("item_{}", i),
                title: format!("Track {}", i + 1),
                name: Some(format!("Track {}", i + 1)),
                artist: Some("Test Artist".to_string()),
                album: Some("Test Album".to_string()),
                album_name: Some("Test Album".to_string()),
                album_id: None,
                artist_items: None,
                artists: Some(vec!["Test Artist".to_string()]),
                primary_image_tag: None,
                image_id: None,
                item_type: Some("Audio".to_string()),
                playlist_id: None,
                duration: Some(180.0),
                artwork_url: None,
                media_type: MediaType::Audio,
                source: MediaSource::DirectUrl {
                    url: format!("http://example.com/track_{}.mp3", i),
                },
                video_codec: None,
                needs_transcoding: false,
                video_width: None,
                video_height: None,
                subtitles: vec![],
                series_id: None,
                server_id: None,
            })
            .collect()
    }

    #[test]
    fn test_skip_preserves_queue() {
        let controller = PlayerController::default();

        // Create a queue with 5 items
        let items = create_test_items(5);
        let items_clone = items.clone();

        // Play the queue starting at index 0
        controller.play_queue(items, 0).unwrap();

        // Verify initial state
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
            assert_eq!(
                queue_lock.current_index(),
                Some(0),
                "Should start at index 0"
            );
            assert_eq!(
                queue_lock.current().unwrap().id,
                "item_0",
                "Current item should be item_0"
            );
        }

        // Skip to next track
        controller.next().unwrap();

        // Verify queue is intact and index advanced
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.items().len(),
                5,
                "Queue should still have 5 items after skip"
            );
            assert_eq!(
                queue_lock.current_index(),
                Some(1),
                "Index should advance to 1"
            );
            assert_eq!(
                queue_lock.current().unwrap().id,
                "item_1",
                "Current item should be item_1"
            );

            // Verify all original items are still present
            let current_items = queue_lock.items();
            for (i, original) in items_clone.iter().enumerate() {
                assert_eq!(
                    current_items[i].id, original.id,
                    "Item {} should still be in queue",
                    i
                );
                assert_eq!(
                    current_items[i].title, original.title,
                    "Item {} title should be unchanged",
                    i
                );
            }
        }

        // Skip again
        controller.next().unwrap();

        // Verify queue still intact and index advanced again
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.items().len(),
                5,
                "Queue should still have 5 items after second skip"
            );
            assert_eq!(
                queue_lock.current_index(),
                Some(2),
                "Index should advance to 2"
            );
            assert_eq!(
                queue_lock.current().unwrap().id,
                "item_2",
                "Current item should be item_2"
            );
        }

        // Skip multiple times to reach the end
        controller.next().unwrap(); // -> item_3
        controller.next().unwrap(); // -> item_4

        // Verify we're at the last item
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.items().len(),
                5,
                "Queue should still have 5 items at end"
            );
            assert_eq!(
                queue_lock.current_index(),
                Some(4),
                "Index should be at last item (4)"
            );
            assert_eq!(
                queue_lock.current().unwrap().id,
                "item_4",
                "Current item should be item_4"
            );
        }
    }

    #[test]
    fn test_skip_at_end_without_repeat() {
        let controller = PlayerController::default();

        // Create a queue with 3 items
        let items = create_test_items(3);
        controller.play_queue(items, 0).unwrap();

        // Skip to last item
        controller.next().unwrap(); // -> item_1
        controller.next().unwrap(); // -> item_2

        // Verify we're at the last item
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.current_index(),
                Some(2),
                "Should be at last item"
            );
        }

        // Try to skip past the end (without repeat mode)
        // This should succeed but stop playback while preserving the queue
        controller.next().unwrap();

        // Verify queue is still intact
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.items().len(),
                3,
                "Queue should still have 3 items after skip at end"
            );
            // When we skip past the end, the queue index should stay at the last item
            // or become None (depending on implementation)
            // The key is the queue items themselves should be preserved
        }
    }

    #[test]
    fn test_skip_with_repeat_all() {
        let controller = PlayerController::default();

        // Create a queue with 3 items
        let items = create_test_items(3);
        controller.play_queue(items, 0).unwrap();

        // Enable repeat all
        controller.cycle_repeat();

        // Skip to last item
        controller.next().unwrap(); // -> item_1
        controller.next().unwrap(); // -> item_2

        // Skip again - should wrap to beginning
        controller.next().unwrap();

        // Verify we wrapped to the first item
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.items().len(),
                3,
                "Queue should still have 3 items"
            );
            assert_eq!(
                queue_lock.current_index(),
                Some(0),
                "Should wrap to index 0"
            );
            assert_eq!(
                queue_lock.current().unwrap().id,
                "item_0",
                "Should be back at item_0"
            );
        }
    }

    #[test]
    fn test_previous_preserves_queue() {
        let controller = PlayerController::default();

        // Create a queue with 5 items, start at item 3
        let items = create_test_items(5);
        let items_clone = items.clone();
        controller.play_queue(items, 3).unwrap();

        // Verify starting position
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.current_index(),
                Some(3),
                "Should start at index 3"
            );
        }

        // Go to previous track
        controller.previous().unwrap();

        // Verify queue is intact and index moved back
        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.items().len(),
                5,
                "Queue should still have 5 items after previous"
            );
            assert_eq!(
                queue_lock.current_index(),
                Some(2),
                "Index should move to 2"
            );
            assert_eq!(
                queue_lock.current().unwrap().id,
                "item_2",
                "Current item should be item_2"
            );

            // Verify all original items are still present
            let current_items = queue_lock.items();
            for (i, original) in items_clone.iter().enumerate() {
                assert_eq!(
                    current_items[i].id, original.id,
                    "Item {} should still be in queue",
                    i
                );
            }
        }
    }

    #[test]
    fn test_seek_updates_position() {
        let controller = PlayerController::default();

        // Create and play a single item
        let item = create_test_items(1).into_iter().next().unwrap();
        controller.play_item(item).unwrap();

        // Verify initial position
        assert_eq!(controller.position(), 0.0, "Initial position should be 0");

        // Seek to 30 seconds
        controller.seek(30.0).unwrap();
        assert_eq!(
            controller.position(),
            30.0,
            "Position should be 30 after seeking"
        );

        // Seek to 60 seconds
        controller.seek(60.0).unwrap();
        assert_eq!(
            controller.position(),
            60.0,
            "Position should be 60 after seeking"
        );

        // Seek backward to 15 seconds
        controller.seek(15.0).unwrap();
        assert_eq!(
            controller.position(),
            15.0,
            "Position should be 15 after seeking backward"
        );
    }

    #[test]
    fn test_seek_while_paused() {
        let controller = PlayerController::default();

        // Create and play a single item
        let item = create_test_items(1).into_iter().next().unwrap();
        controller.play_item(item).unwrap();

        // Pause playback
        controller.pause().unwrap();

        // Verify paused state
        assert!(controller.state().is_paused(), "Should be paused");

        // Seek while paused
        controller.seek(45.0).unwrap();
        assert_eq!(
            controller.position(),
            45.0,
            "Position should update while paused"
        );

        // Verify still paused after seeking
        assert!(
            controller.state().is_paused(),
            "Should still be paused after seeking"
        );
    }

    #[test]
    fn test_seek_while_playing() {
        let controller = PlayerController::default();

        // Create and play a single item
        let item = create_test_items(1).into_iter().next().unwrap();
        controller.play_item(item).unwrap();

        // Ensure playing
        controller.play().unwrap();

        // Verify playing state
        assert!(controller.state().is_playing(), "Should be playing");

        // Seek while playing
        controller.seek(20.0).unwrap();
        assert_eq!(
            controller.position(),
            20.0,
            "Position should update while playing"
        );

        // Verify still playing after seeking
        assert!(
            controller.state().is_playing(),
            "Should still be playing after seeking"
        );
    }

    #[test]
    fn test_multiple_sequential_seeks() {
        let controller = PlayerController::default();

        let item = create_test_items(1).into_iter().next().unwrap();
        controller.play_item(item).unwrap();

        // Perform multiple seeks in sequence
        let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];

        for pos in positions {
            controller.seek(pos).unwrap();
            assert_eq!(
                controller.position(),
                pos,
                "Position should match after seeking to {}",
                pos
            );
        }
    }

    /// Resuming a queue at a position seeks the starting track immediately.
    /// Regression guard for taking over a remote session: the local player must
    /// pick up where the remote left off, not restart from 0.
    #[test]
    fn test_play_queue_from_resumes_at_position() {
        let controller = PlayerController::default();
        let items = create_test_items(3);

        controller.play_queue_from(items, 1, Some(42.5)).unwrap();

        {
            let queue = controller.queue();
            let queue_lock = queue.lock_safe();
            assert_eq!(
                queue_lock.current_index(),
                Some(1),
                "Should start at index 1"
            );
        }
        assert_eq!(
            controller.position(),
            42.5,
            "Should resume at the requested position"
        );
    }

    /// A None / near-zero start position starts the track from the beginning.
    #[test]
    fn test_play_queue_from_without_position_starts_at_zero() {
        let controller = PlayerController::default();

        controller
            .play_queue_from(create_test_items(2), 0, None)
            .unwrap();
        assert_eq!(controller.position(), 0.0, "No resume position starts at 0");

        controller
            .play_queue_from(create_test_items(2), 0, Some(0.2))
            .unwrap();
        assert_eq!(
            controller.position(),
            0.0,
            "Sub-threshold resume position is ignored (starts at 0)"
        );
    }

    #[test]
    fn test_seek_to_zero() {
        let controller = PlayerController::default();

        let item = create_test_items(1).into_iter().next().unwrap();
        controller.play_item(item).unwrap();

        // Seek forward
        controller.seek(60.0).unwrap();
        assert_eq!(controller.position(), 60.0);

        // Seek back to zero
        controller.seek(0.0).unwrap();
        assert_eq!(
            controller.position(),
            0.0,
            "Should be able to seek to position 0"
        );
    }

    // Autoplay decision tests
    #[tokio::test]
    async fn test_audio_with_next_advances() {
        let controller = PlayerController::default();

        // Create queue with 2 audio items
        let items = create_test_items(2);
        controller.play_queue(items, 0).unwrap();

        // Clear the NewTrackLoaded reason set by play_queue to simulate natural track end
        controller.take_end_reason();

        // Simulate first track ending naturally
        let decision = controller.on_playback_ended().await.unwrap();

        // Should decide to advance to next
        assert!(
            matches!(decision, AutoplayDecision::AdvanceToNext),
            "Expected AdvanceToNext decision when queue has next item"
        );
    }

    #[tokio::test]
    async fn test_audio_at_end_stops() {
        let controller = PlayerController::default();

        // Create queue with 2 items, start at last one
        let items = create_test_items(2);
        controller.play_queue(items, 1).unwrap();

        // Clear the NewTrackLoaded reason to simulate natural track end
        controller.take_end_reason();

        // Simulate last track ending naturally
        let decision = controller.on_playback_ended().await.unwrap();

        // Should decide to stop (no more items)
        assert!(
            matches!(decision, AutoplayDecision::Stop),
            "Expected Stop decision when at end of queue without repeat"
        );
    }

    #[tokio::test]
    async fn test_sleep_timer_end_of_track() {
        let controller = PlayerController::default();

        // Create queue with next items
        let items = create_test_items(3);
        controller.play_queue(items, 0).unwrap();

        // Clear the NewTrackLoaded reason to simulate natural track end
        controller.take_end_reason();

        // Set sleep timer to end of track
        {
            let mut timer = controller.sleep_timer.lock_safe();
            timer.mode = SleepTimerMode::EndOfTrack;
        }

        // Simulate track ending naturally
        let decision = controller.on_playback_ended().await.unwrap();

        // Should stop despite having next items
        assert!(
            matches!(decision, AutoplayDecision::Stop),
            "Expected Stop decision when sleep timer is EndOfTrack"
        );

        // Verify timer was cancelled
        {
            let timer = controller.sleep_timer.lock_safe();
            assert!(
                matches!(timer.mode, SleepTimerMode::Off),
                "Sleep timer should be cancelled after EndOfTrack"
            );
        }
    }

    /// A time-based sleep timer that fires mid-episode must not let the ended
    /// callback fall through to autoplay.
    ///
    /// The timer thread stops the backend directly, which makes ExoPlayer emit
    /// its ended callback. That callback races the thread's own `timer.cancel()`:
    /// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
    /// so the timer branch is skipped and the episode path runs — showing a
    /// next-episode popup (or advancing) after the user's sleep timer expired.
    #[tokio::test]
    async fn test_expired_time_sleep_timer_stops_without_autoplay() {
        let controller = PlayerController::default();

        let items = create_test_items(3);
        controller.play_queue(items, 0).unwrap();
        controller.take_end_reason();

        // Arm a time-based timer that is already due, then let the real timer
        // thread (started in the constructor, 1s tick) observe the expiry and
        // run its stop path. Driving the actual thread is the point: the bug was
        // that this path stopped the backend without recording an end reason.
        let now = chrono::Utc::now().timestamp_millis();
        controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });

        // Wait for the timer thread to process the expiry (tick is 1s).
        for _ in 0..40 {
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            if !controller.sleep_timer.lock_safe().is_active() {
                break;
            }
        }
        assert!(
            !controller.sleep_timer.lock_safe().is_active(),
            "Timer thread should have expired and cancelled the sleep timer"
        );

        // The backend stop above makes the native player fire its ended callback.
        let decision = controller.on_playback_ended().await.unwrap();

        assert!(
            matches!(decision, AutoplayDecision::Stop),
            "Expected Stop after an expired time-based sleep timer, got {:?}",
            decision
        );
    }

    #[tokio::test]
    async fn test_empty_queue_stops() {
        let controller = PlayerController::default();

        // Don't set up any queue
        let decision = controller.on_playback_ended().await.unwrap();

        // Should stop (no current item)
        assert!(
            matches!(decision, AutoplayDecision::Stop),
            "Expected Stop decision when queue is empty"
        );
    }

    #[tokio::test]
    async fn test_repeat_all_advances_at_end() {
        let controller = PlayerController::default();

        // Create queue with 2 items, enable repeat all
        let items = create_test_items(2);
        controller.play_queue(items, 1).unwrap(); // Start at last item
        controller.cycle_repeat(); // Enable repeat all

        // Clear the NewTrackLoaded reason to simulate natural track end
        controller.take_end_reason();

        // Simulate last track ending naturally
        let decision = controller.on_playback_ended().await.unwrap();

        // Should advance (will wrap to beginning due to repeat all)
        assert!(
            matches!(decision, AutoplayDecision::AdvanceToNext),
            "Expected AdvanceToNext decision at end of queue with repeat all"
        );
    }

    #[tokio::test]
    async fn test_repeat_one_advances() {
        let controller = PlayerController::default();

        // Create queue with 2 items
        let items = create_test_items(2);
        controller.play_queue(items, 0).unwrap();

        // Enable repeat one
        controller.cycle_repeat(); // Once for all
        controller.cycle_repeat(); // Twice for one

        // Clear the NewTrackLoaded reason to simulate natural track end
        controller.take_end_reason();

        // Simulate track ending naturally
        let decision = controller.on_playback_ended().await.unwrap();

        // Should advance (which repeats the same track)
        assert!(
            matches!(decision, AutoplayDecision::AdvanceToNext),
            "Expected AdvanceToNext decision with repeat one (repeats same track)"
        );
    }

    #[test]
    fn test_native_load_returns_transport_authority_to_the_backend() {
        // Play/pause did nothing on the Android native video path, from the
        // on-screen tap AND from the control-bar button, while seek and skip
        // worked — those take a different decision path.
        //
        // `html5_playing` is written only by the webview element's own reports
        // and cleared only when it reports "stopped"/"idle" (or on a
        // background-audio handoff). A previous element that went away without
        // that final report — or webview-rendered music earlier in the same
        // process — therefore left `is_html5_active()` true, and every transport
        // intent was emitted as a ControlCommand at an element that no longer
        // existed. Nothing reached ExoPlayer. It looked intermittent because it
        // depends entirely on what played before.
        //
        // Loading into the native backend IS the statement that native renders
        // this item, so it hands authority back — the same "element is gone"
        // semantics the "stopped"/"idle" report already has.
        //
        // TRACES: UR-005, UR-003 | DR-193
        let controller = PlayerController::default();
        let emitter = Arc::new(CapturingEmitter::new());
        controller.set_event_emitter(emitter.clone());

        // A webview element reported itself playing and never said "stopped".
        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
        assert!(controller.is_html5_active());

        // Now a native item loads — Android video through ExoPlayer.
        let item = create_test_items(1).into_iter().next().unwrap();
        controller.play_item(item).unwrap();

        assert!(
            !controller.is_html5_active(),
            "loading into the native backend hands transport back to it"
        );

        // The toggle must reach the backend, not be emitted at a dead element.
        controller.toggle_playback().unwrap();
        let controls: Vec<_> = emitter
            .events()
            .into_iter()
            .filter_map(|e| match e {
                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
                _ => None,
            })
            .collect();
        assert!(
            controls.is_empty(),
            "transport went to a webview element that is not rendering: {controls:?}"
        );
    }

    // EndReason state machine tests
    #[test]
    fn test_load_and_play_sets_new_track_loaded() {
        let controller = PlayerController::default();
        let item = create_test_items(1).into_iter().next().unwrap();

        // End reason should be None initially
        assert!(controller.take_end_reason().is_none());

        // Load and play should set NewTrackLoaded
        controller.load_and_play(&item).unwrap();

        // Verify end reason was set
        let reason = controller.take_end_reason();
        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
    }

    #[test]
    fn test_stop_sets_user_stop() {
        let controller = PlayerController::default();
        let item = create_test_items(1).into_iter().next().unwrap();

        // Play an item first
        controller.play_item(item).unwrap();

        // Clear any end reason from load_and_play
        controller.take_end_reason();

        // Stop should set UserStop
        controller.stop().unwrap();

        // Verify end reason was set
        let reason = controller.take_end_reason();
        assert_eq!(reason, Some(EndReason::UserStop));
    }

    #[tokio::test]
    async fn test_on_playback_ended_with_new_track_loaded_stops() {
        let controller = PlayerController::default();

        // Create queue with 2 items
        let items = create_test_items(2);
        controller.play_queue(items, 0).unwrap();

        // Manually set end reason to NewTrackLoaded
        controller.set_end_reason(EndReason::NewTrackLoaded);

        // Call on_playback_ended
        let decision = controller.on_playback_ended().await.unwrap();

        // Should stop without advancing
        assert!(
            matches!(decision, AutoplayDecision::Stop),
            "Expected Stop decision when EndReason is NewTrackLoaded"
        );
    }

    #[tokio::test]
    async fn test_on_playback_ended_with_user_stop_stops() {
        let controller = PlayerController::default();

        // Create queue with 2 items
        let items = create_test_items(2);
        controller.play_queue(items, 0).unwrap();

        // Manually set end reason to UserStop
        controller.set_end_reason(EndReason::UserStop);

        // Call on_playback_ended
        let decision = controller.on_playback_ended().await.unwrap();

        // Should stop without advancing
        assert!(
            matches!(decision, AutoplayDecision::Stop),
            "Expected Stop decision when EndReason is UserStop"
        );
    }

    #[tokio::test]
    async fn test_on_playback_ended_natural_end_advances() {
        let controller = PlayerController::default();

        // Create queue with 2 items
        let items = create_test_items(2);
        controller.play_queue(items, 0).unwrap();

        // Clear the NewTrackLoaded reason to simulate natural track end
        controller.take_end_reason();

        // Call on_playback_ended (no end reason set = natural end)
        let decision = controller.on_playback_ended().await.unwrap();

        // Should advance to next (natural end with next track available)
        assert!(
            matches!(decision, AutoplayDecision::AdvanceToNext),
            "Expected AdvanceToNext decision when track ends naturally with next track available"
        );
    }

    #[tokio::test]
    async fn test_on_playback_ended_with_user_skip_stops() {
        let controller = PlayerController::default();

        // Create queue with 2 items
        let items = create_test_items(2);
        controller.play_queue(items, 0).unwrap();

        // Set end reason to UserSkip
        controller.set_end_reason(EndReason::UserSkip);

        // Call on_playback_ended
        let decision = controller.on_playback_ended().await.unwrap();

        // Should stop without advancing (skip already handled)
        assert!(
            matches!(decision, AutoplayDecision::Stop),
            "Expected Stop decision when EndReason is UserSkip"
        );
    }

    #[tokio::test]
    async fn test_on_playback_ended_with_error_stops() {
        let controller = PlayerController::default();

        // Create queue with 2 items
        let items = create_test_items(2);
        controller.play_queue(items, 0).unwrap();

        // Set end reason to Error
        controller.set_end_reason(EndReason::Error);

        // Call on_playback_ended
        let decision = controller.on_playback_ended().await.unwrap();

        // Should stop without advancing
        assert!(
            matches!(decision, AutoplayDecision::Stop),
            "Expected Stop decision when EndReason is Error"
        );
    }

    #[tokio::test]
    async fn test_take_end_reason_clears_state() {
        let controller = PlayerController::default();

        // Set a reason
        controller.set_end_reason(EndReason::NewTrackLoaded);

        // Take it once
        let reason = controller.take_end_reason();
        assert_eq!(reason, Some(EndReason::NewTrackLoaded));

        // Take it again - should be None
        let reason = controller.take_end_reason();
        assert!(reason.is_none(), "take_end_reason should clear the state");
    }

    // ===== Next-episode autoplay decision tests =====

    use crate::repository::types as repo_types;

    /// Mock repository serving a single season of episodes for next-episode
    /// lookup tests. Only `get_item` and `get_items` are used by
    /// `fetch_next_episode_for_item`; everything else is unreachable.
    struct MockEpisodeRepo {
        episodes: Vec<repo_types::MediaItem>,
    }

    impl MockEpisodeRepo {
        fn season(count: usize) -> Self {
            let episodes = (1..=count)
                .map(|i| {
                    let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
                    item.name = format!("Episode {}", i);
                    item
                })
                .collect();
            Self { episodes }
        }
    }

    fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
        repo_types::MediaItem {
            id: id.to_string(),
            name: format!("Episode {}", index),
            item_type: "Episode".to_string(),
            kind: crate::domain::MediaKind::Episode,
            is_folder: false,
            server_id: "server".to_string(),
            parent_id: Some("season1".to_string()),
            library_id: None,
            overview: None,
            genres: None,
            runtime_ticks: None,
            duration_ms: None,
            production_year: None,
            premiere_date: None,
            community_rating: None,
            official_rating: None,
            primary_image_tag: None,
            image_id: None,
            backdrop_image_tags: None,
            parent_backdrop_image_tags: None,
            album_id: None,
            album_name: None,
            album_artist: None,
            artists: None,
            artist_items: None,
            index_number: Some(index),
            series_id: Some("series1".to_string()),
            series_name: Some("Test Series".to_string()),
            season_id: Some("season1".to_string()),
            season_name: Some("Season 1".to_string()),
            parent_index_number: Some(1),
            user_data: None,
            media_streams: None,
            media_sources: None,
            people: None,
        }
    }

    #[async_trait::async_trait]
    impl crate::repository::MediaRepository for MockEpisodeRepo {
        async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_items(
            &self,
            parent_id: &str,
            _options: Option<repo_types::GetItemsOptions>,
        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
            assert_eq!(parent_id, "season1", "episode lookup must query the season");
            Ok(repo_types::SearchResult {
                items: self.episodes.clone(),
                total_record_count: self.episodes.len(),
            })
        }
        async fn get_item(
            &self,
            item_id: &str,
        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
            self.episodes
                .iter()
                .find(|e| e.id == item_id)
                .cloned()
                .ok_or(repo_types::RepoError::NotFound {
                    message: format!("{} not found", item_id),
                })
        }
        async fn get_latest_items(
            &self,
            _: &str,
            _: Option<usize>,
        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_resume_items(
            &self,
            _: Option<&str>,
            _: Option<usize>,
        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_next_up_episodes(
            &self,
            _: Option<&str>,
            _: Option<usize>,
        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_recently_played_audio(
            &self,
            _: Option<usize>,
        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_rediscover_albums(
            &self,
            _: Option<&str>,
            _: Option<usize>,
        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_resume_movies(
            &self,
            _: Option<usize>,
        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_genres(
            &self,
            _: Option<&str>,
        ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn search(
            &self,
            _: &str,
            _: Option<repo_types::SearchOptions>,
        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_playback_info(
            &self,
            _: &str,
        ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_audio_only_stream_url_for_video(
            &self,
            item_id: &str,
            _media_source_id: Option<&str>,
            _start_time_seconds: Option<f64>,
            _audio_stream_index: Option<i32>,
        ) -> Result<String, repo_types::RepoError> {
            Ok(format!("http://example.com/{}-audio.mp3", item_id))
        }
        async fn get_live_tv_channels(
            &self,
        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
            unimplemented!()
        }
        async fn open_live_stream(
            &self,
            _: &str,
        ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
            unimplemented!()
        }
        async fn report_playback_start(
            &self,
            _: &str,
            _: i64,
        ) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn report_playback_progress(
            &self,
            _: &str,
            _: i64,
        ) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn report_playback_stopped(
            &self,
            _: &str,
            _: i64,
        ) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        fn get_image_url(
            &self,
            _: &str,
            _: repo_types::ImageType,
            _: Option<repo_types::ImageOptions>,
        ) -> String {
            unimplemented!()
        }
        fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
            unimplemented!()
        }
        fn get_video_download_url(
            &self,
            _: &str,
            _: &str,
            _: Option<&str>,
            _: Option<&str>,
        ) -> String {
            unimplemented!()
        }
        async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_favorites(
            &self,
            _: repo_types::SearchScope,
            _: Option<repo_types::GetItemsOptions>,
        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
            unimplemented!()
        }
        async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_person(
            &self,
            _: &str,
        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_items_by_person(
            &self,
            _: &str,
            _: Option<repo_types::GetItemsOptions>,
        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_similar_items(
            &self,
            _: &str,
            _: Option<usize>,
        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
            unimplemented!()
        }
        async fn create_playlist(
            &self,
            _: &str,
            _: &[String],
        ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
            unimplemented!()
        }
        async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn get_playlist_items(
            &self,
            _: &str,
        ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
            unimplemented!()
        }
        async fn add_to_playlist(
            &self,
            _: &str,
            _: &[String],
        ) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn remove_from_playlist(
            &self,
            _: &str,
            _: &[String],
        ) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
        async fn move_playlist_item(
            &self,
            _: &str,
            _: &str,
            _: u32,
        ) -> Result<(), repo_types::RepoError> {
            unimplemented!()
        }
    }

    /// Video (HTML5/Linux) path: ending mid-season must produce the
    /// next-episode popup with auto-advance.
    #[tokio::test]
    async fn test_video_playback_ended_offers_next_episode() {
        let controller = PlayerController::default();
        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));

        let decision = controller
            .on_video_playback_ended("ep2", repo)
            .await
            .expect("decision should succeed");

        match decision {
            AutoplayDecision::ShowNextEpisodePopup {
                current_episode,
                next_episode,
                auto_advance,
                ..
            } => {
                assert_eq!(current_episode.id, "ep2");
                assert_eq!(next_episode.id, "ep3");
                assert!(auto_advance, "default settings should auto-advance");
            }
            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
        }
    }

    /// Last episode of the season: no popup, stop.
    #[tokio::test]
    async fn test_video_playback_ended_last_episode_stops() {
        let controller = PlayerController::default();
        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));

        let decision = controller
            .on_video_playback_ended("ep3", repo)
            .await
            .expect("decision should succeed");

        assert!(matches!(decision, AutoplayDecision::Stop));
    }

    /// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
    /// so the controller-level repository (wired up in `repository_create`)
    /// must be used for the next-episode lookup. Regression test for episode
    /// autoplay never triggering on Android because no repository was set.
    #[tokio::test]
    async fn test_playback_ended_uses_controller_repository_for_episodes() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        // Queue holds the episode that just finished playing
        let episode = MediaItem {
            media_type: MediaType::Video,
            source: MediaSource::Remote {
                stream_url: "http://example.com/ep1.mkv".to_string(),
                jellyfin_item_id: "ep1".to_string(),
            },
            ..create_test_items(1).remove(0)
        };
        controller.play_queue(vec![episode], 0).unwrap();

        // Clear the NewTrackLoaded reason to simulate natural track end
        controller.take_end_reason();

        let decision = controller.on_playback_ended().await.unwrap();

        match decision {
            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
                assert_eq!(next_episode.id, "ep2");
            }
            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
        }
    }

    /// Background audio-only mode (UR-040): a video episode is handed off to the
    /// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
    /// playing while the app is backgrounded. When that audio track ends, autoplay
    /// must STILL recognise it as an episode and offer the next one — otherwise
    /// playback just pauses at the episode boundary (the reported bug). The item
    /// carries its episode identity via `item_type: "Episode"` + `series_id`.
    #[tokio::test]
    async fn test_playback_ended_background_audio_episode_advances() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        // Mirrors what player_enter_background_audio builds: the episode as AUDIO.
        let episode = MediaItem {
            item_type: Some("Episode".to_string()),
            media_type: MediaType::Audio, // audio-only handoff, not Video
            series_id: Some("series1".to_string()),
            duration: Some(180.0),
            source: MediaSource::Remote {
                stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
                jellyfin_item_id: "ep2".to_string(),
            },
            ..create_test_items(1).remove(0)
        };
        controller.play_queue(vec![episode], 0).unwrap();

        // Played through to the end — a natural finish, not a stream cut short.
        controller.seek(180.0).unwrap();
        // Clear the NewTrackLoaded reason to simulate natural track end.
        controller.take_end_reason();

        let decision = controller.on_playback_ended().await.unwrap();

        match decision {
            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
                assert_eq!(next_episode.id, "ep3");
            }
            other => panic!(
                "background-audio episode end must advance to the next episode, got {:?}",
                other
            ),
        }
    }

    /// The backend-driven advance (used when backgrounded) must load the next
    /// episode as an AUDIO item carrying its episode identity, so the *following*
    /// end-of-track also advances rather than stopping.
    #[tokio::test]
    async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        controller
            .advance_to_next_episode_audio_only("ep2")
            .await
            .expect("advance should succeed");

        let current = controller
            .queue
            .lock_safe()
            .current()
            .cloned()
            .expect("an item should be loaded");
        assert_eq!(current.id, "ep2");
        assert_eq!(current.media_type, MediaType::Audio);
        assert_eq!(current.item_type.as_deref(), Some("Episode"));
        assert_eq!(current.series_id.as_deref(), Some("series1"));
        // Uses the audio-only URL, not a video stream.
        match &current.source {
            MediaSource::Remote { stream_url, .. } => {
                assert!(
                    stream_url.contains("audio"),
                    "expected audio-only URL, got {}",
                    stream_url
                );
            }
            other => panic!("expected Remote source, got {:?}", other),
        }

        // The controller now considers itself mid background-audio episode, so the
        // next end-of-track will advance again rather than stop.
        assert!(controller.current_is_audio_episode());
    }

    /// The handoff base offset describes ONE stream: the audio-only URL built
    /// with `StartTimeTicks` = the position the video was handed off at, whose
    /// timeline therefore starts at that point. The next episode is loaded from
    /// its own beginning, so its timeline is already absolute and the base must
    /// be cleared — otherwise returning to the foreground resolves the resume
    /// position as `old_base + position_in_new_episode` and the video jumps to a
    /// point that has nothing to do with what was playing.
    #[tokio::test]
    async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        // Handed off 20 minutes into the previous episode.
        controller.set_background_audio_base(1200.0);

        controller
            .advance_to_next_episode_audio_only("ep2")
            .await
            .expect("advance should succeed");

        assert_eq!(
            controller.take_background_audio_base(),
            0.0,
            "the next episode starts at its own zero, so the previous handoff \
             base must not survive the advance"
        );
    }

    /// A background audio-only episode must advance IN THE BACKEND when the
    /// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
    /// countdown the frontend is supposed to act on.
    ///
    /// The countdown only emits CountdownTick events; the actual advance is a
    /// `goto('/player/<id>')` in the webview. While the app is backgrounded that
    /// navigation cannot start audio, so playback stalls at the episode boundary
    /// with ExoPlayer parked in STATE_ENDED — and any later play intent
    /// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
    /// start, which is what surfaces to the user as "the episode randomly
    /// restarted".
    #[tokio::test]
    async fn test_auto_advance_background_audio_episode_advances_in_backend() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        // Currently playing: ep2 handed off to audio-only background playback.
        let episode = MediaItem {
            id: "ep2".to_string(),
            item_type: Some("Episode".to_string()),
            media_type: MediaType::Audio,
            series_id: Some("series1".to_string()),
            source: MediaSource::Remote {
                stream_url: "http://example.com/ep2-audio.mp3".to_string(),
                jellyfin_item_id: "ep2".to_string(),
            },
            ..create_test_items(1).remove(0)
        };
        controller.play_queue(vec![episode], 0).unwrap();

        let next = make_repo_episode("ep3", 3);
        controller.auto_advance_to_next_episode(next, 10).await;

        let current = controller
            .queue
            .lock_safe()
            .current()
            .cloned()
            .expect("an item should still be loaded");
        assert_eq!(
            current.id, "ep3",
            "background audio-only episode must advance in the backend, not wait \
             for a frontend navigation that cannot happen while backgrounded"
        );
        assert_eq!(current.media_type, MediaType::Audio);
        assert!(controller.current_is_audio_episode());
    }

    /// Build the audio-only episode the background handoff loads: a video item
    /// played through the native audio path, with a known runtime and a stream
    /// URL carrying the handoff position.
    fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
        MediaItem {
            id: "ep2".to_string(),
            item_type: Some("Episode".to_string()),
            media_type: MediaType::Audio,
            series_id: Some("series1".to_string()),
            duration: Some(runtime_seconds),
            source: MediaSource::Remote {
                stream_url:
                    "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
                        .to_string(),
                jellyfin_item_id: "ep2".to_string(),
            },
            ..create_test_items(1).remove(0)
        }
    }

    /// The same episode handed off from a **downloaded file** — the handoff's
    /// other source, which starts at the episode's own zero rather than at the
    /// handoff point.
    fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
        MediaItem {
            source: MediaSource::Local {
                file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
                jellyfin_item_id: Some("ep2".to_string()),
            },
            ..audio_only_episode(runtime_seconds)
        }
    }

    /// A file seeks like a file. The rebuild path exists because a chunked
    /// length-less transcode cannot honour a seek, which is not true of local
    /// media — and `resume_stream_at` refuses a non-remote source outright, so
    /// routing a lockscreen scrub through it fails the seek instead of doing it.
    ///
    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
    #[tokio::test]
    async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
        let controller = PlayerController::default();
        controller
            .play_queue(vec![local_audio_only_episode(1500.0)], 0)
            .unwrap();
        // A downloaded handoff claims no base: the file's zero is the episode's.
        controller.enter_background_audio(0.0);

        controller.seek_absolute(900.0).await.unwrap();

        assert_eq!(controller.position(), 900.0);
    }

    /// A flaky connection truncates the progressive mp3 transcode that carries
    /// background audio-only playback. ExoPlayer sees end-of-input on a stream
    /// with no reliable length, so it reports STATE_ENDED ten minutes into a
    /// twenty-five minute episode — indistinguishable, to the player, from the
    /// real end.
    ///
    /// Treating that as "the episode finished" is what the user experiences as
    /// the episode randomly restarting: playback parks in STATE_ENDED and the
    /// next play intent (lockscreen, notification, Bluetooth reconnect) seeks an
    /// ended player to position 0 before playing. The runtime we already know
    /// says the stream died early, so the decision must be to resume it.
    #[tokio::test]
    async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        // The connection dropped 10 minutes into a 25-minute episode.
        controller.seek(600.0).unwrap();
        controller.take_end_reason();

        let decision = controller.on_playback_ended().await.unwrap();

        match decision {
            AutoplayDecision::ResumeStream { position } => {
                assert_eq!(position, 600.0, "must resume where the stream died");
            }
            other => panic!(
                "a stream that ended 15 minutes short of the runtime must resume, \
                 not run end-of-episode logic; got {:?}",
                other
            ),
        }
    }

    /// A seek arriving during a background-audio handoff is **absolute** — the
    /// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
    /// 25:00 of the episode, not 25:00 into the handoff stream.
    ///
    /// The handoff stream cannot be seeked at all (a chunked, length-less
    /// transcode), so honouring it means re-opening the URL at the new position,
    /// exactly as the truncation recovery does. Passing the number through to
    /// ExoPlayer instead — which is what used to happen — asked a stream that
    /// cannot seek to jump past its own end, and a clamped seek lands at stream
    /// zero: the handoff point.
    ///
    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
    #[tokio::test]
    async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();

        // Handed off 20 minutes in, so the stream's zero is 1200s.
        controller.enter_background_audio(1200.0);

        // The viewer scrubs the lockscreen to 25:00 absolute.
        controller.seek_absolute(1490.0).await.unwrap();

        let url = {
            let queue = controller.queue();
            let queue = queue.lock_safe();
            match &queue.current().unwrap().source {
                MediaSource::Remote { stream_url, .. } => stream_url.clone(),
                other => panic!("expected a remote source, got {:?}", other),
            }
        };
        assert!(
            url.contains(&format!(
                "StartTimeTicks={}",
                (1490.0 * 10_000_000.0) as i64
            )),
            "the stream must be re-opened at the absolute position; got {}",
            url
        );

        assert_eq!(
            *controller.background_audio_base.lock_safe(),
            1490.0,
            "the re-opened stream's zero is the position it was opened at, or \
             every later reading is off by the difference"
        );
    }

    /// Outside a handoff there is no base and nothing to re-open: an absolute
    /// seek is just a seek, and must not be turned into a stream rebuild.
    ///
    /// TRACES: UR-005 | DR-159 | UT-155
    #[tokio::test]
    async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();

        controller.seek_absolute(300.0).await.unwrap();

        assert_eq!(controller.position(), 300.0);
        assert_eq!(
            *controller.background_audio_base.lock_safe(),
            0.0,
            "an ordinary seek must not invent a handoff base"
        );
    }

    // ===== Position authority and reporting (DR-178, DR-179) =====
    //
    // Every position that leaves the app — the resume point Jellyfin stores, the
    // point the video reloads at on the way back from a handoff, the truncation
    // maths — is read off the controller. The device trace showed all of them
    // reading 0: the native backend is not the player on the webview path, and
    // during a handoff its base is only applied once ExoPlayer has ticked, which
    // it has not while the audio-only transcode is still opening.

    /// Returning to the foreground before the audio-only stream has started
    /// playing hands back the handoff's own starting point, never zero.
    ///
    /// Observed on device: locked at 18.4s, unlocked 3.5s later with ExoPlayer
    /// still `IDLE`, `player_exit_background_audio` returned `0.0`, and the video
    /// reloaded with `StartTimeTicks=0` — the episode restarted from the
    /// beginning, and the `Stopped` report that followed wiped the server's
    /// resume point too.
    ///
    /// TRACES: UR-040 | DR-178 | UT-176
    #[test]
    fn test_absolute_position_floors_at_the_handoff_base() {
        let controller = PlayerController::default();
        controller.enter_background_audio(18.4);

        // No tick has landed, so nothing has applied the base yet.
        assert_eq!(controller.position(), 0.0);
        assert_eq!(
            controller.absolute_position(),
            18.4,
            "the audio stream's zero IS the handoff point, so the position can \
             never legitimately read below it"
        );
    }

    /// Once ticks are flowing the base has already been applied at the native
    /// boundary (DR-159), so flooring must not add it a second time.
    ///
    /// TRACES: UR-040 | DR-178 | UT-176
    #[test]
    fn test_absolute_position_does_not_double_count_the_handoff_base() {
        let controller = PlayerController::default();
        controller.enter_background_audio(18.4);

        // What the real backend reports after a tick: already absolute.
        controller.seek(120.0).unwrap();

        assert_eq!(controller.absolute_position(), 120.0);
    }

    /// On the webview path the `<video>` element is the player and the native
    /// backend holds nothing, so the position it reports is the only one there
    /// is. It used to be re-emitted to the frontend and then dropped, leaving
    /// every backend-side report at 0.
    ///
    /// TRACES: UR-005, UR-025 | DR-178 | UT-177
    #[test]
    fn test_webview_position_reports_become_the_controllers_position() {
        let controller = PlayerController::default();

        controller.report_html5_position(253.4, 2640.0);

        assert_eq!(controller.absolute_position(), 253.4);
        assert_eq!(controller.observed_duration(), Some(2640.0));
    }

    /// A torn-down element's last position must not outlive it: the next thing
    /// to play is loaded into the native backend, and a stale 253s would be
    /// reported against it.
    ///
    /// TRACES: UR-005 | DR-178 | UT-177
    #[test]
    fn test_webview_teardown_clears_the_observed_position() {
        let controller = PlayerController::default();
        controller.report_html5_position(253.4, 2640.0);

        controller.report_html5_state("stopped".to_string(), None);

        assert_eq!(controller.absolute_position(), 0.0);
    }

    /// Entering a handoff tears the element down, so its position stops being
    /// the answer at that exact moment — the native audio player's does.
    ///
    /// TRACES: UR-040 | DR-178 | UT-177
    #[test]
    fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
        let controller = PlayerController::default();
        controller.report_html5_position(253.4, 2640.0);

        controller.enter_background_audio(18.4);

        assert_eq!(
            controller.absolute_position(),
            18.4,
            "the video element is gone; only the handoff base describes the \
             stream that is now playing"
        );
    }

    /// Nobody ever watched zero seconds of anything. A `Stopped` at 0 carries no
    /// information and Jellyfin stores it as the resume point, so the only thing
    /// it can do is destroy one — which is what the device trace caught it doing
    /// 14 times in 35 minutes, including 40s after the frontend had correctly
    /// reported 922s for the same episode.
    ///
    /// TRACES: UR-025 | DR-179 | UT-178
    #[tokio::test]
    async fn test_a_stop_at_zero_is_never_reported() {
        let controller = PlayerController::default();
        let reports = Arc::new(CapturingReports::new());
        controller.set_report_sink(reports.clone());
        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();

        // Nothing ever played: the backend is at 0 and no element reported in.
        controller.stop().unwrap();

        assert!(
            reports.stops().is_empty(),
            "a zero-position stop must be withheld, not sent; got {:?}",
            reports.stops()
        );
    }

    /// A real position is still reported, so withholding zero cannot be
    /// mistaken for withholding everything — from either rendering path.
    ///
    /// The webview half is the one that was broken: the element reports 253s, the
    /// native backend holds nothing, and the stop report went out as 0 and
    /// overwrote the resume point the frontend had just written correctly.
    ///
    /// TRACES: UR-025 | DR-178, DR-179 | UT-178
    #[tokio::test]
    async fn test_a_stop_reports_the_position_actually_reached() {
        // Webview-rendered: the element is the only thing that knows.
        let webview = PlayerController::default();
        let webview_reports = Arc::new(CapturingReports::new());
        webview.set_report_sink(webview_reports.clone());
        webview
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        webview.report_html5_position(253.0, 1500.0);

        webview.stop().unwrap();

        assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);

        // Natively rendered: the backend is authoritative and still is.
        let native = PlayerController::default();
        let native_reports = Arc::new(CapturingReports::new());
        native.set_report_sink(native_reports.clone());
        native
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        native.seek(253.0).unwrap();

        native.stop().unwrap();

        assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
    }

    /// An episode listened to end-to-end on the lockscreen must count as
    /// watched. Jellyfin decides that on the `PlaybackStopped` report — no
    /// report, no completion — and in background audio-only mode there is
    /// nobody else to send one: the webview is suspended and its `<video>` was
    /// torn down at the handoff, so the frontend's end-of-playback reporting
    /// cannot run. The backend advanced to the next episode and said nothing
    /// about the one that finished.
    ///
    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
    #[tokio::test]
    async fn test_a_finished_audio_only_episode_is_reported_complete() {
        let controller = PlayerController::default();
        let reports = Arc::new(CapturingReports::new());
        controller.set_report_sink(reports.clone());
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        // Played out to the end of the 25-minute episode.
        controller.seek(1499.0).unwrap();
        controller.take_end_reason();

        controller.on_playback_ended().await.unwrap();

        assert_eq!(
            reports.stops(),
            vec![("ep2".to_string(), 1500.0)],
            "the finished episode must be reported stopped at its runtime, or \
             Jellyfin's ≥90% rule never marks it played"
        );
    }

    /// The completion report is for ends that are really ends. A truncated
    /// stream is about to be re-opened and the episode is nowhere near over, so
    /// reporting it stopped would tell Jellyfin the opposite of the truth.
    ///
    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
    #[tokio::test]
    async fn test_a_truncated_stream_reports_no_completion() {
        let controller = PlayerController::default();
        let reports = Arc::new(CapturingReports::new());
        controller.set_report_sink(reports.clone());
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        controller.seek(600.0).unwrap();
        controller.take_end_reason();

        let decision = controller.on_playback_ended().await.unwrap();

        assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
        assert!(
            reports.stops().is_empty(),
            "a dropped connection is not a finished episode; got {:?}",
            reports.stops()
        );
    }

    /// Position ticks reach Jellyfin while playback is still going, so closing
    /// the app — or losing it to a crash — cannot cost the whole session. The
    /// device trace requested `/Sessions/Playing/Progress` exactly zero times in
    /// 35 minutes: the frontend service writes progress to the local DB only,
    /// and nothing on the Rust side reported it for webview-rendered media.
    ///
    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
    #[tokio::test]
    async fn test_webview_position_ticks_report_progress_to_the_server() {
        let controller = PlayerController::default();
        let reports = Arc::new(CapturingReports::new());
        controller.set_report_sink(reports.clone());
        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();

        controller.report_html5_position(253.4, 1500.0);

        assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
    }

    /// Ticks arrive four times a second; reports must not. The throttler the
    /// controller already owns bounds them to one per item per 30s.
    ///
    /// TRACES: UR-005 | DR-179 | UT-180
    #[tokio::test]
    async fn test_progress_reports_are_throttled_not_sent_per_tick() {
        let controller = PlayerController::default();
        let reports = Arc::new(CapturingReports::new());
        controller.set_report_sink(reports.clone());
        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();

        for tick in 0..12 {
            controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
        }

        assert_eq!(
            reports.progress().len(),
            1,
            "twelve ticks inside one throttle window are one report"
        );
    }

    /// The truncation check compares the position against the item's runtime, so
    /// both must be on the same timeline.
    ///
    /// They now are by construction: the Android position tick shifts by the
    /// handoff base before anything sees the value, so what the player reports is
    /// already a position on the episode. The base is therefore *not* added here —
    /// doing so would double-count it and make the last minute of a handoff look
    /// like a truncation. What the mock backend holds is what the real one would
    /// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
    #[tokio::test]
    async fn test_truncated_check_uses_the_absolute_position() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        // Handed off at 24:00; the stream then played its last 56 seconds out, so
        // the player reports 24:56 of the episode.
        controller.set_background_audio_base(1440.0);
        controller.seek(1496.0).unwrap();
        controller.take_end_reason();

        let decision = controller.on_playback_ended().await.unwrap();

        assert!(
            matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
            "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
            decision
        );
    }

    /// The resume re-opens the same URL, so a server that is actually gone would
    /// otherwise end → resume → end forever. After the budget runs out the
    /// decision falls back to normal end-of-item handling.
    #[tokio::test]
    async fn test_repeated_truncation_at_the_same_position_gives_up() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        controller.seek(600.0).unwrap();

        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
            controller.take_end_reason();
            let decision = controller.on_playback_ended().await.unwrap();
            assert!(
                matches!(decision, AutoplayDecision::ResumeStream { .. }),
                "attempt {} should still resume, got {:?}",
                attempt,
                decision
            );
        }

        controller.take_end_reason();
        let decision = controller.on_playback_ended().await.unwrap();
        assert!(
            !matches!(decision, AutoplayDecision::ResumeStream { .. }),
            "a stream stuck at the same position must stop retrying, got {:?}",
            decision
        );
    }

    /// Ordinary music is not covered: its streams are not the length-less
    /// progressive transcode this guards, and a short track legitimately ends
    /// well before a stale duration would suggest.
    #[tokio::test]
    async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
        let controller = PlayerController::default();

        let mut items = create_test_items(2);
        items[0].duration = Some(1500.0);
        controller.play_queue(items, 0).unwrap();
        controller.seek(60.0).unwrap();
        controller.take_end_reason();

        let decision = controller.on_playback_ended().await.unwrap();
        assert!(
            matches!(decision, AutoplayDecision::AdvanceToNext),
            "plain queue audio must keep advancing, got {:?}",
            decision
        );
    }

    /// Music and video stream from URLs that declare their own length (a static
    /// file with byte ranges, an HLS playlist), so a truncation reaches the
    /// player as an *error* rather than a phantom end. It is the same network
    /// failure, and the same recovery applies — the previous behaviour turned it
    /// into `playerStop()` and silence.
    #[tokio::test]
    async fn test_recoverable_error_resumes_a_music_track() {
        let controller = PlayerController::default();

        let mut items = create_test_items(3);
        for item in &mut items {
            item.source = MediaSource::Remote {
                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
                jellyfin_item_id: item.id.clone(),
            };
        }
        controller.play_queue(items, 1).unwrap();
        controller.seek(45.0).unwrap();

        let (position, _) = controller
            .recoverable_error_resume()
            .expect("a streamed music track must be resumable after a network error");
        assert_eq!(position, 45.0);
    }

    /// The resume must reload the failed track IN PLACE. `play_item` replaces the
    /// whole queue with a single item, so recovering a track that way would throw
    /// away the rest of the album — turning a network blip into lost state.
    #[tokio::test]
    async fn test_resume_keeps_the_rest_of_the_queue() {
        let controller = PlayerController::default();

        let mut items = create_test_items(3);
        for item in &mut items {
            item.source = MediaSource::Remote {
                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
                jellyfin_item_id: item.id.clone(),
            };
        }
        controller.play_queue(items, 1).unwrap();

        controller
            .resume_stream_at(45.0)
            .await
            .expect("resume should succeed");

        let queue = controller.queue.lock_safe();
        assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
        assert_eq!(queue.current_index(), Some(1), "still on the same track");
        assert_eq!(queue.current().unwrap().id, "item_1");
    }

    /// A seekable stream is re-opened by re-preparing the URL it already has and
    /// seeking — its timeline is intact, and rewriting the URL would restart a
    /// transcode session for no reason.
    #[tokio::test]
    async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
        let controller = PlayerController::default();

        let mut items = create_test_items(1);
        items[0].source = MediaSource::Remote {
            stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
            jellyfin_item_id: "item_0".to_string(),
        };
        controller.play_queue(items, 0).unwrap();

        controller.resume_stream_at(45.0).await.unwrap();

        match &controller.queue.lock_safe().current().unwrap().source {
            MediaSource::Remote { stream_url, .. } => {
                assert_eq!(
                    stream_url, "http://s/Audio/item_0/stream?Static=true",
                    "a seekable stream's URL must be left alone"
                );
            }
            other => panic!("expected Remote source, got {:?}", other),
        }
        assert_eq!(
            controller.position(),
            45.0,
            "and it must land at the position"
        );
    }

    /// Downloaded media cannot fail from the network, and re-opening a local file
    /// would paper over a real read error.
    #[tokio::test]
    async fn test_recoverable_error_ignores_local_media() {
        let controller = PlayerController::default();

        let mut items = create_test_items(1);
        items[0].source = MediaSource::Local {
            file_path: "/music/track.flac".into(),
            jellyfin_item_id: Some("item_0".to_string()),
        };
        controller.play_queue(items, 0).unwrap();
        controller.seek(45.0).unwrap();

        assert!(controller.recoverable_error_resume().is_none());
    }

    /// A recoverable error during background audio-only playback is the network,
    /// not the media — the previous behaviour (surface it, frontend stops the
    /// player) turned a hiccup into silence. Retrying must also back off, or the
    /// three attempts are spent inside a second and the outage outlives them.
    #[tokio::test]
    async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
        let controller = PlayerController::default();

        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        controller.seek(600.0).unwrap();

        let mut waits = Vec::new();
        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
            let (position, delay) = controller
                .recoverable_error_resume()
                .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
            assert_eq!(position, 600.0);
            waits.push(delay);
        }
        assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
        assert!(
            controller.recoverable_error_resume().is_none(),
            "a stream that keeps failing at the same spot must surface the error"
        );
    }

    /// Plugin/channel `DirectUrl` sources are somebody else's endpoint with no
    /// Jellyfin item behind them, so the resume has nothing to re-request.
    #[tokio::test]
    async fn test_recoverable_error_ignores_direct_url_playback() {
        let controller = PlayerController::default();
        controller.play_queue(create_test_items(2), 0).unwrap();

        assert!(controller.recoverable_error_resume().is_none());
    }

    /// Re-opening the stream must land where it died and keep playing, with the
    /// handoff base moved to the new stream's zero so returning to the
    /// foreground still resolves an absolute position.
    #[tokio::test]
    async fn test_resume_truncated_stream_reloads_at_position() {
        let controller = PlayerController::default();

        controller
            .play_queue(vec![audio_only_episode(1500.0)], 0)
            .unwrap();
        controller.set_background_audio_base(0.0);

        controller
            .resume_stream_at(600.0)
            .await
            .expect("resume should succeed");

        let current = controller
            .queue
            .lock_safe()
            .current()
            .cloned()
            .expect("the same item should still be loaded");
        assert_eq!(current.id, "ep2", "resume must not change the item");
        match &current.source {
            MediaSource::Remote { stream_url, .. } => {
                assert!(
                    stream_url.contains("StartTimeTicks=6000000000"),
                    "stream must re-open at 600s, got {}",
                    stream_url
                );
                assert!(
                    stream_url.contains("AudioStreamIndex=2"),
                    "the selected audio track must survive the resume, got {}",
                    stream_url
                );
            }
            other => panic!("expected Remote source, got {:?}", other),
        }
        assert_eq!(
            controller.take_background_audio_base(),
            600.0,
            "the re-opened stream's zero is the resume position"
        );
    }

    /// Foreground video playback keeps the countdown-driven advance: the frontend
    /// owns the navigation there, so the backend must NOT load the next episode
    /// itself (that would race the page transition and double-start playback).
    #[tokio::test]
    async fn test_auto_advance_foreground_video_episode_uses_countdown() {
        let controller = PlayerController::default();
        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));

        let episode = MediaItem {
            id: "ep2".to_string(),
            item_type: Some("Episode".to_string()),
            media_type: MediaType::Video,
            series_id: Some("series1".to_string()),
            source: MediaSource::Remote {
                stream_url: "http://example.com/ep2.m3u8".to_string(),
                jellyfin_item_id: "ep2".to_string(),
            },
            ..create_test_items(1).remove(0)
        };
        controller.play_queue(vec![episode], 0).unwrap();

        let next = make_repo_episode("ep3", 3);
        controller.auto_advance_to_next_episode(next, 10).await;

        let current = controller
            .queue
            .lock_safe()
            .current()
            .cloned()
            .expect("an item should still be loaded");
        assert_eq!(
            current.id, "ep2",
            "foreground video advance is frontend-driven; the backend must not \
             swap the queue item out from under it"
        );
    }

    /// Without a controller repository the Android episode path must still
    /// stop gracefully (previous behavior) rather than error.
    #[tokio::test]
    async fn test_playback_ended_without_repository_stops() {
        let controller = PlayerController::default();

        let episode = MediaItem {
            media_type: MediaType::Video,
            source: MediaSource::Remote {
                stream_url: "http://example.com/ep1.mkv".to_string(),
                jellyfin_item_id: "ep1".to_string(),
            },
            ..create_test_items(1).remove(0)
        };
        controller.play_queue(vec![episode], 0).unwrap();
        controller.take_end_reason();

        let decision = controller.on_playback_ended().await.unwrap();
        assert!(matches!(decision, AutoplayDecision::Stop));
    }
}
