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///
9/// Every video renderer is a native backend (mpv, ExoPlayer) since the webview
10/// `<video>` path was deleted (DR-235), so the backend performs every seek; what
11/// remains to decide is whether it can move the stream in place.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum VideoSeekStrategy {
14    /// Local file - always use native seek on backend
15    LocalNativeSeek,
16    /// Seekable where it sits (direct play/stream, or a transcode the engine
17    /// can move) - backend seeks
18    BackendNativeSeek,
19    /// A transcode the engine cannot move - re-open the stream at the target
20    BackendReloadStream,
21}
22
23/// Determine the video seek strategy based on stream characteristics.
24///
25/// This is a pure function extracted for testability.
26///
27/// # Arguments
28/// * `is_local` - Whether the file is a local download
29/// * `seeks_transcoded_in_place` - Whether the engine rendering this stream
30///   can seek a server-side transcode without re-opening it. Declared by the
31///   engine via `Capabilities`, never inferred from the URL or the renderer.
32/// * `needs_transcoding` - Whether the content needs transcoding
33pub fn determine_video_seek_strategy(
34    is_local: bool,
35    seeks_transcoded_in_place: bool,
36    needs_transcoding: bool,
37) -> VideoSeekStrategy {
38    // Local files always support native seeking via backend
39    if is_local {
40        return VideoSeekStrategy::LocalNativeSeek;
41    }
42
43    // A server-side transcode is produced *from* `StartTimeTicks`, so where the
44    // seek lands is a property of the request, not of the stream in hand. mpv's
45    // HLS demuxer cannot make Jellyfin transcode from a new offset, so for it a
46    // transcoded seek must re-negotiate the stream regardless of container.
47    //
48    // Whether a transcode can be seeked in place is a property of the engine,
49    // and the engine states it. This used to be inferred from `is_hls`, which
50    // held only while hls.js was the sole HLS renderer — and stopped holding the
51    // moment mpv became one (DR-238).
52    if needs_transcoding && !seeks_transcoded_in_place {
53        return VideoSeekStrategy::BackendReloadStream;
54    }
55
56    // Direct play, direct stream, or a transcode the engine can move.
57    VideoSeekStrategy::BackendNativeSeek
58}
59
60// The four items below are consumed by the Android MediaSessionHandler; on other
61// targets only the tests exercise them, so dead-code analysis would flag them.
62
63/// How far a lockscreen skip-forward jumps while background audio owns playback.
64#[cfg_attr(not(target_os = "android"), allow(dead_code))]
65pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
66
67/// How far a lockscreen skip-back jumps while background audio owns playback.
68///
69/// Deliberately shorter than the forward jump: the back button is used to replay
70/// dialogue just missed, not to travel.
71#[cfg_attr(not(target_os = "android"), allow(dead_code))]
72pub const SKIP_BACK_SECONDS: f64 = 10.0;
73
74/// What a lockscreen skip button means for the playback that is actually running.
75#[cfg_attr(not(target_os = "android"), allow(dead_code))]
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub enum SkipAction {
78    /// Move to the next/previous queue entry — a track, or an episode.
79    Advance,
80    /// Scrub within the current item, to this absolute position in seconds.
81    SeekTo(f64),
82}
83
84/// Decide whether a lockscreen skip advances the queue or scrubs the current item.
85///
86/// Music gets queue advance, which is what the buttons look like they do. A video
87/// whose audio is playing through a background-audio handoff (UR-040) gets a
88/// relative scrub instead: there is no meaningful "next track" inside a film, and
89/// jumping to the next *episode* because the user wanted to re-hear a line is a
90/// much worse outcome than a scrub.
91///
92/// `is_background_audio` is the whole test, and it is sufficient on its own —
93/// the handoff exists only for video, and an episode played through it reports
94/// `MediaType::Audio`, so media type cannot distinguish this case (see the note
95/// at `PlayerController::auto_advance_to_next_episode`).
96///
97/// Clamped to `[0, duration]` so a skip near either end lands in the item rather
98/// than at a negative offset or past the end, which some backends treat as EOF
99/// and would turn a scrub into an unintended advance.
100///
101/// TRACES: UR-040, UR-006 | DR-201
102#[cfg_attr(not(target_os = "android"), allow(dead_code))]
103pub fn resolve_skip_action(
104    is_next: bool,
105    is_background_audio: bool,
106    position: f64,
107    duration: Option<f64>,
108) -> SkipAction {
109    if !is_background_audio {
110        return SkipAction::Advance;
111    }
112
113    let target = if is_next {
114        position + SKIP_FORWARD_SECONDS
115    } else {
116        position - SKIP_BACK_SECONDS
117    };
118
119    let clamped = match duration {
120        Some(d) if d > 0.0 => target.clamp(0.0, d),
121        _ => target.max(0.0),
122    };
123
124    SkipAction::SeekTo(clamped)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    /// Music (no background-audio handoff) keeps queue advance on both buttons.
132    ///
133    /// TRACES: UR-006 | DR-201 | UT-194
134    #[test]
135    fn test_skip_advances_queue_for_normal_audio() {
136        assert_eq!(
137            resolve_skip_action(true, false, 42.0, Some(300.0)),
138            SkipAction::Advance
139        );
140        assert_eq!(
141            resolve_skip_action(false, false, 42.0, Some(300.0)),
142            SkipAction::Advance
143        );
144    }
145
146    /// The reported bug: in background-audio mode the lockscreen skip buttons
147    /// advanced to the next/previous episode instead of scrubbing, so trying to
148    /// re-hear a line jumped out of the film entirely.
149    ///
150    /// TRACES: UR-040 | DR-201 | UT-195
151    #[test]
152    fn test_skip_scrubs_in_background_audio_mode() {
153        assert_eq!(
154            resolve_skip_action(true, true, 100.0, Some(3600.0)),
155            SkipAction::SeekTo(130.0)
156        );
157        assert_eq!(
158            resolve_skip_action(false, true, 100.0, Some(3600.0)),
159            SkipAction::SeekTo(90.0)
160        );
161    }
162
163    /// Skipping back near the start clamps to zero rather than going negative,
164    /// which backends reject (the "Raw(-10)" class of error).
165    ///
166    /// TRACES: UR-040 | DR-201 | UT-196
167    #[test]
168    fn test_skip_back_clamps_at_start() {
169        assert_eq!(
170            resolve_skip_action(false, true, 4.0, Some(3600.0)),
171            SkipAction::SeekTo(0.0)
172        );
173    }
174
175    /// Skipping forward near the end clamps to the duration instead of running
176    /// past it, which would read as end-of-stream and advance — the very thing
177    /// this function exists to prevent.
178    ///
179    /// TRACES: UR-040 | DR-201 | UT-197
180    #[test]
181    fn test_skip_forward_clamps_at_end() {
182        assert_eq!(
183            resolve_skip_action(true, true, 3590.0, Some(3600.0)),
184            SkipAction::SeekTo(3600.0)
185        );
186    }
187
188    /// An unknown duration still scrubs, and still refuses to go negative.
189    ///
190    /// TRACES: UR-040 | DR-201 | UT-198
191    #[test]
192    fn test_skip_without_duration_still_scrubs() {
193        assert_eq!(
194            resolve_skip_action(true, true, 10.0, None),
195            SkipAction::SeekTo(40.0)
196        );
197        assert_eq!(
198            resolve_skip_action(false, true, 3.0, None),
199            SkipAction::SeekTo(0.0)
200        );
201    }
202
203    /// Test video seek strategy for local files
204    #[test]
205    fn test_seek_strategy_local_file() {
206        // Local files always use native backend seek regardless of other flags
207        for (in_place, transcode) in [(false, false), (true, true), (false, true)] {
208            assert_eq!(
209                determine_video_seek_strategy(true, in_place, transcode),
210                VideoSeekStrategy::LocalNativeSeek
211            );
212        }
213    }
214
215    /// Direct play and direct stream seek in place, whatever the engine's
216    /// transcode ability — that only applies to transcodes.
217    #[test]
218    fn test_seek_strategy_direct_stream() {
219        for in_place in [false, true] {
220            assert_eq!(
221                determine_video_seek_strategy(false, in_place, false),
222                VideoSeekStrategy::BackendNativeSeek
223            );
224        }
225    }
226
227    /// A server-side transcode is re-opened by an engine that cannot move it,
228    /// and seeked in place by one that says it can.
229    ///
230    /// Jellyfin produces a transcode from `StartTimeTicks`; mpv's HLS demuxer
231    /// cannot make the server transcode from a new offset, so the stream has to
232    /// be re-negotiated. Inferring this from the container once routed every
233    /// transcoded seek into a native seek that silently does nothing, which
234    /// presents as "resume does not work".
235    ///
236    /// TRACES: UR-040 | DR-238, DR-246 | UT-217
237    #[test]
238    fn test_transcoded_seek_follows_the_engines_declared_ability() {
239        assert_eq!(
240            determine_video_seek_strategy(false, false, true),
241            VideoSeekStrategy::BackendReloadStream
242        );
243        assert_eq!(
244            determine_video_seek_strategy(false, true, true),
245            VideoSeekStrategy::BackendNativeSeek
246        );
247    }
248}