Skip to main content

## PlayerController

# Struct PlayerController

Source

    pub struct PlayerController {Show 19 fields
        backend: Arc<Mutex<Box<dyn MediaPlayer>>>,
        queue: Arc<Mutex<QueueManager>>,
        jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
        muted: bool,
        sleep_timer: Arc<Mutex<SleepTimerState>>,
        autoplay_settings: Arc<Mutex<AutoplaySettings>>,
        repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
        event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
        countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>,
        playback_reporter: Arc<Mutex<Option<PlaybackReporter>>>,
        reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>,
        position_throttler: Arc<EventThrottler>,
        end_reason: Arc<Mutex<Option<EndReason>>>,
        autoplay_episode_count: Arc<Mutex<u32>>,
        background_audio_base: Arc<Mutex<f64>>,
        background_audio_active: Arc<Mutex<bool>>,
        stream_resume: Arc<Mutex<ResumeTracker>>,
        html5_playing: Arc<Mutex<Option<bool>>>,
        reported_time: Arc<Mutex<ObservedTime>>,
    }

Expand description

Central player controller that coordinates playback

## Fields§

§ `backend: Arc<Mutex<Box<dyn MediaPlayer>>>`

The engine. One contract, so the controller stops branching on which platform it is running on — see docs/specs/media-player-controller.md.

§ `queue: Arc<Mutex<QueueManager>>` § `jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>` § `muted: bool` § `sleep_timer: Arc<Mutex<SleepTimerState>>` § `autoplay_settings: Arc<Mutex<AutoplaySettings>>` § `repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>` § `event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>` § `countdown_cancel: Arc<Mutex<Option<Arc<Mutex<bool>>>>>` § `playback_reporter: Arc<Mutex<Option<PlaybackReporter>>>` § `reports: Arc<Mutex<Arc<dyn PlaybackReportSink>>>` § `position_throttler: Arc<EventThrottler>` § `end_reason: Arc<Mutex<Option<EndReason>>>` § `autoplay_episode_count: Arc<Mutex<u32>>` § `background_audio_base: Arc<Mutex<f64>>` § `background_audio_active: Arc<Mutex<bool>>` § `stream_resume: Arc<Mutex<ResumeTracker>>` § `html5_playing: Arc<Mutex<Option<bool>>>` § `reported_time: Arc<Mutex<ObservedTime>>`

## Implementations§

Source §

### impl PlayerController

Source

#### pub fn new( backend: Box<dyn MediaPlayer>, playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>, position_throttler: Arc<EventThrottler>, ) -> Self

Source

#### pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>)

Configure the Jellyfin API client for automatic playback reporting

Source

#### pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> ⓘ

Get a reference to the Jellyfin client (for remote session control)

Source

#### pub fn set_repository(&self, repo: Arc<dyn MediaRepository>)

Configure the media repository used for next-episode lookups.

The Android ExoPlayer ended-callback calls `on_playback_ended` with no repository handle (unlike the Linux HTML5 path, which passes one per call), so the controller needs a repository of its own or episode autoplay silently decides Stop.

Source

#### pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>)

Configure the playback reporter for dual sync (local DB + server). Called from `player_configure_jellyfin` on login/restore/reauth.

Source

#### pub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> ⓘ

Get a reference to the playback reporter (for backend position updates) Will be used when position update hooks are added to backends

Source

#### pub fn position_throttler(&self) -> Arc<EventThrottler> ⓘ

Get a reference to the position throttler (for backend position updates) Will be used when position update hooks are added to backends

Source

#### fn set_end_reason(&self, reason: EndReason)

Set the end reason for the next playback end event

Source

#### fn take_end_reason(&self) -> Option<EndReason>

Get and clear the current end reason

Source

#### fn peek_end_reason(&self) -> Option<EndReason>

Read the end reason WITHOUT consuming it.

`take_end_reason` has an owner: on Android the JNI ended-callback consumes the `NewTrackLoaded` every load sets, and the frontend’s echoed call is the one that sees `None` and decides. The truncated-stream check runs in both calls and must not disturb that hand-off, so it peeks.

