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}