diff --git a/CONTEXT.md b/CONTEXT.md index 07ee4c2..a22d79d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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, не подключены ### Ключевая архитектура diff --git a/Cargo.lock b/Cargo.lock index da960f8..6c7b974 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3377,6 +3377,7 @@ version = "0.1.0" dependencies = [ "arboard", "async-trait", + "base64", "chrono", "criterion", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index ae8c878..8610cf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/app/mod.rs b/src/app/mod.rs index 21f856f..b918e5a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -107,6 +107,8 @@ pub struct App { pub voice_cache: Option, /// Состояние текущего воспроизведения pub playback_state: Option, + /// Время последнего тика для обновления позиции воспроизведения + pub last_playback_tick: Option, } impl App { @@ -126,6 +128,8 @@ impl App { 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 App { 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 App { player.stop(); } self.playback_state = None; + self.last_playback_tick = None; self.status_message = None; } diff --git a/src/audio/cache.rs b/src/audio/cache.rs index 3487e92..9861284 100644 --- a/src/audio/cache.rs +++ b/src/audio/cache.rs @@ -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 { + /// Creates a new VoiceCache with the given max size in MB + pub fn new(max_size_mb: u64) -> Result { 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(); diff --git a/src/audio/player.rs b/src/audio/player.rs index a5689d7..1805727 100644 --- a/src/audio/player.rs +++ b/src/audio/player.rs @@ -14,6 +14,10 @@ pub struct AudioPlayer { current_pid: Arc>>, /// Whether the process is currently paused (SIGSTOP) paused: Arc>, + /// Path to the currently playing file (for restart with seek) + current_path: Arc>>, + /// True between play_from() call and ffplay actually starting (race window) + starting: Arc>, } 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>(&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>(&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::*; diff --git a/src/config/mod.rs b/src/config/mod.rs index 61d7107..fdd3844 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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(), } } } diff --git a/src/constants.rs b/src/constants.rs index a1a13cc..4321107 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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; diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index 5ff37a2..b47af58 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -504,11 +504,21 @@ async fn handle_toggle_voice_playback(app: &mut App) { 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( 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(app: &mut App) { 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(app: &mut App) { } }; + // Кэшируем файл если ещё не в кэше + 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)); diff --git a/src/main.rs b/src/main.rs index 52b74e4..912d019 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,6 +167,42 @@ async fn run_app( 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( // Graceful shutdown should_stop.store(true, Ordering::Relaxed); + // Останавливаем воспроизведение голосового (убиваем ffplay) + app.stop_playback(); + // Закрываем TDLib клиент let _ = tdlib_rs::functions::close(app.td_client.client_id()).await; diff --git a/src/ui/components/message_bubble.rs b/src/ui/components/message_bubble.rs index 87d4b0e..8143f79 100644 --- a/src/ui/components/message_bubble.rs +++ b/src/ui/components/message_bubble.rs @@ -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, + playback_state: Option<&PlaybackState>, ) -> Vec> { 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 +} diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 468d483..3a9f4d2 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -234,6 +234,7 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &mut Ap app.config(), content_width, selected_msg_id, + app.playback_state.as_ref(), ); // Собираем deferred image renders для всех загруженных фото diff --git a/tests/config.rs b/tests/config.rs index f6fa24c..631dcd7 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -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