From 2036c49b525a5c48aa1ad27f35568baca29666c1 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 16 Apr 2026 18:46:01 +0200 Subject: [PATCH] feat: add mpv sidecar IPC and Tauri commands Persistent BufReader + request_id matching for correct event handling. Audio-file passed during loadfile for frame-accurate sync. Co-Authored-By: Claude Opus 4.6 --- client/src-tauri/src/commands.rs | 56 +++++++++++ client/src-tauri/src/lib.rs | 25 +++-- client/src-tauri/src/mpv.rs | 162 +++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 client/src-tauri/src/commands.rs create mode 100644 client/src-tauri/src/mpv.rs diff --git a/client/src-tauri/src/commands.rs b/client/src-tauri/src/commands.rs new file mode 100644 index 0000000..c75e0ad --- /dev/null +++ b/client/src-tauri/src/commands.rs @@ -0,0 +1,56 @@ +use tauri::State; +use std::sync::Mutex; +use crate::mpv::Mpv; + +pub struct MpvState(pub Mutex); + +#[tauri::command] +pub fn mpv_start(state: State) -> Result<(), String> { + state.0.lock().unwrap().start() +} + +#[tauri::command] +pub fn mpv_stop(state: State) -> Result<(), String> { + state.0.lock().unwrap().stop(); + Ok(()) +} + +#[tauri::command] +pub fn mpv_load(state: State, video_url: String, audio_url: String) -> Result<(), String> { + state.0.lock().unwrap().load_file(&video_url, &audio_url) +} + +#[tauri::command] +pub fn mpv_seek(state: State, time: f64) -> Result<(), String> { + state.0.lock().unwrap().seek(time) +} + +#[tauri::command] +pub fn mpv_pause(state: State) -> Result<(), String> { + state.0.lock().unwrap().pause() +} + +#[tauri::command] +pub fn mpv_resume(state: State) -> Result<(), String> { + state.0.lock().unwrap().resume() +} + +#[tauri::command] +pub fn mpv_set_loop(state: State, a: f64, b: f64) -> Result<(), String> { + state.0.lock().unwrap().set_loop(a, b) +} + +#[tauri::command] +pub fn mpv_clear_loop(state: State) -> Result<(), String> { + state.0.lock().unwrap().clear_loop() +} + +#[tauri::command] +pub fn mpv_time_pos(state: State) -> Result { + state.0.lock().unwrap().time_pos() +} + +#[tauri::command] +pub fn mpv_duration(state: State) -> Result { + state.0.lock().unwrap().get_duration() +} diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 4a277ef..d7d2efb 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -1,14 +1,27 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +mod mpv; +mod commands; + +use commands::MpvState; +use mpv::Mpv; +use std::sync::Mutex; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .manage(MpvState(Mutex::new(Mpv::new()))) + .invoke_handler(tauri::generate_handler![ + commands::mpv_start, + commands::mpv_stop, + commands::mpv_load, + commands::mpv_seek, + commands::mpv_pause, + commands::mpv_resume, + commands::mpv_set_loop, + commands::mpv_clear_loop, + commands::mpv_time_pos, + commands::mpv_duration, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/client/src-tauri/src/mpv.rs b/client/src-tauri/src/mpv.rs new file mode 100644 index 0000000..9dc0009 --- /dev/null +++ b/client/src-tauri/src/mpv.rs @@ -0,0 +1,162 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::process::{Child, Command}; +use std::sync::atomic::{AtomicU64, Ordering}; +use serde_json::{json, Value}; + +pub struct Mpv { + process: Option, + writer: Option, + reader: Option>, + socket_path: String, + next_id: AtomicU64, +} + +impl Mpv { + pub fn new() -> Self { + let socket_path = format!("/tmp/8cut-mpv-{}", std::process::id()); + Mpv { + process: None, + writer: None, + reader: None, + socket_path, + next_id: AtomicU64::new(1), + } + } + + pub fn start(&mut self) -> Result<(), String> { + self.stop(); + + let child = Command::new("mpv") + .args([ + "--idle=yes", + "--force-window=no", + "--vo=null", + "--keep-open=yes", + &format!("--input-ipc-server={}", self.socket_path), + ]) + .spawn() + .map_err(|e| format!("Failed to start mpv: {e}"))?; + + self.process = Some(child); + + // Wait for socket + for _ in 0..50 { + std::thread::sleep(std::time::Duration::from_millis(100)); + if let Ok(stream) = UnixStream::connect(&self.socket_path) { + stream.set_nonblocking(false).ok(); + let reader_stream = stream.try_clone().map_err(|e| e.to_string())?; + self.writer = Some(stream); + self.reader = Some(BufReader::new(reader_stream)); + return Ok(()); + } + } + Err("Timeout waiting for mpv IPC socket".into()) + } + + pub fn stop(&mut self) { + if let Some(ref mut child) = self.process { + child.kill().ok(); + child.wait().ok(); + } + self.process = None; + self.writer = None; + self.reader = None; + std::fs::remove_file(&self.socket_path).ok(); + } + + /// Send a command and wait for the matching response (by request_id). + /// Skips over asynchronous mpv events while waiting. + fn send_and_recv(&mut self, cmd: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let writer = self.writer.as_mut().ok_or("mpv not running")?; + let reader = self.reader.as_mut().ok_or("mpv not running")?; + + let mut msg_val = cmd; + msg_val["request_id"] = json!(id); + let mut msg = serde_json::to_string(&msg_val).unwrap(); + msg.push('\n'); + writer.write_all(msg.as_bytes()).map_err(|e| e.to_string())?; + + // Read lines until we find the response matching our request_id + let mut line = String::new(); + loop { + line.clear(); + reader.read_line(&mut line).map_err(|e| e.to_string())?; + let parsed: Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; + // mpv events have "event" key, responses have "request_id" + if parsed.get("request_id").and_then(|v| v.as_u64()) == Some(id) { + return Ok(parsed); + } + // Otherwise it's an async event — skip it + } + } + + pub fn command(&mut self, args: &[&str]) -> Result<(), String> { + let resp = self.send_and_recv(json!({ "command": args }))?; + if resp.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv error: {}", resp.get("error").unwrap_or(&Value::Null))); + } + Ok(()) + } + + pub fn set_property(&mut self, name: &str, value: Value) -> Result<(), String> { + let resp = self.send_and_recv(json!({ "command": ["set_property", name, value] }))?; + if resp.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv error: {}", resp.get("error").unwrap_or(&Value::Null))); + } + Ok(()) + } + + pub fn get_property(&mut self, name: &str) -> Result { + let resp = self.send_and_recv(json!({ "command": ["get_property", name] }))?; + if resp.get("error").and_then(|e| e.as_str()) != Some("success") { + return Err(format!("mpv error: {}", resp.get("error").unwrap_or(&Value::Null))); + } + Ok(resp.get("data").cloned().unwrap_or(Value::Null)) + } + + pub fn load_file(&mut self, video_url: &str, audio_url: &str) -> Result<(), String> { + // Pass audio-file option during load so both streams sync from the start + let options = format!("audio-file={}", audio_url); + self.command(&["loadfile", video_url, "replace", &options]) + } + + pub fn seek(&mut self, time: f64) -> Result<(), String> { + self.command(&["seek", &time.to_string(), "absolute"]) + } + + pub fn pause(&mut self) -> Result<(), String> { + self.set_property("pause", json!(true)) + } + + pub fn resume(&mut self) -> Result<(), String> { + self.set_property("pause", json!(false)) + } + + pub fn set_loop(&mut self, a: f64, b: f64) -> Result<(), String> { + self.set_property("ab-loop-a", json!(a))?; + self.set_property("ab-loop-b", json!(b)) + } + + pub fn clear_loop(&mut self) -> Result<(), String> { + self.set_property("ab-loop-a", json!("no"))?; + self.set_property("ab-loop-b", json!("no")) + } + + pub fn time_pos(&mut self) -> Result { + let val = self.get_property("time-pos")?; + val.as_f64().ok_or("time-pos not a number".into()) + } + + pub fn get_duration(&mut self) -> Result { + let val = self.get_property("duration")?; + val.as_f64().ok_or("duration not a number".into()) + } +} + +impl Drop for Mpv { + fn drop(&mut self) { + self.stop(); + } +}