feat: implement Phase 12 — voice message playback with ffplay
Add voice message playback infrastructure: - AudioPlayer using ffplay subprocess with SIGSTOP/SIGCONT for pause/resume - VoiceCache with LRU eviction (100 MB limit) - TDLib integration: VoiceInfo, VoiceDownloadState, PlaybackState types - download_voice_note() in TdClientTrait - Keybindings: Space (play/pause), ←/→ (seek ±5s) - Auto-stop playback on message navigation - Remove debug_log module
This commit is contained in:
150
src/audio/player.rs
Normal file
150
src/audio/player.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
//! 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>>,
|
||||
}
|
||||
|
||||
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)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Plays an audio file from the given path
|
||||
pub fn play<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
|
||||
self.stop();
|
||||
|
||||
let path_owned = path.as_ref().to_path_buf();
|
||||
let current_pid = self.current_pid.clone();
|
||||
let paused = self.paused.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(mut child) = Command::new("ffplay")
|
||||
.arg("-nodisp")
|
||||
.arg("-autoexit")
|
||||
.arg("-loglevel").arg("quiet")
|
||||
.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;
|
||||
|
||||
let _ = child.wait();
|
||||
|
||||
*current_pid.lock().unwrap() = None;
|
||||
*paused.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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops playback (kills the process)
|
||||
pub fn stop(&self) {
|
||||
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)
|
||||
pub fn is_playing(&self) -> bool {
|
||||
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Returns true if paused
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Returns true if no active process
|
||||
pub fn is_stopped(&self) -> bool {
|
||||
self.current_pid.lock().unwrap().is_none()
|
||||
}
|
||||
|
||||
pub fn set_volume(&self, _volume: f32) {}
|
||||
pub fn adjust_volume(&self, _delta: f32) {}
|
||||
|
||||
pub fn volume(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
pub fn seek(&self, _delta: Duration) -> Result<(), String> {
|
||||
Err("Seeking not supported".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user