Some checks failed
ci/woodpecker/pr/check Pipeline was successful
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
- Add #[allow(unused_imports)] on pub re-exports used only by lib/tests - Add #[allow(dead_code)] on public API items unused in binary target - Fix collapsible_if, redundant_closure, unnecessary_map_or in main.rs - Prefix unused test variables with underscore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
199 lines
6.1 KiB
Rust
199 lines
6.1 KiB
Rust
//! 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<Mutex<Option<u32>>>,
|
||
/// Whether the process is currently paused (SIGSTOP)
|
||
paused: Arc<Mutex<bool>>,
|
||
/// Path to the currently playing file (for restart with seek)
|
||
current_path: Arc<Mutex<Option<std::path::PathBuf>>>,
|
||
/// True between play_from() call and ffplay actually starting (race window)
|
||
starting: Arc<Mutex<bool>>,
|
||
}
|
||
|
||
impl AudioPlayer {
|
||
/// Creates a new AudioPlayer
|
||
pub fn new() -> Result<Self, String> {
|
||
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<P: AsRef<Path>>(&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<P: AsRef<Path>>(&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);
|
||
}
|
||
}
|
||
}
|