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            }
249        } else {
250            // For simple names, check if they can be found in PATH
251            use which::which;
252            if which(&self.options.executable).is_err() {
253                eprintln!(
254                    "Player executable '{}' not found in PATH. Please install the player or set ANI_CLI_PLAYER environment variable.",
255                    self.options.executable.display()
256                );
257                return Err(AniError::PlayerNotFound);
258            }
259        }
260
261        let mut command = Command::new(&self.options.executable);
262        let attached = self.options.no_detach || force_attached;
263        let args = self.command_args_inner(stream, title, attached);
264        info!(
265            title = %title,
266            executable = %self.options.executable.display(),
267            kind = %self.options.kind,
268            attached,
269            "launching external player",
270        );
271        // The URL is logged at debug level so that long playlist URLs do not
272        // clutter the default log output, but the rest of the player command
273        // line (including the referer / user-agent switches) is logged at
274        // debug level for the same reason.
275        debug!(
276            title = %title,
277            stream_url = %stream.url,
278            stream_hls = stream.hls,
279            args = ?args,
280            "full player command line",
281        );
282        command.args(args);
283        if attached {
284            match command.status().await {
285                Ok(status) => {
286                    let code = status.code().unwrap_or(1);
287                    info!(
288                        title = %title,
289                        exit_code = code,
290                        success = status.success(),
291                        "player exited",
292                    );
293                    if !status.success() && self.options.exit_after_play {
294                        eprintln!("Player exited with {code}");
295                        return Err(AniError::PlayerExitFailed);
296                    }
297                    Ok(Some(code))
298                }
299                Err(error) => {
300                    warn!(
301                        title = %title,
302                        executable = %self.options.executable.display(),
303                        error = %error,
304                        "failed to launch player in attached mode",
305                    );
306                    eprintln!(
307                        "Could not launch {}: {error}",
308                        self.options.executable.display()
309                    );
310                    Err(AniError::PlayerLaunchFailed)
311                }
312            }
313        } else {
314            // ensure proper process detachment
315            command
316                .stdin(Stdio::null())
317                .stdout(Stdio::null())
318                .stderr(Stdio::null());
319            match command.spawn() {
320                Ok(child) => {
321                    info!(
322                        title = %title,
323                        pid = child.id().unwrap_or(0),
324                        executable = %self.options.executable.display(),
325                        "player launched in the background",
326                    );
327                    // Immediately detach the child process
328                    let _ = child.id(); // Ensure the process handle is consumed
329                    Ok(None)
330                }
331                Err(error) => {
332                    warn!(
333                        title = %title,
334                        executable = %self.options.executable.display(),
335                        error = %error,
336                        "failed to launch player in detached mode",
337                    );
338                    eprintln!(
339                        "Could not launch {}: {error}",
340                        self.options.executable.display()
341                    );
342                    Err(AniError::PlayerLaunchFailed)
343                }
344            }
345        }
346    }
347
348    fn is_android_player(&self) -> bool {
349        matches!(
350            self.options.kind,
351            PlayerKind::AndroidMpv | PlayerKind::AndroidVlc
352        )
353    }
354
355    async fn play_android(
356        &self,
357        stream: &StreamLink,
358        title: &str,
359        relay_active: bool,
360    ) -> Result<Option<i32>> {
361        let terminal = io::stdin().is_terminal();
362        if relay_active && !terminal {
363            return Err(AniError::PlayerAndroidTerminalRequired);
364        }
365
366        let launch_args = self.command_args_inner(stream, title, true);
367        info!(
368            title = %title,
369            executable = %self.options.executable.display(),
370            "launching Android activity for playback",
371        );
372        debug!(
373            title = %title,
374            stream_url = %stream.url,
375            args = ?launch_args,
376            "full Android intent command line",
377        );
378        let launch_result = Command::new(&self.options.executable)
379            .args(launch_args)
380            .status()
381            .await;
382        let code = match launch_result {
383            Ok(status) if status.success() => {
384                let code = status.code().unwrap_or(0);
385                info!(
386                    title = %title,
387                    exit_code = code,
388                    "Android player activity returned successfully",
389                );
390                code
391            }
392            result => {
393                let primary_error = match &result {
394                    Ok(status) => format!(
395                        "Android activity launcher {} exited with {}",
396                        self.options.executable.display(),
397                        status.code().unwrap_or(1)
398                    ),
399                    Err(error) => format!(
400                        "could not launch Android player through {}: {error}",
401                        self.options.executable.display()
402                    ),
403                };
404                warn!(
405                    title = %title,
406                    executable = %self.options.executable.display(),
407                    error = %primary_error,
408                    "primary Android launch failed; trying termux-open fallback",
409                );
410                match launch_android_url_fallback(&self.options.executable, &stream.url, stream.hls)
411                    .await
412                {
413                    Ok(code) => {
414                        warn!(
415                            title = %title,
416                            "opened the stream through Android's default URL handler instead",
417                        );
418                        eprintln!(
419                            "warning: {primary_error}; opened the stream through Android's default URL handler instead"
420                        );
421                        code
422                    }
423                    Err(fallback_error) => {
424                        warn!(
425                            title = %title,
426                            error = %fallback_error,
427                            "termux-open fallback also failed",
428                        );
429                        eprintln!("{primary_error}; {fallback_error}");
430                        return Err(AniError::PlayerLaunchFailed);
431                    }
432                }
433            }
434        };
435
436        if terminal {
437            debug!(
438                title = %title,
439                "waiting for the Android player to finish before returning control to the TUI",
440            );
441            wait_for_android_player().await?;
442        }
443        Ok(Some(code))
444    }
445}
446
447fn android_intent_args(component: &str, url: &str, title: &str) -> Vec<String> {
448    vec![
449        "start".into(),
450        "--user".into(),
451        "0".into(),
452        "-a".into(),
453        "android.intent.action.VIEW".into(),
454        "-d".into(),
455        url.into(),
456        "-n".into(),
457        component.into(),
458        "--es".into(),
459        "title".into(),
460        title.into(),
461    ]
462}
463
464fn android_intent_launcher() -> PathBuf {
465    if let Some(executable) = std::env::var_os("ANI_CLI_PLAYER") {
466        return PathBuf::from(executable);
467    }
468    let path = std::env::var_os("PATH").unwrap_or_default();
469    ["termux-am-starter", "termux-am", "am"]
470        .iter()
471        .find_map(|name| find_in_path(name, &path))
472        .unwrap_or_else(|| PathBuf::from("termux-am-starter"))
473}
474
475fn find_in_path(executable: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
476    std::env::split_paths(path)
477        .map(|directory| directory.join(executable))
478        .find(|candidate| candidate.is_file())
479}
480
481async fn launch_android_url_fallback(
482    executable: &Path,
483    url: &str,
484    hls: bool,
485) -> std::result::Result<i32, String> {
486    if !is_termux_activity_launcher(executable) {
487        return Err(
488            "the configured custom launcher failed and cannot use the automatic Termux fallback"
489                .into(),
490        );
491    }
492    let path = std::env::var_os("PATH").unwrap_or_default();
493    let mut open_error = None;
494    if let Some(opener) = find_in_path("termux-open", &path) {
495        let status = Command::new(&opener)
496            .args(["--view", "--content-type", android_media_type(hls), url])
497            .status()
498            .await;
499        match status {
500            Ok(status) if status.success() => return Ok(status.code().unwrap_or(0)),
501            Ok(status) => {
502                open_error = Some(format!(
503                    "{} exited with {}",
504                    opener.display(),
505                    status.code().unwrap_or(1)
506                ));
507            }
508            Err(error) => {
509                open_error = Some(format!("could not run {}: {error}", opener.display()));
510            }
511        }
512    }
513    let opener = find_in_path("termux-open-url", &path).ok_or_else(|| {
514        let prefix = open_error
515            .map(|error| format!("{error}; "))
516            .unwrap_or_default();
517        format!(
518            "{prefix}termux-open-url is unavailable; install or update the termux-tools package"
519        )
520    })?;
521    let status = Command::new(&opener)
522        .arg(url)
523        .status()
524        .await
525        .map_err(|error| format!("could not run {}: {error}", opener.display()))?;
526    if !status.success() {
527        return Err(format!(
528            "{} exited with {}",
529            opener.display(),
530            status.code().unwrap_or(1)
531        ));
532    }
533    Ok(status.code().unwrap_or(0))
534}
535
536fn android_media_type(hls: bool) -> &'static str {
537    if hls {
538        "application/vnd.apple.mpegurl"
539    } else {
540        "video/mp4"
541    }
542}
543
544fn is_termux_activity_launcher(executable: &Path) -> bool {
545    executable
546        .file_name()
547        .and_then(|name| name.to_str())
548        .is_some_and(|name| matches!(name, "termux-am-starter" | "termux-am" | "am"))
549}
550
551async fn wait_for_android_player() -> Result<()> {
552    tokio::task::spawn_blocking(|| {
553        println!(
554            "Opened the Android player. Return to Termux and press Enter after playback ends."
555        );
556        print!("Waiting for Android player... ");
557        io::stdout().flush()?;
558        let mut input = String::new();
559        io::stdin().read_line(&mut input)?;
560        Ok::<(), io::Error>(())
561    })
562    .await
563    .map_err(|error| {
564        eprintln!("Android playback prompt failed: {error}");
565        AniError::PlayerLaunchFailed
566    })??;
567    Ok(())
568}
569
570fn mpv_options(stream: &StreamLink, title: &str, referer: &str) -> Vec<String> {
571    let mut args = vec![
572        "--tls-verify=no".into(),
573        format!("--force-media-title={title}"),
574    ];
575    if !referer.is_empty() {
576        args.push(format!("--referrer={referer}"));
577    }
578    append_mpv_headers(&mut args, stream);
579    for track in &stream.subtitles {
580        args.push(format!("--sub-file={}", track.url));
581    }
582    if let Some(track) = stream.subtitles.iter().find(|track| track.default) {
583        args.push(format!("--slang={}", track.label));
584    }
585    // Add cache settings for HLS relay streams
586    if stream.hls && requires_hls_relay(stream) {
587        args.push("--cache=yes".into());
588        args.push("--cache-secs=120".into());
589        args.push("--demuxer-max-bytes=512MiB".into());
590        args.push("--demuxer-max-back-bytes=256MiB".into());
591    }
592    args
593}
594
595fn append_mpv_headers(args: &mut Vec<String>, stream: &StreamLink) {
596    let mut headers = Vec::new();
597    if let Some(origin) = &stream.headers.origin
598        && safe_header_value(origin)
599    {
600        headers.push(format!("Origin: {origin}"));
601    }
602    headers.extend(
603        stream
604            .headers
605            .extra
606            .iter()
607            .filter(|(name, value)| safe_header_value(name) && safe_header_value(value))
608            .map(|(name, value)| format!("{name}: {value}")),
609    );
610    if !headers.is_empty() {
611        args.push(format!("--http-header-fields={}", headers.join(",")));
612    }
613}
614
615fn safe_header_value(value: &str) -> bool {
616    !value.contains(['\r', '\n'])
617}
618
619fn env_bool(name: &str) -> bool {
620    std::env::var(name)
621        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
622        .unwrap_or(false)
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::{RequestHeaders, SubtitleTrack};
629    #[test]
630    fn mpv_arguments_preserve_referrer_as_one_argument() {
631        let player = Player::new(PlayerOptions {
632            executable: "mpv".into(),
633            kind: PlayerKind::Mpv,
634            no_detach: true,
635            exit_after_play: false,
636            force_hls_relay: false,
637        });
638        let stream = StreamLink {
639            url: "https://media/a.m3u8".into(),
640            resolution: "1080p".into(),
641            hls: true,
642            provider: "Default".into(),
643            downloadable: true,
644            headers: RequestHeaders {
645                referer: Some("https://ref.example".into()),
646                ..Default::default()
647            },
648            subtitles: vec![],
649        };
650        assert!(
651            player
652                .command_args(&stream, "Anime Episode 1")
653                .contains(&"--referrer=https://ref.example".into())
654        );
655    }
656
657    #[test]
658    fn iina_arguments_put_stream_before_raw_mpv_options() {
659        let player = Player::new(PlayerOptions {
660            executable: "iina".into(),
661            kind: PlayerKind::Iina,
662            no_detach: false,
663            exit_after_play: false,
664            force_hls_relay: false,
665        });
666        let stream = StreamLink {
667            url: "https://media/a.m3u8".into(),
668            resolution: "1080p".into(),
669            hls: true,
670            provider: "Default".into(),
671            downloadable: true,
672            headers: RequestHeaders {
673                referer: Some("https://ref.example".into()),
674                origin: Some("https://origin.example".into()),
675                ..Default::default()
676            },
677            subtitles: vec![SubtitleTrack {
678                label: "English".into(),
679                url: "https://media/subtitles.vtt".into(),
680                default: true,
681            }],
682        };
683
684        assert_eq!(
685            player.command_args(&stream, "Anime Episode 1"),
686            vec![
687                "--no-stdin",
688                "https://media/a.m3u8",
689                "--",
690                "--tls-verify=no",
691                "--force-media-title=Anime Episode 1",
692                "--referrer=https://ref.example",
693                "--http-header-fields=Origin: https://origin.example",
694                "--sub-file=https://media/subtitles.vtt",
695                "--slang=English",
696            ]
697        );
698    }
699
700    #[test]
701    fn forced_attached_iina_keeps_cli_running() {
702        let player = Player::new(PlayerOptions {
703            executable: "iina".into(),
704            kind: PlayerKind::Iina,
705            no_detach: false,
706            exit_after_play: false,
707            force_hls_relay: false,
708        });
709        let stream = StreamLink {
710            url: "https://media/a.m3u8".into(),
711            resolution: "1080p".into(),
712            hls: true,
713            provider: "Default".into(),
714            downloadable: true,
715            headers: RequestHeaders::default(),
716            subtitles: vec![],
717        };
718
719        assert_eq!(
720            &player.command_args_inner(&stream, "Anime", true)[..3],
721            ["--no-stdin", "--keep-running", "https://media/a.m3u8"]
722        );
723    }
724
725    #[test]
726    fn android_mpv_arguments_use_an_explicit_view_intent() {
727        let player = Player::new(PlayerOptions {
728            executable: "termux-am-starter".into(),
729            kind: PlayerKind::AndroidMpv,
730            no_detach: true,
731            exit_after_play: false,
732            force_hls_relay: false,
733        });
734        let stream = StreamLink {
735            url: "http://127.0.0.1:43123/stream-token".into(),
736            resolution: "1080p".into(),
737            hls: true,
738            provider: "Anikoto".into(),
739            downloadable: true,
740            headers: RequestHeaders::default(),
741            subtitles: vec![],
742        };
743
744        assert_eq!(
745            player.command_args(&stream, "Anime Episode 1"),
746            vec![
747                "start",
748                "--user",
749                "0",
750                "-a",
751                "android.intent.action.VIEW",
752                "-d",
753                "http://127.0.0.1:43123/stream-token",
754                "-n",
755                "is.xyz.mpv/.MPVActivity",
756                "--es",
757                "title",
758                "Anime Episode 1",
759            ]
760        );
761    }
762
763    #[test]
764    fn android_vlc_arguments_target_the_android_app_not_terminal_vlc() {
765        let player = Player::new(PlayerOptions {
766            executable: "am".into(),
767            kind: PlayerKind::AndroidVlc,
768            no_detach: true,
769            exit_after_play: false,
770            force_hls_relay: false,
771        });
772        let stream = StreamLink {
773            url: "https://media.example/episode.m3u8".into(),
774            resolution: "720p".into(),
775            hls: true,
776            provider: "Anikoto".into(),
777            downloadable: true,
778            headers: RequestHeaders::default(),
779            subtitles: vec![],
780        };
781
782        assert!(
783            player.command_args(&stream, "Episode").contains(
784                &"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity".into()
785            )
786        );
787    }
788
789    #[test]
790    fn android_launcher_lookup_prefers_the_first_available_candidate() {
791        let directory = tempfile::tempdir().unwrap();
792        std::fs::write(directory.path().join("termux-am"), "").unwrap();
793        let path = std::env::join_paths([directory.path()]).unwrap();
794
795        assert_eq!(
796            find_in_path("termux-am", &path),
797            Some(directory.path().join("termux-am"))
798        );
799        assert_eq!(find_in_path("termux-am-starter", &path), None);
800    }
801
802    #[test]
803    fn only_termux_activity_launchers_allow_the_url_opener_fallback() {
804        assert!(is_termux_activity_launcher(Path::new("termux-am-starter")));
805        assert!(is_termux_activity_launcher(Path::new(
806            "/data/data/com.termux/files/usr/bin/termux-am"
807        )));
808        assert!(is_termux_activity_launcher(Path::new("am")));
809        assert!(!is_termux_activity_launcher(Path::new(
810            "/data/local/tmp/custom-launcher"
811        )));
812    }
813
814    #[test]
815    fn android_fallback_uses_specific_media_types() {
816        assert_eq!(android_media_type(true), "application/vnd.apple.mpegurl");
817        assert_eq!(android_media_type(false), "video/mp4");
818    }
819
820    #[test]
821    fn platform_default_selects_expected_player() {
822        let options = PlayerOptions::default_player();
823        if cfg!(target_os = "android") {
824            assert_eq!(options.kind, PlayerKind::AndroidMpv);
825        } else if cfg!(target_os = "macos") {
826            assert_eq!(options.kind, PlayerKind::Iina);
827            if std::env::var_os("ANI_CLI_PLAYER").is_none() {
828                assert_eq!(options.executable, PathBuf::from("iina"));
829            }
830        } else {
831            assert_eq!(options.kind, PlayerKind::Mpv);
832        }
833    }
834}