All checks were successful
Build and Release / build-and-release (push) Successful in 1m18s
Replace separate property queries with unified batch fetching: - Consolidate position, duration, and metadata into one IPC call - Reduce polling from 100ms to 200ms (5 FPS) - Remove complex timeout handling in favor of simple blocking reads - Remove unused is_idle, is_paused, and get_property methods This eliminates status bar flashing and reduces CPU usage.
361 lines
12 KiB
Rust
361 lines
12 KiB
Rust
use anyhow::{Context, Result};
|
|
use serde_json::{json, Value};
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::time::Duration;
|
|
use tempfile::NamedTempFile;
|
|
|
|
pub struct Player {
|
|
process: Child,
|
|
socket_path: PathBuf,
|
|
socket: Option<UnixStream>,
|
|
position: f64,
|
|
duration: f64,
|
|
pub media_title: Option<String>,
|
|
pub artist: Option<String>,
|
|
pub album: Option<String>,
|
|
pub audio_codec: Option<String>,
|
|
pub audio_bitrate: Option<f64>,
|
|
pub sample_rate: Option<i64>,
|
|
pub cache_duration: Option<f64>,
|
|
}
|
|
|
|
impl Player {
|
|
pub fn new() -> Result<Self> {
|
|
// Create temporary socket file
|
|
let socket_file = NamedTempFile::new().context("Failed to create temp socket")?;
|
|
let socket_path = socket_file.path().to_path_buf();
|
|
drop(socket_file); // Remove the file so MPV can create the socket
|
|
|
|
// Spawn MPV with IPC server
|
|
let process = Command::new("mpv")
|
|
.arg("--idle")
|
|
.arg("--no-terminal")
|
|
.arg("--profile=fast")
|
|
.arg("--audio-display=no") // Don't show cover art for audio files
|
|
.arg("--audio-buffer=2") // Larger buffer for WSLg audio stability
|
|
.arg(format!("--input-ipc-server={}", socket_path.display()))
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.context("Failed to spawn MPV process")?;
|
|
|
|
tracing::info!("MPV process started with IPC at {:?}", socket_path);
|
|
|
|
Ok(Self {
|
|
process,
|
|
socket_path,
|
|
socket: None,
|
|
position: 0.0,
|
|
duration: 0.0,
|
|
media_title: None,
|
|
artist: None,
|
|
album: None,
|
|
audio_codec: None,
|
|
audio_bitrate: None,
|
|
sample_rate: None,
|
|
cache_duration: None,
|
|
})
|
|
}
|
|
|
|
fn connect(&mut self) -> Result<()> {
|
|
if self.socket.is_none() {
|
|
// CRITICAL: Only try to connect if socket file exists
|
|
// If socket doesn't exist, MPV hasn't created it yet - fail fast
|
|
if !self.socket_path.exists() {
|
|
return Err(anyhow::anyhow!("Socket file doesn't exist yet"));
|
|
}
|
|
|
|
// Try to connect with a timeout using non-blocking mode
|
|
// IMPORTANT: UnixStream::connect() blocks in the kernel if socket exists
|
|
// but server isn't listening yet. We check existence first but still
|
|
// need to handle connect blocking if MPV just created socket but isn't ready.
|
|
let stream = match UnixStream::connect(&self.socket_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
// Connection failed - MPV probably not ready yet
|
|
return Err(anyhow::anyhow!("Failed to connect: {}", e));
|
|
}
|
|
};
|
|
|
|
// Set non-blocking and timeout to prevent hangs on reads/writes
|
|
stream.set_nonblocking(true)?;
|
|
stream.set_read_timeout(Some(Duration::from_millis(100)))?;
|
|
stream.set_write_timeout(Some(Duration::from_millis(100)))?;
|
|
|
|
self.socket = Some(stream);
|
|
tracing::debug!("Connected to MPV socket successfully");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn send_command(&mut self, command: &str, args: &[Value]) -> Result<()> {
|
|
self.connect()?;
|
|
|
|
let mut command_array = vec![json!(command)];
|
|
command_array.extend_from_slice(args);
|
|
|
|
let cmd = json!({
|
|
"command": command_array
|
|
});
|
|
|
|
if let Some(ref mut socket) = self.socket {
|
|
let msg = format!("{}\n", cmd);
|
|
// Ignore broken pipe errors (mpv closed)
|
|
if let Err(e) = socket.write_all(msg.as_bytes()) {
|
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
|
self.socket = None;
|
|
// Clean up dead process
|
|
self.process.kill().ok();
|
|
return Ok(());
|
|
}
|
|
return Err(e).context("Failed to write to socket");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn get_properties_batch(&mut self, properties: &[&str]) -> std::collections::HashMap<String, Value> {
|
|
let mut results = std::collections::HashMap::new();
|
|
|
|
// Try to connect
|
|
if self.connect().is_err() {
|
|
return results;
|
|
}
|
|
|
|
if let Some(ref mut socket) = self.socket {
|
|
// Send all property requests at once with unique request_ids
|
|
for (idx, property) in properties.iter().enumerate() {
|
|
let cmd = json!({
|
|
"command": ["get_property", property],
|
|
"request_id": idx + 1
|
|
});
|
|
let msg = format!("{}\n", cmd);
|
|
if socket.write_all(msg.as_bytes()).is_err() {
|
|
return results;
|
|
}
|
|
}
|
|
|
|
// Read all responses
|
|
// IMPORTANT: Socket is non-blocking, need to set blocking mode for read
|
|
socket.set_nonblocking(false).ok();
|
|
|
|
let cloned_socket = match socket.try_clone() {
|
|
Ok(s) => s,
|
|
Err(_) => {
|
|
socket.set_nonblocking(true).ok();
|
|
return results;
|
|
}
|
|
};
|
|
|
|
cloned_socket.set_nonblocking(false).ok();
|
|
|
|
let mut reader = BufReader::new(cloned_socket);
|
|
|
|
// Read responses - stop if we timeout or hit an error
|
|
for _ in 0..properties.len() {
|
|
let mut response = String::new();
|
|
if reader.read_line(&mut response).is_err() {
|
|
// Timeout or error - stop reading
|
|
break;
|
|
}
|
|
|
|
// Parse response
|
|
if let Ok(parsed) = serde_json::from_str::<Value>(&response) {
|
|
// Check for success
|
|
if let Some(error) = parsed.get("error").and_then(|e| e.as_str()) {
|
|
if error == "success" {
|
|
// Get request_id to match with property
|
|
if let Some(req_id) = parsed.get("request_id").and_then(|id| id.as_i64()) {
|
|
let idx = (req_id - 1) as usize;
|
|
if idx < properties.len() {
|
|
if let Some(data) = parsed.get("data") {
|
|
results.insert(properties[idx].to_string(), data.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore non-blocking mode
|
|
socket.set_nonblocking(true).ok();
|
|
}
|
|
|
|
results
|
|
}
|
|
|
|
pub fn play(&mut self, path: &Path) -> Result<()> {
|
|
let path_str = path.to_string_lossy();
|
|
// Reset position/duration before loading new file to avoid showing stale values
|
|
self.position = 0.0;
|
|
self.duration = 0.0;
|
|
self.send_command("loadfile", &[json!(path_str), json!("replace")])?;
|
|
tracing::info!("Playing: {}", path_str);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn play_paused(&mut self, path: &Path) -> Result<()> {
|
|
let path_str = path.to_string_lossy();
|
|
// Reset position/duration before loading new file to avoid showing stale values
|
|
self.position = 0.0;
|
|
self.duration = 0.0;
|
|
// Load file but start paused - avoids audio blip when jumping tracks while paused
|
|
self.send_command("loadfile", &[json!(path_str), json!("replace"), json!({"pause": true})])?;
|
|
tracing::info!("Playing (paused): {}", path_str);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn pause(&mut self) -> Result<()> {
|
|
self.send_command("set_property", &[json!("pause"), json!(true)])?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn resume(&mut self) -> Result<()> {
|
|
self.send_command("set_property", &[json!("pause"), json!(false)])?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stop(&mut self) -> Result<()> {
|
|
self.send_command("stop", &[])?;
|
|
self.position = 0.0;
|
|
self.duration = 0.0;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_volume(&mut self, volume: i64) -> Result<()> {
|
|
self.send_command("set_property", &[json!("volume"), json!(volume)])?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn update_all_properties(&mut self) {
|
|
// Fetch ALL properties in one batch
|
|
let results = self.get_properties_batch(&[
|
|
"time-pos",
|
|
"duration",
|
|
"metadata/by-key/artist",
|
|
"metadata/by-key/ARTIST",
|
|
"metadata/by-key/album",
|
|
"metadata/by-key/ALBUM",
|
|
"metadata/by-key/title",
|
|
"metadata/by-key/TITLE",
|
|
"media-title",
|
|
"audio-codec-name",
|
|
"audio-bitrate",
|
|
"audio-params/samplerate",
|
|
"demuxer-cache-duration",
|
|
]);
|
|
|
|
// Position
|
|
if let Some(val) = results.get("time-pos") {
|
|
if let Some(pos) = val.as_f64() {
|
|
self.position = pos;
|
|
}
|
|
}
|
|
|
|
// Duration
|
|
if let Some(val) = results.get("duration") {
|
|
if let Some(dur) = val.as_f64() {
|
|
self.duration = dur;
|
|
}
|
|
}
|
|
|
|
// Artist - try lowercase first, then uppercase
|
|
self.artist = results.get("metadata/by-key/artist")
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
.or_else(|| results.get("metadata/by-key/ARTIST")
|
|
.and_then(|v| v.as_str().map(|s| s.to_string())));
|
|
|
|
// Album - try lowercase first, then uppercase
|
|
self.album = results.get("metadata/by-key/album")
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
.or_else(|| results.get("metadata/by-key/ALBUM")
|
|
.and_then(|v| v.as_str().map(|s| s.to_string())));
|
|
|
|
// Title - try lowercase, then uppercase, then media-title
|
|
self.media_title = results.get("metadata/by-key/title")
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
.or_else(|| results.get("metadata/by-key/TITLE")
|
|
.and_then(|v| v.as_str().map(|s| s.to_string())))
|
|
.or_else(|| results.get("media-title")
|
|
.and_then(|v| v.as_str().map(|s| s.to_string())));
|
|
|
|
// Audio codec
|
|
self.audio_codec = results.get("audio-codec-name")
|
|
.and_then(|v| v.as_str().map(|s| s.to_string()));
|
|
|
|
// Audio bitrate (convert from bps to kbps)
|
|
self.audio_bitrate = results.get("audio-bitrate")
|
|
.and_then(|v| v.as_f64().map(|b| b / 1000.0));
|
|
|
|
// Sample rate
|
|
self.sample_rate = results.get("audio-params/samplerate")
|
|
.and_then(|v| v.as_i64());
|
|
|
|
// Cache duration
|
|
self.cache_duration = results.get("demuxer-cache-duration")
|
|
.and_then(|v| v.as_f64());
|
|
}
|
|
|
|
pub fn get_position(&self) -> Option<f64> {
|
|
Some(self.position)
|
|
}
|
|
|
|
pub fn get_duration(&self) -> Option<f64> {
|
|
Some(self.duration)
|
|
}
|
|
|
|
pub fn get_player_state(&mut self) -> Option<crate::state::PlayerState> {
|
|
use crate::state::PlayerState;
|
|
|
|
// Batch fetch both properties at once
|
|
let results = self.get_properties_batch(&["idle-active", "pause"]);
|
|
|
|
let is_idle = results.get("idle-active").and_then(|v| v.as_bool())?;
|
|
let is_paused = results.get("pause").and_then(|v| v.as_bool())?;
|
|
|
|
Some(if is_idle {
|
|
PlayerState::Stopped
|
|
} else if is_paused {
|
|
PlayerState::Paused
|
|
} else {
|
|
PlayerState::Playing
|
|
})
|
|
}
|
|
|
|
pub fn is_process_alive(&mut self) -> bool {
|
|
// Check if mpv process is still running
|
|
match self.process.try_wait() {
|
|
Ok(Some(_)) => {
|
|
// Process has exited - clean up socket
|
|
self.socket = None;
|
|
false
|
|
}
|
|
Ok(None) => true, // Process is still running
|
|
Err(_) => {
|
|
// Error checking, assume dead and clean up
|
|
self.socket = None;
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn seek(&mut self, seconds: f64) -> Result<()> {
|
|
self.send_command("seek", &[json!(seconds), json!("relative")])?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for Player {
|
|
fn drop(&mut self) {
|
|
self.send_command("quit", &[]).ok();
|
|
self.process.kill().ok();
|
|
std::fs::remove_file(&self.socket_path).ok();
|
|
}
|
|
}
|