//! Audio player for voice messages. //! //! Uses ffplay (from FFmpeg) for reliable Opus/OGG playback. //! Pause/resume implemented via SIGSTOP/SIGCONT signals. use std::path::Path; use std::process::Command; use std::sync::{Arc, Mutex}; use std::time::Duration; /// Audio player state and controls pub struct AudioPlayer { /// PID of current playback process (if any) current_pid: Arc>>, /// Whether the process is currently paused (SIGSTOP) paused: Arc>, /// Path to the currently playing file (for restart with seek) current_path: Arc>>, /// True between play_from() call and ffplay actually starting (race window) starting: Arc>, } impl AudioPlayer { /// Creates a new AudioPlayer pub fn new() -> Result { Command::new("which") .arg("ffplay") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() .map_err(|_| "ffplay not found (install ffmpeg)".to_string())?; Ok(Self { current_pid: Arc::new(Mutex::new(None)), paused: Arc::new(Mutex::new(false)), current_path: Arc::new(Mutex::new(None)), starting: Arc::new(Mutex::new(false)), }) } /// Plays an audio file from the given path pub fn play>(&self, path: P) -> Result<(), String> { self.play_from(path, 0.0) } /// Plays an audio file starting from the given position (seconds) pub fn play_from>(&self, path: P, start_secs: f32) -> Result<(), String> { self.stop(); let path_owned = path.as_ref().to_path_buf(); *self.current_path.lock().unwrap() = Some(path_owned.clone()); *self.starting.lock().unwrap() = true; let current_pid = self.current_pid.clone(); let paused = self.paused.clone(); let starting = self.starting.clone(); std::thread::spawn(move || { let mut cmd = Command::new("ffplay"); cmd.arg("-nodisp") .arg("-autoexit") .arg("-loglevel") .arg("quiet"); if start_secs > 0.0 { cmd.arg("-ss").arg(format!("{:.1}", start_secs)); } if let Ok(mut child) = cmd .arg(&path_owned) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() { let pid = child.id(); *current_pid.lock().unwrap() = Some(pid); *paused.lock().unwrap() = false; *starting.lock().unwrap() = false; let _ = child.wait(); // Обнуляем только если это наш pid (новый play мог уже заменить его) let mut pid_guard = current_pid.lock().unwrap(); if *pid_guard == Some(pid) { *pid_guard = None; *paused.lock().unwrap() = false; } } else { *starting.lock().unwrap() = false; } }); Ok(()) } /// Pauses playback via SIGSTOP pub fn pause(&self) { if let Some(pid) = *self.current_pid.lock().unwrap() { let _ = Command::new("kill") .arg("-STOP") .arg(pid.to_string()) .output(); *self.paused.lock().unwrap() = true; } } /// Resumes playback via SIGCONT (from the same position) pub fn resume(&self) { if let Some(pid) = *self.current_pid.lock().unwrap() { let _ = Command::new("kill") .arg("-CONT") .arg(pid.to_string()) .output(); *self.paused.lock().unwrap() = false; } } /// Resumes playback from a specific position (restarts ffplay with -ss) pub fn resume_from(&self, position_secs: f32) -> Result<(), String> { let path = self.current_path.lock().unwrap().clone(); if let Some(path) = path { self.play_from(&path, position_secs) } else { Err("No file to resume".to_string()) } } /// Stops playback (kills the process) pub fn stop(&self) { *self.starting.lock().unwrap() = false; if let Some(pid) = self.current_pid.lock().unwrap().take() { // Resume first if paused, then kill let _ = Command::new("kill") .arg("-CONT") .arg(pid.to_string()) .output(); let _ = Command::new("kill").arg(pid.to_string()).output(); } *self.paused.lock().unwrap() = false; } /// Returns true if a process is active (playing or paused) #[allow(dead_code)] pub fn is_playing(&self) -> bool { self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap() } /// Returns true if paused #[allow(dead_code)] pub fn is_paused(&self) -> bool { self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap() } /// Returns true if no active process and not starting a new one pub fn is_stopped(&self) -> bool { self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap() } #[allow(dead_code)] pub fn set_volume(&self, _volume: f32) {} #[allow(dead_code)] pub fn adjust_volume(&self, _delta: f32) {} pub fn volume(&self) -> f32 { 1.0 } #[allow(dead_code)] pub fn seek(&self, _delta: Duration) -> Result<(), String> { Err("Seeking not supported".to_string()) } } impl Drop for AudioPlayer { fn drop(&mut self) { self.stop(); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_audio_player_creation() { if let Ok(player) = AudioPlayer::new() { assert!(player.is_stopped()); assert!(!player.is_playing()); assert!(!player.is_paused()); } } #[test] fn test_volume() { if let Ok(player) = AudioPlayer::new() { assert_eq!(player.volume(), 1.0); } } }