Source

#### fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>)

Record that playback is being stopped by an expiring sleep timer.

Stopping the backend makes it fire its ended callback (ExoPlayer does on Android), which lands in `on_playback_ended`. Without an end reason that reads as a natural finish and autoplay advances — defeating the timer. `UserStop` is the honest label: the stop was user-initiated, just via the timer they set rather than the stop button.

Takes the shared slot rather than `&self` so the sleep-timer thread — which owns clones, not the controller — records it the same way.

TRACES: UR-023, UR-026 | DR-029

Source

#### fn increment_autoplay_count(&self) -> bool

Increment autoplay episode counter. Returns true if limit is reached.

Source

#### fn reset_autoplay_count(&self)

Reset autoplay episode counter (called on manual play actions)

Source

#### pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError>

Load and play a single item (also sets the queue to contain only this item)

Source

#### pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError>

Set the current queue item without loading it into the playback backend.

Used on platforms where video is rendered outside the native backend (Linux WebKitGTK HTML5 ): the queue/UI state must reflect the item, but MPV must not start a redundant decode for it.

Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became a runtime question — “does this renderer draw the picture?” — so the `else` arm is compiled on every platform even where it never runs. The gate outliving its caller broke the Android build outright, which went unnoticed because nothing built for Android afterwards.

Source

#### pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError>

Load and play an item without modifying the queue Use this when the queue is already set up and you just want to play a specific item from it

Source

#### pub fn play_queue( &self, items: Vec<MediaItem>, start_index: usize, ) -> Result<(), PlayerError>

Set the queue and start playing from the specified index

Source

#### pub fn play_queue_from( &self, items: Vec<MediaItem>, start_index: usize, start_position: Option<f64>, ) -> Result<(), PlayerError>

Set the queue and start playing from the specified index, optionally resuming the starting track at `start_position` (seconds).

The seek happens immediately after load so the backend never audibly starts at 0 and there’s no race against a fixed delay. Used when taking over playback from a remote session.

Source

#### pub fn set_queue( &self, items: Vec<MediaItem>, start_index: usize, ) -> Result<(), PlayerError>

Replace the queue without starting local playback.

Used when we’re controlling a remote session: the tracks play on the remote device, but we keep the local queue in sync so the UI reflects what’s playing and a later transfer-to-local has the queue to resume.

Source

#### pub fn is_html5_active(&self) -> bool

True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real player, so transport must be routed to it rather than the native backend.

TRACES: UR-005 | DR-097

Source

#### pub fn html5_is_playing(&self) -> bool

Whether the webview element last reported itself as playing. Meaningless unless `Self::is_html5_active` is true.

TRACES: UR-005 | DR-097

Source

#### fn emit_html5_control(&self, action: &str)

Send a transport intent to the webview element that is rendering media.

Source

#### pub fn play(&self) -> Result<(), PlayerError>

Play/resume playback

Source

#### pub fn pause(&self) -> Result<(), PlayerError>

Pause playback

Source

#### pub fn toggle_playback(&self) -> Result<(), PlayerError>

Toggle play/pause.

The decision is made HERE, from authoritative state — the reported webview state for HTML5-rendered media, or the native backend’s state otherwise. The frontend must never decide this from the DOM (see DR-097).

TRACES: UR-005 | DR-097

Source

#### pub fn stop(&self) -> Result<(), PlayerError>

Stop playback

Source

#### fn report_stopped_at(&self, jellyfin_id: String, position: f64)

Tell Jellyfin playback stopped at `position`, unless that position is zero.

Jellyfin stores the reported position as the resume point, so a zero is not a harmless no-op — it is an instruction to forget where the viewer was. And it is never information: nobody watched zero seconds of anything, so every zero this app ever sent came from asking a player that was not rendering the media (webview video, or a handoff whose first tick had not landed). On a device trace, 14 of 14 stop reports in 35 minutes were zeroes, one of them 40s after the frontend had correctly reported 15:22 for the same episode.

