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}