Skip to main content

ani_lib/
player.rs

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    /// Human-readable summary of the configured player (executable + kind).
114    /// Useful for debug logs and error messages.
115    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                // Ensure URL is passed last
132                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            // Android players receive a single intent URL and cannot be given
208            // an explicit `--sub-file`, so they need subtitles exposed as
209            // synthetic HLS renditions. Desktop players already receive
210            // subtitles via `--sub-file`, and wrapping a long subtitle file as
211            // a single oversized HLS segment produces unreliable cue timing
212            // in some HLS demuxers (see issue #18).
213            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        // Validate that the player executable exists before attempting to launch
239        // For simple names (e.g., "mpv"), check if they exist in PATH
240        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            // For simple names, check if they can be found in PATH
253            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        // The URL is logged at debug level so that long playlist URLs do not
276        // clutter the default log output, but the rest of the player command
277        // line (including the referer / user-agent switches) is logged at
278        // debug level for the same reason.
279        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            // ensure proper process detachment
322            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                    // Immediately detach the child process
335                    let _ = child.id(); // Ensure the process handle is consumed
336                    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    // Add cache settings for HLS relay streams
602    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}