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:
Mikhail Kilin
2026-02-09 02:35:49 +03:00
parent 2a5fd6aa35
commit 7bc264198f
17 changed files with 750 additions and 103 deletions

158
src/audio/cache.rs Normal file
View File

@@ -0,0 +1,158 @@
//! Voice message cache management.
//!
//! Caches downloaded OGG voice files in ~/.cache/tele-tui/voice/
//! with LRU eviction when cache size exceeds limit.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// Maximum cache size in bytes (100 MB default)
const MAX_CACHE_SIZE_BYTES: u64 = 100 * 1024 * 1024;
/// Cache for voice message files
pub struct VoiceCache {
cache_dir: PathBuf,
/// file_id -> (path, size_bytes, access_count)
files: HashMap<String, (PathBuf, u64, usize)>,
access_counter: usize,
max_size_bytes: u64,
}
impl VoiceCache {
/// Creates a new VoiceCache
pub fn new() -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Failed to get cache directory")?
.join("tele-tui")
.join("voice");
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
Ok(Self {
cache_dir,
files: HashMap::new(),
access_counter: 0,
max_size_bytes: MAX_CACHE_SIZE_BYTES,
})
}
/// Gets the path for a cached voice file, if it exists
pub fn get(&mut self, file_id: &str) -> Option<PathBuf> {
if let Some((path, _, access)) = self.files.get_mut(file_id) {
// Update access count for LRU
self.access_counter += 1;
*access = self.access_counter;
Some(path.clone())
} else {
None
}
}
/// Stores a voice file in the cache
pub fn store(&mut self, file_id: &str, source_path: &Path) -> Result<PathBuf, String> {
// Copy file to cache
let filename = format!("{}.ogg", file_id.replace('/', "_"));
let dest_path = self.cache_dir.join(&filename);
fs::copy(source_path, &dest_path)
.map_err(|e| format!("Failed to copy voice file to cache: {}", e))?;
// Get file size
let size = fs::metadata(&dest_path)
.map_err(|e| format!("Failed to get file size: {}", e))?
.len();
// Store in cache
self.access_counter += 1;
self.files
.insert(file_id.to_string(), (dest_path.clone(), size, self.access_counter));
// Check if we need to evict
self.evict_if_needed()?;
Ok(dest_path)
}
/// Returns the total size of all cached files
pub fn total_size(&self) -> u64 {
self.files.values().map(|(_, size, _)| size).sum()
}
/// Evicts oldest files until cache is under max size
fn evict_if_needed(&mut self) -> Result<(), String> {
while self.total_size() > self.max_size_bytes && !self.files.is_empty() {
// Find least recently accessed file
let oldest_id = self
.files
.iter()
.min_by_key(|(_, (_, _, access))| access)
.map(|(id, _)| id.clone());
if let Some(id) = oldest_id {
self.evict(&id)?;
}
}
Ok(())
}
/// Evicts a specific file from cache
fn evict(&mut self, file_id: &str) -> Result<(), String> {
if let Some((path, _, _)) = self.files.remove(file_id) {
fs::remove_file(&path)
.map_err(|e| format!("Failed to remove cached file: {}", e))?;
}
Ok(())
}
/// Clears all cached files
pub fn clear(&mut self) -> Result<(), String> {
for (path, _, _) in self.files.values() {
let _ = fs::remove_file(path); // Ignore errors
}
self.files.clear();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_voice_cache_creation() {
let cache = VoiceCache::new();
assert!(cache.is_ok());
}
#[test]
fn test_cache_get_nonexistent() {
let mut cache = VoiceCache::new().unwrap();
assert!(cache.get("nonexistent").is_none());
}
#[test]
fn test_cache_store_and_get() {
let mut cache = VoiceCache::new().unwrap();
// Create temporary file
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join("test_voice.ogg");
let mut file = fs::File::create(&temp_file).unwrap();
file.write_all(b"test audio data").unwrap();
// Store in cache
let result = cache.store("test123", &temp_file);
assert!(result.is_ok());
// Get from cache
let cached_path = cache.get("test123");
assert!(cached_path.is_some());
assert!(cached_path.unwrap().exists());
// Cleanup
fs::remove_file(&temp_file).unwrap();
}
}