Skip to main content

jellytau_lib/player/
mod.rs

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