Skip to main content

jellytau_lib/player/
seek.rs

1//! Video seek strategy decision logic.
2//!
3//! This is pure logic, extracted from the command layer so it can be unit-tested
4//! in the player core. The `player_seek_video` command translates the resulting
5//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
6
7/// Seek strategy for video playback, derived from a stream's characteristics.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum VideoSeekStrategy {
10    /// Local file - always use native seek on backend
11    LocalNativeSeek,
12    /// HLS or direct stream with HTML5 - frontend handles seek, skip backend
13    Html5NativeSeek,
14    /// HLS or direct stream with native backend - backend handles seek
15    BackendNativeSeek,
16    /// Transcoded non-HLS with HTML5 - reload stream, frontend handles
17    Html5ReloadStream,
18    /// Transcoded non-HLS with native backend - reload stream, backend handles
19    BackendReloadStream,
20}
21
22/// Determine the video seek strategy based on stream characteristics.
23///
24/// This is a pure function extracted for testability.
25///
26/// # Arguments
27/// * `is_local` - Whether the file is a local download
28/// * `seeks_transcoded_in_place` - Whether the engine rendering this stream
29///   can seek a server-side transcode without re-opening it. Declared by the
30///   engine via `Capabilities`, never inferred from the URL or the renderer.
31/// * `needs_transcoding` - Whether the content needs transcoding
32/// * `use_html5` - Whether frontend is using HTML5 video element
33pub fn determine_video_seek_strategy(
34    is_local: bool,
35    seeks_transcoded_in_place: bool,
36    needs_transcoding: bool,
37    use_html5: bool,
38) -> VideoSeekStrategy {
39    // Local files always support native seeking via backend
40    if is_local {
41        return VideoSeekStrategy::LocalNativeSeek;
42    }
43
44    // A server-side transcode is produced *from* `StartTimeTicks`, so where the
45    // seek lands is a property of the request, not of the stream in hand.
46    //
47    // hls.js is the exception: handed a VOD playlist it seeks within it and lets
48    // the server catch up segment by segment. mpv's HLS demuxer cannot make
49    // Jellyfin transcode from a new offset, so for the native backend a
50    // transcoded seek must re-negotiate the stream regardless of container.
51    //
52    // Before native video shipped, `use_html5` was always true for HLS and the
53    // native+HLS+transcode cell was unreachable, which is why `is_hls` alone
54    // used to be a safe proxy for "seekable in place". It no longer is: turning
55    // native video on routed every transcoded seek into a backend seek that
56    // silently does nothing, and presents as "resume does not work".
57    if needs_transcoding {
58        // Whether a transcode can be seeked in place is a property of the
59        // engine, and the engine states it. This used to be inferred from
60        // `is_hls`, which held only while hls.js was the sole HLS renderer —
61        // and stopped holding the moment mpv became one (DR-238).
62        return match (seeks_transcoded_in_place, use_html5) {
63            (true, true) => VideoSeekStrategy::Html5NativeSeek,
64            (true, false) => VideoSeekStrategy::BackendNativeSeek,
65            (false, true) => VideoSeekStrategy::Html5ReloadStream,
66            (false, false) => VideoSeekStrategy::BackendReloadStream,
67        };
68    }
69
70    // Direct play and direct stream are seekable where they sit.
71    if use_html5 {
72        // The frontend seeks via videoElement.currentTime; calling backend.seek()
73        // would move a player that is not the one rendering.
74        VideoSeekStrategy::Html5NativeSeek
75    } else {
76        VideoSeekStrategy::BackendNativeSeek
77    }
78}
79
80// The four items below are consumed by the Android MediaSessionHandler; on other
81// targets only the tests exercise them, so dead-code analysis would flag them.
82
83/// How far a lockscreen skip-forward jumps while background audio owns playback.
84#[cfg_attr(not(target_os = "android"), allow(dead_code))]
85pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
86
87/// How far a lockscreen skip-back jumps while background audio owns playback.
88///
89/// Deliberately shorter than the forward jump: the back button is used to replay
90/// dialogue just missed, not to travel.
91#[cfg_attr(not(target_os = "android"), allow(dead_code))]
92pub const SKIP_BACK_SECONDS: f64 = 10.0;
93
94/// What a lockscreen skip button means for the playback that is actually running.
95#[cfg_attr(not(target_os = "android"), allow(dead_code))]
96#[derive(Debug, Clone, Copy, PartialEq)]
97pub enum SkipAction {
98    /// Move to the next/previous queue entry — a track, or an episode.
99    Advance,
100    /// Scrub within the current item, to this absolute position in seconds.
101    SeekTo(f64),
102}
103
104/// Decide whether a lockscreen skip advances the queue or scrubs the current item.
105///
106/// Music gets queue advance, which is what the buttons look like they do. A video
107/// whose audio is playing through a background-audio handoff (UR-040) gets a
108/// relative scrub instead: there is no meaningful "next track" inside a film, and
109/// jumping to the next *episode* because the user wanted to re-hear a line is a
110/// much worse outcome than a scrub.
111///
112/// `is_background_audio` is the whole test, and it is sufficient on its own —
113/// the handoff exists only for video, and an episode played through it reports
114/// `MediaType::Audio`, so media type cannot distinguish this case (see the note
115/// at `PlayerController::auto_advance_to_next_episode`).
116///
117/// Clamped to `[0, duration]` so a skip near either end lands in the item rather
118/// than at a negative offset or past the end, which some backends treat as EOF
119/// and would turn a scrub into an unintended advance.
120///
121/// TRACES: UR-040, UR-006 | DR-201
122#[cfg_attr(not(target_os = "android"), allow(dead_code))]
123pub fn resolve_skip_action(
124    is_next: bool,
125    is_background_audio: bool,
126    position: f64,
127    duration: Option<f64>,
128) -> SkipAction {
129    if !is_background_audio {
130        return SkipAction::Advance;
131    }
132
133    let target = if is_next {
134        position + SKIP_FORWARD_SECONDS
135    } else {
136        position - SKIP_BACK_SECONDS
137    };
138
139    let clamped = match duration {
140        Some(d) if d > 0.0 => target.clamp(0.0, d),
141        _ => target.max(0.0),
142    };
143
144    SkipAction::SeekTo(clamped)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    /// Music (no background-audio handoff) keeps queue advance on both buttons.
152    ///
153    /// TRACES: UR-006 | DR-201 | UT-194
154    #[test]
155    fn test_skip_advances_queue_for_normal_audio() {
156        assert_eq!(
157            resolve_skip_action(true, false, 42.0, Some(300.0)),
158            SkipAction::Advance
159        );
160        assert_eq!(
161            resolve_skip_action(false, false, 42.0, Some(300.0)),
162            SkipAction::Advance
163        );
164    }
165
166    /// The reported bug: in background-audio mode the lockscreen skip buttons
167    /// advanced to the next/previous episode instead of scrubbing, so trying to
168    /// re-hear a line jumped out of the film entirely.
169    ///
170    /// TRACES: UR-040 | DR-201 | UT-195
171    #[test]
172    fn test_skip_scrubs_in_background_audio_mode() {
173        assert_eq!(
174            resolve_skip_action(true, true, 100.0, Some(3600.0)),
175            SkipAction::SeekTo(130.0)
176        );
177        assert_eq!(
178            resolve_skip_action(false, true, 100.0, Some(3600.0)),
179            SkipAction::SeekTo(90.0)
180        );
181    }
182
183    /// Skipping back near the start clamps to zero rather than going negative,
184    /// which backends reject (the "Raw(-10)" class of error).
185    ///
186    /// TRACES: UR-040 | DR-201 | UT-196
187    #[test]
188    fn test_skip_back_clamps_at_start() {
189        assert_eq!(
190            resolve_skip_action(false, true, 4.0, Some(3600.0)),
191            SkipAction::SeekTo(0.0)
192        );
193    }
194
195    /// Skipping forward near the end clamps to the duration instead of running
196    /// past it, which would read as end-of-stream and advance — the very thing
197    /// this function exists to prevent.
198    ///
199    /// TRACES: UR-040 | DR-201 | UT-197
200    #[test]
201    fn test_skip_forward_clamps_at_end() {
202        assert_eq!(
203            resolve_skip_action(true, true, 3590.0, Some(3600.0)),
204            SkipAction::SeekTo(3600.0)
205        );
206    }
207
208    /// An unknown duration still scrubs, and still refuses to go negative.
209    ///
210    /// TRACES: UR-040 | DR-201 | UT-198
211    #[test]
212    fn test_skip_without_duration_still_scrubs() {
213        assert_eq!(
214            resolve_skip_action(true, true, 10.0, None),
215            SkipAction::SeekTo(40.0)
216        );
217        assert_eq!(
218            resolve_skip_action(false, true, 3.0, None),
219            SkipAction::SeekTo(0.0)
220        );
221    }
222
223    /// Test video seek strategy for local files
224    #[test]
225    fn test_seek_strategy_local_file() {
226        // Local files always use native backend seek regardless of other flags
227        assert_eq!(
228            determine_video_seek_strategy(true, false, false, false),
229            VideoSeekStrategy::LocalNativeSeek
230        );
231        assert_eq!(
232            determine_video_seek_strategy(true, false, false, true),
233            VideoSeekStrategy::LocalNativeSeek
234        );
235        assert_eq!(
236            determine_video_seek_strategy(true, true, true, true),
237            VideoSeekStrategy::LocalNativeSeek
238        );
239    }
240
241    /// Non-transcoded streams seek in place regardless of the engine's
242    /// transcode ability, which only applies to transcodes.
243    #[test]
244    fn test_seek_strategy_direct_stream() {
245        // HTML5 renders, so the frontend seeks the element
246        assert_eq!(
247            determine_video_seek_strategy(false, true, false, true),
248            VideoSeekStrategy::Html5NativeSeek
249        );
250        // The native engine renders, so it seeks
251        assert_eq!(
252            determine_video_seek_strategy(false, true, false, false),
253            VideoSeekStrategy::BackendNativeSeek
254        );
255        // A transcode an engine says it can move: seek in place
256        assert_eq!(
257            determine_video_seek_strategy(false, true, true, true),
258            VideoSeekStrategy::Html5NativeSeek
259        );
260    }
261
262    /// A server-side transcode cannot be seeked by the native backend.
263    ///
264    /// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
265    /// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
266    /// the server transcode from a new offset, so the stream has to be
267    /// re-negotiated. Before native video existed, `use_html5` was always true
268    /// for HLS and this case was unreachable — turning native video on routed
269    /// every transcoded seek into a native seek that silently does nothing,
270    /// which presents as "resume does not work".
271    ///
272    /// TRACES: UR-040 | DR-238, DR-246 | UT-217
273    #[test]
274    fn test_transcoded_seek_follows_the_engines_declared_ability() {
275        // An engine that cannot move a server-side transcode re-opens it,
276        // whichever side is rendering.
277        assert_eq!(
278            determine_video_seek_strategy(false, false, true, false),
279            VideoSeekStrategy::BackendReloadStream
280        );
281        assert_eq!(
282            determine_video_seek_strategy(false, false, true, true),
283            VideoSeekStrategy::Html5ReloadStream
284        );
285        // hls.js can, and says so, so it seeks in place.
286        assert_eq!(
287            determine_video_seek_strategy(false, true, true, true),
288            VideoSeekStrategy::Html5NativeSeek
289        );
290        // The container the stream arrives in no longer decides anything: the
291        // same declared ability gives the same answer on the native side.
292        assert_eq!(
293            determine_video_seek_strategy(false, true, true, false),
294            VideoSeekStrategy::BackendNativeSeek
295        );
296    }
297
298    /// Test video seek strategy for direct play (non-transcoded) streams
299    #[test]
300    fn test_seek_strategy_direct_play() {
301        // Direct play with HTML5 - frontend handles seek
302        assert_eq!(
303            determine_video_seek_strategy(false, false, false, true),
304            VideoSeekStrategy::Html5NativeSeek
305        );
306        // Direct play with native backend - backend handles seek
307        assert_eq!(
308            determine_video_seek_strategy(false, false, false, false),
309            VideoSeekStrategy::BackendNativeSeek
310        );
311    }
312
313    /// Test video seek strategy for transcoded non-HLS streams
314    #[test]
315    fn test_seek_strategy_transcoded_non_hls() {
316        // Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
317        assert_eq!(
318            determine_video_seek_strategy(false, false, true, true),
319            VideoSeekStrategy::Html5ReloadStream
320        );
321        // Transcoded non-HLS with native backend - need to reload stream, backend handles
322        assert_eq!(
323            determine_video_seek_strategy(false, false, true, false),
324            VideoSeekStrategy::BackendReloadStream
325        );
326    }
327
328    /// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
329    /// This was the bug causing "Raw(-10)" errors
330    #[test]
331    fn test_hls_html5_does_not_use_backend_seek() {
332        let strategy = determine_video_seek_strategy(false, true, false, true);
333        // Should be Html5NativeSeek, NOT BackendNativeSeek
334        assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
335        assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
336    }
337}