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.playable_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        let repo = self
1999            .repository
2000            .lock_safe()
2001            .clone()
2002            .ok_or_else(|| "No repository for background episode advance".to_string())?;
2003
2004        // Details for session metadata (title/series/artwork) and the stream URL.
2005        let next = repo
2006            .get_item(next_episode_id)
2007            .await
2008            .map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
2009
2010        // Audio-only transcode from the start of the episode (no resume offset —
2011        // a freshly-started next episode always plays from the beginning).
2012        let stream_url = repo
2013            .get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
2014            .await
2015            .map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
2016
2017        let media_item = MediaItem {
2018            // Audio and direct-URL items never negotiate a transport.
2019            transport: None,
2020            id: next.id.clone(),
2021            title: next.name.clone(),
2022            name: Some(next.name.clone()),
2023            artist: next.series_name.clone(),
2024            album: None,
2025            album_name: None,
2026            album_id: None,
2027            artist_items: None,
2028            artists: None,
2029            primary_image_tag: next.primary_image_tag.clone(),
2030            image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
2031            // Preserve episode identity so the NEXT end-of-track also advances.
2032            item_type: Some("Episode".to_string()),
2033            playlist_id: None,
2034            duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
2035            artwork_url: None,
2036            media_type: MediaType::Audio,
2037            source: MediaSource::Remote {
2038                stream_url,
2039                jellyfin_item_id: next.id.clone(),
2040            },
2041            video_codec: None,
2042            needs_transcoding: false,
2043            video_width: None,
2044            video_height: None,
2045            subtitles: vec![],
2046            series_id: next.series_id.clone(),
2047            server_id: Some(next.server_id.clone()),
2048        };
2049
2050        // The previous episode's handoff base described the stream we are leaving.
2051        // This one is built without StartTimeTicks, so its timeline is already
2052        // absolute: clear the base (used to resolve the resume position on the way
2053        // back to the foreground) and the lockscreen scrubber's matching shift.
2054        self.set_background_audio_base(0.0);
2055        let _ = set_lockscreen_position_offset(0.0);
2056        // Different stream entirely: whatever was stuck about the last one is not
2057        // this one's problem.
2058        self.stream_resume.lock_safe().reset();
2059
2060        self.play_item(media_item).map_err(|e| e.to_string())
2061    }
2062
2063    /// Handle video playback ended from HTML5 video element.
2064    ///
2065    /// HTML5 video plays independently of the Rust backend, so the backend
2066    /// queue has no knowledge of the video item. This method bypasses the
2067    /// queue lookup and end_reason check, using the provided Jellyfin item ID
2068    /// to look up the item and check for next episodes.
2069    pub async fn on_video_playback_ended(
2070        &self,
2071        item_id: &str,
2072        repo: Arc<dyn crate::repository::MediaRepository>,
2073    ) -> Result<AutoplayDecision, String> {
2074        // Clear any stale end_reason (e.g., UserStop from stopping audio before video)
2075        let stale_reason = self.take_end_reason();
2076        if stale_reason.is_some() {
2077            debug!(
2078                "[PlayerController] Cleared stale end_reason for video: {:?}",
2079                stale_reason
2080            );
2081        }
2082
2083        log::info!(
2084            "[PlayerController] on_video_playback_ended: item_id={}",
2085            item_id
2086        );
2087
2088        // Check sleep timer state
2089        let timer_mode = {
2090            let timer = self.sleep_timer.lock_safe();
2091            timer.mode.clone()
2092        };
2093
2094        match &timer_mode {
2095            SleepTimerMode::Time { end_time } => {
2096                let now = chrono::Utc::now().timestamp_millis();
2097                if now >= *end_time {
2098                    debug!("[PlayerController] Time-based sleep timer expired at video end");
2099                    self.sleep_timer.lock_safe().cancel();
2100                    self.emit_sleep_timer_changed();
2101                    return Ok(AutoplayDecision::Stop);
2102                }
2103            }
2104            SleepTimerMode::EndOfTrack => {
2105                self.sleep_timer.lock_safe().cancel();
2106                self.emit_sleep_timer_changed();
2107                return Ok(AutoplayDecision::Stop);
2108            }
2109            SleepTimerMode::Episodes { .. } => {
2110                let should_stop = self.sleep_timer.lock_safe().decrement_episode();
2111                self.emit_sleep_timer_changed();
2112                if should_stop {
2113                    return Ok(AutoplayDecision::Stop);
2114                }
2115            }
2116            _ => {}
2117        }
2118
2119        // Fetch next episode for the video that just ended. Degrade lookup
2120        // failures to Stop: playback already ended, and propagating an error
2121        // here just kills autoplay silently upstream.
2122        let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
2123            Ok(next) => next,
2124            Err(e) => {
2125                warn!(
2126                    "[PlayerController] Next-episode lookup failed for {}: {}",
2127                    item_id, e
2128                );
2129                None
2130            }
2131        };
2132        if let Some(next_ep) = next_ep_result {
2133            let settings = self.autoplay_settings.lock_safe().clone();
2134
2135            let limit_reached = self.increment_autoplay_count();
2136            if limit_reached {
2137                debug!(
2138                    "[PlayerController] Auto-play episode limit reached ({} episodes)",
2139                    settings.max_episodes
2140                );
2141            }
2142
2143            return Ok(AutoplayDecision::ShowNextEpisodePopup {
2144                current_episode: next_ep.0,
2145                next_episode: next_ep.1,
2146                countdown_seconds: settings.countdown_seconds,
2147                auto_advance: settings.enabled && !limit_reached,
2148            });
2149        }
2150
2151        // No next episode found
2152        debug!("[PlayerController] No next episode found for {}", item_id);
2153        Ok(AutoplayDecision::Stop)
2154    }
2155
2156    /// Check if a media item is an episode (has Jellyfin ID to query).
2157    ///
2158    /// An explicit `item_type == "Episode"` wins so that a TV episode handed off
2159    /// to the audio path for background playback (UR-040) is still recognised as
2160    /// an episode — otherwise autoplay would fall through to the queue-based
2161    /// audio path, find nothing next, and stop at the episode boundary. When the
2162    /// type is unknown we fall back to the historical heuristic (video == episode).
2163    async fn is_episode_item(&self, item: &MediaItem) -> bool {
2164        match item.item_type.as_deref() {
2165            Some("Episode") => true,
2166            Some(_) => item.media_type == MediaType::Video,
2167            None => item.media_type == MediaType::Video,
2168        }
2169    }
2170
2171    /// Fetch next episode for a series by looking up the season's episodes
2172    /// sorted by index number and picking the one after the current episode.
2173    ///
2174    /// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
2175    /// (which relies on watch history that may not be updated yet due to
2176    /// the async nature of playback progress reporting).
2177    async fn fetch_next_episode_for_item(
2178        &self,
2179        item_id: &str,
2180        repo: &Arc<dyn crate::repository::MediaRepository>,
2181    ) -> Result<
2182        Option<(
2183            crate::repository::types::MediaItem,
2184            crate::repository::types::MediaItem,
2185        )>,
2186        String,
2187    > {
2188        use crate::repository::types::GetItemsOptions;
2189
2190        // Get the current item details from repository
2191        let current_repo_item = repo
2192            .get_item(item_id)
2193            .await
2194            .map_err(|e| format!("Failed to get current item: {}", e))?;
2195
2196        // Need season_id to fetch sibling episodes
2197        let season_id = match &current_repo_item.season_id {
2198            Some(sid) => sid.clone(),
2199            None => {
2200                log::info!(
2201                    "[PlayerController] Current item has no season_id, cannot find next episode"
2202                );
2203                return Ok(None);
2204            }
2205        };
2206
2207        // Fetch all episodes in the season sorted by episode number
2208        let options = GetItemsOptions {
2209            sort_by: Some("IndexNumber".to_string()),
2210            sort_order: Some("Ascending".to_string()),
2211            limit: Some(500),
2212            include_item_types: Some(vec!["Episode".to_string()]),
2213            ..Default::default()
2214        };
2215
2216        let result = repo
2217            .get_items(&season_id, Some(options))
2218            .await
2219            .map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
2220
2221        // Sort client-side by index_number to ensure correct ordering
2222        // (offline repo ignores sort_by and sorts by sort_name instead)
2223        let mut episodes = result.items;
2224        episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
2225        log::info!(
2226            "[PlayerController] Season has {} episodes, looking for next after {}",
2227            episodes.len(),
2228            current_repo_item.id
2229        );
2230
2231        // Find the current episode by ID and return the next one
2232        if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
2233            if current_idx + 1 < episodes.len() {
2234                let next = &episodes[current_idx + 1];
2235                log::info!(
2236                    "[PlayerController] Found next episode: {} (index {})",
2237                    next.name,
2238                    current_idx + 1
2239                );
2240                return Ok(Some((current_repo_item, next.clone())));
2241            } else {
2242                log::info!("[PlayerController] Current episode is the last in the season");
2243            }
2244        } else {
2245            log::info!(
2246                "[PlayerController] Current episode not found in season episodes (ids: {:?})",
2247                episodes
2248                    .iter()
2249                    .map(|e| e.id.as_str())
2250                    .take(20)
2251                    .collect::<Vec<_>>()
2252            );
2253        }
2254
2255        Ok(None)
2256    }
2257
2258    /// Start autoplay countdown thread
2259    pub fn start_autoplay_countdown(
2260        &self,
2261        _next_item: crate::repository::types::MediaItem,
2262        countdown_seconds: u32,
2263    ) {
2264        // Create cancellation flag
2265        let cancel_flag = Arc::new(Mutex::new(false));
2266        *self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
2267
2268        let event_emitter = self.event_emitter.clone();
2269
2270        std::thread::spawn(move || {
2271            let mut remaining = countdown_seconds;
2272
2273            while remaining > 0 {
2274                std::thread::sleep(Duration::from_secs(1));
2275
2276                // Check cancellation
2277                if *cancel_flag.lock_safe() {
2278                    log::info!("[PlayerController] Autoplay countdown cancelled");
2279                    return;
2280                }
2281
2282                remaining -= 1;
2283
2284                // Emit countdown tick event
2285                if let Some(emitter) = event_emitter.lock_safe().as_ref() {
2286                    emitter.emit(PlayerStatusEvent::CountdownTick {
2287                        remaining_seconds: remaining,
2288                    });
2289                }
2290            }
2291
2292            // Countdown finished (final tick at 0 was already emitted inside the loop)
2293            log::info!("[PlayerController] Autoplay countdown finished");
2294        });
2295    }
2296}
2297
2298impl Default for PlayerController {
2299    fn default() -> Self {
2300        let playback_reporter = Arc::new(TokioMutex::new(None));
2301        let position_throttler = Arc::new(EventThrottler::new());
2302        Self::new(
2303            Box::new(LegacyPlayer::new(
2304                NullBackend::new(),
2305                crate::player::media_player::Capabilities::mpv(),
2306            )),
2307            playback_reporter,
2308            position_throttler,
2309        )
2310    }
2311}
2312
2313#[cfg(test)]
2314mod tests {
2315
2316    /// Stopping clears a background-audio handoff.
2317    ///
2318    /// This was verified by listening to a tablet, which is not a test. The
2319    /// handoff swaps which renderer owns playback, and the swap is bookkeeping:
2320    /// leaving the base offset and the active flag behind after a stop lets a
2321    /// later position read be interpreted against a handoff that no longer
2322    /// exists, and left the film playing on as an audio track in the mini
2323    /// player.
2324    ///
2325    /// TRACES: UR-040, UR-005 | DR-250 | UT-224
2326    #[test]
2327    fn test_stop_clears_an_active_background_audio_handoff() {
2328        let controller = PlayerController::default();
2329        let item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2330        {
2331            let queue_arc = controller.queue();
2332            let mut queue = queue_arc.lock_safe();
2333            queue.set_queue(vec![item], 0);
2334        }
2335
2336        controller.enter_background_audio(557.5);
2337        assert!(
2338            controller.is_background_audio_active(),
2339            "precondition: the handoff is active"
2340        );
2341
2342        controller.stop().expect("stop failed");
2343
2344        assert!(
2345            !controller.is_background_audio_active(),
2346            "a stop must not leave a handoff behind for the next position read"
2347        );
2348        assert_eq!(
2349            *controller.background_audio_base.lock_safe(),
2350            0.0,
2351            "the handoff base must be cleared with it"
2352        );
2353    }
2354
2355    /// A duration the engine does not know must fall back to the one the item
2356    /// carries, and zero must count as "does not know".
2357    ///
2358    /// ExoPlayer reports `C.TIME_UNSET` for a duration it has not resolved;
2359    /// `JellyTauPlayer.getDuration()` maps that to `0.0`, so the engine answers
2360    /// `Some(0.0)` rather than `None` and every "unknown duration" fallback is
2361    /// skipped. The seek bar then has no scale, which presents as scrubbing
2362    /// being dead rather than as a missing duration.
2363    ///
2364    /// TRACES: UR-005, UR-040 | DR-251 | UT-221
2365    #[test]
2366    fn test_duration_falls_back_to_the_item_when_the_engine_does_not_know() {
2367        let controller = PlayerController::default();
2368        let mut item = MediaItem::sample("item-1", "https://example.invalid/a.mp4");
2369        item.duration = Some(1800.0);
2370
2371        {
2372            let queue_arc = controller.queue();
2373            let mut queue = queue_arc.lock_safe();
2374            queue.set_queue(vec![item], 0);
2375        }
2376
2377        assert_eq!(
2378            controller.duration(),
2379            Some(1800.0),
2380            "an engine that cannot report a duration should not erase the one the item carries"
2381        );
2382    }
2383    use super::*;
2384
2385    /// Test emitter that captures events for asserting the HTML5 report methods
2386    /// re-emit through the normal PlayerStatusEvent pipeline.
2387    struct CapturingEmitter {
2388        events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
2389    }
2390
2391    impl CapturingEmitter {
2392        fn new() -> Self {
2393            Self {
2394                events: std::sync::Mutex::new(Vec::new()),
2395            }
2396        }
2397        fn events(&self) -> Vec<PlayerStatusEvent> {
2398            self.events.lock_safe().clone()
2399        }
2400    }
2401
2402    impl PlayerEventEmitter for CapturingEmitter {
2403        fn emit(&self, event: PlayerStatusEvent) {
2404            self.events.lock_safe().push(event);
2405        }
2406    }
2407
2408    /// Captures what the controller reports to Jellyfin, so tests can assert on
2409    /// the operations themselves rather than on a database and an HTTP client.
2410    struct CapturingReports {
2411        operations: std::sync::Mutex<Vec<PlaybackOperation>>,
2412    }
2413
2414    impl CapturingReports {
2415        fn new() -> Self {
2416            Self {
2417                operations: std::sync::Mutex::new(Vec::new()),
2418            }
2419        }
2420
2421        /// Every `Stopped` report as `(item_id, position_seconds)`.
2422        fn stops(&self) -> Vec<(String, f64)> {
2423            self.operations
2424                .lock_safe()
2425                .iter()
2426                .filter_map(|op| match op {
2427                    PlaybackOperation::Stopped {
2428                        item_id,
2429                        position_ticks,
2430                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2431                    _ => None,
2432                })
2433                .collect()
2434        }
2435
2436        /// Every `Progress` report as `(item_id, position_seconds)`.
2437        fn progress(&self) -> Vec<(String, f64)> {
2438            self.operations
2439                .lock_safe()
2440                .iter()
2441                .filter_map(|op| match op {
2442                    PlaybackOperation::Progress {
2443                        item_id,
2444                        position_ticks,
2445                        ..
2446                    } => Some((item_id.clone(), *position_ticks as f64 / 10_000_000.0)),
2447                    _ => None,
2448                })
2449                .collect()
2450        }
2451    }
2452
2453    impl PlaybackReportSink for CapturingReports {
2454        fn send(&self, operation: PlaybackOperation) {
2455            self.operations.lock_safe().push(operation);
2456        }
2457    }
2458
2459    #[test]
2460    fn test_report_html5_state_emits_state_changed() {
2461        let controller = PlayerController::default();
2462        let emitter = Arc::new(CapturingEmitter::new());
2463        controller.set_event_emitter(emitter.clone());
2464
2465        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2466
2467        let events = emitter.events();
2468        assert_eq!(events.len(), 1);
2469        match &events[0] {
2470            PlayerStatusEvent::StateChanged { state, media_id } => {
2471                assert_eq!(state, "playing");
2472                assert_eq!(media_id.as_deref(), Some("item-1"));
2473            }
2474            other => panic!("expected StateChanged, got {:?}", other),
2475        }
2476    }
2477
2478    #[test]
2479    fn test_report_html5_position_emits_position_update() {
2480        let controller = PlayerController::default();
2481        let emitter = Arc::new(CapturingEmitter::new());
2482        controller.set_event_emitter(emitter.clone());
2483
2484        controller.report_html5_position(12.5, 300.0);
2485
2486        let events = emitter.events();
2487        assert_eq!(events.len(), 1);
2488        match &events[0] {
2489            PlayerStatusEvent::PositionUpdate { position, duration } => {
2490                assert_eq!(*position, 12.5);
2491                assert_eq!(*duration, 300.0);
2492            }
2493            other => panic!("expected PositionUpdate, got {:?}", other),
2494        }
2495    }
2496
2497    #[test]
2498    fn test_report_html5_media_loaded_emits_media_loaded() {
2499        let controller = PlayerController::default();
2500        let emitter = Arc::new(CapturingEmitter::new());
2501        controller.set_event_emitter(emitter.clone());
2502
2503        controller.report_html5_media_loaded(420.0);
2504
2505        let events = emitter.events();
2506        assert_eq!(events.len(), 1);
2507        match &events[0] {
2508            PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
2509            other => panic!("expected MediaLoaded, got {:?}", other),
2510        }
2511    }
2512
2513    // ===== HTML5 transport authority (DR-097) =====
2514    //
2515    // Webview-rendered video is played by an element the native backend cannot
2516    // reach, so transport for it must be decided from the state the element
2517    // REPORTS and executed by emitting a ControlCommand. Previously the frontend
2518    // decided play-vs-pause itself by reading `el.paused` off the DOM, which
2519    // flips transiently while buffering/seeking — two intents ~150ms apart read
2520    // different values, took opposing actions, and self-sustained a pause loop.
2521
2522    #[test]
2523    fn test_html5_state_is_tracked_from_reports() {
2524        let controller = PlayerController::default();
2525        let emitter = Arc::new(CapturingEmitter::new());
2526        controller.set_event_emitter(emitter.clone());
2527
2528        // No HTML5 media reported yet: the native backend stays authoritative.
2529        assert!(!controller.is_html5_active());
2530
2531        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2532        assert!(controller.is_html5_active());
2533        assert!(controller.html5_is_playing());
2534
2535        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2536        assert!(controller.is_html5_active());
2537        assert!(!controller.html5_is_playing());
2538    }
2539
2540    #[test]
2541    fn test_html5_toggle_from_paused_emits_play_control() {
2542        let controller = PlayerController::default();
2543        let emitter = Arc::new(CapturingEmitter::new());
2544        controller.set_event_emitter(emitter.clone());
2545        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2546
2547        controller.toggle_playback().unwrap();
2548
2549        let controls: Vec<_> = emitter
2550            .events()
2551            .into_iter()
2552            .filter_map(|e| match e {
2553                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2554                _ => None,
2555            })
2556            .collect();
2557        assert_eq!(controls, vec!["play".to_string()]);
2558    }
2559
2560    #[test]
2561    fn test_html5_toggle_from_playing_emits_pause_control() {
2562        let controller = PlayerController::default();
2563        let emitter = Arc::new(CapturingEmitter::new());
2564        controller.set_event_emitter(emitter.clone());
2565        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2566
2567        controller.toggle_playback().unwrap();
2568
2569        let controls: Vec<_> = emitter
2570            .events()
2571            .into_iter()
2572            .filter_map(|e| match e {
2573                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2574                _ => None,
2575            })
2576            .collect();
2577        assert_eq!(controls, vec!["pause".to_string()]);
2578    }
2579
2580    #[test]
2581    fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
2582        // The loop signature: two intents in quick succession must NOT both
2583        // resolve the same way, and must not produce opposing actions from a
2584        // stale read. Rust's own tracked state makes the sequence deterministic
2585        // as long as the element reports back between intents.
2586        let controller = PlayerController::default();
2587        let emitter = Arc::new(CapturingEmitter::new());
2588        controller.set_event_emitter(emitter.clone());
2589        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2590
2591        controller.toggle_playback().unwrap();
2592        // Element confirms the pause it was told to do.
2593        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2594        controller.toggle_playback().unwrap();
2595
2596        let controls: Vec<_> = emitter
2597            .events()
2598            .into_iter()
2599            .filter_map(|e| match e {
2600                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2601                _ => None,
2602            })
2603            .collect();
2604        assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
2605    }
2606
2607    #[test]
2608    fn test_html5_play_and_pause_emit_control_commands() {
2609        let controller = PlayerController::default();
2610        let emitter = Arc::new(CapturingEmitter::new());
2611        controller.set_event_emitter(emitter.clone());
2612        controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
2613
2614        controller.play().unwrap();
2615        controller.pause().unwrap();
2616
2617        let controls: Vec<_> = emitter
2618            .events()
2619            .into_iter()
2620            .filter_map(|e| match e {
2621                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2622                _ => None,
2623            })
2624            .collect();
2625        assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
2626    }
2627
2628    #[test]
2629    fn test_background_audio_handoff_moves_transport_to_native_backend() {
2630        // Lockscreen pause while playing a video's audio in the background.
2631        //
2632        // The handoff tears the WebView <video> down AFTER native audio starts,
2633        // and that teardown fires a DOM `pause` the frontend dutifully reports.
2634        // That report used to leave `html5_playing = Some(false)`, so transport
2635        // kept being aimed at an element that no longer exists: the lockscreen
2636        // pause emitted a ControlCommand into the void and the audio played on.
2637        let controller = PlayerController::default();
2638        let emitter = Arc::new(CapturingEmitter::new());
2639        controller.set_event_emitter(emitter.clone());
2640
2641        // Video was playing in the webview.
2642        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2643        assert!(controller.is_html5_active());
2644
2645        // Hand off to the native audio player, then tear the element down.
2646        controller.enter_background_audio(1200.0);
2647        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2648
2649        assert!(
2650            !controller.is_html5_active(),
2651            "native audio owns transport during a background-audio handoff"
2652        );
2653
2654        controller.pause().unwrap();
2655        let controls: Vec<_> = emitter
2656            .events()
2657            .into_iter()
2658            .filter_map(|e| match e {
2659                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
2660                _ => None,
2661            })
2662            .collect();
2663        assert!(
2664            controls.is_empty(),
2665            "pause must drive the native backend, not a torn-down element: {:?}",
2666            controls
2667        );
2668    }
2669
2670    #[test]
2671    fn test_background_audio_handoff_suppresses_stale_element_events() {
2672        // The dying element's pause/position reports describe the video, not the
2673        // audio now playing — re-emitting them flips the UI to paused and yanks
2674        // the position backwards while native audio keeps going.
2675        let controller = PlayerController::default();
2676        let emitter = Arc::new(CapturingEmitter::new());
2677        controller.set_event_emitter(emitter.clone());
2678
2679        controller.enter_background_audio(1200.0);
2680        controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
2681        controller.report_html5_position(1200.0, 2400.0);
2682
2683        assert!(
2684            emitter.events().is_empty(),
2685            "stale webview reports must not reach the event pipeline: {:?}",
2686            emitter.events()
2687        );
2688    }
2689
2690    #[test]
2691    fn test_exit_background_audio_returns_transport_to_the_webview() {
2692        // Back in the foreground the <video> is the player again, so its reports
2693        // must be honoured — and the base offset still comes back for the resume.
2694        let controller = PlayerController::default();
2695        let emitter = Arc::new(CapturingEmitter::new());
2696        controller.set_event_emitter(emitter.clone());
2697
2698        controller.enter_background_audio(1200.0);
2699        assert_eq!(controller.exit_background_audio(), 1200.0);
2700
2701        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
2702        assert!(controller.is_html5_active());
2703        assert!(controller.html5_is_playing());
2704    }
2705
2706    #[test]
2707    fn test_html5_stopped_report_releases_transport_to_native_backend() {
2708        // When webview video goes away, transport must fall back to the native
2709        // backend (music playback must not keep emitting ControlCommands).
2710        let controller = PlayerController::default();
2711        let emitter = Arc::new(CapturingEmitter::new());
2712        controller.set_event_emitter(emitter.clone());
2713
2714        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2715        assert!(controller.is_html5_active());
2716
2717        controller.report_html5_state("stopped".to_string(), None);
2718        assert!(!controller.is_html5_active());
2719    }
2720
2721    #[test]
2722    fn test_html5_transport_emits_exactly_one_control_per_intent() {
2723        // Guards against a double-drive on platforms where the *backend* is also
2724        // webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
2725        // must replace the backend call, not run in addition to it.
2726        let controller = PlayerController::default();
2727        let emitter = Arc::new(CapturingEmitter::new());
2728        controller.set_event_emitter(emitter.clone());
2729        controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
2730
2731        controller.pause().unwrap();
2732
2733        let controls = emitter
2734            .events()
2735            .into_iter()
2736            .filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
2737            .count();
2738        assert_eq!(controls, 1, "one intent must produce exactly one control");
2739    }
2740
2741    #[test]
2742    fn test_controller_volume_default() {
2743        let controller = PlayerController::default();
2744        assert_eq!(controller.volume(), 1.0);
2745    }
2746
2747    #[test]
2748    fn test_controller_set_volume() {
2749        let controller = PlayerController::default();
2750        controller.set_volume(0.5).unwrap();
2751        assert_eq!(controller.volume(), 0.5);
2752    }
2753
2754    #[test]
2755    fn test_controller_muted_default() {
2756        let controller = PlayerController::default();
2757        assert!(!controller.muted());
2758    }
2759
2760    #[test]
2761    fn test_controller_volume_delegates_to_backend() {
2762        let controller = PlayerController::default();
2763
2764        // Set volume through controller
2765        controller.set_volume(0.75).unwrap();
2766
2767        // Verify it's reflected in both controller.volume() and backend
2768        assert_eq!(controller.volume(), 0.75);
2769    }
2770
2771    fn create_test_items(count: usize) -> Vec<MediaItem> {
2772        (0..count)
2773            .map(|i| MediaItem {
2774                // Audio and direct-URL items never negotiate a transport.
2775                transport: None,
2776                id: format!("item_{}", i),
2777                title: format!("Track {}", i + 1),
2778                name: Some(format!("Track {}", i + 1)),
2779                artist: Some("Test Artist".to_string()),
2780                album: Some("Test Album".to_string()),
2781                album_name: Some("Test Album".to_string()),
2782                album_id: None,
2783                artist_items: None,
2784                artists: Some(vec!["Test Artist".to_string()]),
2785                primary_image_tag: None,
2786                image_id: None,
2787                item_type: Some("Audio".to_string()),
2788                playlist_id: None,
2789                duration: Some(180.0),
2790                artwork_url: None,
2791                media_type: MediaType::Audio,
2792                source: MediaSource::DirectUrl {
2793                    url: format!("http://example.com/track_{}.mp3", i),
2794                },
2795                video_codec: None,
2796                needs_transcoding: false,
2797                video_width: None,
2798                video_height: None,
2799                subtitles: vec![],
2800                series_id: None,
2801                server_id: None,
2802            })
2803            .collect()
2804    }
2805
2806    #[test]
2807    fn test_skip_preserves_queue() {
2808        let controller = PlayerController::default();
2809
2810        // Create a queue with 5 items
2811        let items = create_test_items(5);
2812        let items_clone = items.clone();
2813
2814        // Play the queue starting at index 0
2815        controller.play_queue(items, 0).unwrap();
2816
2817        // Verify initial state
2818        {
2819            let queue = controller.queue();
2820            let queue_lock = queue.lock_safe();
2821            assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
2822            assert_eq!(
2823                queue_lock.current_index(),
2824                Some(0),
2825                "Should start at index 0"
2826            );
2827            assert_eq!(
2828                queue_lock.current().unwrap().id,
2829                "item_0",
2830                "Current item should be item_0"
2831            );
2832        }
2833
2834        // Skip to next track
2835        controller.next().unwrap();
2836
2837        // Verify queue is intact and index advanced
2838        {
2839            let queue = controller.queue();
2840            let queue_lock = queue.lock_safe();
2841            assert_eq!(
2842                queue_lock.items().len(),
2843                5,
2844                "Queue should still have 5 items after skip"
2845            );
2846            assert_eq!(
2847                queue_lock.current_index(),
2848                Some(1),
2849                "Index should advance to 1"
2850            );
2851            assert_eq!(
2852                queue_lock.current().unwrap().id,
2853                "item_1",
2854                "Current item should be item_1"
2855            );
2856
2857            // Verify all original items are still present
2858            let current_items = queue_lock.items();
2859            for (i, original) in items_clone.iter().enumerate() {
2860                assert_eq!(
2861                    current_items[i].id, original.id,
2862                    "Item {} should still be in queue",
2863                    i
2864                );
2865                assert_eq!(
2866                    current_items[i].title, original.title,
2867                    "Item {} title should be unchanged",
2868                    i
2869                );
2870            }
2871        }
2872
2873        // Skip again
2874        controller.next().unwrap();
2875
2876        // Verify queue still intact and index advanced again
2877        {
2878            let queue = controller.queue();
2879            let queue_lock = queue.lock_safe();
2880            assert_eq!(
2881                queue_lock.items().len(),
2882                5,
2883                "Queue should still have 5 items after second skip"
2884            );
2885            assert_eq!(
2886                queue_lock.current_index(),
2887                Some(2),
2888                "Index should advance to 2"
2889            );
2890            assert_eq!(
2891                queue_lock.current().unwrap().id,
2892                "item_2",
2893                "Current item should be item_2"
2894            );
2895        }
2896
2897        // Skip multiple times to reach the end
2898        controller.next().unwrap(); // -> item_3
2899        controller.next().unwrap(); // -> item_4
2900
2901        // Verify we're at the last item
2902        {
2903            let queue = controller.queue();
2904            let queue_lock = queue.lock_safe();
2905            assert_eq!(
2906                queue_lock.items().len(),
2907                5,
2908                "Queue should still have 5 items at end"
2909            );
2910            assert_eq!(
2911                queue_lock.current_index(),
2912                Some(4),
2913                "Index should be at last item (4)"
2914            );
2915            assert_eq!(
2916                queue_lock.current().unwrap().id,
2917                "item_4",
2918                "Current item should be item_4"
2919            );
2920        }
2921    }
2922
2923    #[test]
2924    fn test_skip_at_end_without_repeat() {
2925        let controller = PlayerController::default();
2926
2927        // Create a queue with 3 items
2928        let items = create_test_items(3);
2929        controller.play_queue(items, 0).unwrap();
2930
2931        // Skip to last item
2932        controller.next().unwrap(); // -> item_1
2933        controller.next().unwrap(); // -> item_2
2934
2935        // Verify we're at the last item
2936        {
2937            let queue = controller.queue();
2938            let queue_lock = queue.lock_safe();
2939            assert_eq!(
2940                queue_lock.current_index(),
2941                Some(2),
2942                "Should be at last item"
2943            );
2944        }
2945
2946        // Try to skip past the end (without repeat mode)
2947        // This should succeed but stop playback while preserving the queue
2948        controller.next().unwrap();
2949
2950        // Verify queue is still intact
2951        {
2952            let queue = controller.queue();
2953            let queue_lock = queue.lock_safe();
2954            assert_eq!(
2955                queue_lock.items().len(),
2956                3,
2957                "Queue should still have 3 items after skip at end"
2958            );
2959            // When we skip past the end, the queue index should stay at the last item
2960            // or become None (depending on implementation)
2961            // The key is the queue items themselves should be preserved
2962        }
2963    }
2964
2965    #[test]
2966    fn test_skip_with_repeat_all() {
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        // Enable repeat all
2974        controller.cycle_repeat();
2975
2976        // Skip to last item
2977        controller.next().unwrap(); // -> item_1
2978        controller.next().unwrap(); // -> item_2
2979
2980        // Skip again - should wrap to beginning
2981        controller.next().unwrap();
2982
2983        // Verify we wrapped to the first item
2984        {
2985            let queue = controller.queue();
2986            let queue_lock = queue.lock_safe();
2987            assert_eq!(
2988                queue_lock.items().len(),
2989                3,
2990                "Queue should still have 3 items"
2991            );
2992            assert_eq!(
2993                queue_lock.current_index(),
2994                Some(0),
2995                "Should wrap to index 0"
2996            );
2997            assert_eq!(
2998                queue_lock.current().unwrap().id,
2999                "item_0",
3000                "Should be back at item_0"
3001            );
3002        }
3003    }
3004
3005    #[test]
3006    fn test_previous_preserves_queue() {
3007        let controller = PlayerController::default();
3008
3009        // Create a queue with 5 items, start at item 3
3010        let items = create_test_items(5);
3011        let items_clone = items.clone();
3012        controller.play_queue(items, 3).unwrap();
3013
3014        // Verify starting position
3015        {
3016            let queue = controller.queue();
3017            let queue_lock = queue.lock_safe();
3018            assert_eq!(
3019                queue_lock.current_index(),
3020                Some(3),
3021                "Should start at index 3"
3022            );
3023        }
3024
3025        // Go to previous track
3026        controller.previous().unwrap();
3027
3028        // Verify queue is intact and index moved back
3029        {
3030            let queue = controller.queue();
3031            let queue_lock = queue.lock_safe();
3032            assert_eq!(
3033                queue_lock.items().len(),
3034                5,
3035                "Queue should still have 5 items after previous"
3036            );
3037            assert_eq!(
3038                queue_lock.current_index(),
3039                Some(2),
3040                "Index should move to 2"
3041            );
3042            assert_eq!(
3043                queue_lock.current().unwrap().id,
3044                "item_2",
3045                "Current item should be item_2"
3046            );
3047
3048            // Verify all original items are still present
3049            let current_items = queue_lock.items();
3050            for (i, original) in items_clone.iter().enumerate() {
3051                assert_eq!(
3052                    current_items[i].id, original.id,
3053                    "Item {} should still be in queue",
3054                    i
3055                );
3056            }
3057        }
3058    }
3059
3060    #[test]
3061    fn test_seek_updates_position() {
3062        let controller = PlayerController::default();
3063
3064        // Create and play a single item
3065        let item = create_test_items(1).into_iter().next().unwrap();
3066        controller.play_item(item).unwrap();
3067
3068        // Verify initial position
3069        assert_eq!(controller.position(), 0.0, "Initial position should be 0");
3070
3071        // Seek to 30 seconds
3072        controller.seek(30.0).unwrap();
3073        assert_eq!(
3074            controller.position(),
3075            30.0,
3076            "Position should be 30 after seeking"
3077        );
3078
3079        // Seek to 60 seconds
3080        controller.seek(60.0).unwrap();
3081        assert_eq!(
3082            controller.position(),
3083            60.0,
3084            "Position should be 60 after seeking"
3085        );
3086
3087        // Seek backward to 15 seconds
3088        controller.seek(15.0).unwrap();
3089        assert_eq!(
3090            controller.position(),
3091            15.0,
3092            "Position should be 15 after seeking backward"
3093        );
3094    }
3095
3096    #[test]
3097    fn test_seek_while_paused() {
3098        let controller = PlayerController::default();
3099
3100        // Create and play a single item
3101        let item = create_test_items(1).into_iter().next().unwrap();
3102        controller.play_item(item).unwrap();
3103
3104        // Pause playback
3105        controller.pause().unwrap();
3106
3107        // Verify paused state
3108        assert!(controller.state().is_paused(), "Should be paused");
3109
3110        // Seek while paused
3111        controller.seek(45.0).unwrap();
3112        assert_eq!(
3113            controller.position(),
3114            45.0,
3115            "Position should update while paused"
3116        );
3117
3118        // Verify still paused after seeking
3119        assert!(
3120            controller.state().is_paused(),
3121            "Should still be paused after seeking"
3122        );
3123    }
3124
3125    #[test]
3126    fn test_seek_while_playing() {
3127        let controller = PlayerController::default();
3128
3129        // Create and play a single item
3130        let item = create_test_items(1).into_iter().next().unwrap();
3131        controller.play_item(item).unwrap();
3132
3133        // Ensure playing
3134        controller.play().unwrap();
3135
3136        // Verify playing state
3137        assert!(controller.state().is_playing(), "Should be playing");
3138
3139        // Seek while playing
3140        controller.seek(20.0).unwrap();
3141        assert_eq!(
3142            controller.position(),
3143            20.0,
3144            "Position should update while playing"
3145        );
3146
3147        // Verify still playing after seeking
3148        assert!(
3149            controller.state().is_playing(),
3150            "Should still be playing after seeking"
3151        );
3152    }
3153
3154    #[test]
3155    fn test_multiple_sequential_seeks() {
3156        let controller = PlayerController::default();
3157
3158        let item = create_test_items(1).into_iter().next().unwrap();
3159        controller.play_item(item).unwrap();
3160
3161        // Perform multiple seeks in sequence
3162        let positions = vec![10.0, 25.0, 50.0, 75.0, 100.0, 30.0];
3163
3164        for pos in positions {
3165            controller.seek(pos).unwrap();
3166            assert_eq!(
3167                controller.position(),
3168                pos,
3169                "Position should match after seeking to {}",
3170                pos
3171            );
3172        }
3173    }
3174
3175    /// Resuming a queue at a position seeks the starting track immediately.
3176    /// Regression guard for taking over a remote session: the local player must
3177    /// pick up where the remote left off, not restart from 0.
3178    #[test]
3179    fn test_play_queue_from_resumes_at_position() {
3180        let controller = PlayerController::default();
3181        let items = create_test_items(3);
3182
3183        controller.play_queue_from(items, 1, Some(42.5)).unwrap();
3184
3185        {
3186            let queue = controller.queue();
3187            let queue_lock = queue.lock_safe();
3188            assert_eq!(
3189                queue_lock.current_index(),
3190                Some(1),
3191                "Should start at index 1"
3192            );
3193        }
3194        assert_eq!(
3195            controller.position(),
3196            42.5,
3197            "Should resume at the requested position"
3198        );
3199    }
3200
3201    /// A None / near-zero start position starts the track from the beginning.
3202    #[test]
3203    fn test_play_queue_from_without_position_starts_at_zero() {
3204        let controller = PlayerController::default();
3205
3206        controller
3207            .play_queue_from(create_test_items(2), 0, None)
3208            .unwrap();
3209        assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
3210
3211        controller
3212            .play_queue_from(create_test_items(2), 0, Some(0.2))
3213            .unwrap();
3214        assert_eq!(
3215            controller.position(),
3216            0.0,
3217            "Sub-threshold resume position is ignored (starts at 0)"
3218        );
3219    }
3220
3221    #[test]
3222    fn test_seek_to_zero() {
3223        let controller = PlayerController::default();
3224
3225        let item = create_test_items(1).into_iter().next().unwrap();
3226        controller.play_item(item).unwrap();
3227
3228        // Seek forward
3229        controller.seek(60.0).unwrap();
3230        assert_eq!(controller.position(), 60.0);
3231
3232        // Seek back to zero
3233        controller.seek(0.0).unwrap();
3234        assert_eq!(
3235            controller.position(),
3236            0.0,
3237            "Should be able to seek to position 0"
3238        );
3239    }
3240
3241    // Autoplay decision tests
3242    #[tokio::test]
3243    async fn test_audio_with_next_advances() {
3244        let controller = PlayerController::default();
3245
3246        // Create queue with 2 audio items
3247        let items = create_test_items(2);
3248        controller.play_queue(items, 0).unwrap();
3249
3250        // Clear the NewTrackLoaded reason set by play_queue to simulate natural track end
3251        controller.take_end_reason();
3252
3253        // Simulate first track ending naturally
3254        let decision = controller.on_playback_ended().await.unwrap();
3255
3256        // Should decide to advance to next
3257        assert!(
3258            matches!(decision, AutoplayDecision::AdvanceToNext),
3259            "Expected AdvanceToNext decision when queue has next item"
3260        );
3261    }
3262
3263    #[tokio::test]
3264    async fn test_audio_at_end_stops() {
3265        let controller = PlayerController::default();
3266
3267        // Create queue with 2 items, start at last one
3268        let items = create_test_items(2);
3269        controller.play_queue(items, 1).unwrap();
3270
3271        // Clear the NewTrackLoaded reason to simulate natural track end
3272        controller.take_end_reason();
3273
3274        // Simulate last track ending naturally
3275        let decision = controller.on_playback_ended().await.unwrap();
3276
3277        // Should decide to stop (no more items)
3278        assert!(
3279            matches!(decision, AutoplayDecision::Stop),
3280            "Expected Stop decision when at end of queue without repeat"
3281        );
3282    }
3283
3284    #[tokio::test]
3285    async fn test_sleep_timer_end_of_track() {
3286        let controller = PlayerController::default();
3287
3288        // Create queue with next items
3289        let items = create_test_items(3);
3290        controller.play_queue(items, 0).unwrap();
3291
3292        // Clear the NewTrackLoaded reason to simulate natural track end
3293        controller.take_end_reason();
3294
3295        // Set sleep timer to end of track
3296        {
3297            let mut timer = controller.sleep_timer.lock_safe();
3298            timer.mode = SleepTimerMode::EndOfTrack;
3299        }
3300
3301        // Simulate track ending naturally
3302        let decision = controller.on_playback_ended().await.unwrap();
3303
3304        // Should stop despite having next items
3305        assert!(
3306            matches!(decision, AutoplayDecision::Stop),
3307            "Expected Stop decision when sleep timer is EndOfTrack"
3308        );
3309
3310        // Verify timer was cancelled
3311        {
3312            let timer = controller.sleep_timer.lock_safe();
3313            assert!(
3314                matches!(timer.mode, SleepTimerMode::Off),
3315                "Sleep timer should be cancelled after EndOfTrack"
3316            );
3317        }
3318    }
3319
3320    /// A time-based sleep timer that fires mid-episode must not let the ended
3321    /// callback fall through to autoplay.
3322    ///
3323    /// The timer thread stops the backend directly, which makes ExoPlayer emit
3324    /// its ended callback. That callback races the thread's own `timer.cancel()`:
3325    /// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
3326    /// so the timer branch is skipped and the episode path runs — showing a
3327    /// next-episode popup (or advancing) after the user's sleep timer expired.
3328    #[tokio::test]
3329    async fn test_expired_time_sleep_timer_stops_without_autoplay() {
3330        let controller = PlayerController::default();
3331
3332        let items = create_test_items(3);
3333        controller.play_queue(items, 0).unwrap();
3334        controller.take_end_reason();
3335
3336        // Arm a time-based timer that is already due, then let the real timer
3337        // thread (started in the constructor, 1s tick) observe the expiry and
3338        // run its stop path. Driving the actual thread is the point: the bug was
3339        // that this path stopped the backend without recording an end reason.
3340        let now = chrono::Utc::now().timestamp_millis();
3341        controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
3342
3343        // Wait for the timer thread to process the expiry (tick is 1s).
3344        for _ in 0..40 {
3345            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3346            if !controller.sleep_timer.lock_safe().is_active() {
3347                break;
3348            }
3349        }
3350        assert!(
3351            !controller.sleep_timer.lock_safe().is_active(),
3352            "Timer thread should have expired and cancelled the sleep timer"
3353        );
3354
3355        // The backend stop above makes the native player fire its ended callback.
3356        let decision = controller.on_playback_ended().await.unwrap();
3357
3358        assert!(
3359            matches!(decision, AutoplayDecision::Stop),
3360            "Expected Stop after an expired time-based sleep timer, got {:?}",
3361            decision
3362        );
3363    }
3364
3365    #[tokio::test]
3366    async fn test_empty_queue_stops() {
3367        let controller = PlayerController::default();
3368
3369        // Don't set up any queue
3370        let decision = controller.on_playback_ended().await.unwrap();
3371
3372        // Should stop (no current item)
3373        assert!(
3374            matches!(decision, AutoplayDecision::Stop),
3375            "Expected Stop decision when queue is empty"
3376        );
3377    }
3378
3379    #[tokio::test]
3380    async fn test_repeat_all_advances_at_end() {
3381        let controller = PlayerController::default();
3382
3383        // Create queue with 2 items, enable repeat all
3384        let items = create_test_items(2);
3385        controller.play_queue(items, 1).unwrap(); // Start at last item
3386        controller.cycle_repeat(); // Enable repeat all
3387
3388        // Clear the NewTrackLoaded reason to simulate natural track end
3389        controller.take_end_reason();
3390
3391        // Simulate last track ending naturally
3392        let decision = controller.on_playback_ended().await.unwrap();
3393
3394        // Should advance (will wrap to beginning due to repeat all)
3395        assert!(
3396            matches!(decision, AutoplayDecision::AdvanceToNext),
3397            "Expected AdvanceToNext decision at end of queue with repeat all"
3398        );
3399    }
3400
3401    #[tokio::test]
3402    async fn test_repeat_one_advances() {
3403        let controller = PlayerController::default();
3404
3405        // Create queue with 2 items
3406        let items = create_test_items(2);
3407        controller.play_queue(items, 0).unwrap();
3408
3409        // Enable repeat one
3410        controller.cycle_repeat(); // Once for all
3411        controller.cycle_repeat(); // Twice for one
3412
3413        // Clear the NewTrackLoaded reason to simulate natural track end
3414        controller.take_end_reason();
3415
3416        // Simulate track ending naturally
3417        let decision = controller.on_playback_ended().await.unwrap();
3418
3419        // Should advance (which repeats the same track)
3420        assert!(
3421            matches!(decision, AutoplayDecision::AdvanceToNext),
3422            "Expected AdvanceToNext decision with repeat one (repeats same track)"
3423        );
3424    }
3425
3426    #[test]
3427    fn test_native_load_returns_transport_authority_to_the_backend() {
3428        // Play/pause did nothing on the Android native video path, from the
3429        // on-screen tap AND from the control-bar button, while seek and skip
3430        // worked — those take a different decision path.
3431        //
3432        // `html5_playing` is written only by the webview element's own reports
3433        // and cleared only when it reports "stopped"/"idle" (or on a
3434        // background-audio handoff). A previous element that went away without
3435        // that final report — or webview-rendered music earlier in the same
3436        // process — therefore left `is_html5_active()` true, and every transport
3437        // intent was emitted as a ControlCommand at an element that no longer
3438        // existed. Nothing reached ExoPlayer. It looked intermittent because it
3439        // depends entirely on what played before.
3440        //
3441        // Loading into the native backend IS the statement that native renders
3442        // this item, so it hands authority back — the same "element is gone"
3443        // semantics the "stopped"/"idle" report already has.
3444        //
3445        // TRACES: UR-005, UR-003 | DR-193
3446        let controller = PlayerController::default();
3447        let emitter = Arc::new(CapturingEmitter::new());
3448        controller.set_event_emitter(emitter.clone());
3449
3450        // A webview element reported itself playing and never said "stopped".
3451        controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
3452        assert!(controller.is_html5_active());
3453
3454        // Now a native item loads — Android video through ExoPlayer.
3455        let item = create_test_items(1).into_iter().next().unwrap();
3456        controller.play_item(item).unwrap();
3457
3458        assert!(
3459            !controller.is_html5_active(),
3460            "loading into the native backend hands transport back to it"
3461        );
3462
3463        // The toggle must reach the backend, not be emitted at a dead element.
3464        controller.toggle_playback().unwrap();
3465        let controls: Vec<_> = emitter
3466            .events()
3467            .into_iter()
3468            .filter_map(|e| match e {
3469                PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
3470                _ => None,
3471            })
3472            .collect();
3473        assert!(
3474            controls.is_empty(),
3475            "transport went to a webview element that is not rendering: {controls:?}"
3476        );
3477    }
3478
3479    // EndReason state machine tests
3480    #[test]
3481    fn test_load_and_play_sets_new_track_loaded() {
3482        let controller = PlayerController::default();
3483        let item = create_test_items(1).into_iter().next().unwrap();
3484
3485        // End reason should be None initially
3486        assert!(controller.take_end_reason().is_none());
3487
3488        // Load and play should set NewTrackLoaded
3489        controller.load_and_play(&item).unwrap();
3490
3491        // Verify end reason was set
3492        let reason = controller.take_end_reason();
3493        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3494    }
3495
3496    #[test]
3497    fn test_stop_sets_user_stop() {
3498        let controller = PlayerController::default();
3499        let item = create_test_items(1).into_iter().next().unwrap();
3500
3501        // Play an item first
3502        controller.play_item(item).unwrap();
3503
3504        // Clear any end reason from load_and_play
3505        controller.take_end_reason();
3506
3507        // Stop should set UserStop
3508        controller.stop().unwrap();
3509
3510        // Verify end reason was set
3511        let reason = controller.take_end_reason();
3512        assert_eq!(reason, Some(EndReason::UserStop));
3513    }
3514
3515    #[tokio::test]
3516    async fn test_on_playback_ended_with_new_track_loaded_stops() {
3517        let controller = PlayerController::default();
3518
3519        // Create queue with 2 items
3520        let items = create_test_items(2);
3521        controller.play_queue(items, 0).unwrap();
3522
3523        // Manually set end reason to NewTrackLoaded
3524        controller.set_end_reason(EndReason::NewTrackLoaded);
3525
3526        // Call on_playback_ended
3527        let decision = controller.on_playback_ended().await.unwrap();
3528
3529        // Should stop without advancing
3530        assert!(
3531            matches!(decision, AutoplayDecision::Stop),
3532            "Expected Stop decision when EndReason is NewTrackLoaded"
3533        );
3534    }
3535
3536    #[tokio::test]
3537    async fn test_on_playback_ended_with_user_stop_stops() {
3538        let controller = PlayerController::default();
3539
3540        // Create queue with 2 items
3541        let items = create_test_items(2);
3542        controller.play_queue(items, 0).unwrap();
3543
3544        // Manually set end reason to UserStop
3545        controller.set_end_reason(EndReason::UserStop);
3546
3547        // Call on_playback_ended
3548        let decision = controller.on_playback_ended().await.unwrap();
3549
3550        // Should stop without advancing
3551        assert!(
3552            matches!(decision, AutoplayDecision::Stop),
3553            "Expected Stop decision when EndReason is UserStop"
3554        );
3555    }
3556
3557    #[tokio::test]
3558    async fn test_on_playback_ended_natural_end_advances() {
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        // Clear the NewTrackLoaded reason to simulate natural track end
3566        controller.take_end_reason();
3567
3568        // Call on_playback_ended (no end reason set = natural end)
3569        let decision = controller.on_playback_ended().await.unwrap();
3570
3571        // Should advance to next (natural end with next track available)
3572        assert!(
3573            matches!(decision, AutoplayDecision::AdvanceToNext),
3574            "Expected AdvanceToNext decision when track ends naturally with next track available"
3575        );
3576    }
3577
3578    #[tokio::test]
3579    async fn test_on_playback_ended_with_user_skip_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        // Set end reason to UserSkip
3587        controller.set_end_reason(EndReason::UserSkip);
3588
3589        // Call on_playback_ended
3590        let decision = controller.on_playback_ended().await.unwrap();
3591
3592        // Should stop without advancing (skip already handled)
3593        assert!(
3594            matches!(decision, AutoplayDecision::Stop),
3595            "Expected Stop decision when EndReason is UserSkip"
3596        );
3597    }
3598
3599    #[tokio::test]
3600    async fn test_on_playback_ended_with_error_stops() {
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        // Set end reason to Error
3608        controller.set_end_reason(EndReason::Error);
3609
3610        // Call on_playback_ended
3611        let decision = controller.on_playback_ended().await.unwrap();
3612
3613        // Should stop without advancing
3614        assert!(
3615            matches!(decision, AutoplayDecision::Stop),
3616            "Expected Stop decision when EndReason is Error"
3617        );
3618    }
3619
3620    #[tokio::test]
3621    async fn test_take_end_reason_clears_state() {
3622        let controller = PlayerController::default();
3623
3624        // Set a reason
3625        controller.set_end_reason(EndReason::NewTrackLoaded);
3626
3627        // Take it once
3628        let reason = controller.take_end_reason();
3629        assert_eq!(reason, Some(EndReason::NewTrackLoaded));
3630
3631        // Take it again - should be None
3632        let reason = controller.take_end_reason();
3633        assert!(reason.is_none(), "take_end_reason should clear the state");
3634    }
3635
3636    // ===== Next-episode autoplay decision tests =====
3637
3638    use crate::repository::types as repo_types;
3639
3640    /// Mock repository serving a single season of episodes for next-episode
3641    /// lookup tests. Only `get_item` and `get_items` are used by
3642    /// `fetch_next_episode_for_item`; everything else is unreachable.
3643    struct MockEpisodeRepo {
3644        episodes: Vec<repo_types::MediaItem>,
3645    }
3646
3647    impl MockEpisodeRepo {
3648        fn season(count: usize) -> Self {
3649            let episodes = (1..=count)
3650                .map(|i| {
3651                    let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
3652                    item.name = format!("Episode {}", i);
3653                    item
3654                })
3655                .collect();
3656            Self { episodes }
3657        }
3658    }
3659
3660    fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
3661        repo_types::MediaItem {
3662            id: id.to_string(),
3663            name: format!("Episode {}", index),
3664            item_type: "Episode".to_string(),
3665            kind: crate::domain::MediaKind::Episode,
3666            is_folder: false,
3667            server_id: "server".to_string(),
3668            parent_id: Some("season1".to_string()),
3669            library_id: None,
3670            overview: None,
3671            genres: None,
3672            runtime_ticks: None,
3673            duration_ms: None,
3674            production_year: None,
3675            premiere_date: None,
3676            community_rating: None,
3677            official_rating: None,
3678            primary_image_tag: None,
3679            image_id: None,
3680            backdrop_image_tags: None,
3681            parent_backdrop_image_tags: None,
3682            album_id: None,
3683            album_name: None,
3684            album_artist: None,
3685            artists: None,
3686            artist_items: None,
3687            index_number: Some(index),
3688            series_id: Some("series1".to_string()),
3689            series_name: Some("Test Series".to_string()),
3690            season_id: Some("season1".to_string()),
3691            season_name: Some("Season 1".to_string()),
3692            parent_index_number: Some(1),
3693            user_data: None,
3694            media_streams: None,
3695            media_sources: None,
3696            people: None,
3697        }
3698    }
3699
3700    #[async_trait::async_trait]
3701    impl crate::repository::MediaRepository for MockEpisodeRepo {
3702        async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
3703            unimplemented!()
3704        }
3705        async fn get_items(
3706            &self,
3707            parent_id: &str,
3708            _options: Option<repo_types::GetItemsOptions>,
3709        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3710            assert_eq!(parent_id, "season1", "episode lookup must query the season");
3711            Ok(repo_types::SearchResult {
3712                items: self.episodes.clone(),
3713                total_record_count: self.episodes.len(),
3714            })
3715        }
3716        async fn get_item(
3717            &self,
3718            item_id: &str,
3719        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3720            self.episodes
3721                .iter()
3722                .find(|e| e.id == item_id)
3723                .cloned()
3724                .ok_or(repo_types::RepoError::NotFound {
3725                    message: format!("{} not found", item_id),
3726                })
3727        }
3728        async fn get_latest_items(
3729            &self,
3730            _: &str,
3731            _: Option<usize>,
3732        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3733            unimplemented!()
3734        }
3735        async fn get_resume_items(
3736            &self,
3737            _: Option<&str>,
3738            _: Option<usize>,
3739        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3740            unimplemented!()
3741        }
3742        async fn get_next_up_episodes(
3743            &self,
3744            _: Option<&str>,
3745            _: Option<usize>,
3746        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3747            unimplemented!()
3748        }
3749        async fn get_recently_played_audio(
3750            &self,
3751            _: Option<usize>,
3752        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3753            unimplemented!()
3754        }
3755        async fn get_rediscover_albums(
3756            &self,
3757            _: Option<&str>,
3758            _: Option<usize>,
3759        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3760            unimplemented!()
3761        }
3762        async fn get_resume_movies(
3763            &self,
3764            _: Option<usize>,
3765        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3766            unimplemented!()
3767        }
3768        async fn get_genres(
3769            &self,
3770            _: Option<&str>,
3771        ) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
3772            unimplemented!()
3773        }
3774        async fn search(
3775            &self,
3776            _: &str,
3777            _: Option<repo_types::SearchOptions>,
3778        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3779            unimplemented!()
3780        }
3781        async fn get_playback_info(
3782            &self,
3783            _: &str,
3784        ) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
3785            unimplemented!()
3786        }
3787        async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
3788            unimplemented!()
3789        }
3790        async fn get_audio_only_stream_url_for_video(
3791            &self,
3792            item_id: &str,
3793            _media_source_id: Option<&str>,
3794            _start_time_seconds: Option<f64>,
3795            _audio_stream_index: Option<i32>,
3796        ) -> Result<String, repo_types::RepoError> {
3797            Ok(format!("http://example.com/{}-audio.mp3", item_id))
3798        }
3799        async fn get_live_tv_channels(
3800            &self,
3801        ) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
3802            unimplemented!()
3803        }
3804        async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3805            unimplemented!()
3806        }
3807        async fn open_live_stream(
3808            &self,
3809            _: &str,
3810        ) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
3811            unimplemented!()
3812        }
3813        async fn report_playback_start(
3814            &self,
3815            _: &str,
3816            _: i64,
3817        ) -> Result<(), repo_types::RepoError> {
3818            unimplemented!()
3819        }
3820        async fn report_playback_progress(
3821            &self,
3822            _: &str,
3823            _: i64,
3824        ) -> Result<(), repo_types::RepoError> {
3825            unimplemented!()
3826        }
3827        async fn report_playback_stopped(
3828            &self,
3829            _: &str,
3830            _: i64,
3831        ) -> Result<(), repo_types::RepoError> {
3832            unimplemented!()
3833        }
3834        fn get_image_url(
3835            &self,
3836            _: &str,
3837            _: repo_types::ImageType,
3838            _: Option<repo_types::ImageOptions>,
3839        ) -> String {
3840            unimplemented!()
3841        }
3842        fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
3843            unimplemented!()
3844        }
3845        fn get_video_download_url(
3846            &self,
3847            _: &str,
3848            _: &str,
3849            _: Option<&str>,
3850            _: Option<&str>,
3851        ) -> String {
3852            unimplemented!()
3853        }
3854        async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
3855            unimplemented!()
3856        }
3857        async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
3858            unimplemented!()
3859        }
3860        async fn get_favorites(
3861            &self,
3862            _: repo_types::SearchScope,
3863            _: Option<repo_types::GetItemsOptions>,
3864        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3865            unimplemented!()
3866        }
3867        async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
3868            unimplemented!()
3869        }
3870        async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
3871            unimplemented!()
3872        }
3873        async fn get_person(
3874            &self,
3875            _: &str,
3876        ) -> Result<repo_types::MediaItem, repo_types::RepoError> {
3877            unimplemented!()
3878        }
3879        async fn get_items_by_person(
3880            &self,
3881            _: &str,
3882            _: Option<repo_types::GetItemsOptions>,
3883        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3884            unimplemented!()
3885        }
3886        async fn get_similar_items(
3887            &self,
3888            _: &str,
3889            _: Option<usize>,
3890        ) -> Result<repo_types::SearchResult, repo_types::RepoError> {
3891            unimplemented!()
3892        }
3893        async fn create_playlist(
3894            &self,
3895            _: &str,
3896            _: &[String],
3897        ) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
3898            unimplemented!()
3899        }
3900        async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
3901            unimplemented!()
3902        }
3903        async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
3904            unimplemented!()
3905        }
3906        async fn get_playlist_items(
3907            &self,
3908            _: &str,
3909        ) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
3910            unimplemented!()
3911        }
3912        async fn add_to_playlist(
3913            &self,
3914            _: &str,
3915            _: &[String],
3916        ) -> Result<(), repo_types::RepoError> {
3917            unimplemented!()
3918        }
3919        async fn remove_from_playlist(
3920            &self,
3921            _: &str,
3922            _: &[String],
3923        ) -> Result<(), repo_types::RepoError> {
3924            unimplemented!()
3925        }
3926        async fn move_playlist_item(
3927            &self,
3928            _: &str,
3929            _: &str,
3930            _: u32,
3931        ) -> Result<(), repo_types::RepoError> {
3932            unimplemented!()
3933        }
3934    }
3935
3936    /// Video (HTML5/Linux) path: ending mid-season must produce the
3937    /// next-episode popup with auto-advance.
3938    #[tokio::test]
3939    async fn test_video_playback_ended_offers_next_episode() {
3940        let controller = PlayerController::default();
3941        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
3942
3943        let decision = controller
3944            .on_video_playback_ended("ep2", repo)
3945            .await
3946            .expect("decision should succeed");
3947
3948        match decision {
3949            AutoplayDecision::ShowNextEpisodePopup {
3950                current_episode,
3951                next_episode,
3952                auto_advance,
3953                ..
3954            } => {
3955                assert_eq!(current_episode.id, "ep2");
3956                assert_eq!(next_episode.id, "ep3");
3957                assert!(auto_advance, "default settings should auto-advance");
3958            }
3959            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
3960        }
3961    }
3962
3963    /// Last episode of the season: no popup, stop.
3964    #[tokio::test]
3965    async fn test_video_playback_ended_last_episode_stops() {
3966        let controller = PlayerController::default();
3967        let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
3968
3969        let decision = controller
3970            .on_video_playback_ended("ep3", repo)
3971            .await
3972            .expect("decision should succeed");
3973
3974        assert!(matches!(decision, AutoplayDecision::Stop));
3975    }
3976
3977    /// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
3978    /// so the controller-level repository (wired up in `repository_create`)
3979    /// must be used for the next-episode lookup. Regression test for episode
3980    /// autoplay never triggering on Android because no repository was set.
3981    #[tokio::test]
3982    async fn test_playback_ended_uses_controller_repository_for_episodes() {
3983        let controller = PlayerController::default();
3984        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
3985
3986        // Queue holds the episode that just finished playing
3987        let episode = MediaItem {
3988            // Audio and direct-URL items never negotiate a transport.
3989            transport: None,
3990            media_type: MediaType::Video,
3991            source: MediaSource::Remote {
3992                stream_url: "http://example.com/ep1.mkv".to_string(),
3993                jellyfin_item_id: "ep1".to_string(),
3994            },
3995            ..create_test_items(1).remove(0)
3996        };
3997        controller.play_queue(vec![episode], 0).unwrap();
3998
3999        // Clear the NewTrackLoaded reason to simulate natural track end
4000        controller.take_end_reason();
4001
4002        let decision = controller.on_playback_ended().await.unwrap();
4003
4004        match decision {
4005            AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
4006                assert_eq!(next_episode.id, "ep2");
4007            }
4008            other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
4009        }
4010    }
4011
4012    /// Background audio-only mode (UR-040): a video episode is handed off to the
4013    /// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
4014    /// playing while the app is backgrounded. When that audio track ends, autoplay
4015    /// must STILL recognise it as an episode and offer the next one — otherwise
4016    /// playback just pauses at the episode boundary (the reported bug). The item
4017    /// carries its episode identity via `item_type: "Episode"` + `series_id`.
4018    #[tokio::test]
4019    async fn test_playback_ended_background_audio_episode_advances() {
4020        let controller = PlayerController::default();
4021        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4022
4023        // Mirrors what player_enter_background_audio builds: the episode as AUDIO.
4024        let episode = MediaItem {
4025            // Audio and direct-URL items never negotiate a transport.
4026            transport: None,
4027            item_type: Some("Episode".to_string()),
4028            media_type: MediaType::Audio, // audio-only handoff, not Video
4029            series_id: Some("series1".to_string()),
4030            duration: Some(180.0),
4031            source: MediaSource::Remote {
4032                stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
4033                jellyfin_item_id: "ep2".to_string(),
4034            },
4035            ..create_test_items(1).remove(0)
4036        };
4037        controller.play_queue(vec![episode], 0).unwrap();
4038
4039        // Played through to the end — a natural finish, not a stream cut short.
4040        controller.seek(180.0).unwrap();
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, "ep3");
4049            }
4050            other => panic!(
4051                "background-audio episode end must advance to the next episode, got {:?}",
4052                other
4053            ),
4054        }
4055    }
4056
4057    /// The backend-driven advance (used when backgrounded) must load the next
4058    /// episode as an AUDIO item carrying its episode identity, so the *following*
4059    /// end-of-track also advances rather than stopping.
4060    #[tokio::test]
4061    async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
4062        let controller = PlayerController::default();
4063        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4064
4065        controller
4066            .advance_to_next_episode_audio_only("ep2")
4067            .await
4068            .expect("advance should succeed");
4069
4070        let current = controller
4071            .queue
4072            .lock_safe()
4073            .current()
4074            .cloned()
4075            .expect("an item should be loaded");
4076        assert_eq!(current.id, "ep2");
4077        assert_eq!(current.media_type, MediaType::Audio);
4078        assert_eq!(current.item_type.as_deref(), Some("Episode"));
4079        assert_eq!(current.series_id.as_deref(), Some("series1"));
4080        // Uses the audio-only URL, not a video stream.
4081        match &current.source {
4082            MediaSource::Remote { stream_url, .. } => {
4083                assert!(
4084                    stream_url.contains("audio"),
4085                    "expected audio-only URL, got {}",
4086                    stream_url
4087                );
4088            }
4089            other => panic!("expected Remote source, got {:?}", other),
4090        }
4091
4092        // The controller now considers itself mid background-audio episode, so the
4093        // next end-of-track will advance again rather than stop.
4094        assert!(controller.current_is_audio_episode());
4095    }
4096
4097    /// The handoff base offset describes ONE stream: the audio-only URL built
4098    /// with `StartTimeTicks` = the position the video was handed off at, whose
4099    /// timeline therefore starts at that point. The next episode is loaded from
4100    /// its own beginning, so its timeline is already absolute and the base must
4101    /// be cleared — otherwise returning to the foreground resolves the resume
4102    /// position as `old_base + position_in_new_episode` and the video jumps to a
4103    /// point that has nothing to do with what was playing.
4104    #[tokio::test]
4105    async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
4106        let controller = PlayerController::default();
4107        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4108
4109        // Handed off 20 minutes into the previous episode.
4110        controller.set_background_audio_base(1200.0);
4111
4112        controller
4113            .advance_to_next_episode_audio_only("ep2")
4114            .await
4115            .expect("advance should succeed");
4116
4117        assert_eq!(
4118            controller.take_background_audio_base(),
4119            0.0,
4120            "the next episode starts at its own zero, so the previous handoff \
4121             base must not survive the advance"
4122        );
4123    }
4124
4125    /// A background audio-only episode must advance IN THE BACKEND when the
4126    /// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
4127    /// countdown the frontend is supposed to act on.
4128    ///
4129    /// The countdown only emits CountdownTick events; the actual advance is a
4130    /// `goto('/player/<id>')` in the webview. While the app is backgrounded that
4131    /// navigation cannot start audio, so playback stalls at the episode boundary
4132    /// with ExoPlayer parked in STATE_ENDED — and any later play intent
4133    /// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
4134    /// start, which is what surfaces to the user as "the episode randomly
4135    /// restarted".
4136    #[tokio::test]
4137    async fn test_auto_advance_background_audio_episode_advances_in_backend() {
4138        let controller = PlayerController::default();
4139        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4140
4141        // Currently playing: ep2 handed off to audio-only background playback.
4142        let episode = MediaItem {
4143            // Audio and direct-URL items never negotiate a transport.
4144            transport: None,
4145            id: "ep2".to_string(),
4146            item_type: Some("Episode".to_string()),
4147            media_type: MediaType::Audio,
4148            series_id: Some("series1".to_string()),
4149            source: MediaSource::Remote {
4150                stream_url: "http://example.com/ep2-audio.mp3".to_string(),
4151                jellyfin_item_id: "ep2".to_string(),
4152            },
4153            ..create_test_items(1).remove(0)
4154        };
4155        controller.play_queue(vec![episode], 0).unwrap();
4156
4157        let next = make_repo_episode("ep3", 3);
4158        controller.auto_advance_to_next_episode(next, 10).await;
4159
4160        let current = controller
4161            .queue
4162            .lock_safe()
4163            .current()
4164            .cloned()
4165            .expect("an item should still be loaded");
4166        assert_eq!(
4167            current.id, "ep3",
4168            "background audio-only episode must advance in the backend, not wait \
4169             for a frontend navigation that cannot happen while backgrounded"
4170        );
4171        assert_eq!(current.media_type, MediaType::Audio);
4172        assert!(controller.current_is_audio_episode());
4173    }
4174
4175    /// Build the audio-only episode the background handoff loads: a video item
4176    /// played through the native audio path, with a known runtime and a stream
4177    /// URL carrying the handoff position.
4178    fn audio_only_episode(runtime_seconds: f64) -> MediaItem {
4179        MediaItem {
4180            // Audio and direct-URL items never negotiate a transport.
4181            transport: None,
4182            id: "ep2".to_string(),
4183            item_type: Some("Episode".to_string()),
4184            media_type: MediaType::Audio,
4185            series_id: Some("series1".to_string()),
4186            duration: Some(runtime_seconds),
4187            source: MediaSource::Remote {
4188                stream_url:
4189                    "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
4190                        .to_string(),
4191                jellyfin_item_id: "ep2".to_string(),
4192            },
4193            ..create_test_items(1).remove(0)
4194        }
4195    }
4196
4197    /// The same episode handed off from a **downloaded file** — the handoff's
4198    /// other source, which starts at the episode's own zero rather than at the
4199    /// handoff point.
4200    fn local_audio_only_episode(runtime_seconds: f64) -> MediaItem {
4201        MediaItem {
4202            // Audio and direct-URL items never negotiate a transport.
4203            transport: None,
4204            source: MediaSource::Local {
4205                file_path: std::path::PathBuf::from("/downloads/ep2.mkv"),
4206                jellyfin_item_id: Some("ep2".to_string()),
4207            },
4208            ..audio_only_episode(runtime_seconds)
4209        }
4210    }
4211
4212    /// A file seeks like a file. The rebuild path exists because a chunked
4213    /// length-less transcode cannot honour a seek, which is not true of local
4214    /// media — and `resume_stream_at` refuses a non-remote source outright, so
4215    /// routing a lockscreen scrub through it fails the seek instead of doing it.
4216    ///
4217    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
4218    #[tokio::test]
4219    async fn test_seek_absolute_on_a_downloaded_handoff_is_an_ordinary_seek() {
4220        let controller = PlayerController::default();
4221        controller
4222            .play_queue(vec![local_audio_only_episode(1500.0)], 0)
4223            .unwrap();
4224        // A downloaded handoff claims no base: the file's zero is the episode's.
4225        controller.enter_background_audio(0.0);
4226
4227        controller.seek_absolute(900.0).await.unwrap();
4228
4229        assert_eq!(controller.position(), 900.0);
4230    }
4231
4232    /// A flaky connection truncates the progressive mp3 transcode that carries
4233    /// background audio-only playback. ExoPlayer sees end-of-input on a stream
4234    /// with no reliable length, so it reports STATE_ENDED ten minutes into a
4235    /// twenty-five minute episode — indistinguishable, to the player, from the
4236    /// real end.
4237    ///
4238    /// Treating that as "the episode finished" is what the user experiences as
4239    /// the episode randomly restarting: playback parks in STATE_ENDED and the
4240    /// next play intent (lockscreen, notification, Bluetooth reconnect) seeks an
4241    /// ended player to position 0 before playing. The runtime we already know
4242    /// says the stream died early, so the decision must be to resume it.
4243    #[tokio::test]
4244    async fn test_truncated_background_audio_stream_resumes_instead_of_ending() {
4245        let controller = PlayerController::default();
4246        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4247
4248        controller
4249            .play_queue(vec![audio_only_episode(1500.0)], 0)
4250            .unwrap();
4251        // The connection dropped 10 minutes into a 25-minute episode.
4252        controller.seek(600.0).unwrap();
4253        controller.take_end_reason();
4254
4255        let decision = controller.on_playback_ended().await.unwrap();
4256
4257        match decision {
4258            AutoplayDecision::ResumeStream { position } => {
4259                assert_eq!(position, 600.0, "must resume where the stream died");
4260            }
4261            other => panic!(
4262                "a stream that ended 15 minutes short of the runtime must resume, \
4263                 not run end-of-episode logic; got {:?}",
4264                other
4265            ),
4266        }
4267    }
4268
4269    /// A seek arriving during a background-audio handoff is **absolute** — the
4270    /// lockscreen scrubber shows the whole episode, so a scrub to 25:00 means
4271    /// 25:00 of the episode, not 25:00 into the handoff stream.
4272    ///
4273    /// The handoff stream cannot be seeked at all (a chunked, length-less
4274    /// transcode), so honouring it means re-opening the URL at the new position,
4275    /// exactly as the truncation recovery does. Passing the number through to
4276    /// ExoPlayer instead — which is what used to happen — asked a stream that
4277    /// cannot seek to jump past its own end, and a clamped seek lands at stream
4278    /// zero: the handoff point.
4279    ///
4280    /// TRACES: UR-040, UR-005 | DR-159 | UT-155
4281    #[tokio::test]
4282    async fn test_seek_during_handoff_reopens_the_stream_at_the_absolute_position() {
4283        let controller = PlayerController::default();
4284        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4285        controller
4286            .play_queue(vec![audio_only_episode(1500.0)], 0)
4287            .unwrap();
4288
4289        // Handed off 20 minutes in, so the stream's zero is 1200s.
4290        controller.enter_background_audio(1200.0);
4291
4292        // The viewer scrubs the lockscreen to 25:00 absolute.
4293        controller.seek_absolute(1490.0).await.unwrap();
4294
4295        let url = {
4296            let queue = controller.queue();
4297            let queue = queue.lock_safe();
4298            match &queue.current().unwrap().source {
4299                MediaSource::Remote { stream_url, .. } => stream_url.clone(),
4300                other => panic!("expected a remote source, got {:?}", other),
4301            }
4302        };
4303        assert!(
4304            url.contains(&format!(
4305                "StartTimeTicks={}",
4306                (1490.0 * 10_000_000.0) as i64
4307            )),
4308            "the stream must be re-opened at the absolute position; got {}",
4309            url
4310        );
4311
4312        assert_eq!(
4313            *controller.background_audio_base.lock_safe(),
4314            1490.0,
4315            "the re-opened stream's zero is the position it was opened at, or \
4316             every later reading is off by the difference"
4317        );
4318    }
4319
4320    /// Outside a handoff there is no base and nothing to re-open: an absolute
4321    /// seek is just a seek, and must not be turned into a stream rebuild.
4322    ///
4323    /// TRACES: UR-005 | DR-159 | UT-155
4324    #[tokio::test]
4325    async fn test_seek_outside_a_handoff_is_an_ordinary_seek() {
4326        let controller = PlayerController::default();
4327        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4328        controller
4329            .play_queue(vec![audio_only_episode(1500.0)], 0)
4330            .unwrap();
4331
4332        controller.seek_absolute(300.0).await.unwrap();
4333
4334        assert_eq!(controller.position(), 300.0);
4335        assert_eq!(
4336            *controller.background_audio_base.lock_safe(),
4337            0.0,
4338            "an ordinary seek must not invent a handoff base"
4339        );
4340    }
4341
4342    // ===== Position authority and reporting (DR-178, DR-179) =====
4343    //
4344    // Every position that leaves the app — the resume point Jellyfin stores, the
4345    // point the video reloads at on the way back from a handoff, the truncation
4346    // maths — is read off the controller. The device trace showed all of them
4347    // reading 0: the native backend is not the player on the webview path, and
4348    // during a handoff its base is only applied once ExoPlayer has ticked, which
4349    // it has not while the audio-only transcode is still opening.
4350
4351    /// Returning to the foreground before the audio-only stream has started
4352    /// playing hands back the handoff's own starting point, never zero.
4353    ///
4354    /// Observed on device: locked at 18.4s, unlocked 3.5s later with ExoPlayer
4355    /// still `IDLE`, `player_exit_background_audio` returned `0.0`, and the video
4356    /// reloaded with `StartTimeTicks=0` — the episode restarted from the
4357    /// beginning, and the `Stopped` report that followed wiped the server's
4358    /// resume point too.
4359    ///
4360    /// TRACES: UR-040 | DR-178 | UT-176
4361    #[test]
4362    fn test_absolute_position_floors_at_the_handoff_base() {
4363        let controller = PlayerController::default();
4364        controller.enter_background_audio(18.4);
4365
4366        // No tick has landed, so nothing has applied the base yet.
4367        assert_eq!(controller.position(), 0.0);
4368        assert_eq!(
4369            controller.absolute_position(),
4370            18.4,
4371            "the audio stream's zero IS the handoff point, so the position can \
4372             never legitimately read below it"
4373        );
4374    }
4375
4376    /// Once ticks are flowing the base has already been applied at the native
4377    /// boundary (DR-159), so flooring must not add it a second time.
4378    ///
4379    /// TRACES: UR-040 | DR-178 | UT-176
4380    #[test]
4381    fn test_absolute_position_does_not_double_count_the_handoff_base() {
4382        let controller = PlayerController::default();
4383        controller.enter_background_audio(18.4);
4384
4385        // What the real backend reports after a tick: already absolute.
4386        controller.seek(120.0).unwrap();
4387
4388        assert_eq!(controller.absolute_position(), 120.0);
4389    }
4390
4391    /// On the webview path the `<video>` element is the player and the native
4392    /// backend holds nothing, so the position it reports is the only one there
4393    /// is. It used to be re-emitted to the frontend and then dropped, leaving
4394    /// every backend-side report at 0.
4395    ///
4396    /// TRACES: UR-005, UR-025 | DR-178 | UT-177
4397    #[test]
4398    fn test_webview_position_reports_become_the_controllers_position() {
4399        let controller = PlayerController::default();
4400
4401        controller.report_html5_position(253.4, 2640.0);
4402
4403        assert_eq!(controller.absolute_position(), 253.4);
4404        assert_eq!(controller.observed_duration(), Some(2640.0));
4405    }
4406
4407    /// A torn-down element's last position must not outlive it: the next thing
4408    /// to play is loaded into the native backend, and a stale 253s would be
4409    /// reported against it.
4410    ///
4411    /// TRACES: UR-005 | DR-178 | UT-177
4412    #[test]
4413    fn test_webview_teardown_clears_the_observed_position() {
4414        let controller = PlayerController::default();
4415        controller.report_html5_position(253.4, 2640.0);
4416
4417        controller.report_html5_state("stopped".to_string(), None);
4418
4419        assert_eq!(controller.absolute_position(), 0.0);
4420    }
4421
4422    /// Entering a handoff tears the element down, so its position stops being
4423    /// the answer at that exact moment — the native audio player's does.
4424    ///
4425    /// TRACES: UR-040 | DR-178 | UT-177
4426    #[test]
4427    fn test_entering_a_handoff_drops_the_torn_down_elements_position() {
4428        let controller = PlayerController::default();
4429        controller.report_html5_position(253.4, 2640.0);
4430
4431        controller.enter_background_audio(18.4);
4432
4433        assert_eq!(
4434            controller.absolute_position(),
4435            18.4,
4436            "the video element is gone; only the handoff base describes the \
4437             stream that is now playing"
4438        );
4439    }
4440
4441    /// Nobody ever watched zero seconds of anything. A `Stopped` at 0 carries no
4442    /// information and Jellyfin stores it as the resume point, so the only thing
4443    /// it can do is destroy one — which is what the device trace caught it doing
4444    /// 14 times in 35 minutes, including 40s after the frontend had correctly
4445    /// reported 922s for the same episode.
4446    ///
4447    /// TRACES: UR-025 | DR-179 | UT-178
4448    #[tokio::test]
4449    async fn test_a_stop_at_zero_is_never_reported() {
4450        let controller = PlayerController::default();
4451        let reports = Arc::new(CapturingReports::new());
4452        controller.set_report_sink(reports.clone());
4453        controller
4454            .play_queue(vec![audio_only_episode(1500.0)], 0)
4455            .unwrap();
4456
4457        // Nothing ever played: the backend is at 0 and no element reported in.
4458        controller.stop().unwrap();
4459
4460        assert!(
4461            reports.stops().is_empty(),
4462            "a zero-position stop must be withheld, not sent; got {:?}",
4463            reports.stops()
4464        );
4465    }
4466
4467    /// A real position is still reported, so withholding zero cannot be
4468    /// mistaken for withholding everything — from either rendering path.
4469    ///
4470    /// The webview half is the one that was broken: the element reports 253s, the
4471    /// native backend holds nothing, and the stop report went out as 0 and
4472    /// overwrote the resume point the frontend had just written correctly.
4473    ///
4474    /// TRACES: UR-025 | DR-178, DR-179 | UT-178
4475    #[tokio::test]
4476    async fn test_a_stop_reports_the_position_actually_reached() {
4477        // Webview-rendered: the element is the only thing that knows.
4478        let webview = PlayerController::default();
4479        let webview_reports = Arc::new(CapturingReports::new());
4480        webview.set_report_sink(webview_reports.clone());
4481        webview
4482            .play_queue(vec![audio_only_episode(1500.0)], 0)
4483            .unwrap();
4484        webview.report_html5_position(253.0, 1500.0);
4485
4486        webview.stop().unwrap();
4487
4488        assert_eq!(webview_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4489
4490        // Natively rendered: the backend is authoritative and still is.
4491        let native = PlayerController::default();
4492        let native_reports = Arc::new(CapturingReports::new());
4493        native.set_report_sink(native_reports.clone());
4494        native
4495            .play_queue(vec![audio_only_episode(1500.0)], 0)
4496            .unwrap();
4497        native.seek(253.0).unwrap();
4498
4499        native.stop().unwrap();
4500
4501        assert_eq!(native_reports.stops(), vec![("ep2".to_string(), 253.0)]);
4502    }
4503
4504    /// An episode listened to end-to-end on the lockscreen must count as
4505    /// watched. Jellyfin decides that on the `PlaybackStopped` report — no
4506    /// report, no completion — and in background audio-only mode there is
4507    /// nobody else to send one: the webview is suspended and its `<video>` was
4508    /// torn down at the handoff, so the frontend's end-of-playback reporting
4509    /// cannot run. The backend advanced to the next episode and said nothing
4510    /// about the one that finished.
4511    ///
4512    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
4513    #[tokio::test]
4514    async fn test_a_finished_audio_only_episode_is_reported_complete() {
4515        let controller = PlayerController::default();
4516        let reports = Arc::new(CapturingReports::new());
4517        controller.set_report_sink(reports.clone());
4518        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4519        controller
4520            .play_queue(vec![audio_only_episode(1500.0)], 0)
4521            .unwrap();
4522        // Played out to the end of the 25-minute episode.
4523        controller.seek(1499.0).unwrap();
4524        controller.take_end_reason();
4525
4526        controller.on_playback_ended().await.unwrap();
4527
4528        assert_eq!(
4529            reports.stops(),
4530            vec![("ep2".to_string(), 1500.0)],
4531            "the finished episode must be reported stopped at its runtime, or \
4532             Jellyfin's ≥90% rule never marks it played"
4533        );
4534    }
4535
4536    /// The completion report is for ends that are really ends. A truncated
4537    /// stream is about to be re-opened and the episode is nowhere near over, so
4538    /// reporting it stopped would tell Jellyfin the opposite of the truth.
4539    ///
4540    /// TRACES: UR-040, UR-025 | DR-179 | UT-179
4541    #[tokio::test]
4542    async fn test_a_truncated_stream_reports_no_completion() {
4543        let controller = PlayerController::default();
4544        let reports = Arc::new(CapturingReports::new());
4545        controller.set_report_sink(reports.clone());
4546        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4547        controller
4548            .play_queue(vec![audio_only_episode(1500.0)], 0)
4549            .unwrap();
4550        controller.seek(600.0).unwrap();
4551        controller.take_end_reason();
4552
4553        let decision = controller.on_playback_ended().await.unwrap();
4554
4555        assert!(matches!(decision, AutoplayDecision::ResumeStream { .. }));
4556        assert!(
4557            reports.stops().is_empty(),
4558            "a dropped connection is not a finished episode; got {:?}",
4559            reports.stops()
4560        );
4561    }
4562
4563    /// Position ticks reach Jellyfin while playback is still going, so closing
4564    /// the app — or losing it to a crash — cannot cost the whole session. The
4565    /// device trace requested `/Sessions/Playing/Progress` exactly zero times in
4566    /// 35 minutes: the frontend service writes progress to the local DB only,
4567    /// and nothing on the Rust side reported it for webview-rendered media.
4568    ///
4569    /// TRACES: UR-005, UR-025 | DR-179 | UT-180
4570    #[tokio::test]
4571    async fn test_webview_position_ticks_report_progress_to_the_server() {
4572        let controller = PlayerController::default();
4573        let reports = Arc::new(CapturingReports::new());
4574        controller.set_report_sink(reports.clone());
4575        controller
4576            .play_queue(vec![audio_only_episode(1500.0)], 0)
4577            .unwrap();
4578
4579        controller.report_html5_position(253.4, 1500.0);
4580
4581        assert_eq!(reports.progress(), vec![("ep2".to_string(), 253.4)]);
4582    }
4583
4584    /// Ticks arrive four times a second; reports must not. The throttler the
4585    /// controller already owns bounds them to one per item per 30s.
4586    ///
4587    /// TRACES: UR-005 | DR-179 | UT-180
4588    #[tokio::test]
4589    async fn test_progress_reports_are_throttled_not_sent_per_tick() {
4590        let controller = PlayerController::default();
4591        let reports = Arc::new(CapturingReports::new());
4592        controller.set_report_sink(reports.clone());
4593        controller
4594            .play_queue(vec![audio_only_episode(1500.0)], 0)
4595            .unwrap();
4596
4597        for tick in 0..12 {
4598            controller.report_html5_position(250.0 + tick as f64 * 0.25, 1500.0);
4599        }
4600
4601        assert_eq!(
4602            reports.progress().len(),
4603            1,
4604            "twelve ticks inside one throttle window are one report"
4605        );
4606    }
4607
4608    /// The truncation check compares the position against the item's runtime, so
4609    /// both must be on the same timeline.
4610    ///
4611    /// They now are by construction: the Android position tick shifts by the
4612    /// handoff base before anything sees the value, so what the player reports is
4613    /// already a position on the episode. The base is therefore *not* added here —
4614    /// doing so would double-count it and make the last minute of a handoff look
4615    /// like a truncation. What the mock backend holds is what the real one would
4616    /// report: 24:56 absolute, not 0:56 into the handoff stream. (DR-159)
4617    #[tokio::test]
4618    async fn test_truncated_check_uses_the_absolute_position() {
4619        let controller = PlayerController::default();
4620        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4621
4622        controller
4623            .play_queue(vec![audio_only_episode(1500.0)], 0)
4624            .unwrap();
4625        // Handed off at 24:00; the stream then played its last 56 seconds out, so
4626        // the player reports 24:56 of the episode.
4627        controller.set_background_audio_base(1440.0);
4628        controller.seek(1496.0).unwrap();
4629        controller.take_end_reason();
4630
4631        let decision = controller.on_playback_ended().await.unwrap();
4632
4633        assert!(
4634            matches!(decision, AutoplayDecision::ShowNextEpisodePopup { .. }),
4635            "24:56 of a 25:00 episode is the real end, not a truncation; got {:?}",
4636            decision
4637        );
4638    }
4639
4640    /// The resume re-opens the same URL, so a server that is actually gone would
4641    /// otherwise end → resume → end forever. After the budget runs out the
4642    /// decision falls back to normal end-of-item handling.
4643    #[tokio::test]
4644    async fn test_repeated_truncation_at_the_same_position_gives_up() {
4645        let controller = PlayerController::default();
4646        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4647
4648        controller
4649            .play_queue(vec![audio_only_episode(1500.0)], 0)
4650            .unwrap();
4651        controller.seek(600.0).unwrap();
4652
4653        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4654            controller.take_end_reason();
4655            let decision = controller.on_playback_ended().await.unwrap();
4656            assert!(
4657                matches!(decision, AutoplayDecision::ResumeStream { .. }),
4658                "attempt {} should still resume, got {:?}",
4659                attempt,
4660                decision
4661            );
4662        }
4663
4664        controller.take_end_reason();
4665        let decision = controller.on_playback_ended().await.unwrap();
4666        assert!(
4667            !matches!(decision, AutoplayDecision::ResumeStream { .. }),
4668            "a stream stuck at the same position must stop retrying, got {:?}",
4669            decision
4670        );
4671    }
4672
4673    /// Ordinary music is not covered: its streams are not the length-less
4674    /// progressive transcode this guards, and a short track legitimately ends
4675    /// well before a stale duration would suggest.
4676    #[tokio::test]
4677    async fn test_truncation_check_does_not_touch_plain_audio_tracks() {
4678        let controller = PlayerController::default();
4679
4680        let mut items = create_test_items(2);
4681        items[0].duration = Some(1500.0);
4682        controller.play_queue(items, 0).unwrap();
4683        controller.seek(60.0).unwrap();
4684        controller.take_end_reason();
4685
4686        let decision = controller.on_playback_ended().await.unwrap();
4687        assert!(
4688            matches!(decision, AutoplayDecision::AdvanceToNext),
4689            "plain queue audio must keep advancing, got {:?}",
4690            decision
4691        );
4692    }
4693
4694    /// Music and video stream from URLs that declare their own length (a static
4695    /// file with byte ranges, an HLS playlist), so a truncation reaches the
4696    /// player as an *error* rather than a phantom end. It is the same network
4697    /// failure, and the same recovery applies — the previous behaviour turned it
4698    /// into `playerStop()` and silence.
4699    #[tokio::test]
4700    async fn test_recoverable_error_resumes_a_music_track() {
4701        let controller = PlayerController::default();
4702
4703        let mut items = create_test_items(3);
4704        for item in &mut items {
4705            item.source = MediaSource::Remote {
4706                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4707                jellyfin_item_id: item.id.clone(),
4708            };
4709        }
4710        controller.play_queue(items, 1).unwrap();
4711        controller.seek(45.0).unwrap();
4712
4713        let (position, _) = controller
4714            .recoverable_error_resume()
4715            .expect("a streamed music track must be resumable after a network error");
4716        assert_eq!(position, 45.0);
4717    }
4718
4719    /// The resume must reload the failed track IN PLACE. `play_item` replaces the
4720    /// whole queue with a single item, so recovering a track that way would throw
4721    /// away the rest of the album — turning a network blip into lost state.
4722    #[tokio::test]
4723    async fn test_resume_keeps_the_rest_of_the_queue() {
4724        let controller = PlayerController::default();
4725
4726        let mut items = create_test_items(3);
4727        for item in &mut items {
4728            item.source = MediaSource::Remote {
4729                stream_url: format!("http://s/Audio/{}/stream?Static=true", item.id),
4730                jellyfin_item_id: item.id.clone(),
4731            };
4732        }
4733        controller.play_queue(items, 1).unwrap();
4734
4735        controller
4736            .resume_stream_at(45.0)
4737            .await
4738            .expect("resume should succeed");
4739
4740        let queue = controller.queue.lock_safe();
4741        assert_eq!(queue.items().len(), 3, "the queue must survive a resume");
4742        assert_eq!(queue.current_index(), Some(1), "still on the same track");
4743        assert_eq!(queue.current().unwrap().id, "item_1");
4744    }
4745
4746    /// A seekable stream is re-opened by re-preparing the URL it already has and
4747    /// seeking — its timeline is intact, and rewriting the URL would restart a
4748    /// transcode session for no reason.
4749    #[tokio::test]
4750    async fn test_resume_seeks_a_seekable_stream_rather_than_rewriting_its_url() {
4751        let controller = PlayerController::default();
4752
4753        let mut items = create_test_items(1);
4754        items[0].source = MediaSource::Remote {
4755            stream_url: "http://s/Audio/item_0/stream?Static=true".to_string(),
4756            jellyfin_item_id: "item_0".to_string(),
4757        };
4758        controller.play_queue(items, 0).unwrap();
4759
4760        controller.resume_stream_at(45.0).await.unwrap();
4761
4762        match &controller.queue.lock_safe().current().unwrap().source {
4763            MediaSource::Remote { stream_url, .. } => {
4764                assert_eq!(
4765                    stream_url, "http://s/Audio/item_0/stream?Static=true",
4766                    "a seekable stream's URL must be left alone"
4767                );
4768            }
4769            other => panic!("expected Remote source, got {:?}", other),
4770        }
4771        assert_eq!(
4772            controller.position(),
4773            45.0,
4774            "and it must land at the position"
4775        );
4776    }
4777
4778    /// Downloaded media cannot fail from the network, and re-opening a local file
4779    /// would paper over a real read error.
4780    #[tokio::test]
4781    async fn test_recoverable_error_ignores_local_media() {
4782        let controller = PlayerController::default();
4783
4784        let mut items = create_test_items(1);
4785        items[0].source = MediaSource::Local {
4786            file_path: "/music/track.flac".into(),
4787            jellyfin_item_id: Some("item_0".to_string()),
4788        };
4789        controller.play_queue(items, 0).unwrap();
4790        controller.seek(45.0).unwrap();
4791
4792        assert!(controller.recoverable_error_resume().is_none());
4793    }
4794
4795    /// A recoverable error during background audio-only playback is the network,
4796    /// not the media — the previous behaviour (surface it, frontend stops the
4797    /// player) turned a hiccup into silence. Retrying must also back off, or the
4798    /// three attempts are spent inside a second and the outage outlives them.
4799    #[tokio::test]
4800    async fn test_recoverable_error_during_audio_only_resumes_with_backoff() {
4801        let controller = PlayerController::default();
4802
4803        controller
4804            .play_queue(vec![audio_only_episode(1500.0)], 0)
4805            .unwrap();
4806        controller.seek(600.0).unwrap();
4807
4808        let mut waits = Vec::new();
4809        for attempt in 1..=stream_end::MAX_STALLED_RESUME_ATTEMPTS {
4810            let (position, delay) = controller
4811                .recoverable_error_resume()
4812                .unwrap_or_else(|| panic!("attempt {} should still retry", attempt));
4813            assert_eq!(position, 600.0);
4814            waits.push(delay);
4815        }
4816        assert_eq!(waits, vec![2, 4, 6], "the wait must grow between attempts");
4817        assert!(
4818            controller.recoverable_error_resume().is_none(),
4819            "a stream that keeps failing at the same spot must surface the error"
4820        );
4821    }
4822
4823    /// Plugin/channel `DirectUrl` sources are somebody else's endpoint with no
4824    /// Jellyfin item behind them, so the resume has nothing to re-request.
4825    #[tokio::test]
4826    async fn test_recoverable_error_ignores_direct_url_playback() {
4827        let controller = PlayerController::default();
4828        controller.play_queue(create_test_items(2), 0).unwrap();
4829
4830        assert!(controller.recoverable_error_resume().is_none());
4831    }
4832
4833    /// Re-opening the stream must land where it died and keep playing, with the
4834    /// handoff base moved to the new stream's zero so returning to the
4835    /// foreground still resolves an absolute position.
4836    #[tokio::test]
4837    async fn test_resume_truncated_stream_reloads_at_position() {
4838        let controller = PlayerController::default();
4839
4840        controller
4841            .play_queue(vec![audio_only_episode(1500.0)], 0)
4842            .unwrap();
4843        controller.set_background_audio_base(0.0);
4844
4845        controller
4846            .resume_stream_at(600.0)
4847            .await
4848            .expect("resume should succeed");
4849
4850        let current = controller
4851            .queue
4852            .lock_safe()
4853            .current()
4854            .cloned()
4855            .expect("the same item should still be loaded");
4856        assert_eq!(current.id, "ep2", "resume must not change the item");
4857        match &current.source {
4858            MediaSource::Remote { stream_url, .. } => {
4859                assert!(
4860                    stream_url.contains("StartTimeTicks=6000000000"),
4861                    "stream must re-open at 600s, got {}",
4862                    stream_url
4863                );
4864                assert!(
4865                    stream_url.contains("AudioStreamIndex=2"),
4866                    "the selected audio track must survive the resume, got {}",
4867                    stream_url
4868                );
4869            }
4870            other => panic!("expected Remote source, got {:?}", other),
4871        }
4872        assert_eq!(
4873            controller.take_background_audio_base(),
4874            600.0,
4875            "the re-opened stream's zero is the resume position"
4876        );
4877    }
4878
4879    /// Foreground video playback keeps the countdown-driven advance: the frontend
4880    /// owns the navigation there, so the backend must NOT load the next episode
4881    /// itself (that would race the page transition and double-start playback).
4882    #[tokio::test]
4883    async fn test_auto_advance_foreground_video_episode_uses_countdown() {
4884        let controller = PlayerController::default();
4885        controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
4886
4887        let episode = MediaItem {
4888            // Audio and direct-URL items never negotiate a transport.
4889            transport: None,
4890            id: "ep2".to_string(),
4891            item_type: Some("Episode".to_string()),
4892            media_type: MediaType::Video,
4893            series_id: Some("series1".to_string()),
4894            source: MediaSource::Remote {
4895                stream_url: "http://example.com/ep2.m3u8".to_string(),
4896                jellyfin_item_id: "ep2".to_string(),
4897            },
4898            ..create_test_items(1).remove(0)
4899        };
4900        controller.play_queue(vec![episode], 0).unwrap();
4901
4902        let next = make_repo_episode("ep3", 3);
4903        controller.auto_advance_to_next_episode(next, 10).await;
4904
4905        let current = controller
4906            .queue
4907            .lock_safe()
4908            .current()
4909            .cloned()
4910            .expect("an item should still be loaded");
4911        assert_eq!(
4912            current.id, "ep2",
4913            "foreground video advance is frontend-driven; the backend must not \
4914             swap the queue item out from under it"
4915        );
4916    }
4917
4918    /// Without a controller repository the Android episode path must still
4919    /// stop gracefully (previous behavior) rather than error.
4920    #[tokio::test]
4921    async fn test_playback_ended_without_repository_stops() {
4922        let controller = PlayerController::default();
4923
4924        let episode = MediaItem {
4925            // Audio and direct-URL items never negotiate a transport.
4926            transport: None,
4927            media_type: MediaType::Video,
4928            source: MediaSource::Remote {
4929                stream_url: "http://example.com/ep1.mkv".to_string(),
4930                jellyfin_item_id: "ep1".to_string(),
4931            },
4932            ..create_test_items(1).remove(0)
4933        };
4934        controller.play_queue(vec![episode], 0).unwrap();
4935        controller.take_end_reason();
4936
4937        let decision = controller.on_playback_ended().await.unwrap();
4938        assert!(matches!(decision, AutoplayDecision::Stop));
4939    }
4940}