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/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
29/// * `needs_transcoding` - Whether the content needs transcoding
30/// * `use_html5` - Whether frontend is using HTML5 video element
31pub fn determine_video_seek_strategy(
32    is_local: bool,
33    is_hls: bool,
34    needs_transcoding: bool,
35    use_html5: bool,
36) -> VideoSeekStrategy {
37    // Local files always support native seeking via backend
38    if is_local {
39        return VideoSeekStrategy::LocalNativeSeek;
40    }
41
42    // HLS streams and direct play (non-transcoded) support native seeking
43    if is_hls || !needs_transcoding {
44        if use_html5 {
45            // HTML5 backend - frontend handles seeking via videoElement.currentTime
46            // We don't call backend.seek() because video is in HTML5 element, not in MPV
47            VideoSeekStrategy::Html5NativeSeek
48        } else {
49            // Native backend (MPV) - backend handles seeking
50            VideoSeekStrategy::BackendNativeSeek
51        }
52    } else {
53        // Transcoded non-HLS streams need server-side seek (reload from new position)
54        if use_html5 {
55            VideoSeekStrategy::Html5ReloadStream
56        } else {
57            VideoSeekStrategy::BackendReloadStream
58        }
59    }
60}
61
62// The four items below are consumed by the Android MediaSessionHandler; on other
63// targets only the tests exercise them, so dead-code analysis would flag them.
64
65/// How far a lockscreen skip-forward jumps while background audio owns playback.
66#[cfg_attr(not(target_os = "android"), allow(dead_code))]
67pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
68
69/// How far a lockscreen skip-back jumps while background audio owns playback.
70///
71/// Deliberately shorter than the forward jump: the back button is used to replay
72/// dialogue just missed, not to travel.
73#[cfg_attr(not(target_os = "android"), allow(dead_code))]
74pub const SKIP_BACK_SECONDS: f64 = 10.0;
75
76/// What a lockscreen skip button means for the playback that is actually running.
77#[cfg_attr(not(target_os = "android"), allow(dead_code))]
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum SkipAction {
80    /// Move to the next/previous queue entry — a track, or an episode.
81    Advance,
82    /// Scrub within the current item, to this absolute position in seconds.
83    SeekTo(f64),
84}
85
86/// Decide whether a lockscreen skip advances the queue or scrubs the current item.
87///
88/// Music gets queue advance, which is what the buttons look like they do. A video
89/// whose audio is playing through a background-audio handoff (UR-040) gets a
90/// relative scrub instead: there is no meaningful "next track" inside a film, and
91/// jumping to the next *episode* because the user wanted to re-hear a line is a
92/// much worse outcome than a scrub.
93///
94/// `is_background_audio` is the whole test, and it is sufficient on its own —
95/// the handoff exists only for video, and an episode played through it reports
96/// `MediaType::Audio`, so media type cannot distinguish this case (see the note
97/// at `PlayerController::auto_advance_to_next_episode`).
98///
99/// Clamped to `[0, duration]` so a skip near either end lands in the item rather
100/// than at a negative offset or past the end, which some backends treat as EOF
101/// and would turn a scrub into an unintended advance.
102///
103/// TRACES: UR-040, UR-006 | DR-201
104#[cfg_attr(not(target_os = "android"), allow(dead_code))]
105pub fn resolve_skip_action(
106    is_next: bool,
107    is_background_audio: bool,
108    position: f64,
109    duration: Option<f64>,
110) -> SkipAction {
111    if !is_background_audio {
112        return SkipAction::Advance;
113    }
114
115    let target = if is_next {
116        position + SKIP_FORWARD_SECONDS
117    } else {
118        position - SKIP_BACK_SECONDS
119    };
120
121    let clamped = match duration {
122        Some(d) if d > 0.0 => target.clamp(0.0, d),
123        _ => target.max(0.0),
124    };
125
126    SkipAction::SeekTo(clamped)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// Music (no background-audio handoff) keeps queue advance on both buttons.
134    ///
135    /// TRACES: UR-006 | DR-201 | UT-194
136    #[test]
137    fn test_skip_advances_queue_for_normal_audio() {
138        assert_eq!(
139            resolve_skip_action(true, false, 42.0, Some(300.0)),
140            SkipAction::Advance
141        );
142        assert_eq!(
143            resolve_skip_action(false, false, 42.0, Some(300.0)),
144            SkipAction::Advance
145        );
146    }
147
148    /// The reported bug: in background-audio mode the lockscreen skip buttons
149    /// advanced to the next/previous episode instead of scrubbing, so trying to
150    /// re-hear a line jumped out of the film entirely.
151    ///
152    /// TRACES: UR-040 | DR-201 | UT-195
153    #[test]
154    fn test_skip_scrubs_in_background_audio_mode() {
155        assert_eq!(
156            resolve_skip_action(true, true, 100.0, Some(3600.0)),
157            SkipAction::SeekTo(130.0)
158        );
159        assert_eq!(
160            resolve_skip_action(false, true, 100.0, Some(3600.0)),
161            SkipAction::SeekTo(90.0)
162        );
163    }
164
165    /// Skipping back near the start clamps to zero rather than going negative,
166    /// which backends reject (the "Raw(-10)" class of error).
167    ///
168    /// TRACES: UR-040 | DR-201 | UT-196
169    #[test]
170    fn test_skip_back_clamps_at_start() {
171        assert_eq!(
172            resolve_skip_action(false, true, 4.0, Some(3600.0)),
173            SkipAction::SeekTo(0.0)
174        );
175    }
176
177    /// Skipping forward near the end clamps to the duration instead of running
178    /// past it, which would read as end-of-stream and advance — the very thing
179    /// this function exists to prevent.
180    ///
181    /// TRACES: UR-040 | DR-201 | UT-197
182    #[test]
183    fn test_skip_forward_clamps_at_end() {
184        assert_eq!(
185            resolve_skip_action(true, true, 3590.0, Some(3600.0)),
186            SkipAction::SeekTo(3600.0)
187        );
188    }
189
190    /// An unknown duration still scrubs, and still refuses to go negative.
191    ///
192    /// TRACES: UR-040 | DR-201 | UT-198
193    #[test]
194    fn test_skip_without_duration_still_scrubs() {
195        assert_eq!(
196            resolve_skip_action(true, true, 10.0, None),
197            SkipAction::SeekTo(40.0)
198        );
199        assert_eq!(
200            resolve_skip_action(false, true, 3.0, None),
201            SkipAction::SeekTo(0.0)
202        );
203    }
204
205    /// Test video seek strategy for local files
206    #[test]
207    fn test_seek_strategy_local_file() {
208        // Local files always use native backend seek regardless of other flags
209        assert_eq!(
210            determine_video_seek_strategy(true, false, false, false),
211            VideoSeekStrategy::LocalNativeSeek
212        );
213        assert_eq!(
214            determine_video_seek_strategy(true, false, false, true),
215            VideoSeekStrategy::LocalNativeSeek
216        );
217        assert_eq!(
218            determine_video_seek_strategy(true, true, true, true),
219            VideoSeekStrategy::LocalNativeSeek
220        );
221    }
222
223    /// Test video seek strategy for HLS streams
224    #[test]
225    fn test_seek_strategy_hls_stream() {
226        // HLS with HTML5 - frontend handles seek, don't call backend
227        assert_eq!(
228            determine_video_seek_strategy(false, true, false, true),
229            VideoSeekStrategy::Html5NativeSeek
230        );
231        // HLS with native backend - backend handles seek
232        assert_eq!(
233            determine_video_seek_strategy(false, true, false, false),
234            VideoSeekStrategy::BackendNativeSeek
235        );
236        // HLS even with needs_transcoding flag - still native seek (HLS supports it)
237        assert_eq!(
238            determine_video_seek_strategy(false, true, true, true),
239            VideoSeekStrategy::Html5NativeSeek
240        );
241    }
242
243    /// Test video seek strategy for direct play (non-transcoded) streams
244    #[test]
245    fn test_seek_strategy_direct_play() {
246        // Direct play with HTML5 - frontend handles seek
247        assert_eq!(
248            determine_video_seek_strategy(false, false, false, true),
249            VideoSeekStrategy::Html5NativeSeek
250        );
251        // Direct play with native backend - backend handles seek
252        assert_eq!(
253            determine_video_seek_strategy(false, false, false, false),
254            VideoSeekStrategy::BackendNativeSeek
255        );
256    }
257
258    /// Test video seek strategy for transcoded non-HLS streams
259    #[test]
260    fn test_seek_strategy_transcoded_non_hls() {
261        // Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
262        assert_eq!(
263            determine_video_seek_strategy(false, false, true, true),
264            VideoSeekStrategy::Html5ReloadStream
265        );
266        // Transcoded non-HLS with native backend - need to reload stream, backend handles
267        assert_eq!(
268            determine_video_seek_strategy(false, false, true, false),
269            VideoSeekStrategy::BackendReloadStream
270        );
271    }
272
273    /// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
274    /// This was the bug causing "Raw(-10)" errors
275    #[test]
276    fn test_hls_html5_does_not_use_backend_seek() {
277        let strategy = determine_video_seek_strategy(false, true, false, true);
278        // Should be Html5NativeSeek, NOT BackendNativeSeek
279        assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
280        assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
281    }
282}