TRACES: UR-025, UR-005 | DR-179 | UT-178

Source

#### pub fn next(&self) -> Result<(), PlayerError>

Skip to next track

Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay from triggering when the current track’s EndFile event fires

Source

#### pub fn previous(&self) -> Result<(), PlayerError>

Skip to previous track

Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay from triggering when the current track’s EndFile event fires

Source

#### pub fn seek(&self, position: f64) -> Result<(), PlayerError>

Seek to a position in seconds, on the player’s own timeline.

During a background-audio handoff that timeline is relative to the handoff point, so this is not the call a lockscreen scrub or a UI seek wants — use `seek_absolute`, which speaks the episode’s timeline and is what every caller outside the player itself means.

Source

#### pub async fn seek_absolute(&self, position: f64) -> Result<(), String>

Seek to an absolute position on the item’s own timeline.

This is the boundary every outside seek comes through — the UI, the lockscreen scrubber, a headset gesture — because all of them are looking at the whole episode, not at whatever fragment of it the player happens to be streaming.

Outside a background-audio handoff the two timelines are the same and this is an ordinary seek. Inside one they differ by the handoff base, and the stream cannot be seeked at all: `/Audio/{id}/universal` is a chunked transcode with no length, so ExoPlayer either refuses or clamps — and a clamped seek lands at stream zero, which is the handoff point. That is the “jumps back to where I locked the screen” symptom. Honouring the seek means re-opening the URL at the new position, which is exactly what the truncation recovery already does, so it shares `resume_stream_at`.

TRACES: UR-040, UR-005 | DR-159 | UT-155

Source

#### pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError>

Set volume (0.0 - 1.0)

Source

#### pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError>

Set the active audio track by stream index

Source

#### pub fn set_subtitle_track( &self, stream_index: Option<i32>, ) -> Result<(), PlayerError>

Set the active subtitle track by stream index (None to disable subtitles)

Source

#### pub fn state(&self) -> PlayerState

Get current state

Source

#### pub fn capabilities(&self) -> Capabilities

What the engine currently rendering can do.

TRACES: UR-081 | DR-246

Source

#### pub fn position(&self) -> f64

Get current position

Source

#### pub fn absolute_position(&self) -> f64

The position on the item’s own timeline, whatever is rendering it.

This is what every outbound position must be taken from — the resume point sent to Jellyfin, the point the video reloads at when a handoff ends, the truncation comparison. `position()` alone answers for exactly one of the three ways this app plays media, and reads 0 for the other two:

- Webview `<video>`/`<audio>`: nothing is loaded into the native backend, so its position is a permanent 0. The element’s own reports are the only reading there is.
- Background-audio handoff: the audio-only stream’s zero is the handoff point, and the base is added at the native tick boundary (DR-159) — so before the first tick lands, nothing has applied it. Flooring at the base is exact rather than approximate: the stream cannot physically be behind its own starting point.
- Native playback: the backend is authoritative and both other terms are zero, so the max is its own value.

Returning to the foreground during that pre-first-tick window is what restarted an episode from 0:00 and wiped its server-side resume point.

TRACES: UR-040, UR-005, UR-025 | DR-178 | UT-176, UT-177

Source

#### pub fn observed_duration(&self) -> Option<f64>

The duration last reported by webview-rendered media, if any.

TRACES: UR-005 | DR-178 | UT-177

Source

#### fn clear_reported_time(&self)

Forget what webview-rendered media reported.

Called wherever that element stops being the player — it was torn down, a handoff took over, or a different item is loading. A stale position outliving its element would be reported against whatever plays next.

TRACES: UR-005 | DR-178 | UT-177

Source

#### pub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>)

Replace the sink playback reports go to. Tests capture; production wires the `PlaybackReporter` at construction and never swaps it.

TRACES: UR-025 | DR-179

Source

#### fn report(&self, operation: PlaybackOperation)

Send a playback report. Fire-and-forget by contract, so callers can do this while holding nothing and waiting for nothing.

TRACES: UR-025 | DR-179

Source

