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/// * `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}