Skip to main content

jellytau_lib/player/
mod.rs

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