Skip to main content

#
jellytau_lib/player/
track_switch.rs

    1//! Audio-track switch strategy decision logic.
    2//!
    3//! Pure logic, extracted from the command layer so it can be unit-tested in the
    4//! player core — the sibling of [`super::seek`]. `player_switch_audio_track`
    5//! turns the resulting [`AudioTrackSwitchStrategy`] into a concrete action.
    6//!
    7//! The rule this module exists to state: **an engine can only select a track
    8//! the stream in front of it actually carries.** A Jellyfin transcode is built
    9//! around one `AudioStreamIndex`, so the alternate tracks are not in the stream
    10//! at all — the switch has to re-open it. Only a direct play/stream hands the
    11//! engine the source file with every track present.
    12
    13/// How a request to change audio track has to be carried out.
    14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    15pub enum AudioTrackSwitchStrategy {
    16    /// Re-open the stream pinned to the chosen track; the frontend reloads its
    17    /// `<video>` element. An HTML5 element cannot select an audio track at all,
    18    /// so this holds whether or not the current stream is a transcode.
    19    Html5ReloadStream,
    20    /// Re-open the stream pinned to the chosen track; the backend reloads
    21    /// itself and restores the position.
    22    BackendReloadStream,
    23    /// The engine already holds every track — select in place, no reload.
    24    BackendSelectInPlace,
    25}
    26
    27/// Decide how to honour an audio-track change.
    28///
    29/// # Arguments
    30/// * `needs_transcoding` - Whether the stream now playing is a server-side
    31///   transcode, which carries exactly the one audio track it was built around.
    32/// * `use_html5` - Whether the frontend `<video>` element is rendering.
    33///
    34/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
    35pub fn determine_audio_track_switch_strategy(
    36    needs_transcoding: bool,
    37    use_html5: bool,
    38) -> AudioTrackSwitchStrategy {
    39    if use_html5 {
    40        return AudioTrackSwitchStrategy::Html5ReloadStream;
    41    }
    42
    43    if needs_transcoding {
    44        AudioTrackSwitchStrategy::BackendReloadStream
    45    } else {
    46        AudioTrackSwitchStrategy::BackendSelectInPlace
    47    }
    48}
    49
    50/// Where to resume after re-opening the stream for a track change.
    51///
    52/// `requested` is what the caller supplied; `engine_position` is where the
    53/// engine itself says it is. The caller wins when it has something real to say,
    54/// and the engine answers otherwise — which is the whole point: **position is
    55/// the player's to know**, not the UI's to remember.
    56///
    57/// The native path proved why. It has no `<video>` element, so the frontend
    58/// sent `null`, the command defaulted to `0.0`, and switching audio track
    59/// re-opened the stream at the beginning of the film — the track changed and
    60/// the viewer lost their place. A non-finite or negative value is treated the
    61/// same as absent rather than passed through to a backend that would reject it.
    62///
    63/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258 | UT-233
    64pub fn resume_position(requested: Option<f64>, engine_position: f64) -> f64 {
    65    let usable = requested.filter(|p| p.is_finite() && *p > 0.0);
    66    let fallback = if engine_position.is_finite() && engine_position > 0.0 {
    67        engine_position
    68    } else {
    69        0.0
    70    };
    71
    72    usable.unwrap_or(fallback)
    73}
    74
    75#[cfg(test)]
    76mod tests {
    77    use super::*;
    78
    79    /// The reported bug, seen on a device: switching audio track changed the
    80    /// track but "restarts from zero". The native path has no `<video>`
    81    /// element, so the frontend passed `null` and the re-opened stream began at
    82    /// the start of the film — logcat: `Re-opened the stream on audio stream 2
    83    /// and resumed at 0` while playback was 22 minutes in.
    84    #[test]
    85    fn a_caller_with_no_position_resumes_where_the_engine_is() {
    86        assert_eq!(resume_position(None, 1337.5), 1337.5);
    87    }
    88
    89    /// The HTML5 path does have an element and its clock is the honest answer
    90    /// there, so what the caller supplies wins.
    91    #[test]
    92    fn a_caller_that_knows_its_position_is_believed() {
    93        assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
    94    }
    95
    96    /// A position that is not a position — NaN from an element with no
    97    /// metadata, or a negative from a clock read mid-teardown — is treated as
    98    /// absent. Passing it through re-opens at a place no backend accepts.
    99    #[test]
    100    fn a_nonsense_position_falls_back_to_the_engine() {
    101        assert_eq!(resume_position(Some(f64::NAN), 90.0), 90.0);
    102        assert_eq!(resume_position(Some(-5.0), 90.0), 90.0);
    103        assert_eq!(resume_position(None, f64::NAN), 0.0);
    104    }
    105
    106    /// Switching track in the first moments of playback resumes at the start,
    107    /// which is where the viewer actually is.
    108    #[test]
    109    fn the_very_beginning_stays_the_very_beginning() {
    110        assert_eq!(resume_position(None, 0.0), 0.0);
    111    }
    112
    113    /// The reported bug: on Android the audio-track menu did nothing and the
    114    /// default track kept playing.
    115    ///
    116    /// Jellyfin had negotiated a transcode (`TranscodeReasons=AudioCodecNot
    117    /// Supported`) whose URL pins `AudioStreamIndex=1`, so ExoPlayer was handed
    118    /// a stream with exactly one audio track — logcat: `Audio tracks: 1`. The
    119    /// native path nonetheless only ever called `setAudioTrack(n)`, which
    120    /// indexes ExoPlayer's audio track *groups* and so found nothing to select:
    121    /// `Invalid audio track index: 1 (available: 1)`, warned and dropped. The
    122    /// track the viewer asked for is not in the stream; it has to be re-opened.
    123    #[test]
    124    fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
    125        assert_eq!(
    126            determine_audio_track_switch_strategy(true, false),
    127            AudioTrackSwitchStrategy::BackendReloadStream
    128        );
    129    }
    130
    131    /// A direct play hands the engine the source file, every track included, so
    132    /// ExoPlayer selects in place — no reload, no re-buffer, no lost position.
    133    #[test]
    134    fn a_direct_play_switches_in_place() {
    135        assert_eq!(
    136            determine_audio_track_switch_strategy(false, false),
    137            AudioTrackSwitchStrategy::BackendSelectInPlace
    138        );
    139    }
    140
    141    /// An HTML5 `<video>` element has no track-selection API, so it reloads
    142    /// either way. This is the path that already worked, and it must keep
    143    /// working: the fix is about the native side only.
    144    #[test]
    145    fn html5_always_reloads_because_the_element_cannot_select() {
    146        assert_eq!(
    147            determine_audio_track_switch_strategy(true, true),
    148            AudioTrackSwitchStrategy::Html5ReloadStream
    149        );
    150        assert_eq!(
    151            determine_audio_track_switch_strategy(false, true),
    152            AudioTrackSwitchStrategy::Html5ReloadStream
    153        );
    154    }
    155}