Skip to main content

jellytau_lib/player/
mod.rs

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