1use std::{
2 fmt,
3 io::{self, IsTerminal, Write},
4 path::{Path, PathBuf},
5 process::Stdio,
6};
7
8use tokio::process::Command;
9use tracing::{debug, info, warn};
10
11use crate::{
12 AniError, Result, StreamLink, relay_stream, relay_stream_without_hls_subtitles,
13 requires_hls_relay,
14};
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum PlayerKind {
18 Mpv,
19 Iina,
20 Vlc,
21 AndroidMpv,
22 AndroidVlc,
23 Syncplay,
24 Custom,
25}
26
27impl fmt::Display for PlayerKind {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 let label = match self {
30 Self::Mpv => "mpv",
31 Self::Iina => "iina",
32 Self::Vlc => "vlc",
33 Self::AndroidMpv => "android-mpv",
34 Self::AndroidVlc => "android-vlc",
35 Self::Syncplay => "syncplay",
36 Self::Custom => "custom",
37 };
38 f.write_str(label)
39 }
40}
41
42#[derive(Clone, Debug)]
43pub struct PlayerOptions {
44 pub executable: PathBuf,
45 pub kind: PlayerKind,
46 pub no_detach: bool,
47 pub exit_after_play: bool,
48 pub force_hls_relay: bool,
49}
50
51impl PlayerOptions {
52 pub fn default_player() -> Self {
53 if cfg!(target_os = "android") {
54 Self::default_android_mpv()
55 } else if cfg!(target_os = "macos") {
56 Self::default_iina()
57 } else {
58 Self::default_mpv()
59 }
60 }
61
62 pub fn default_mpv() -> Self {
63 let executable = std::env::var_os("ANI_CLI_PLAYER")
64 .map(PathBuf::from)
65 .unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "mpv.exe" } else { "mpv" }));
66 Self {
67 executable,
68 kind: PlayerKind::Mpv,
69 no_detach: env_bool("ANI_CLI_NO_DETACH"),
70 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
71 force_hls_relay: false,
72 }
73 }
74
75 pub fn default_iina() -> Self {
76 let executable = std::env::var_os("ANI_CLI_PLAYER")
77 .map(PathBuf::from)
78 .unwrap_or_else(|| PathBuf::from("iina"));
79 Self {
80 executable,
81 kind: PlayerKind::Iina,
82 no_detach: env_bool("ANI_CLI_NO_DETACH"),
83 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
84 force_hls_relay: false,
85 }
86 }
87
88 pub fn default_android_mpv() -> Self {
89 Self {
90 executable: android_intent_launcher(),
91 kind: PlayerKind::AndroidMpv,
92 no_detach: true,
93 exit_after_play: env_bool("ANI_CLI_EXIT_AFTER_PLAY"),
94 force_hls_relay: false,
95 }
96 }
97}
98
99#[derive(Clone, Debug)]
100pub struct Player {
101 options: PlayerOptions,
102}
103
104impl Player {
105 pub fn new(options: PlayerOptions) -> Self {
106 Self { options }
107 }
108
109 pub fn command_args(&self, stream: &StreamLink, title: &str) -> Vec<String> {
110 self.command_args_inner(stream, title, self.options.no_detach)
111 }
112
113 pub fn describe(&self) -> String {
116 format!(
117 "{} ({}) [no_detach={}, exit_after_play={}, force_hls_relay={}]",
118 self.options.executable.display(),
119 self.options.kind,
120 self.options.no_detach,
121 self.options.exit_after_play,
122 self.options.force_hls_relay,
123 )
124 }
125
126 fn command_args_inner(&self, stream: &StreamLink, title: &str, attached: bool) -> Vec<String> {
127 let referer = stream.headers.referer.as_deref().unwrap_or("");
128 match self.options.kind {
129 PlayerKind::Mpv => {
130 let mut args = mpv_options(stream, title, referer);
131 args.push(stream.url.clone());
133 args
134 }
135 PlayerKind::Iina => {
136 let mut args = vec!["--no-stdin".into()];
137 if attached {
138 args.push("--keep-running".into());
139 }
140 args.push(stream.url.clone());
141 args.push("--".into());
142 args.extend(mpv_options(stream, title, referer));
143 args
144 }
145 PlayerKind::Vlc => {
146 let mut args = vec!["--play-and-exit".into(), format!("--meta-title={title}")];
147 if !referer.is_empty() {
148 args.push(format!("--http-referrer={referer}"));
149 }
150 if let Some(agent) = stream.headers.extra.get("User-Agent") {
151 args.push(format!("--http-user-agent={agent}"));
152 }
153 for track in &stream.subtitles {
154 args.push(format!("--sub-file={}", track.url));
155 }
156 args.push(stream.url.clone());
157 args
158 }
159 PlayerKind::AndroidMpv => {
160 android_intent_args("is.xyz.mpv/.MPVActivity", &stream.url, title)
161 }
162 PlayerKind::AndroidVlc => android_intent_args(
163 "org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
164 &stream.url,
165 title,
166 ),
167 PlayerKind::Syncplay => {
168 let mut args = vec![
169 stream.url.clone(),
170 "--".into(),
171 "--tls-verify=no".into(),
172 format!("--force-media-title={title}"),
173 ];
174 if !referer.is_empty() {
175 args.push(format!("--referrer={referer}"));
176 }
177 append_mpv_headers(&mut args, stream);
178 for track in &stream.subtitles {
179 args.push(format!("--sub-file={}", track.url));
180 }
181 args
182 }
183 PlayerKind::Custom => vec![stream.url.clone()],
184 }
185 }
186
187 pub async fn play(&self, stream: &StreamLink, title: &str) -> Result<Option<i32>> {
188 info!(
189 title = %title,
190 player = %self.describe(),
191 stream_url = %stream.url,
192 hls = stream.hls,
193 subtitles = stream.subtitles.len(),
194 "playback requested",
195 );
196 if self.options.force_hls_relay
197 || crate::requires_hls_relay(stream)
198 || (self.is_android_player() && stream.hls)
199 {
200 debug!(
201 title = %title,
202 player = %self.options.kind.to_string(),
203 android_player = self.is_android_player(),
204 hls = stream.hls,
205 "stream requires the loopback HLS relay",
206 );
207 let (_relay, local) = if self.is_android_player() {
214 relay_stream(stream).await?
215 } else {
216 relay_stream_without_hls_subtitles(stream).await?
217 };
218 debug!(
219 title = %title,
220 local_url = %local.url,
221 "HLS relay is serving the rewritten stream URL to the player",
222 );
223 return self.play_inner(&local, title, true).await;
224 }
225 self.play_inner(stream, title, false).await
226 }
227
228 async fn play_inner(
229 &self,
230 stream: &StreamLink,
231 title: &str,
232 force_attached: bool,
233 ) -> Result<Option<i32>> {
234 if self.is_android_player() {
235 return self.play_android(stream, title, force_attached).await;
236 }
237
238 let needs_validation = self.options.executable.components().count() > 1;
241 if needs_validation {
242 if !self.options.executable.exists() {
243 eprintln!(
244 "Player executable not found: {}. Please install the player or set ANI_CLI_PLAYER environment variable.",
245 self.options.executable.display()
246 );
247 return Err(AniError::PlayerNotFound {
248 executable: self.options.executable.display().to_string(),
249 });
250 }
251 } else {
252 use which::which;
254 if which(&self.options.executable).is_err() {
255 eprintln!(
256 "Player executable '{}' not found in PATH. Please install the player or set ANI_CLI_PLAYER environment variable.",
257 self.options.executable.display()
258 );
259 return Err(AniError::PlayerNotFound {
260 executable: self.options.executable.display().to_string(),
261 });
262 }
263 }
264
265 let mut command = Command::new(&self.options.executable);
266 let attached = self.options.no_detach || force_attached;
267 let args = self.command_args_inner(stream, title, attached);
268 info!(
269 title = %title,
270 executable = %self.options.executable.display(),
271 kind = %self.options.kind,
272 attached,
273 "launching external player",
274 );
275 debug!(
280 title = %title,
281 stream_url = %stream.url,
282 stream_hls = stream.hls,
283 args = ?args,
284 "full player command line",
285 );
286 command.args(args);
287 if attached {
288 match command.status().await {
289 Ok(status) => {
290 let code = status.code().unwrap_or(1);
291 info!(
292 title = %title,
293 exit_code = code,
294 success = status.success(),
295 "player exited",
296 );
297 if !status.success() && self.options.exit_after_play {
298 eprintln!("Player exited with {code}");
299 return Err(AniError::PlayerExitFailed);
300 }
301 Ok(Some(code))
302 }
303 Err(error) => {
304 warn!(
305 title = %title,
306 executable = %self.options.executable.display(),
307 error = %error,
308 "failed to launch player in attached mode",
309 );
310 eprintln!(
311 "Could not launch {}: {error}",
312 self.options.executable.display()
313 );
314 Err(AniError::PlayerLaunchFailed {
315 executable: self.options.executable.display().to_string(),
316 reason: format!("attached mode launch failed: {}", error),
317 })
318 }
319 }
320 } else {
321 command
323 .stdin(Stdio::null())
324 .stdout(Stdio::null())
325 .stderr(Stdio::null());
326 match command.spawn() {
327 Ok(child) => {
328 info!(
329 title = %title,
330 pid = child.id().unwrap_or(0),
331 executable = %self.options.executable.display(),
332 "player launched in the background",
333 );
334 let _ = child.id(); Ok(None)
337 }
338 Err(error) => {
339 warn!(
340 title = %title,
341 executable = %self.options.executable.display(),
342 error = %error,
343 "failed to launch player in detached mode",
344 );
345 eprintln!(
346 "Could not launch {}: {error}",
347 self.options.executable.display()
348 );
349 Err(AniError::PlayerLaunchFailed {
350 executable: self.options.executable.display().to_string(),
351 reason: format!("detached mode launch failed: {}", error),
352 })
353 }
354 }
355 }
356 }
357
358 fn is_android_player(&self) -> bool {
359 matches!(
360 self.options.kind,
361 PlayerKind::AndroidMpv | PlayerKind::AndroidVlc
362 )
363 }
364
365 async fn play_android(
366 &self,
367 stream: &StreamLink,
368 title: &str,
369 relay_active: bool,
370 ) -> Result<Option<i32>> {
371 let terminal = io::stdin().is_terminal();
372 if relay_active && !terminal {
373 return Err(AniError::PlayerAndroidTerminalRequired);
374 }
375
376 let launch_args = self.command_args_inner(stream, title, true);
377 info!(
378 title = %title,
379 executable = %self.options.executable.display(),
380 "launching Android activity for playback",
381 );
382 debug!(
383 title = %title,
384 stream_url = %stream.url,
385 args = ?launch_args,
386 "full Android intent command line",
387 );
388 let launch_result = Command::new(&self.options.executable)
389 .args(launch_args)
390 .status()
391 .await;
392 let code = match launch_result {
393 Ok(status) if status.success() => {
394 let code = status.code().unwrap_or(0);
395 info!(
396 title = %title,
397 exit_code = code,
398 "Android player activity returned successfully",
399 );
400 code
401 }
402 result => {
403 let primary_error = match &result {
404 Ok(status) => format!(
405 "Android activity launcher {} exited with {}",
406 self.options.executable.display(),
407 status.code().unwrap_or(1)
408 ),
409 Err(error) => format!(
410 "could not launch Android player through {}: {error}",
411 self.options.executable.display()
412 ),
413 };
414 warn!(
415 title = %title,
416 executable = %self.options.executable.display(),
417 error = %primary_error,
418 "primary Android launch failed; trying termux-open fallback",
419 );
420 match launch_android_url_fallback(&self.options.executable, &stream.url, stream.hls)
421 .await
422 {
423 Ok(code) => {
424 warn!(
425 title = %title,
426 "opened the stream through Android's default URL handler instead",
427 );
428 eprintln!(
429 "warning: {primary_error}; opened the stream through Android's default URL handler instead"
430 );
431 code
432 }
433 Err(fallback_error) => {
434 warn!(
435 title = %title,
436 error = %fallback_error,
437 "termux-open fallback also failed",
438 );
439 eprintln!("{primary_error}; {fallback_error}");
440 return Err(AniError::PlayerLaunchFailed {
441 executable: self.options.executable.display().to_string(),
442 reason: format!("Android launch failed: {}", fallback_error),
443 });
444 }
445 }
446 }
447 };
448
449 if terminal {
450 debug!(
451 title = %title,
452 "waiting for the Android player to finish before returning control to the TUI",
453 );
454 wait_for_android_player().await?;
455 }
456 Ok(Some(code))
457 }
458}
459
460fn android_intent_args(component: &str, url: &str, title: &str) -> Vec<String> {
461 vec![
462 "start".into(),
463 "--user".into(),
464 "0".into(),
465 "-a".into(),
466 "android.intent.action.VIEW".into(),
467 "-d".into(),
468 url.into(),
469 "-n".into(),
470 component.into(),
471 "--es".into(),
472 "title".into(),
473 title.into(),
474 ]
475}
476
477fn android_intent_launcher() -> PathBuf {
478 if let Some(executable) = std::env::var_os("ANI_CLI_PLAYER") {
479 return PathBuf::from(executable);
480 }
481 let path = std::env::var_os("PATH").unwrap_or_default();
482 ["termux-am-starter", "termux-am", "am"]
483 .iter()
484 .find_map(|name| find_in_path(name, &path))
485 .unwrap_or_else(|| PathBuf::from("termux-am-starter"))
486}
487
488fn find_in_path(executable: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
489 std::env::split_paths(path)
490 .map(|directory| directory.join(executable))
491 .find(|candidate| candidate.is_file())
492}
493
494async fn launch_android_url_fallback(
495 executable: &Path,
496 url: &str,
497 hls: bool,
498) -> std::result::Result<i32, String> {
499 if !is_termux_activity_launcher(executable) {
500 return Err(
501 "the configured custom launcher failed and cannot use the automatic Termux fallback"
502 .into(),
503 );
504 }
505 let path = std::env::var_os("PATH").unwrap_or_default();
506 let mut open_error = None;
507 if let Some(opener) = find_in_path("termux-open", &path) {
508 let status = Command::new(&opener)
509 .args(["--view", "--content-type", android_media_type(hls), url])
510 .status()
511 .await;
512 match status {
513 Ok(status) if status.success() => return Ok(status.code().unwrap_or(0)),
514 Ok(status) => {
515 open_error = Some(format!(
516 "{} exited with {}",
517 opener.display(),
518 status.code().unwrap_or(1)
519 ));
520 }
521 Err(error) => {
522 open_error = Some(format!("could not run {}: {error}", opener.display()));
523 }
524 }
525 }
526 let opener = find_in_path("termux-open-url", &path).ok_or_else(|| {
527 let prefix = open_error
528 .map(|error| format!("{error}; "))
529 .unwrap_or_default();
530 format!(
531 "{prefix}termux-open-url is unavailable; install or update the termux-tools package"
532 )
533 })?;
534 let status = Command::new(&opener)
535 .arg(url)
536 .status()
537 .await
538 .map_err(|error| format!("could not run {}: {error}", opener.display()))?;
539 if !status.success() {
540 return Err(format!(
541 "{} exited with {}",
542 opener.display(),
543 status.code().unwrap_or(1)
544 ));
545 }
546 Ok(status.code().unwrap_or(0))
547}
548
549fn android_media_type(hls: bool) -> &'static str {
550 if hls {
551 "application/vnd.apple.mpegurl"
552 } else {
553 "video/mp4"
554 }
555}
556
557fn is_termux_activity_launcher(executable: &Path) -> bool {
558 executable
559 .file_name()
560 .and_then(|name| name.to_str())
561 .is_some_and(|name| matches!(name, "termux-am-starter" | "termux-am" | "am"))
562}
563
564async fn wait_for_android_player() -> Result<()> {
565 tokio::task::spawn_blocking(|| {
566 println!(
567 "Opened the Android player. Return to Termux and press Enter after playback ends."
568 );
569 print!("Waiting for Android player... ");
570 io::stdout().flush()?;
571 let mut input = String::new();
572 io::stdin().read_line(&mut input)?;
573 Ok::<(), io::Error>(())
574 })
575 .await
576 .map_err(|error| {
577 eprintln!("Android playback prompt failed: {error}");
578 AniError::PlayerLaunchFailed {
579 executable: "android".to_string(),
580 reason: format!("playback prompt failed: {}", error),
581 }
582 })??;
583 Ok(())
584}
585
586fn mpv_options(stream: &StreamLink, title: &str, referer: &str) -> Vec<String> {
587 let mut args = vec![
588 "--tls-verify=no".into(),
589 format!("--force-media-title={title}"),
590 ];
591 if !referer.is_empty() {
592 args.push(format!("--referrer={referer}"));
593 }
594 append_mpv_headers(&mut args, stream);
595 for track in &stream.subtitles {
596 args.push(format!("--sub-file={}", track.url));
597 }
598 if let Some(track) = stream.subtitles.iter().find(|track| track.default) {
599 args.push(format!("--slang={}", track.label));
600 }
601 if stream.hls && requires_hls_relay(stream) {
603 args.push("--cache=yes".into());
604 args.push("--cache-secs=120".into());
605 args.push("--demuxer-max-bytes=512MiB".into());
606 args.push("--demuxer-max-back-bytes=256MiB".into());
607 }
608 args
609}
610
611fn append_mpv_headers(args: &mut Vec<String>, stream: &StreamLink) {
612 let mut headers = Vec::new();
613 if let Some(origin) = &stream.headers.origin
614 && safe_header_value(origin)
615 {
616 headers.push(format!("Origin: {origin}"));
617 }
618 headers.extend(
619 stream
620 .headers
621 .extra
622 .iter()
623 .filter(|(name, value)| safe_header_value(name) && safe_header_value(value))
624 .map(|(name, value)| format!("{name}: {value}")),
625 );
626 if !headers.is_empty() {
627 args.push(format!("--http-header-fields={}", headers.join(",")));
628 }
629}
630
631fn safe_header_value(value: &str) -> bool {
632 !value.contains(['\r', '\n'])
633}
634
635fn env_bool(name: &str) -> bool {
636 std::env::var(name)
637 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
638 .unwrap_or(false)
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644 use crate::{RequestHeaders, SubtitleTrack};
645 #[test]
646 fn mpv_arguments_preserve_referrer_as_one_argument() {
647 let player = Player::new(PlayerOptions {
648 executable: "mpv".into(),
649 kind: PlayerKind::Mpv,
650 no_detach: true,
651 exit_after_play: false,
652 force_hls_relay: false,
653 });
654 let stream = StreamLink {
655 url: "https://media/a.m3u8".into(),
656 resolution: "1080p".into(),
657 hls: true,
658 provider: "Default".into(),
659 downloadable: true,
660 headers: RequestHeaders {
661 referer: Some("https://ref.example".into()),
662 ..Default::default()
663 },
664 subtitles: vec![],
665 };
666 assert!(
667 player
668 .command_args(&stream, "Anime Episode 1")
669 .contains(&"--referrer=https://ref.example".into())
670 );
671 }
672
673 #[test]
674 fn iina_arguments_put_stream_before_raw_mpv_options() {
675 let player = Player::new(PlayerOptions {
676 executable: "iina".into(),
677 kind: PlayerKind::Iina,
678 no_detach: false,
679 exit_after_play: false,
680 force_hls_relay: false,
681 });
682 let stream = StreamLink {
683 url: "https://media/a.m3u8".into(),
684 resolution: "1080p".into(),
685 hls: true,
686 provider: "Default".into(),
687 downloadable: true,
688 headers: RequestHeaders {
689 referer: Some("https://ref.example".into()),
690 origin: Some("https://origin.example".into()),
691 ..Default::default()
692 },
693 subtitles: vec![SubtitleTrack {
694 label: "English".into(),
695 url: "https://media/subtitles.vtt".into(),
696 default: true,
697 }],
698 };
699
700 assert_eq!(
701 player.command_args(&stream, "Anime Episode 1"),
702 vec![
703 "--no-stdin",
704 "https://media/a.m3u8",
705 "--",
706 "--tls-verify=no",
707 "--force-media-title=Anime Episode 1",
708 "--referrer=https://ref.example",
709 "--http-header-fields=Origin: https://origin.example",
710 "--sub-file=https://media/subtitles.vtt",
711 "--slang=English",
712 ]
713 );
714 }
715
716 #[test]
717 fn forced_attached_iina_keeps_cli_running() {
718 let player = Player::new(PlayerOptions {
719 executable: "iina".into(),
720 kind: PlayerKind::Iina,
721 no_detach: false,
722 exit_after_play: false,
723 force_hls_relay: false,
724 });
725 let stream = StreamLink {
726 url: "https://media/a.m3u8".into(),
727 resolution: "1080p".into(),
728 hls: true,
729 provider: "Default".into(),
730 downloadable: true,
731 headers: RequestHeaders::default(),
732 subtitles: vec![],
733 };
734
735 assert_eq!(
736 &player.command_args_inner(&stream, "Anime", true)[..3],
737 ["--no-stdin", "--keep-running", "https://media/a.m3u8"]
738 );
739 }
740
741 #[test]
742 fn android_mpv_arguments_use_an_explicit_view_intent() {
743 let player = Player::new(PlayerOptions {
744 executable: "termux-am-starter".into(),
745 kind: PlayerKind::AndroidMpv,
746 no_detach: true,
747 exit_after_play: false,
748 force_hls_relay: false,
749 });
750 let stream = StreamLink {
751 url: "http://127.0.0.1:43123/stream-token".into(),
752 resolution: "1080p".into(),
753 hls: true,
754 provider: "Anikoto".into(),
755 downloadable: true,
756 headers: RequestHeaders::default(),
757 subtitles: vec![],
758 };
759
760 assert_eq!(
761 player.command_args(&stream, "Anime Episode 1"),
762 vec![
763 "start",
764 "--user",
765 "0",
766 "-a",
767 "android.intent.action.VIEW",
768 "-d",
769 "http://127.0.0.1:43123/stream-token",
770 "-n",
771 "is.xyz.mpv/.MPVActivity",
772 "--es",
773 "title",
774 "Anime Episode 1",
775 ]
776 );
777 }
778
779 #[test]
780 fn android_vlc_arguments_target_the_android_app_not_terminal_vlc() {
781 let player = Player::new(PlayerOptions {
782 executable: "am".into(),
783 kind: PlayerKind::AndroidVlc,
784 no_detach: true,
785 exit_after_play: false,
786 force_hls_relay: false,
787 });
788 let stream = StreamLink {
789 url: "https://media.example/episode.m3u8".into(),
790 resolution: "720p".into(),
791 hls: true,
792 provider: "Anikoto".into(),
793 downloadable: true,
794 headers: RequestHeaders::default(),
795 subtitles: vec![],
796 };
797
798 assert!(
799 player.command_args(&stream, "Episode").contains(
800 &"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity".into()
801 )
802 );
803 }
804
805 #[test]
806 fn android_launcher_lookup_prefers_the_first_available_candidate() {
807 let directory = tempfile::tempdir().unwrap();
808 std::fs::write(directory.path().join("termux-am"), "").unwrap();
809 let path = std::env::join_paths([directory.path()]).unwrap();
810
811 assert_eq!(
812 find_in_path("termux-am", &path),
813 Some(directory.path().join("termux-am"))
814 );
815 assert_eq!(find_in_path("termux-am-starter", &path), None);
816 }
817
818 #[test]
819 fn only_termux_activity_launchers_allow_the_url_opener_fallback() {
820 assert!(is_termux_activity_launcher(Path::new("termux-am-starter")));
821 assert!(is_termux_activity_launcher(Path::new(
822 "/data/data/com.termux/files/usr/bin/termux-am"
823 )));
824 assert!(is_termux_activity_launcher(Path::new("am")));
825 assert!(!is_termux_activity_launcher(Path::new(
826 "/data/local/tmp/custom-launcher"
827 )));
828 }
829
830 #[test]
831 fn android_fallback_uses_specific_media_types() {
832 assert_eq!(android_media_type(true), "application/vnd.apple.mpegurl");
833 assert_eq!(android_media_type(false), "video/mp4");
834 }
835
836 #[test]
837 fn platform_default_selects_expected_player() {
838 let options = PlayerOptions::default_player();
839 if cfg!(target_os = "android") {
840 assert_eq!(options.kind, PlayerKind::AndroidMpv);
841 } else if cfg!(target_os = "macos") {
842 assert_eq!(options.kind, PlayerKind::Iina);
843 if std::env::var_os("ANI_CLI_PLAYER").is_none() {
844 assert_eq!(options.executable, PathBuf::from("iina"));
845 }
846 } else {
847 assert_eq!(options.kind, PlayerKind::Mpv);
848 }
849 }
850}