1use libmpv::Mpv;
26
27use super::mpv_command;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct TrackInfo {
32 pub id: i64,
33 pub kind: String,
35 pub external: bool,
36}
37
38pub 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
55pub 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
70pub 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 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
92pub 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
107pub 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 #[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 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 #[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 #[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 #[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 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}