use std::io::Write;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use ratatui::layout::Rect;
use serde_json::{Value, json};
use crate::app::PlayerCommand;
use crate::config::{Language, PlayerConfig, PlayerMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Embedded,
Window,
Overlay,
}
impl Mode {
pub fn in_pane(self) -> bool {
self != Self::Window
}
}
const MULTIPLEXERS: [&str; 3] = ["TMUX", "ZELLIJ", "HERDR_ENV"];
pub fn in_multiplexer(env: impl Fn(&str) -> Option<String>) -> bool {
MULTIPLEXERS.iter().any(|var| env(var).is_some())
}
pub fn resolve_mode(mode: PlayerMode, env: impl Fn(&str) -> Option<String>) -> Mode {
match mode {
PlayerMode::Embedded => Mode::Embedded,
PlayerMode::Window => Mode::Window,
PlayerMode::Overlay => {
let x11 = env("DISPLAY").is_some() && env("XDG_SESSION_TYPE").as_deref() != Some("wayland");
if x11 && env("WINDOWID").is_some() && !in_multiplexer(&env) { Mode::Overlay } else { Mode::Window }
}
PlayerMode::Auto => {
let kitty = env("KITTY_WINDOW_ID").is_some() || env("TERM").as_deref() == Some("xterm-kitty");
if kitty && !in_multiplexer(&env) { Mode::Embedded } else { Mode::Window }
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Geometry {
pub left: u16,
pub top: u16,
pub cols: u16,
pub rows: u16,
pub width_px: u32,
pub height_px: u32,
pub screen: Option<(i32, i32)>,
}
impl Geometry {
pub fn new(area: Rect, cell: (u16, u16)) -> Self {
Self {
left: area.x + 1,
top: area.y + 1,
cols: area.width,
rows: area.height,
width_px: u32::from(area.width) * u32::from(cell.0),
height_px: u32::from(area.height) * u32::from(cell.1),
screen: None,
}
}
pub fn on_screen(self, grid: (i32, i32)) -> Self {
let cell = |px: u32, n: u16| if n == 0 { 0 } else { px / u32::from(n) };
let x = grid.0 + (i32::from(self.left) - 1) * cell(self.width_px, self.cols) as i32;
let y = grid.1 + (i32::from(self.top) - 1) * cell(self.height_px, self.rows) as i32;
Self { screen: Some((x, y)), ..self }
}
fn x11(&self) -> Option<String> {
let (x, y) = self.screen?;
Some(format!("{}x{}+{x}+{y}", self.width_px, self.height_px))
}
fn options(&self) -> [(&'static str, u32); 6] {
[
("left", self.left.into()),
("top", self.top.into()),
("cols", self.cols.into()),
("rows", self.rows.into()),
("width", self.width_px),
("height", self.height_px),
]
}
}
pub fn cell_size() -> (u16, u16) {
match ratatui::crossterm::terminal::window_size() {
Ok(ws) if ws.columns > 0 && ws.rows > 0 && ws.width > 0 && ws.height > 0 => {
(ws.width / ws.columns, ws.height / ws.rows)
}
_ => (10, 20),
}
}
pub fn ytdl_format(config: &PlayerConfig, mode: Mode) -> String {
let fits = format!("[height<={}]", config.max_height);
let audios = match config.language {
Language::Dubbed => vec![format!("ba[language^={}]", config.sub_lang), "ba".into()],
Language::Subtitles | Language::Original => vec!["ba".into()],
};
let codecs: Vec<String> = match config.prefer_codec.as_str() {
"" => vec![String::new()],
codec => vec![format!("[vcodec^={codec}]"), String::new()],
};
let rates: &[&str] = if mode == Mode::Embedded { &["[fps<=30]", ""] } else { &[""] };
let mut choices = Vec::new();
for rate in rates {
for codec in &codecs {
for audio in &audios {
choices.push(format!("bv*{fits}{rate}{codec}+{audio}"));
}
}
}
choices.extend([format!("b{fits}"), "b".into()]);
choices.join("/")
}
pub struct Launch<'a> {
pub config: &'a PlayerConfig,
pub mode: Mode,
pub cookies: Option<&'a std::path::Path>,
pub queue: &'a [String],
pub audio_only: bool,
}
pub fn mpv_args(launch: &Launch, geometry: Option<Geometry>, ipc: &str) -> Vec<String> {
let Launch { config, mode, cookies, queue, audio_only } = *launch;
let lang = &config.sub_lang;
let raw = raw_options(config, cookies);
let mut args = vec![
format!("--input-ipc-server={ipc}"),
format!("--ytdl-format={}", ytdl_format(config, mode)),
format!("--ytdl-raw-options={raw}"),
format!("--slang={lang}"),
"--sub-auto=fuzzy".into(),
format!("--sid={}", if config.language == Language::Subtitles { "auto" } else { "no" }),
"--really-quiet".into(),
"--input-terminal=no".into(),
];
args.push(match (config.hwdec, mode) {
(true, Mode::Window | Mode::Overlay) => "--hwdec=auto".into(),
_ => "--hwdec=no".into(),
});
match mode {
_ if audio_only => args.push("--vid=no".into()),
Mode::Window => args.push("--force-window=immediate".into()),
Mode::Overlay => {
args.extend(
[
"--force-window=immediate",
"--no-border",
"--ontop",
"--focus-on=never",
"--input-cursor-passthrough=yes",
"--cursor-autohide=always",
"--osc=no",
"--keepaspect-window=no",
]
.map(String::from),
);
args.push(format!("--x11-name={OVERLAY_NAME}"));
if let Some(geometry) = geometry.and_then(|g| g.x11()) {
args.push(format!("--geometry={geometry}"));
}
}
Mode::Embedded => {
args.extend(
["--vo=kitty", "--vo-kitty-alt-screen=no", "--vo-kitty-config-clear=no", "--sws-scaler=fast-bilinear"]
.map(String::from),
);
args.extend(["--framedrop=decoder+vo", "--vd-lavc-skiploopfilter=all", "--vd-lavc-fast"].map(String::from));
if cfg!(unix) {
args.push("--vo-kitty-use-shm=yes".into());
}
if let Some(g) = geometry {
args.extend(g.options().iter().map(|(name, value)| format!("--vo-kitty-{name}={value}")));
}
}
}
args.push("--".into());
args.extend(queue.iter().cloned());
args
}
const OVERLAY_NAME: &str = "tubetui-overlay";
pub fn grid_origin(cell: (u16, u16)) -> Option<(i32, i32)> {
let window = std::env::var("WINDOWID").ok()?;
let out = Command::new("xwininfo").args(["-id", &window]).stderr(Stdio::null()).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let field = |name: &str| -> Option<i32> {
let line = text.lines().find(|l| l.trim_start().starts_with(name))?;
line.split(':').nth(1)?.trim().parse().ok()
};
let (x, y, width) = (field("Absolute upper-left X")?, field("Absolute upper-left Y")?, field("Width")?);
let columns = ratatui::crossterm::terminal::size().ok()?.0;
Some(grid_from_window((x, y, width), columns, cell.0))
}
fn grid_from_window((x, y, width): (i32, i32, i32), columns: u16, cell_width: u16) -> (i32, i32) {
let margin = (width - i32::from(columns) * i32::from(cell_width)).max(0) / 2;
(x + margin, y + margin)
}
fn give_back_focus(wait: Duration) {
let Ok(terminal) = std::env::var("WINDOWID") else { return };
std::thread::spawn(move || {
let deadline = Instant::now() + wait;
while Instant::now() < deadline {
if let Some(overlay) = focused_overlay() {
let _ = Command::new("xdotool").args(["windowactivate", &terminal]).stderr(Stdio::null()).status();
let _ = Command::new("wmctrl")
.args(["-i", "-r", &overlay, "-b", "add,skip_taskbar,skip_pager"])
.stderr(Stdio::null())
.status();
return;
}
std::thread::sleep(Duration::from_millis(50));
}
});
}
pub fn overlay_has_focus() -> bool {
focused_overlay().is_some()
}
fn focused_overlay() -> Option<String> {
let run = |args: &[&str]| {
let out = Command::new(args[0]).args(&args[1..]).stderr(Stdio::null()).output().ok()?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_owned())
};
let id = run(&["xdotool", "getwindowfocus"])?;
run(&["xprop", "-id", &id, "WM_CLASS"]).filter(|class| class.contains(OVERLAY_NAME)).map(|_| id)
}
pub fn clear_images() {
let mut out = std::io::stdout();
let _ = out.write_all(b"\x1b\\\x1b_Ga=d,d=a\x1b\\");
let _ = out.flush();
}
pub fn set_window_fullscreen(on: bool) {
if in_multiplexer(|var| std::env::var(var).ok()) {
return;
}
let Ok(window) = std::env::var("WINDOWID") else { return };
let action = if on { "add,fullscreen" } else { "remove,fullscreen" };
let _ = Command::new("wmctrl")
.args(["-i", "-r", &window, "-b", action])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
pub fn reload_line(path: &str, seconds: f64) -> String {
json!({ "command": ["loadfile", path, "replace", -1, format!("start={seconds:.1}")] }).to_string()
}
fn raw_options(config: &PlayerConfig, cookies: Option<&std::path::Path>) -> String {
let mut raw = format!("sub-langs=[{}],write-subs=,write-auto-subs=", config.sub_lang);
if let Some(cookies) = cookies {
raw.push_str(&format!(",cookies={}", cookies.display()));
}
raw
}
pub fn command_lines(
config: &PlayerConfig,
mode: Mode,
cookies: Option<&std::path::Path>,
command: PlayerCommand,
) -> Vec<String> {
let line = |value: Value| vec![value.to_string()];
match command {
PlayerCommand::TogglePause => line(json!({ "command": ["cycle", "pause"] })),
PlayerCommand::Seek(seconds) => line(json!({ "command": ["seek", seconds, "relative"] })),
PlayerCommand::SeekTo(second) => line(json!({ "command": ["seek", second, "absolute"] })),
PlayerCommand::Volume(delta) => line(json!({ "command": ["add", "volume", delta] })),
PlayerCommand::ToggleMute => line(json!({ "command": ["cycle", "mute"] })),
PlayerCommand::CycleSubtitles => line(json!({ "command": ["cycle", "sub"] })),
PlayerCommand::NextTrack => line(json!({ "command": ["playlist-next"] })),
PlayerCommand::Fullscreen(on) => line(set_property("fullscreen", on.into())),
PlayerCommand::PrevTrack => line(json!({ "command": ["playlist-prev"] })),
PlayerCommand::Language(_)
| PlayerCommand::SubLang(_)
| PlayerCommand::Quality(_)
| PlayerCommand::Codec(_) => {
let sid = if config.language == Language::Subtitles { "auto" } else { "no" };
vec![
set_property("ytdl-format", ytdl_format(config, mode).into()).to_string(),
set_property("ytdl-raw-options", raw_options(config, cookies).into()).to_string(),
set_property("slang", config.sub_lang.clone().into()).to_string(),
set_property("sid", sid.into()).to_string(),
]
}
}
}
fn set_property(name: &str, value: Value) -> Value {
json!({ "command": ["set_property", name, value] })
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Info {
pub volume: Option<f64>,
pub muted: bool,
pub height: Option<i64>,
pub codec: Option<String>,
pub position: Option<f64>,
pub duration: Option<f64>,
pub buffered: Option<f64>,
pub paused: bool,
}
const OBSERVED: [&str; 8] =
["volume", "mute", "height", "video-format", "time-pos", "duration", "pause", "demuxer-cache-time"];
impl Info {
fn apply(&mut self, name: &str, data: &Value) -> bool {
let before = self.clone();
match name {
"volume" => self.volume = data.as_f64(),
"mute" => self.muted = data.as_bool().unwrap_or(false),
"height" => self.height = data.as_i64(),
"video-format" => self.codec = data.as_str().map(str::to_owned),
"time-pos" => self.position = data.as_f64(),
"duration" => self.duration = data.as_f64(),
"pause" => self.paused = data.as_bool().unwrap_or(false),
"demuxer-cache-time" => self.buffered = data.as_f64(),
_ => {}
}
let second = |p: Option<f64>| p.map(|p| p as i64);
let coarse = |i: &Self| Self {
position: second(i.position).map(|s| s as f64),
buffered: second(i.buffered).map(|s| s as f64),
..i.clone()
};
coarse(self) != coarse(&before)
}
}
#[derive(Default)]
struct Observed {
info: Mutex<Info>,
changed: AtomicBool,
}
pub struct Player {
child: Child,
ipc_path: String,
ipc: Option<Box<dyn Write + Send>>,
mode: Mode,
config: PlayerConfig,
cookies: Option<std::path::PathBuf>,
shown_at: Option<Geometry>,
paused_by_us: bool,
opened_at: Instant,
started: Arc<AtomicBool>,
observed: Arc<Observed>,
}
impl Player {
pub fn spawn(launch: &Launch, geometry: Option<Geometry>) -> std::io::Result<Self> {
let ipc_path = ipc_path();
let mode = if launch.audio_only { Mode::Window } else { launch.mode };
let stdout = match mode {
Mode::Embedded => Stdio::inherit(), Mode::Window | Mode::Overlay => Stdio::null(),
};
let child = Command::new("mpv")
.args(mpv_args(launch, geometry, &ipc_path))
.stdin(Stdio::null())
.stdout(stdout)
.stderr(Stdio::null())
.spawn()?;
let shown_at = if mode.in_pane() { geometry } else { None };
if mode == Mode::Overlay {
give_back_focus(Duration::from_secs(30));
}
let (config, cookies) = (launch.config.clone(), launch.cookies.map(Into::into));
let started = Arc::new(AtomicBool::new(false));
let observed = Arc::new(Observed::default());
watch_start(ipc_path.clone(), Arc::clone(&started), Arc::clone(&observed));
let opened_at = Instant::now();
Ok(Self {
child,
ipc_path,
ipc: None,
mode,
config,
cookies,
shown_at,
paused_by_us: false,
opened_at,
started,
observed,
})
}
pub fn opening(&self) -> Option<Duration> {
(!self.started.load(Ordering::Relaxed)).then(|| self.opened_at.elapsed())
}
pub fn info(&self) -> Info {
self.observed.info.lock().map(|i| i.clone()).unwrap_or_default()
}
pub fn info_changed(&self) -> bool {
self.observed.changed.swap(false, Ordering::Relaxed)
}
pub fn send(&mut self, command: PlayerCommand) {
match command {
PlayerCommand::Language(language) => self.config.language = language,
PlayerCommand::SubLang(code) => self.config.sub_lang = code.into(),
PlayerCommand::Quality(height) => self.config.max_height = height,
PlayerCommand::Codec(codec) => self.config.prefer_codec = codec.into(),
_ => {}
}
for line in command_lines(&self.config, self.mode, self.cookies.as_deref(), command) {
self.write(&line);
}
if matches!(
command,
PlayerCommand::Language(_)
| PlayerCommand::SubLang(_)
| PlayerCommand::Quality(_)
| PlayerCommand::Codec(_)
) {
let path = self.query("path").and_then(|v| v.as_str().map(str::to_owned));
let seconds = self.query("time-pos").and_then(|v| v.as_f64()).unwrap_or(0.0);
if let Some(path) = path {
self.write(&reload_line(&path, seconds));
}
}
}
fn query(&self, property: &str) -> Option<Value> {
query(&self.ipc_path, property)
}
pub fn show(&mut self, geometry: Geometry) {
if self.mode == Mode::Overlay {
if self.shown_at != Some(geometry) {
if let Some(place) = geometry.x11() {
self.write(&set_property("geometry", place.into()).to_string());
}
if self.shown_at.is_none() {
self.write(&set_property("window-minimized", false.into()).to_string());
give_back_focus(Duration::from_secs(2));
}
self.shown_at = Some(geometry);
}
self.resume();
return;
}
if self.mode != Mode::Embedded {
return;
}
if self.shown_at != Some(geometry) {
clear_images();
for (name, value) in geometry.options() {
self.write(&set_property(&format!("options/vo-kitty-{name}"), value.into()).to_string());
}
if self.shown_at.is_some() {
self.write(&set_property("vo", "null".into()).to_string());
}
self.write(&set_property("vo", "kitty".into()).to_string());
self.shown_at = Some(geometry);
}
self.resume();
}
pub fn hide(&mut self, keep_audio: bool) -> bool {
if !self.mode.in_pane() {
return false;
}
let was_shown = self.shown_at.take().is_some();
if was_shown && self.mode == Mode::Overlay {
self.write(&set_property("window-minimized", true.into()).to_string());
} else if was_shown {
self.write(&set_property("vo", "null".into()).to_string());
}
if keep_audio {
self.resume();
} else if !self.paused_by_us {
self.write(&set_property("pause", true.into()).to_string());
self.paused_by_us = true;
}
was_shown && self.mode == Mode::Embedded
}
fn resume(&mut self) {
if std::mem::take(&mut self.paused_by_us) {
self.write(&set_property("pause", false.into()).to_string());
}
}
pub fn used_cookies(&self) -> bool {
self.cookies.is_some()
}
pub fn has_exited(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(Some(_)))
}
fn write(&mut self, line: &str) {
if self.ipc.is_none() {
self.ipc = connect(&self.ipc_path).ok();
if let Some(ipc) = self.ipc.as_mut() {
let _ = writeln!(ipc, "{}", json!({ "command": ["disable_event", "all"] }));
}
}
if let Some(ipc) = self.ipc.as_mut()
&& writeln!(ipc, "{line}").is_err()
{
self.ipc = None;
}
}
}
impl Drop for Player {
fn drop(&mut self) {
self.write(&json!({ "command": ["quit"] }).to_string());
let deadline = Instant::now() + Duration::from_millis(500);
while Instant::now() < deadline && !self.has_exited() {
std::thread::sleep(Duration::from_millis(20));
}
let _ = self.child.kill();
let _ = self.child.wait();
#[cfg(unix)]
let _ = std::fs::remove_file(&self.ipc_path);
}
}
fn query(ipc_path: &str, property: &str) -> Option<Value> {
use std::io::{BufRead, BufReader};
let mut stream = connect_stream(ipc_path).ok()?;
#[cfg(unix)]
stream.set_read_timeout(Some(Duration::from_millis(500))).ok()?;
writeln!(stream, "{}", json!({ "command": ["get_property", property], "request_id": 1 })).ok()?;
BufReader::new(stream)
.lines()
.map_while(Result::ok)
.filter_map(|line| serde_json::from_str::<Value>(&line).ok())
.find(|reply| reply["request_id"] == 1)
.map(|reply| reply["data"].clone())
}
fn watch_start(ipc_path: String, started: Arc<AtomicBool>, observed: Arc<Observed>) {
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(120);
while Instant::now() < deadline && Arc::strong_count(&started) > 1 {
if query(&ipc_path, "time-pos").is_some_and(|v| v.is_f64()) {
started.store(true, Ordering::Relaxed);
observe(&ipc_path, &observed);
return;
}
std::thread::sleep(Duration::from_millis(200));
}
});
}
fn observe(ipc_path: &str, observed: &Observed) {
use std::io::{BufRead, BufReader};
let Ok(mut stream) = connect_stream(ipc_path) else { return };
for (id, name) in OBSERVED.iter().enumerate() {
if writeln!(stream, "{}", json!({ "command": ["observe_property", id + 1, name] })).is_err() {
return;
}
}
for line in BufReader::new(stream).lines().map_while(Result::ok) {
let Ok(event) = serde_json::from_str::<Value>(&line) else { continue };
if event["event"] == "property-change"
&& let Ok(mut info) = observed.info.lock()
&& info.apply(event["name"].as_str().unwrap_or_default(), &event["data"])
{
observed.changed.store(true, Ordering::Relaxed);
}
}
}
fn ipc_path() -> String {
static NEXT: AtomicU64 = AtomicU64::new(0);
let name = format!("tubetui-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
if cfg!(windows) {
format!(r"\\.\pipe\{name}")
} else {
std::env::temp_dir().join(format!("{name}.sock")).display().to_string()
}
}
fn connect(path: &str) -> std::io::Result<Box<dyn Write + Send>> {
let stream = connect_stream(path)?;
let mut replies = stream.try_clone()?;
std::thread::spawn(move || std::io::copy(&mut replies, &mut std::io::sink()));
Ok(Box::new(stream))
}
#[cfg(unix)]
fn connect_stream(path: &str) -> std::io::Result<std::os::unix::net::UnixStream> {
std::os::unix::net::UnixStream::connect(path)
}
#[cfg(windows)]
fn connect_stream(path: &str) -> std::io::Result<std::fs::File> {
std::fs::OpenOptions::new().read(true).write(true).open(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |key| vars.iter().find(|(k, _)| *k == key).map(|(_, v)| v.to_string())
}
#[cfg(unix)]
fn fake_mpv(data: Value) -> String {
use std::io::{BufRead, BufReader};
let path = ipc_path();
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
std::thread::spawn(move || {
for mut stream in listener.incoming().map_while(Result::ok) {
let mut line = String::new();
BufReader::new(&stream).read_line(&mut line).unwrap();
writeln!(stream, "{}", json!({ "data": data, "request_id": 1, "error": "success" })).unwrap();
}
});
path
}
#[cfg(unix)]
#[test]
fn start_is_detected_when_mpv_reports_a_position() {
let loading = Arc::new(AtomicBool::new(false));
watch_start(fake_mpv(Value::Null), Arc::clone(&loading), Arc::default());
let playing = Arc::new(AtomicBool::new(false));
watch_start(fake_mpv(json!(0.5)), Arc::clone(&playing), Arc::default());
std::thread::sleep(Duration::from_millis(300));
assert!(playing.load(Ordering::Relaxed));
assert!(!loading.load(Ordering::Relaxed), "no position yet: still loading");
}
#[test]
fn info_follows_property_changes() {
let mut info = Info::default();
assert!(info.apply("volume", &json!(85.0)));
info.apply("mute", &json!(true));
info.apply("height", &json!(480));
info.apply("video-format", &json!("h264"));
info.apply("duration", &json!(300.5));
assert!(info.apply("time-pos", &json!(3.0)));
assert!(!info.apply("time-pos", &json!(3.4)), "same second: nothing to redraw");
assert!(info.apply("time-pos", &json!(4.1)));
assert!(!info.apply("chapter", &json!(1)));
assert!(info.apply("demuxer-cache-time", &json!(60.2)));
assert!(!info.apply("demuxer-cache-time", &json!(60.7)));
assert_eq!(
info,
Info {
volume: Some(85.0),
muted: true,
height: Some(480),
codec: Some("h264".into()),
position: Some(4.1),
duration: Some(300.5),
buffered: Some(60.7),
paused: false,
}
);
info.apply("height", &Value::Null);
assert_eq!(info.height, None, "unloaded while reloading");
}
#[test]
fn explicit_modes_win() {
assert_eq!(resolve_mode(PlayerMode::Window, env(&[("KITTY_WINDOW_ID", "1")])), Mode::Window);
assert_eq!(resolve_mode(PlayerMode::Embedded, env(&[])), Mode::Embedded);
}
#[test]
fn auto_embeds_only_in_kitty_without_multiplexer() {
assert_eq!(resolve_mode(PlayerMode::Auto, env(&[("KITTY_WINDOW_ID", "1")])), Mode::Embedded);
assert_eq!(resolve_mode(PlayerMode::Auto, env(&[("TERM", "xterm-kitty")])), Mode::Embedded);
assert_eq!(resolve_mode(PlayerMode::Auto, env(&[("TERM", "xterm-256color")])), Mode::Window);
for multiplexer in MULTIPLEXERS {
let vars = [("KITTY_WINDOW_ID", "1"), (multiplexer, "1")];
assert_eq!(resolve_mode(PlayerMode::Auto, env(&vars)), Mode::Window, "{multiplexer}");
assert_eq!(resolve_mode(PlayerMode::Embedded, env(&vars)), Mode::Embedded, "explicit wins");
}
}
#[test]
fn geometry_is_one_based_with_pixel_size() {
let g = Geometry::new(Rect::new(25, 2, 80, 20), (10, 22));
assert_eq!(g, Geometry { left: 26, top: 3, cols: 80, rows: 20, width_px: 800, height_px: 440, screen: None });
}
const QUEUE: &[String] = &[];
fn launch(config: &PlayerConfig, mode: Mode) -> Launch<'_> {
Launch { config, mode, cookies: None, queue: QUEUE, audio_only: false }
}
#[test]
fn queue_goes_last_after_a_separator() {
let config = PlayerConfig::default();
let queue = ["/m/a.mp3".to_string(), "-raro.mp3".to_string()];
let args = mpv_args(&Launch { queue: &queue, audio_only: true, ..launch(&config, Mode::Embedded) }, None, "s");
assert_eq!(&args[args.len() - 3..], ["--", "/m/a.mp3", "-raro.mp3"]);
assert!(args.contains(&"--vid=no".into()));
assert!(!args.iter().any(|a| a.starts_with("--vo=")), "music needs no video output");
}
#[test]
fn format_prefers_codec_then_falls_back() {
assert_eq!(
ytdl_format(&PlayerConfig::default(), Mode::Window),
"bv*[height<=720][vcodec^=avc1]+ba/bv*[height<=720]+ba/b[height<=720]/b"
);
let any = PlayerConfig { max_height: 480, prefer_codec: String::new(), ..Default::default() };
assert_eq!(ytdl_format(&any, Mode::Window), "bv*[height<=480]+ba/b[height<=480]/b");
}
#[test]
fn embedded_prefers_30_fps() {
let any = PlayerConfig { max_height: 480, prefer_codec: String::new(), ..Default::default() };
assert_eq!(
ytdl_format(&any, Mode::Embedded),
"bv*[height<=480][fps<=30]+ba/bv*[height<=480]+ba/b[height<=480]/b"
);
let format = ytdl_format(&PlayerConfig::default(), Mode::Embedded);
assert!(
format.starts_with("bv*[height<=720][fps<=30][vcodec^=avc1]+ba/bv*[height<=720][fps<=30]+ba/"),
"{format}"
);
}
#[test]
fn dubbed_prefers_audio_in_sub_lang() {
let config = PlayerConfig { language: Language::Dubbed, prefer_codec: String::new(), ..Default::default() };
assert_eq!(
ytdl_format(&config, Mode::Window),
"bv*[height<=720]+ba[language^=es]/bv*[height<=720]+ba/b[height<=720]/b",
"never a bare `ba`: that would be sound without picture"
);
}
#[test]
fn subtitles_are_fetched_and_shown_only_in_subtitles_mode() {
let config = PlayerConfig::default();
let args = mpv_args(&launch(&config, Mode::Window), None, "s");
assert!(args.contains(&"--sid=auto".into()));
assert!(args.contains(&"--slang=es".into()));
assert!(args.contains(&"--ytdl-raw-options=sub-langs=[es],write-subs=,write-auto-subs=".into()));
let original = PlayerConfig { language: Language::Original, ..Default::default() };
assert!(mpv_args(&launch(&original, Mode::Window), None, "s").contains(&"--sid=no".into()));
}
#[test]
fn cookies_go_to_yt_dlp() {
let config = PlayerConfig::default();
let with = Launch { cookies: Some(std::path::Path::new("/c.txt")), ..launch(&config, Mode::Window) };
let args = mpv_args(&with, None, "s");
assert!(
args.iter().any(|a| a.starts_with("--ytdl-raw-options=") && a.ends_with(",cookies=/c.txt")),
"{args:?}"
);
}
#[test]
fn overlay_needs_x11_and_the_terminal_window() {
let x11 = [("DISPLAY", ":0"), ("WINDOWID", "42")];
assert_eq!(resolve_mode(PlayerMode::Overlay, env(&x11)), Mode::Overlay);
let wayland = [("DISPLAY", ":0"), ("WINDOWID", "42"), ("XDG_SESSION_TYPE", "wayland")];
assert_eq!(resolve_mode(PlayerMode::Overlay, env(&wayland)), Mode::Window);
assert_eq!(resolve_mode(PlayerMode::Overlay, env(&[("DISPLAY", ":0")])), Mode::Window);
let tmux = [("DISPLAY", ":0"), ("WINDOWID", "42"), ("TMUX", "1")];
assert_eq!(resolve_mode(PlayerMode::Overlay, env(&tmux)), Mode::Window);
assert!(Mode::Overlay.in_pane() && Mode::Embedded.in_pane() && !Mode::Window.in_pane());
}
#[test]
fn overlay_is_placed_in_screen_pixels() {
let g = Geometry::new(Rect::new(28, 3, 50, 20), (10, 20));
assert_eq!(g.x11(), None, "not placed yet");
let placed = g.on_screen((118, 153));
assert_eq!(placed.screen, Some((118 + 280, 153 + 60)));
assert_eq!(placed.x11().as_deref(), Some("500x400+398+213"));
assert_eq!(grid_from_window((100, 50, 1000), 98, 10), (110, 60));
assert_eq!(grid_from_window((0, 0, 100), 50, 10), (0, 0), "never negative");
}
#[test]
fn overlay_args_make_a_borderless_window_that_lets_input_through() {
let config = PlayerConfig::default();
let g = Geometry::new(Rect::new(28, 3, 50, 20), (10, 20)).on_screen((118, 153));
let args = mpv_args(&launch(&config, Mode::Overlay), Some(g), "/tmp/s");
for arg in
["--no-border", "--ontop", "--input-cursor-passthrough=yes", "--hwdec=auto", "--geometry=500x400+398+213"]
{
assert!(args.contains(&arg.to_string()), "{arg}: {args:?}");
}
assert!(!args.iter().any(|a| a.starts_with("--vo=")), "mpv's own video output");
assert!(ytdl_format(&config, Mode::Overlay).starts_with("bv*[height<=720][vcodec^=avc1]"), "no 30 fps cap");
}
#[test]
fn window_args() {
let config = PlayerConfig::default();
let args = mpv_args(&launch(&config, Mode::Window), None, "/tmp/s");
assert!(args.contains(&"--hwdec=auto".into()));
assert!(args.contains(&"--force-window=immediate".into()));
assert!(!args.iter().any(|a| a.starts_with("--vo")));
assert_eq!(args.last().unwrap(), "--");
}
#[test]
fn embedded_args_place_video_and_leave_screen_to_us() {
let g = Geometry::new(Rect::new(0, 1, 40, 10), (10, 20));
let config = PlayerConfig::default();
let args = mpv_args(&launch(&config, Mode::Embedded), Some(g), "/tmp/s");
for expected in [
"--vo=kitty",
"--vo-kitty-alt-screen=no",
"--vo-kitty-config-clear=no",
"--hwdec=no",
"--sws-scaler=fast-bilinear",
"--framedrop=decoder+vo",
"--vo-kitty-left=1",
"--vo-kitty-top=2",
"--vo-kitty-width=400",
"--vo-kitty-height=200",
"--input-ipc-server=/tmp/s",
] {
assert!(args.contains(&expected.to_string()), "missing {expected}: {args:?}");
}
}
#[test]
fn hwdec_can_be_disabled() {
let config = PlayerConfig { hwdec: false, ..Default::default() };
assert!(mpv_args(&launch(&config, Mode::Window), None, "s").contains(&"--hwdec=no".into()));
}
#[test]
fn commands_serialize_to_mpv_ipc() {
let config = PlayerConfig::default();
let one = |command| command_lines(&config, Mode::Embedded, None, command).join("\n");
assert_eq!(one(PlayerCommand::TogglePause), r#"{"command":["cycle","pause"]}"#);
assert_eq!(one(PlayerCommand::Seek(-5)), r#"{"command":["seek",-5,"relative"]}"#);
assert_eq!(one(PlayerCommand::Volume(5)), r#"{"command":["add","volume",5]}"#);
assert_eq!(one(PlayerCommand::ToggleMute), r#"{"command":["cycle","mute"]}"#);
assert_eq!(one(PlayerCommand::CycleSubtitles), r#"{"command":["cycle","sub"]}"#);
assert_eq!(one(PlayerCommand::NextTrack), r#"{"command":["playlist-next"]}"#);
}
#[test]
fn language_change_reloads_at_the_same_second() {
let config = PlayerConfig {
prefer_codec: String::new(),
language: Language::Dubbed,
sub_lang: "en".into(),
..Default::default()
};
let cookies = std::path::Path::new("/c.txt");
let lines = command_lines(&config, Mode::Window, Some(cookies), PlayerCommand::SubLang("en"));
assert_eq!(
lines,
[
r#"{"command":["set_property","ytdl-format","bv*[height<=720]+ba[language^=en]/bv*[height<=720]+ba/b[height<=720]/b"]}"#,
r#"{"command":["set_property","ytdl-raw-options","sub-langs=[en],write-subs=,write-auto-subs=,cookies=/c.txt"]}"#,
r#"{"command":["set_property","slang","en"]}"#,
r#"{"command":["set_property","sid","no"]}"#,
]
);
assert_eq!(
reload_line("https://youtu.be/x", 83.456),
r#"{"command":["loadfile","https://youtu.be/x","replace",-1,"start=83.5"]}"#
);
let subs = PlayerConfig { language: Language::Subtitles, ..config };
let lines = command_lines(&subs, Mode::Embedded, None, PlayerCommand::Language(Language::Subtitles));
assert_eq!(lines[3], r#"{"command":["set_property","sid","auto"]}"#);
}
#[test]
fn ipc_paths_are_unique() {
assert_ne!(ipc_path(), ipc_path());
}
}