#### fn current_jellyfin_id(&self) -> Option<String>

The Jellyfin id of whatever is currently queued, if it has one.

Source

#### pub fn duration(&self) -> Option<f64>

Get duration.

Falls back to what webview-rendered media reported for the same reason `absolute_position` does: on that path nothing is loaded into the native backend, so its duration is `None` and the element’s report is the only one there is.

TRACES: UR-005 | DR-178

Source

#### pub fn queue(&self) -> Arc<Mutex<QueueManager>> ⓘ

Get queue reference

Source

#### pub fn current_is_audio_episode(&self) -> bool

True when the current item is a TV episode being played in audio-only (background) mode — i.e. an `item_type == "Episode"` item loaded as `MediaType::Audio`. Used to decide whether the backend must drive the next-episode advance itself (the frontend is suspended in the background).

Only called from the Android autoplay dispatch (`#[cfg(android)]`), but compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.

Source

#### pub fn clear_queue(&self)

Clear the queue entirely (used when playback genuinely stops, e.g. the sleep timer fires or the queue ends with repeat off). Pair with `emit_queue_changed` so the frontend hides the mini player.

Source

#### pub fn toggle_shuffle(&self)

Toggle shuffle

Source

#### pub fn cycle_repeat(&self)

Cycle repeat mode

Source

#### pub fn is_shuffle(&self) -> bool

Check if shuffle is enabled

Source

#### pub fn repeat_mode(&self) -> RepeatMode

Get repeat mode

Source

#### pub fn volume(&self) -> f32

Get current volume (0.0 - 1.0)

Source

#### pub fn muted(&self) -> bool

Check if muted

Source

#### pub fn set_audio_settings( &mut self, settings: &AudioSettings, ) -> Result<(), PlayerError>

Set audio settings (crossfade, gapless, normalization)

Source

#### pub fn audio_settings(&self) -> AudioSettings

Get current audio settings

Source

#### pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>)

Set the event emitter for notifications

Source

#### pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>>

Get the event emitter

Source

#### pub fn sleep_timer_state(&self) -> SleepTimerState

Get sleep timer state

Source

#### pub fn set_sleep_timer(&self, mode: SleepTimerMode)

Set sleep timer mode (in-memory only, not persisted)

Source

#### pub fn cancel_sleep_timer(&self)

Cancel sleep timer

Source

#### fn start_timer_thread(&self)

Start background timer thread for sleep timer countdown updates

Source

#### fn emit_sleep_timer_changed(&self)

Emit sleep timer changed event to frontend

Source

#### pub fn emit_queue_changed(&self)

Emit queue changed event to frontend

Source

#### pub fn report_html5_state(&self, state: String, media_id: Option<String>)

Report an HTML5 state change (playing/paused/loading/stopped).

Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer would emit, so `playerEvents.ts` needs no HTML5-specific branch.

Source

#### pub fn report_html5_position(&self, position: f64, duration: f64)

Report an HTML5 position tick.

Re-emits a `PositionUpdate` event mirroring the native backends’ periodic position updates (the adapter is expected to throttle to ~250ms like MPV).

Source

#### fn report_progress_throttled(&self, position: f64)

Send a throttled progress report to Jellyfin.

Progress is what makes a position survive anything other than a clean exit — a crash, a swipe-away, a battery death — and lets another device resume mid-episode. Webview-rendered media reported none: the frontend service writes progress to the local DB only, and Rust had no position for it to report. A device trace covering 35 minutes of playback hit `/Sessions/Playing/Progress` exactly zero times.

The throttler is the one the controller already owned for this purpose (30s per item), so ticks arriving four times a second cost one request per half-minute.

TRACES: UR-005, UR-025 | DR-179 | UT-180

Source

#### pub fn report_html5_media_loaded(&self, duration: f64)

Report that the HTML5 element finished loading and knows its duration. Mirrors the native `MediaLoaded` event.

Source

#### pub fn autoplay_settings(&self) -> AutoplaySettings

Get autoplay settings

Source

#### pub fn set_autoplay_settings(&self, settings: AutoplaySettings)

