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:
Mikhail Kilin
2026-02-09 16:37:02 +03:00
parent 7bc264198f
commit 8a467b6418
13 changed files with 278 additions and 48 deletions

View File

@@ -1,6 +1,6 @@
# Текущий контекст проекта
## Статус: Фаза 12 — Прослушивание голосовых сообщений (IN PROGRESS)
## Статус: Фаза 12 — Прослушивание голосовых сообщений (DONE)
### Завершённые фазы (краткий итог)
@@ -17,7 +17,7 @@
| 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE |
| 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) |
| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE |
| 12 | Прослушивание голосовых сообщений (ffplay, play/pause, seek) | IN PROGRESS |
| 12 | Прослушивание голосовых сообщений (ffplay, play/pause, seek, ticker, cache, config) | DONE |
| 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE |
### Фаза 11: Inline фото + оптимизации (подробности)
@@ -68,11 +68,13 @@ Feature-gated (`images`), 2-tier архитектура:
- **Хоткеи**: Space (play/pause), ←/→ (seek ±5s)
- **Автостоп**: при навигации на другое сообщение воспроизведение останавливается
**Не реализовано:**
- UI индикаторы в сообщениях (🎤, progress bar, waveform)
- AudioConfig в config.toml
- Ticker для progress bar
- VoiceCache не интегрирован в handlers
**Доделано в этой сессии:**
- **Ticker**: `last_playback_tick` в App + обновление position в event loop каждые 16ms
- **VoiceCache интеграция**: проверка кэша перед загрузкой, кэширование после download
- **AudioConfig**: `[audio]` секция в config.toml (cache_size_mb, auto_download_voice)
**Не реализовано (optional):**
- UI индикаторы в сообщениях (🎤, progress bar, waveform) — начаты в diff, не подключены
### Ключевая архитектура

1
Cargo.lock generated
View File

