Skip to main content

jellytau_lib/player/
mpv_tracks.rs

1//! Subtitle and audio-track selection on mpv, by *position*, the way the
2//! frontend asks for it.
3//!
4//! Until DR-235 mpv never drew video, so it never had to: subtitles were
5//! `<track>` children of the webview `<video>` and an audio track change
6//! re-opened the stream. With mpv the only desktop video renderer, it has to
7//! answer the same two calls ExoPlayer does, with the same meaning:
8//!
9//! - **Subtitles** are the sideloaded WebVTT list the play request carries
10//!   (`MediaItem::subtitles`), and `set_subtitle_track(n)` selects the *n-th of
11//!   those* — the position the frontend computes with `nativeSubtitleArrayIndex`.
12//!   They reach mpv as external files queued on `sub-files` before the load, and
13//!   selection starts off, because the menu opens on "Off".
14//! - **Audio** `set_audio_track(n)` selects the n-th audio track of the file —
15//!   the position in the item's audio streams, which is file order. Only a direct
16//!   play/stream carries every track; a transcode is re-opened instead
17//!   (`AudioTrackSwitchStrategy`).
18//!
19//! mpv's own track ids are not positions: they count every track of a type,
20//! embedded before external, from 1. So a position is always resolved against
21//! the live `track-list`.
22//!
23//! TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
24
25use libmpv::Mpv;
26
27use super::mpv_command;
28
29/// One entry of mpv's `track-list`, reduced to what selection needs.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TrackInfo {
32    pub id: i64,
33    /// "video", "audio" or "sub".
34    pub kind: String,
35    pub external: bool,
36}
37
38/// The mpv id of the `position`-th track of `kind` (optionally only external or
39/// only embedded ones), in `track-list` order.
40///
41/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
42pub fn nth_track_id(
43    tracks: &[TrackInfo],
44    kind: &str,
45    external: Option<bool>,
46    position: usize,
47) -> Option<i64> {
48    tracks
49        .iter()
50        .filter(|t| t.kind == kind && external.is_none_or(|e| t.external == e))
51        .nth(position)
52        .map(|t| t.id)
53}
54
55/// Read the current `track-list`.
56pub fn track_list(mpv: &Mpv) -> Vec<TrackInfo> {
57    let count: i64 = mpv.get_property("track-list/count").unwrap_or(0);
58    (0..count)
59        .filter_map(|i| {
60            let id: i64 = mpv.get_property(&format!("track-list/{i}/id")).ok()?;
61            let kind: String = mpv.get_property(&format!("track-list/{i}/type")).ok()?;
62            let external: bool = mpv
63                .get_property(&format!("track-list/{i}/external"))
64                .unwrap_or(false);
65            Some(TrackInfo { id, kind, external })
66        })
67        .collect()
68}
69
70/// Prepare the next `loadfile`: these subtitle files will be loaded with it, no
71/// subtitle is shown, and the file's default audio track plays.
72///
73/// `sid`/`aid` set while idle become the options the next file opens with, so
74/// a track chosen for the previous item cannot leak into this one.
75///
76/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
77pub fn prepare_load(mpv: &Mpv, subtitle_urls: &[&str]) -> Result<(), String> {
78    mpv_command::command(mpv, &["change-list", "sub-files", "clr", ""])?;
79    for url in subtitle_urls {
80        // `append` adds one item without splitting on the list separator, which
81        // a URL's `:` would otherwise trip. Through the argv form (DR-298), so the
82        // URL is never parsed as command text.
83        mpv_command::command(mpv, &["change-list", "sub-files", "append", url])?;
84    }
85    mpv.set_property("sid", "no")
86        .map_err(|e| format!("could not set sid=no: {e:?}"))?;
87    mpv.set_property("aid", "auto")
88        .map_err(|e| format!("could not set aid=auto: {e:?}"))?;
89    Ok(())
90}
91
92/// Show the `position`-th sideloaded subtitle, or none.
93///
94/// TRACES: UR-020 | DR-023 | UT-275
95pub fn select_subtitle(mpv: &Mpv, position: Option<usize>) -> Result<(), String> {
96    let Some(position) = position else {
97        return mpv
98            .set_property("sid", "no")
99            .map_err(|e| format!("could not set sid=no: {e:?}"));
100    };
101    let id = nth_track_id(&track_list(mpv), "sub", Some(true), position)
102        .ok_or_else(|| format!("no sideloaded subtitle at position {position}"))?;
103    mpv.set_property("sid", id)
104        .map_err(|e| format!("could not set sid={id}: {e:?}"))
105}
106
107/// Play the `position`-th audio track of the file.
108///
109/// TRACES: UR-021 | DR-024 | UT-275
110pub fn select_audio(mpv: &Mpv, position: usize) -> Result<(), String> {
111    let id = nth_track_id(&track_list(mpv), "audio", Some(false), position)
112        .ok_or_else(|| format!("no audio track at position {position}"))?;
113    mpv.set_property("aid", id)
114        .map_err(|e| format!("could not set aid={id}: {e:?}"))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::time::{Duration, Instant};
121
122    fn fixture(name: &str) -> String {
123        format!("{}/src/player/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
124    }
125
126    fn track(id: i64, kind: &str, external: bool) -> TrackInfo {
127        TrackInfo {
128            id,
129            kind: kind.to_string(),
130            external,
131        }
132    }
133
134    /// mpv numbers each type from 1, embedded before external, so a position in
135    /// the sideloaded list is not an id. The file here has two embedded
136    /// subtitles; the first sideloaded one is mpv's sub 3.
137    ///
138    /// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
139    #[test]
140    fn positions_resolve_to_mpv_ids_per_kind() {
141        let tracks = [
142            track(1, "video", false),
143            track(1, "audio", false),
144            track(2, "audio", false),
145            track(1, "sub", false),
146            track(2, "sub", false),
147            track(3, "sub", true),
148            track(4, "sub", true),
149        ];
150        assert_eq!(nth_track_id(&tracks, "sub", Some(true), 0), Some(3));
151        assert_eq!(nth_track_id(&tracks, "sub", Some(true), 1), Some(4));
152        assert_eq!(nth_track_id(&tracks, "sub", Some(true), 2), None);
153        assert_eq!(nth_track_id(&tracks, "audio", Some(false), 1), Some(2));
154        assert_eq!(nth_track_id(&tracks, "audio", None, 0), Some(1));
155    }
156
157    fn loaded_mpv(subs: &[&str]) -> Mpv {
158        let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
159        mpv.set_property("ao", "null").unwrap();
160        mpv.set_property("vo", "null").unwrap();
161        mpv.set_property("pause", true).unwrap();
162        prepare_load(&mpv, subs).unwrap();
163        mpv_command::command(&mpv, &["loadfile", &fixture("two-audio-tracks.mkv")]).unwrap();
164
165        // Video, two audio tracks, and one track per sideloaded subtitle.
166        let expected = 3 + subs.len() as i64;
167        let deadline = Instant::now() + Duration::from_secs(10);
168        while mpv.get_property::<i64>("track-list/count").unwrap_or(0) < expected {
169            assert!(
170                Instant::now() < deadline,
171                "the fixture never finished loading"
172            );
173            std::thread::sleep(Duration::from_millis(20));
174        }
175        mpv
176    }
177
178    /// Against a real file: the sideloaded subtitle arrives, starts hidden, and
179    /// is shown and hidden by position.
180    ///
181    /// TRACES: UR-020 | DR-023 | UT-275
182    #[test]
183    fn a_sideloaded_subtitle_starts_off_and_is_selected_by_position() {
184        let vtt = fixture("sub.vtt");
185        let mpv = loaded_mpv(&[&vtt]);
186
187        assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
188
189        select_subtitle(&mpv, Some(0)).unwrap();
190        let expected = nth_track_id(&track_list(&mpv), "sub", Some(true), 0).unwrap();
191        assert_eq!(mpv.get_property::<i64>("sid").unwrap(), expected);
192
193        select_subtitle(&mpv, None).unwrap();
194        assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
195
196        assert!(select_subtitle(&mpv, Some(5)).is_err());
197    }
198
199    /// Against a real file with two audio tracks: position 1 is the second one.
200    ///
201    /// TRACES: UR-021 | DR-024 | UT-275
202    #[test]
203    fn an_audio_track_is_selected_by_position_in_the_file() {
204        let mpv = loaded_mpv(&[]);
205
206        select_audio(&mpv, 1).unwrap();
207        assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 2);
208        select_audio(&mpv, 0).unwrap();
209        assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 1);
210        assert!(select_audio(&mpv, 2).is_err());
211    }
212
213    /// The next item opens with no subtitle and its own default audio, whatever
214    /// the last one had chosen, and with only its own subtitle files.
215    ///
216    /// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
217    #[test]
218    fn preparing_a_load_forgets_the_previous_items_choices() {
219        let vtt = fixture("sub.vtt");
220        let mpv = loaded_mpv(&[&vtt]);
221        select_subtitle(&mpv, Some(0)).unwrap();
222        select_audio(&mpv, 1).unwrap();
223
224        // The next item: no subtitles of its own this time.
225        prepare_load(&mpv, &[]).unwrap();
226        mpv_command::command(
227            &mpv,
228            &["loadfile", &fixture("two-audio-tracks.mkv"), "replace"],
229        )
230        .unwrap();
231        let deadline = Instant::now() + Duration::from_secs(10);
232        loop {
233            let tracks = track_list(&mpv);
234            let settled = tracks.len() == 3 && mpv.get_property::<i64>("aid").is_ok();
235            if settled {
236                break;
237            }
238            assert!(
239                Instant::now() < deadline,
240                "the second load never settled: {tracks:?}"
241            );
242            std::thread::sleep(Duration::from_millis(20));
243        }
244
245        assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
246        assert_eq!(
247            mpv.get_property::<i64>("aid").unwrap(),
248            1,
249            "default audio again"
250        );
251        assert_eq!(
252            nth_track_id(&track_list(&mpv), "sub", None, 0),
253            None,
254            "the previous item's subtitle file came along"
255        );
256    }
257}