Set autoplay settings (in-memory only, persistence handled by command layer)

Source

#### pub fn cancel_autoplay_countdown(&self)

Cancel active autoplay countdown

Source

#### pub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String>

Handle playback ended event - decides what to do next

Only triggers autoplay if the track finished naturally (EndReason::Finished or None). If EndReason is NewTrackLoaded, UserStop, UserSkip, or Error, returns Stop without autoplay.

Source

#### fn report_completion(&self, item: &MediaItem)

Report an item that just finished as stopped at its runtime, so Jellyfin marks it played.

Jellyfin decides “watched” from the `PlaybackStopped` report and its position — no report, no completion, however much of the episode was actually heard. In the foreground the frontend sends one when the `<video>` ends. In background audio-only mode there is nobody: the webview is suspended and its element was torn down at the handoff, while the backend drove the advance to the next episode and said nothing about the one that ended. An episode listened to end-to-end on the lockscreen therefore never counted, and (before DR-179) was often reset to 0 by the stop report that followed.

Scoped to the audio-only handoff — the case the frontend provably cannot report — so foreground playback keeps its single existing report rather than gaining a second one. Music tracks ending natively remain unreported; that is the same gap through a different door and wants its own change.

TRACES: UR-040, UR-025 | DR-179 | UT-179

Source

#### pub fn set_background_audio_base(&self, seconds: f64)

Record the base offset of a background-audio handoff (the position the video was handed off at, which is the audio stream’s zero).

TRACES: UR-040 | DR-052

Source

#### pub fn enter_background_audio(&self, position: f64)

Enter a background-audio handoff at `position` (the video’s position, and therefore the audio stream’s zero).

Hands transport authority to the native audio player: the webview `<video>` is about to be torn down, so its last reports — including the `pause` the teardown itself fires — must not keep it looking like the player. Without this the lockscreen pause emitted a ControlCommand at a dead element and the audio played straight through it.

TRACES: UR-040, UR-005 | DR-052, DR-097

Source

#### pub fn exit_background_audio(&self) -> f64

Leave a background-audio handoff, returning the base offset to add to the native player’s relative position.

The webview `<video>` becomes the player again once it reloads, so its reports are honoured from here on.

TRACES: UR-040, UR-005 | DR-052, DR-097

Source

#### pub fn is_background_audio_active(&self) -> bool

True while the native audio player owns playback via a background-audio handoff.

TRACES: UR-040 | DR-052

Source

#### pub fn take_background_audio_base(&self) -> f64

Read and clear the background-audio base offset.

TRACES: UR-040 | DR-052

Source

#### pub async fn auto_advance_to_next_episode( &self, next_episode: MediaItem, countdown_seconds: u32, )

Perform the auto-advance for a `ShowNextEpisodePopup` decision.

Single place both end-of-playback dispatchers agree on: the Android JNI callback (`nativeOnPlaybackEnded`) and the frontend-invoked command (`player_on_playback_ended`). They used to each carry their own copy of this branch, and the command’s copy was missing the background-audio case entirely — so an audio-only episode ending while backgrounded only ever started a countdown that nothing could act on.

TRACES: UR-040, UR-023 | DR-052

Source

#### fn is_audio_only_video(item: &MediaItem) -> bool

A video item played through the native audio path — i.e. the background audio-only handoff, the only place a length-less progressive transcode is used. Jellyfin’s item-type taxonomy stays in Rust (CLAUDE.md).

Source

#### fn claim_stream_resume(&self) -> Option<(f64, u32)>

Claim a resume attempt for the current stream, returning the absolute position to re-open at and the 1-based attempt number. `None` when the current item cannot meaningfully be re-requested, or when retrying at this position has stopped helping.

Only `Remote` sources qualify. A downloaded file cannot fail because of the network, so re-opening one would paper over a real read error; a `DirectUrl` is a plugin’s endpoint with no Jellyfin item behind it.

The player’s position is relative to the stream’s own zero (the handoff URL’s `StartTimeTicks`), so the base is added back to get an absolute one. It is zero for everything else, where positions are already absolute.

