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