Skip to main content

jellytau_lib/player/
mod.rs

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