TRACES: UR-040, UR-004 | DR-129 | UT-117

Source

#### fn truncated_stream_resume_position(&self) -> Option<f64>

The absolute position to re-open the current stream at, when the reported end was really a dropped connection — `None` when the end looks genuine, when this is not an audio-only handoff, or when retrying has stopped helping.

TRACES: UR-040 | DR-129 | UT-117

Source

#### pub fn recoverable_error_resume(&self) -> Option<(f64, u64)>

Where to re-open the current stream after a recoverable playback error, plus how many seconds to wait first.

The media was decoding fine a moment ago, so a mid-playback failure on a server stream is the network — and stopping the player (the previous behaviour, via the frontend’s error handler) turns a hiccup into “playback just died”. Applies to every streamed item, not only the audio-only handoff: music and video reach here instead of the truncation path because their streams declare a length, so a cut connection surfaces as an error rather than a phantom end.

The wait grows with the attempt number so a short outage has time to clear, and the shared budget stops the retries when it doesn’t.

Called from the Android error callback, which decides in-process, and from `player_recover_stream`, which is how the same decision reaches the backends whose event thread has no controller to call — MPV is built before the controller exists, so on Linux the error is emitted, echoed by the frontend, and decided here.

TRACES: UR-040, UR-004 | DR-129, DR-130 | UT-117

Source

#### pub async fn resume_stream_at(&self, position: f64) -> Result<(), String>

Re-open the current stream at `position` after the network cut it short.

Single place every dispatcher agrees on, for the same reason `auto_advance_to_next_episode` is: the Android JNI callbacks and the frontend-invoked command must not disagree about what a failed stream means. None of them emits `PlaybackEnded` for this, so nothing downstream clears the queue or tears the session down — from the outside this is a buffering hiccup, which is what it actually was.

Reloads the item in place rather than through `play_item`, which replaces the queue with a single item: recovering a track that way would throw away the rest of the album, turning a network blip into lost state.

Two shapes of stream, two ways back to `position`:

- The audio-only handoff’s `/Audio/{id}/universal` transcode is chunked with no length, so it cannot be seeked. Its URL is rewritten to start at the position instead — edited, not rebuilt from the repository, since it already carries the user’s audio track and media source and recovering from a network failure must not itself need a network round-trip.
- Everything else (a static file with byte ranges, an HLS playlist) declares its whole timeline, so re-preparing the URL it already has and seeking lands in the right place — and leaves any transcode session behind it alone.

TRACES: UR-040, UR-004 | DR-129 | UT-117

Source

#### pub async fn advance_to_next_episode_audio_only( &self, next_episode_id: &str, ) -> Result<(), String>

Advance to the next episode while playing audio-only in the background.

The normal autoplay-next path navigates the frontend to `/player/<id>`, which is unavailable when the app is backgrounded and the WebView is suspended. This drives the advance entirely in the backend: build the next episode’s audio-only stream URL and load it into the native audio player, so playback continues without any frontend involvement (UR-040).

`next_episode_id` is the Jellyfin item ID of the episode to play next.

Reached through `auto_advance_to_next_episode`, which gates it on `current_is_audio_episode()` — only ever true after a background-audio handoff (Android), but compiled and unit-tested on every platform. TRACES: UR-040, UR-023 | DR-052

Source

#### pub async fn on_video_playback_ended( &self, item_id: &str, repo: Arc<dyn MediaRepository>, ) -> Result<AutoplayDecision, String>

Handle video playback ended from HTML5 video element.

HTML5 video plays independently of the Rust backend, so the backend queue has no knowledge of the video item. This method bypasses the queue lookup and end_reason check, using the provided Jellyfin item ID to look up the item and check for next episodes.

Source

#### async fn is_episode_item(&self, item: &MediaItem) -> bool

Check if a media item is an episode (has Jellyfin ID to query).

