Files
cm-player/src/player/mod.rs
Christoffer Martinsson 0ec328881a
All checks were successful
Build and Release / build-and-release (push) Successful in 1m18s
Optimize MPV polling with single batch query every 200ms
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.
2025-12-12 11:54:42 +01:00

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();
}
}