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