feat: complete Phase 12 — voice playback ticker, cache, config, and UI
Add playback position ticker in event loop with 1s UI refresh rate, integrate VoiceCache for downloaded voice files, add [audio] config section (cache_size_mb, auto_download_voice), and render progress bar with waveform visualization in message bubbles. Fix race conditions in AudioPlayer: add `starting` flag to prevent false `is_stopped()` during ffplay startup, guard pid cleanup so old threads don't overwrite newer process pids. Implement `resume_from()` with ffplay `-ss` for real audio seek on unpause (-1s rewind). Kill ffplay on app exit via `stop_playback()` in shutdown + Drop impl. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ pub struct AudioPlayer {
|
||||
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 {
|
||||
@@ -29,22 +33,38 @@ impl AudioPlayer {
|
||||
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 || {
|
||||
if let Ok(mut child) = Command::new("ffplay")
|
||||
.arg("-nodisp")
|
||||
let mut cmd = Command::new("ffplay");
|
||||
cmd.arg("-nodisp")
|
||||
.arg("-autoexit")
|
||||
.arg("-loglevel").arg("quiet")
|
||||
.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())
|
||||
@@ -53,11 +73,18 @@ impl AudioPlayer {
|
||||
let pid = child.id();
|
||||
*current_pid.lock().unwrap() = Some(pid);
|
||||
*paused.lock().unwrap() = false;
|
||||
*starting.lock().unwrap() = false;
|
||||
|
||||
let _ = child.wait();
|
||||
|
||||
*current_pid.lock().unwrap() = None;
|
||||
*paused.lock().unwrap() = false;
|
||||
// Обнуляем только если это наш 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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,7 +102,7 @@ impl AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes playback via SIGCONT
|
||||
/// 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")
|
||||
@@ -86,8 +113,19 @@ impl AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
@@ -111,9 +149,9 @@ impl AudioPlayer {
|
||||
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Returns true if no active process
|
||||
/// 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.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_volume(&self, _volume: f32) {}
|
||||
@@ -128,6 +166,12 @@ impl AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioPlayer {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user