@@ -3377,6 +3377,7 @@ version = "0.1.0"
dependencies = [
"arboard",
"async-trait",
"base64",
"chrono",
"criterion",
"crossterm",

View File

@@ -36,6 +36,7 @@ dirs = "5.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1"
[dev-dependencies]
insta = "1.34"

View File

@@ -107,6 +107,8 @@ pub struct App<T: TdClientTrait = TdClient> {
pub voice_cache: Option<crate::audio::VoiceCache>,
/// Состояние текущего воспроизведения
pub playback_state: Option<crate::tdlib::PlaybackState>,
/// Время последнего тика для обновления позиции воспроизведения
pub last_playback_tick: Option<std::time::Instant>,
}
impl<T: TdClientTrait> App<T> {
@@ -126,6 +128,8 @@ impl<T: TdClientTrait> App<T> {
let mut state = ListState::default();
state.select(Some(0));
let audio_cache_size_mb = config.audio.cache_size_mb;
#[cfg(feature = "images")]
let image_cache = Some(crate::media::cache::ImageCache::new(
config.images.cache_size_mb,
@@ -169,8 +173,9 @@ impl<T: TdClientTrait> App<T> {
last_image_render_time: None,
// Voice playback
audio_player: crate::audio::AudioPlayer::new().ok(),
voice_cache: crate::audio::VoiceCache::new().ok(),
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(),
playback_state: None,
last_playback_tick: None,
}
}
@@ -198,6 +203,7 @@ impl<T: TdClientTrait> App<T> {
player.stop();
}
self.playback_state = None;
self.last_playback_tick = None;
self.status_message = None;
}

View File

@@ -7,9 +7,6 @@ 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,
@@ -20,8 +17,8 @@ pub struct VoiceCache {
}
impl VoiceCache {
/// Creates a new VoiceCache
pub fn new() -> Result<Self, String> {
/// Creates a new VoiceCache with the given max size in MB
pub fn new(max_size_mb: u64) -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Failed to get cache directory")?
.join("tele-tui")
@@ -34,7 +31,7 @@ impl VoiceCache {
cache_dir,
files: HashMap::new(),
access_counter: 0,
max_size_bytes: MAX_CACHE_SIZE_BYTES,
max_size_bytes: max_size_mb * 1024 * 1024,
})
}
@@ -123,19 +120,19 @@ mod tests {
#[test]
fn test_voice_cache_creation() {
let cache = VoiceCache::new();
let cache = VoiceCache::new(100);
assert!(cache.is_ok());
}
#[test]
fn test_cache_get_nonexistent() {
let mut cache = VoiceCache::new().unwrap();
let mut cache = VoiceCache::new(100).unwrap();
assert!(cache.get("nonexistent").is_none());
}
#[test]
fn test_cache_store_and_get() {
let mut cache = VoiceCache::new().unwrap();
let mut cache = VoiceCache::new(100).unwrap();
// Create temporary file
let temp_dir = std::env::temp_dir();

View File

@@ -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::*;

View File

@@ -47,6 +47,10 @@ pub struct Config {
/// Настройки отображения изображений.
#[serde(default)]
pub images: ImagesConfig,
/// Настройки аудио (голосовые сообщения).
#[serde(default)]
pub audio: AudioConfig,
}
/// Общие настройки приложения.
@@ -140,6 +144,27 @@ impl Default for ImagesConfig {
}
}
/// Настройки аудио (голосовые сообщения).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
/// Размер кэша голосовых файлов (в МБ)
#[serde(default = "default_audio_cache_size_mb")]
pub cache_size_mb: u64,
/// Автоматически загружать голосовые при открытии чата
#[serde(default = "default_auto_download_voice")]
pub auto_download_voice: bool,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
cache_size_mb: default_audio_cache_size_mb(),
auto_download_voice: default_auto_download_voice(),
}
}
}
// Дефолтные значения (используются serde атрибутами)
fn default_timezone() -> String {
"+03:00".to_string()
@@ -197,6 +222,14 @@ fn default_auto_download_images() -> bool {
true
}
fn default_audio_cache_size_mb() -> u64 {
crate::constants::DEFAULT_AUDIO_CACHE_SIZE_MB
}
fn default_auto_download_voice() -> bool {
false
}
impl Default for GeneralConfig {
fn default() -> Self {
Self { timezone: default_timezone() }
@@ -235,6 +268,7 @@ impl Default for Config {
keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
audio: AudioConfig::default(),
}
}
}

View File

@@ -58,3 +58,10 @@ pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
/// Максимальная ширина inline превью изображений (в символах)
#[cfg(feature = "images")]
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
// ============================================================================
// Audio
// ============================================================================
/// Размер кэша голосовых сообщений по умолчанию (в МБ)
pub const DEFAULT_AUDIO_CACHE_SIZE_MB: u64 = 100;

View File

@@ -504,11 +504,21 @@ async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
PlaybackStatus::Playing => {
player.pause();
playback.status = PlaybackStatus::Paused;
app.last_playback_tick = None;
app.status_message = Some("⏸ Пауза".to_string());
}
PlaybackStatus::Paused => {
player.resume();
// Откатываем на 1 секунду для контекста
let resume_pos = (playback.position - 1.0).max(0.0);
// Перезапускаем ffplay с нужной позиции (-ss)
if player.resume_from(resume_pos).is_ok() {
playback.position = resume_pos;
} else {
// Fallback: простой SIGCONT без перемотки
player.resume();
}
playback.status = PlaybackStatus::Playing;
app.last_playback_tick = Some(Instant::now());
app.status_message = Some("▶ Воспроизведение".to_string());
}
_ => {}
@@ -612,6 +622,7 @@ async fn handle_play_voice_from_path<T: TdClientTrait>(
duration: voice.duration as f32,
volume: player.volume(),
});
app.last_playback_tick = Some(Instant::now());
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
app.needs_redraw = true;
}
@@ -658,7 +669,12 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
for entry in entries.flatten() {
let entry_name = entry.file_name();
if entry_name.to_string_lossy().starts_with(&stem.to_string_lossy().to_string()) {
return handle_play_voice_from_path(app, &entry.path().to_string_lossy(), &voice, &msg).await;
let found_path = entry.path().to_string_lossy().to_string();
// Кэшируем найденный файл
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&file_id.to_string(), Path::new(&found_path));
}
return handle_play_voice_from_path(app, &found_path, &voice, &msg).await;
}
}
}
@@ -669,37 +685,35 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
}
};
// Кэшируем файл если ещё не в кэше
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
}
handle_play_voice_from_path(app, &audio_path, &voice, &msg).await;
}
VoiceDownloadState::Downloading => {
app.status_message = Some("Загрузка голосового...".to_string());
}
VoiceDownloadState::NotDownloaded => {
use crate::tdlib::{PlaybackState, PlaybackStatus};
// Проверяем кэш перед загрузкой
let cache_key = file_id.to_string();
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
let path_str = cached_path.to_string_lossy().to_string();
handle_play_voice_from_path(app, &path_str, &voice, &msg).await;
return;
}
// Начинаем загрузку
app.status_message = Some("Загрузка голосового...".to_string());
match app.td_client.download_voice_note(file_id).await {
Ok(path) => {
// Пытаемся воспроизвести после загрузки
if let Some(ref player) = app.audio_player {
match player.play(&path) {
Ok(_) => {
app.playback_state = Some(PlaybackState {
message_id: msg.id(),
status: PlaybackStatus::Playing,
position: 0.0,
duration: voice.duration as f32,
volume: player.volume(),
});
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
}
}
// Кэшируем загруженный файл
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&cache_key, std::path::Path::new(&path));
}
handle_play_voice_from_path(app, &path, &voice, &msg).await;
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));