An explicit `item_type == "Episode"` wins so that a TV episode handed off to the audio path for background playback (UR-040) is still recognised as an episode — otherwise autoplay would fall through to the queue-based audio path, find nothing next, and stop at the episode boundary. When the type is unknown we fall back to the historical heuristic (video == episode).

Source

#### async fn fetch_next_episode_for_item( &self, item_id: &str, repo: &Arc<dyn MediaRepository>, ) -> Result<Option<(MediaItem, MediaItem)>, String>

Fetch next episode for a series by looking up the season’s episodes sorted by index number and picking the one after the current episode.

This is deterministic and doesn’t depend on Jellyfin’s “Next Up” API (which relies on watch history that may not be updated yet due to the async nature of playback progress reporting).

Source

#### async fn first_episode_of_next_season( &self, current: &MediaItem, repo: &Arc<dyn MediaRepository>, ) -> Option<MediaItem>

The first episode of the season after this one, if the series has one.

A season boundary is not the end of a series, and stopping there is felt most sharply on the background-audio path (UR-040): the screen is locked, nothing shows a “next” button, and playback simply stops mid-binge. Every other autoplay entry point shares this lookup, so foreground video and the Android native path cross the boundary too.

Lookup failures degrade to `None` rather than an error: the episode has already finished, and the caller’s only alternative is to stop anyway.

TRACES: UR-023, UR-040 | DR-263 | UT-238

Source

#### pub fn start_autoplay_countdown( &self, _next_item: MediaItem, countdown_seconds: u32, )

Start autoplay countdown thread

## Trait Implementations§

Source §

### impl Default for PlayerController

Source §

#### fn default() -> Self

Returns the “default value” for a type. Read more

## Auto Trait Implementations§

§

### impl Freeze for PlayerController

§

### impl !RefUnwindSafe for PlayerController

§

### impl Send for PlayerController

§

### impl Sync for PlayerController

§

### impl Unpin for PlayerController

§

### impl UnsafeUnpin for PlayerController

§

### impl !UnwindSafe for PlayerController

## Blanket Implementations§

Source §

### impl<T> Any for T
where T: 'static + ?Sized,

Source §

#### fn type_id(&self) -> TypeId

Gets the `TypeId` of `self`. Read more
Source §

### impl<T> Borrow<T> for T
where T: ?Sized,

Source §

#### fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source §

### impl<T> BorrowMut<T> for T
where T: ?Sized,

Source §

#### fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source §

### impl<T> From<T> for T

Source §

#### fn from(t: T) -> T

Returns the argument unchanged.

§

### impl<T> Instrument for T

§

#### fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [`Span`], returning an `Instrumented` wrapper. Read more
§

#### fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current `Span`, returning an `Instrumented` wrapper. Read more
Source §

### impl<T, U> Into<U> for T
where U: From<T>,

Source §

#### fn into(self) -> U

Calls `U::from(self)`.

That is, this conversion is whatever the implementation of `From<T> for U` chooses to do.

§

### impl<T> NoneValue for T
where T: Default,

§

#### type NoneType = T

§

#### fn null_value() -> T

The none-equivalent value.
§

### impl<T> PolicyExt for T
where T: ?Sized,

§

#### fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Policy<B, E>, P: Policy<B, E>,

Create a new `Policy` that returns [`Action::Follow`] only if `self` and `other` return `Action::Follow`. Read more
§

#### fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Policy<B, E>, P: Policy<B, E>,

Create a new `Policy` that returns [`Action::Follow`] if either `self` or `other` returns `Action::Follow`. Read more
Source §

### impl<T> Same for T

Source §

#### type Output = T

Should always be `Self`
Source §

### impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source §

#### type Error = Infallible

The type returned in the event of a conversion error.
Source §

#### fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source §

### impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source §

#### type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source §

#### fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

### impl<V, T> VZip<V> for T
where V: MultiLane<T>,

§

#### fn vzip(self) -> V

§

### impl<T> WithSubscriber for T

§

#### fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided `Subscriber` to this type, returning a [`WithDispatch`] wrapper. Read more
§

#### fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default `Subscriber` to this type, returning a [`WithDispatch`] wrapper. Read more