pub struct PlayerController {Show 19 fields
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
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 PlayerBackend>>>§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
impl PlayerController
pub fn new( backend: Box<dyn PlayerBackend>, playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>, position_throttler: Arc<EventThrottler>, ) -> Self
Sourcepub fn set_jellyfin_client(&self, client: Option<JellyfinClient>)
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>)
Configure the Jellyfin API client for automatic playback reporting
Sourcepub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> ⓘ
pub fn jellyfin_client(&self) -> Arc<Mutex<Option<JellyfinClient>>> ⓘ
Get a reference to the Jellyfin client (for remote session control)
Sourcepub fn set_repository(&self, repo: Arc<dyn MediaRepository>)
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.
Sourcepub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>)
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.
Sourcepub fn playback_reporter(&self) -> Arc<TokioMutex<Option<PlaybackReporter>>> ⓘ
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
Sourcepub fn position_throttler(&self) -> Arc<EventThrottler> ⓘ
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
Sourcefn set_end_reason(&self, reason: EndReason)
fn set_end_reason(&self, reason: EndReason)
Set the end reason for the next playback end event
Sourcefn take_end_reason(&self) -> Option<EndReason>
fn take_end_reason(&self) -> Option<EndReason>
Get and clear the current end reason
Sourcefn peek_end_reason(&self) -> Option<EndReason>
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.
Sourcefn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>)
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
Sourcefn increment_autoplay_count(&self) -> bool
fn increment_autoplay_count(&self) -> bool
Increment autoplay episode counter. Returns true if limit is reached.
Sourcefn reset_autoplay_count(&self)
fn reset_autoplay_count(&self)
Reset autoplay episode counter (called on manual play actions)
Sourcepub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError>
pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError>
Load and play a single item (also sets the queue to contain only this item)
Sourcepub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError>
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
Sourcepub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError>
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
Sourcepub fn play_queue(
&self,
items: Vec<MediaItem>,
start_index: usize,
) -> Result<(), PlayerError>
pub fn play_queue( &self, items: Vec<MediaItem>, start_index: usize, ) -> Result<(), PlayerError>
Set the queue and start playing from the specified index
Sourcepub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError>
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.
Sourcepub fn set_queue(
&self,
items: Vec<MediaItem>,
start_index: usize,
) -> Result<(), PlayerError>
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.
Sourcepub fn is_html5_active(&self) -> bool
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
Sourcepub fn html5_is_playing(&self) -> bool
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
Sourcefn emit_html5_control(&self, action: &str)
fn emit_html5_control(&self, action: &str)
Send a transport intent to the webview element that is rendering media.
Sourcepub fn play(&self) -> Result<(), PlayerError>
pub fn play(&self) -> Result<(), PlayerError>
Play/resume playback
Sourcepub fn pause(&self) -> Result<(), PlayerError>
pub fn pause(&self) -> Result<(), PlayerError>
Pause playback
Sourcepub fn toggle_playback(&self) -> Result<(), PlayerError>
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
Sourcepub fn stop(&self) -> Result<(), PlayerError>
pub fn stop(&self) -> Result<(), PlayerError>
Stop playback
Sourcefn report_stopped_at(&self, jellyfin_id: String, position: f64)
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
Sourcepub fn next(&self) -> Result<(), PlayerError>
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
Sourcepub fn previous(&self) -> Result<(), PlayerError>
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
Sourcepub fn seek(&self, position: f64) -> Result<(), PlayerError>
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.
Sourcepub async fn seek_absolute(&self, position: f64) -> Result<(), String>
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
Sourcepub fn set_volume(&self, volume: f32) -> Result<(), PlayerError>
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError>
Set volume (0.0 - 1.0)
Sourcepub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError>
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError>
Set the active audio track by stream index
Sourcepub fn set_subtitle_track(
&self,
stream_index: Option<i32>,
) -> Result<(), PlayerError>
pub fn set_subtitle_track( &self, stream_index: Option<i32>, ) -> Result<(), PlayerError>
Set the active subtitle track by stream index (None to disable subtitles)
Sourcepub fn state(&self) -> PlayerState
pub fn state(&self) -> PlayerState
Get current state
Sourcepub fn absolute_position(&self) -> f64
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
Sourcepub fn observed_duration(&self) -> Option<f64>
pub fn observed_duration(&self) -> Option<f64>
The duration last reported by webview-rendered media, if any.
TRACES: UR-005 | DR-178 | UT-177
Sourcefn clear_reported_time(&self)
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
Sourcepub fn set_report_sink(&self, sink: Arc<dyn PlaybackReportSink>)
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
Sourcefn report(&self, operation: PlaybackOperation)
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
Sourcefn current_jellyfin_id(&self) -> Option<String>
fn current_jellyfin_id(&self) -> Option<String>
The Jellyfin id of whatever is currently queued, if it has one.
Sourcepub fn duration(&self) -> Option<f64>
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
Sourcepub fn current_is_audio_episode(&self) -> bool
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.
Sourcepub fn clear_queue(&self)
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.
Sourcepub fn toggle_shuffle(&self)
pub fn toggle_shuffle(&self)
Toggle shuffle
Sourcepub fn cycle_repeat(&self)
pub fn cycle_repeat(&self)
Cycle repeat mode
Sourcepub fn is_shuffle(&self) -> bool
pub fn is_shuffle(&self) -> bool
Check if shuffle is enabled
Sourcepub fn repeat_mode(&self) -> RepeatMode
pub fn repeat_mode(&self) -> RepeatMode
Get repeat mode
Sourcepub fn set_audio_settings(
&mut self,
settings: &AudioSettings,
) -> Result<(), PlayerError>
pub fn set_audio_settings( &mut self, settings: &AudioSettings, ) -> Result<(), PlayerError>
Set audio settings (crossfade, gapless, normalization)
Sourcepub fn audio_settings(&self) -> AudioSettings
pub fn audio_settings(&self) -> AudioSettings
Get current audio settings
Sourcepub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>)
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>)
Set the event emitter for notifications
Sourcepub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>>
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>>
Get the event emitter
Sourcepub fn sleep_timer_state(&self) -> SleepTimerState
pub fn sleep_timer_state(&self) -> SleepTimerState
Get sleep timer state
Sourcepub fn set_sleep_timer(&self, mode: SleepTimerMode)
pub fn set_sleep_timer(&self, mode: SleepTimerMode)
Set sleep timer mode (in-memory only, not persisted)
Sourcepub fn cancel_sleep_timer(&self)
pub fn cancel_sleep_timer(&self)
Cancel sleep timer
Sourcefn start_timer_thread(&self)
fn start_timer_thread(&self)
Start background timer thread for sleep timer countdown updates
Sourcefn emit_sleep_timer_changed(&self)
fn emit_sleep_timer_changed(&self)
Emit sleep timer changed event to frontend
Sourcepub fn emit_queue_changed(&self)
pub fn emit_queue_changed(&self)
Emit queue changed event to frontend
Sourcepub fn report_html5_state(&self, state: String, media_id: Option<String>)
pub fn report_html5_state(&self, state: String, media_id: Option<String>)
Report an HTML5
Re-emits a StateChanged event identical to what MpvBackend/ExoPlayer
would emit, so playerEvents.ts needs no HTML5-specific branch.
Sourcepub fn report_html5_position(&self, position: f64, duration: f64)
pub fn report_html5_position(&self, position: f64, duration: f64)
Report an HTML5
Re-emits a PositionUpdate event mirroring the native backends’ periodic
position updates (the adapter is expected to throttle to ~250ms like MPV).
Sourcefn report_progress_throttled(&self, position: f64)
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
Sourcepub fn report_html5_media_loaded(&self, duration: f64)
pub fn report_html5_media_loaded(&self, duration: f64)
Report that the HTML5
Sourcepub fn autoplay_settings(&self) -> AutoplaySettings
pub fn autoplay_settings(&self) -> AutoplaySettings
Get autoplay settings
Sourcepub fn set_autoplay_settings(&self, settings: AutoplaySettings)
pub fn set_autoplay_settings(&self, settings: AutoplaySettings)
Set autoplay settings (in-memory only, persistence handled by command layer)
Sourcepub fn cancel_autoplay_countdown(&self)
pub fn cancel_autoplay_countdown(&self)
Cancel active autoplay countdown
Sourcepub async fn on_playback_ended(&self) -> Result<AutoplayDecision, String>
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.
Sourcefn report_completion(&self, item: &MediaItem)
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
Sourcepub fn set_background_audio_base(&self, seconds: f64)
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
Sourcepub fn enter_background_audio(&self, position: f64)
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
Sourcepub fn exit_background_audio(&self) -> f64
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
Sourcepub fn is_background_audio_active(&self) -> bool
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
Sourcepub fn take_background_audio_base(&self) -> f64
pub fn take_background_audio_base(&self) -> f64
Read and clear the background-audio base offset.
TRACES: UR-040 | DR-052
Sourcepub async fn auto_advance_to_next_episode(
&self,
next_episode: MediaItem,
countdown_seconds: u32,
)
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
Sourcefn is_audio_only_video(item: &MediaItem) -> bool
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).
Sourcefn claim_stream_resume(&self) -> Option<(f64, u32)>
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
Sourcefn truncated_stream_resume_position(&self) -> Option<f64>
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
Sourcepub fn recoverable_error_resume(&self) -> Option<(f64, u64)>
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
Sourcepub async fn resume_stream_at(&self, position: f64) -> Result<(), String>
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}/universaltranscode 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
Sourcepub async fn advance_to_next_episode_audio_only(
&self,
next_episode_id: &str,
) -> Result<(), String>
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
Sourcepub async fn on_video_playback_ended(
&self,
item_id: &str,
repo: Arc<dyn MediaRepository>,
) -> Result<AutoplayDecision, String>
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.
Sourceasync fn is_episode_item(&self, item: &MediaItem) -> bool
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).
Sourceasync fn fetch_next_episode_for_item(
&self,
item_id: &str,
repo: &Arc<dyn MediaRepository>,
) -> Result<Option<(MediaItem, MediaItem)>, String>
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).
Sourcepub fn start_autoplay_countdown(
&self,
_next_item: MediaItem,
countdown_seconds: u32,
)
pub fn start_autoplay_countdown( &self, _next_item: MediaItem, countdown_seconds: u32, )
Start autoplay countdown thread