View File

@@ -167,6 +167,42 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
app.needs_redraw = true;
}
// Обновляем позицию воспроизведения голосового сообщения
{
let mut stop_playback = false;
if let Some(ref mut playback) = app.playback_state {
use crate::tdlib::PlaybackStatus;
match playback.status {
PlaybackStatus::Playing => {
let prev_second = playback.position as u32;
if let Some(last_tick) = app.last_playback_tick {
let delta = last_tick.elapsed().as_secs_f32();
playback.position += delta;
}
app.last_playback_tick = Some(std::time::Instant::now());
// Проверяем завершение воспроизведения
if playback.position >= playback.duration
|| app.audio_player.as_ref().map_or(false, |p| p.is_stopped())
{
stop_playback = true;
}
// Перерисовка только при смене секунды (не 60 FPS)
if playback.position as u32 != prev_second || stop_playback {
app.needs_redraw = true;
}
}
_ => {
app.last_playback_tick = None;
}
}
}
if stop_playback {
app.stop_playback();
app.last_playback_tick = None;
}
}
// Рендерим только если есть изменения
if app.needs_redraw {
terminal.draw(|f| ui::render(f, app))?;
@@ -185,6 +221,9 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Graceful shutdown
should_stop.store(true, Ordering::Relaxed);
// Останавливаем воспроизведение голосового (убиваем ffplay)
app.stop_playback();
// Закрываем TDLib клиент
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;

View File

@@ -7,7 +7,7 @@
use crate::config::Config;
use crate::formatting;
use crate::tdlib::MessageInfo;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
#[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::types::MessageId;
@@ -200,6 +200,7 @@ pub fn render_message_bubble(
config: &Config,
content_width: usize,
selected_msg_id: Option<MessageId>,
playback_state: Option<&PlaybackState>,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let is_selected = selected_msg_id == Some(msg.id());
@@ -394,6 +395,47 @@ pub fn render_message_bubble(
}
}
// Отображаем индикатор воспроизведения голосового
if msg.has_voice() {
if let Some(voice) = msg.voice_info() {
let is_this_playing = playback_state
.map(|ps| ps.message_id == msg.id())
.unwrap_or(false);
let status_line = if is_this_playing {
let ps = playback_state.unwrap();
let icon = match ps.status {
PlaybackStatus::Playing => "",
PlaybackStatus::Paused => "",
PlaybackStatus::Loading => "",
_ => "",
};
let bar = render_progress_bar(ps.position, ps.duration, 20);
format!(
"{} {} {:.0}s/{:.0}s",
icon, bar, ps.position, ps.duration
)
} else {
let waveform = render_waveform(&voice.waveform, 20);
format!(" {} {:.0}s", waveform, voice.duration)
};
let status_len = status_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(Span::styled(
status_line,
Style::default().fg(Color::Cyan),
)));
}
}
}
// Отображаем статус фото (если есть)
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
@@ -469,3 +511,43 @@ pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: us
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
}
/// Рендерит progress bar для воспроизведения
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
if duration <= 0.0 {
return "".repeat(width);
}
let ratio = (position / duration).clamp(0.0, 1.0);
let filled = (ratio * width as f32) as usize;
let empty = width.saturating_sub(filled + 1);
format!("{}{}", "".repeat(filled), "".repeat(empty))
}
/// Рендерит waveform из base64-encoded данных TDLib
fn render_waveform(waveform_b64: &str, width: usize) -> String {
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if waveform_b64.is_empty() {
return "".repeat(width);
}
// Декодируем waveform (каждый байт = амплитуда 0-255)
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(waveform_b64)
.unwrap_or_default();
if bytes.is_empty() {
return "".repeat(width);
}
// Сэмплируем до нужной ширины
let mut result = String::with_capacity(width * 4);
for i in 0..width {
let byte_idx = i * bytes.len() / width;
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
result.push(BARS[bar_idx]);
}
result
}

View File

@@ -234,6 +234,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
app.config(),
content_width,
selected_msg_id,
app.playback_state.as_ref(),
);
// Собираем deferred image renders для всех загруженных фото

View File

@@ -1,6 +1,6 @@
// Integration tests for config flow
use tele_tui::config::{Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig};
use tele_tui::config::{AudioConfig, Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig};
/// Test: Дефолтные значения конфигурации
#[test]
@@ -35,6 +35,7 @@ fn test_config_custom_values() {
keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
audio: AudioConfig::default(),
};
assert_eq!(config.general.timezone, "+05:00");
@@ -120,6 +121,7 @@ fn test_config_toml_serialization() {
keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
audio: AudioConfig::default(),
};
// Сериализуем в TOML