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