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}