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}