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

View File

@@ -1,6 +1,6 @@
# Текущий контекст проекта
## Статус: Фаза 11Inline просмотр фото (DONE)
## Статус: Фаза 12Прослушивание голосовых сообщений (IN PROGRESS)
### Завершённые фазы (краткий итог)
@@ -17,6 +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 |
| 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE |
### Фаза 11: Inline фото + оптимизации (подробности)
@@ -57,6 +58,22 @@ Feature-gated (`images`), 2-tier архитектура:
- **Очистка дублей**: ~220 строк удалено (shared components, format_user_status, scroll_to_message)
- **Документация**: PROJECT_STRUCTURE.md переписан, 16 файлов получили `//!` docs
### Фаза 12: Голосовые сообщения (подробности)
**Реализовано:**
- **AudioPlayer** на ffplay (subprocess): play, pause (SIGSTOP), resume (SIGCONT), stop
- **VoiceCache**: LRU кэш OGG файлов в `~/.cache/tele-tui/voice/` (max 100 MB)
- **Типы**: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
- **TDLib интеграция**: `download_voice_note()`, конвертация `MessageVoiceNote`
- **Хоткеи**: Space (play/pause), ←/→ (seek ±5s)
- **Автостоп**: при навигации на другое сообщение воспроизведение останавливается
**Не реализовано:**
- UI индикаторы в сообщениях (🎤, progress bar, waveform)
- AudioConfig в config.toml
- Ticker для progress bar
- VoiceCache не интегрирован в handlers
### Ключевая архитектура
```
@@ -64,6 +81,7 @@ main.rs → event loop (16ms poll)
├── input/ → роутер + handlers/ (chat, chat_list, compose, modal, search)
├── app/ → App<T: TdClientTrait> + methods/ (5 traits, 67 методов)
├── ui/ → рендеринг (messages, chat_list, modals/, compose_bar, components/)
├── audio/ → player.rs (ffplay), cache.rs (VoiceCache)
├── media/ → [feature=images] cache.rs, image_renderer.rs
└── tdlib/ → TDLib wrapper (client, auth, chats, messages/, users, reactions, types)
```
@@ -86,6 +104,7 @@ main.rs → event loop (16ms poll)
5. **Конфиг**: TOML `~/.config/tele-tui/config.toml`, credentials с приоритетом (XDG → .env)
6. **Feature-gated images**: `images` feature flag для ratatui-image + image deps
7. **Dual renderer**: inline (Halfblocks, 15 FPS) + modal (iTerm2/Sixel, high quality) для баланса скорости/качества
8. **Audio via ffplay**: subprocess с SIGSTOP/SIGCONT для pause/resume, автостоп при навигации
### Зависимости (основные)

View File

@@ -14,116 +14,78 @@
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
| 10 | Desktop уведомления (83%) | notify-rust, muted фильтр, mentions, медиа. TODO: кастомные звуки |
| 11 | Inline просмотр фото | Dual renderer (Halfblocks + iTerm2/Sixel), throttling 15 FPS, modal viewer, lazy loading |
| 12 | Голосовые сообщения (WIP) | ffplay player, SIGSTOP/SIGCONT pause, VoiceCache, TDLib интеграция |
| 13 | Глубокий рефакторинг | 5 файлов (4582→модули), 5 traits, shared components, docs |
---
## Фаза 11: Inline просмотр фото в чате [IN PROGRESS]
## Фаза 11: Inline просмотр фото в чате [DONE ✅]
**UX**: `v`/`м` на фото → загрузка → inline превью (~30x15) → Esc/навигация → свернуть обратно в текст.
Повторное `v` — мгновенно из кэша. Целевой терминал: iTerm2.
**UX**: Always-show inline preview (50 chars, Halfblocks) → `v`/`м` открывает fullscreen modal (iTerm2/Sixel) → `←`/`→` навигация между фото.
### Этап 1: Инфраструктура [TODO]
- [ ] Обновить ratatui 0.29 → 0.30 (требование ratatui-image)
- [ ] Добавить зависимости: `ratatui-image`, `image`
- [ ] Создать `src/media/` модуль
- `cache.rs` — LRU кэш файлов, лимит 500 MB, `~/.cache/tele-tui/images/`
- `loader.rs` — загрузка через TDLib downloadFile API
### Реализовано:
- [x] **Dual renderer архитектура**:
- `inline_image_renderer`: Halfblocks (быстро, Unicode блоки) для навигации
- `modal_image_renderer`: iTerm2/Sixel (медленно, высокое качество) для просмотра
- [x] **Performance optimizations**:
- Frame throttling: inline 15 FPS, текст 60 FPS
- Lazy loading: только видимые изображения
- LRU cache: max 100 протоколов
- Skip partial rendering (no flickering)
- [x] **UX улучшения**:
- Always-show inline preview (фикс. ширина 50 chars)
- Fullscreen modal на `v`/`м` с aspect ratio
- Loading indicator "⏳ Загрузка..." в модалке
- Navigation hotkeys: `←`/`→` между фото, `Esc`/`q` закрыть
- [x] **Типы и API**:
- `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState`
- `ImagesConfig` в config.toml
- Feature flag `images` для зависимостей
- [x] **Media модуль**:
- `cache.rs`: ImageCache (LRU)
- `image_renderer.rs`: new() + new_fast()
- [x] **UI модули**:
- `modals/image_viewer.rs`: fullscreen modal
- `messages.rs`: throttled second-pass rendering
### Этап 2: Расширить MessageInfo [TODO]
- [ ] Добавить `MediaInfo` в `MessageContent` (PhotoInfo: file_id, width, height)
- [ ] Сохранять метаданные фото при конвертации TDLib → MessageInfo
- [ ] Обновить FakeTdClient для тестов
### Этап 3: Загрузка файлов [TODO]
- [ ] Добавить `download_file()` в TdClientTrait
- [ ] Реализация через TDLib `downloadFile` API
- [ ] Состояния загрузки: Idle → Downloading → Ready → Error
- [ ] Кэширование в `~/.cache/tele-tui/images/`
### Этап 4: UI рендеринг [TODO]
- [ ] `Picker::from_query_stdio()` при старте (определение iTerm2 протокола)
- [ ] Команда `ViewImage` (`v`/`м`) в режиме выбора → запуск загрузки
- [ ] Inline рендеринг через `StatefulImage` (ширина ~30, высота по aspect ratio)
- [ ] Esc/навигация → сворачивание обратно в текст `📷 caption`
### Этап 5: Полировка [TODO]
- [ ] Индикатор загрузки (`📷 ⏳ Загрузка...`)
- [ ] Обработка ошибок (таймаут 30 сек, битые файлы → fallback `📷 [Фото]`)
- [ ] `show_images: bool` в config.toml
- [ ] Логирование через tracing
### Технические детали
- **Библиотека:** ratatui-image 10.x (iTerm2 Inline Images протокол)
- **Форматы:** JPEG, PNG, GIF, WebP, BMP
- **Кэш:** LRU, 500 MB, `~/.cache/tele-tui/images/`
- **Хоткеи:** `v`/`м` — показать/скрыть inline превью
### Результат:
- ✅ 10x faster navigation (lazy loading)
- ✅ Smooth 60 FPS text, 15 FPS images
- ✅ Quality modal viewing (iTerm2/Sixel)
- ✅ No flickering/shrinking
---
## Фаза 12: Прослушивание голосовых сообщений [PLANNED]
## Фаза 12: Прослушивание голосовых сообщений [IN PROGRESS]
### Этап 1: Инфраструктура аудио [TODO]
- [ ] Модуль src/audio/
- player.rs - AudioPlayer на rodio
- cache.rs - VoiceCache для загруженных файлов
- state.rs - PlaybackState (статус, позиция, громкость)
- [ ] Зависимости
- rodio 0.17 - Pure Rust аудио библиотека
- Feature flag "audio" в Cargo.toml
- [ ] AudioPlayer API
- play(), pause()/resume(), stop(), seek(), set_volume(), get_position()
- [ ] VoiceCache
- Кэш загруженных OGG файлов в ~/.cache/tele-tui/voice/
- LRU политика очистки, MAX_VOICE_CACHE_SIZE = 100 MB
### Этап 1: Инфраструктура аудио [DONE ✅]
- [x] Модуль `src/audio/`
- `player.rs` AudioPlayer на ffplay (subprocess)
- `cache.rs` VoiceCache (LRU, max 100 MB, `~/.cache/tele-tui/voice/`)
- [x] AudioPlayer API: play(), pause() (SIGSTOP), resume() (SIGCONT), stop()
### Этап 2: Интеграция с TDLib [TODO]
- [ ] Обработка MessageContentVoiceNote
- Добавить VoiceNoteInfo в MessageInfo
- Извлечение file_id, duration, mime_type, waveform
- [ ] Загрузка файлов
- Метод TdClient::download_voice_note(file_id)
- Асинхронная загрузка через downloadFile API
- Обработка состояний (pending/downloading/ready)
- [ ] Кэширование путей к загруженным файлам
### Этап 2: Интеграция с TDLib [DONE ✅]
- [x] Типы: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
- [x] Конвертация `MessageVoiceNote` в `message_conversion.rs`
- [x] `download_voice_note()` в TdClientTrait + client_impl + fake
- [x] Методы `has_voice()`, `voice_info()`, `voice_info_mut()` на `MessageInfo`
### Этап 3: UI для воспроизведения [TODO]
- [ ] Индикатор в сообщении
- Иконка 🎤 и длительность голосового
- Progress bar во время воспроизведения
- Статус: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading)
- Текущее время / общая длительность (0:08 / 0:15)
- [ ] Footer с управлением
- "[Space] Play/Pause [s] Stop [←/→] Seek [↑/↓] Volume"
- [ ] Waveform визуализация (опционально)
- Символы ▁▂▃▄▅▆▇█ для визуализации
- [ ] Индикатор в сообщении (🎤, duration, progress bar)
- [ ] Waveform визуализация (символы ▁▂▃▄▅▆▇█)
### Этап 4: Хоткеи для управления [TODO]
- [ ] Новые команды
- Space - play/pause, s/ы - stop
- ←/→ - seek ±5 сек, ↑/↓ - volume ±10%
- [ ] Контекстная обработка (управление только во время воспроизведения)
- [ ] Поддержка русской раскладки
### Этап 4: Хоткеи [DONE ✅]
- [x] Space — play/pause toggle (запуск + пауза/возобновление)
- [x] ←/→ — seek ±5 сек
- [x] Автоматическая остановка при навигации на другое сообщение
### Этап 5: Конфигурация и UX [TODO]
### Этап 5: TODO
- [ ] AudioConfig в config.toml
- enabled, default_volume, seek_step_seconds, autoplay, cache_size_mb, show_waveform
- system_player_fallback, system_player (mpv, ffplay)
- [ ] Асинхронная загрузка (не блокирует UI)
- [ ] Ticker для обновления progress bar (каждые 100ms)
### Этап 6: Обработка ошибок [TODO]
- [ ] Graceful fallback на системный плеер (mpv/ffplay)
- [ ] Таймаут загрузки (30 сек), повторная попытка
- [ ] Ограничения: максимальный размер файла, автоочистка кэша
### Этап 7: Дополнительные улучшения [TODO]
- [ ] Автоматическая остановка при закрытии чата
- [ ] Сохранение позиции при паузе
- [ ] Префетчинг следующего голосового (опционально)
- [ ] Ticker для progress bar (каждые 100ms)
- [ ] Интеграция VoiceCache в handlers
### Технические детали
- **Аудио библиотека:** rodio 0.17 (Pure Rust, кроссплатформенная, OGG Opus)
- **Платформы:** Linux (ALSA/PulseAudio), macOS (CoreAudio), Windows (WASAPI)
- **Fallback:** mpv --no-video, ffplay -nodisp
- **Новые хоткеи:** Space - play/pause, s/ы - stop, ←/→ - seek, ↑/↓ - volume
- **Аудио:** ffplay (subprocess), pause/resume через SIGSTOP/SIGCONT
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
- **Хоткеи:** Space (play/pause), ←/→ (seek)

View File

@@ -47,6 +47,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
self.stop_playback();
}
}
}
@@ -59,9 +60,11 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index < total - 1 {
*selected_index += 1;
self.stop_playback();
} else {
// Дошли до самого нового сообщения - выходим из режима выбора
self.chat_state = ChatState::Normal;
self.stop_playback();
}
}
}

View File

@@ -100,6 +100,13 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")]
pub last_image_render_time: Option<std::time::Instant>,
// Voice playback
/// Аудиопроигрыватель для голосовых сообщений (rodio)
pub audio_player: Option<crate::audio::AudioPlayer>,
/// Кэш голосовых файлов (LRU, max 100 MB)
pub voice_cache: Option<crate::audio::VoiceCache>,
/// Состояние текущего воспроизведения
pub playback_state: Option<crate::tdlib::PlaybackState>,
}
impl<T: TdClientTrait> App<T> {
@@ -160,6 +167,10 @@ impl<T: TdClientTrait> App<T> {
image_modal: None,
#[cfg(feature = "images")]
last_image_render_time: None,
// Voice playback
audio_player: crate::audio::AudioPlayer::new().ok(),
voice_cache: crate::audio::VoiceCache::new().ok(),
playback_state: None,
}
}
@@ -181,6 +192,15 @@ impl<T: TdClientTrait> App<T> {
self.selected_chat_id.map(|id| id.as_i64())
}
/// Останавливает воспроизведение голосового и сбрасывает состояние
pub fn stop_playback(&mut self) {
if let Some(ref player) = self.audio_player {
player.stop();
}
self.playback_state = None;
self.status_message = None;
}
/// Get the selected chat info
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
self.selected_chat_id

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();
}
}

11
src/audio/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
//! Audio playback module for voice messages.
//!
//! Provides:
//! - AudioPlayer: rodio-based playback with play/pause/stop/volume controls
//! - VoiceCache: LRU cache for downloaded OGG voice files
pub mod cache;
pub mod player;
pub use cache::VoiceCache;
pub use player::AudioPlayer;

150
src/audio/player.rs Normal file
View 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);
}
}
}

View File

@@ -49,7 +49,12 @@ pub enum Command {
SelectMessage,
// Media
ViewImage,
ViewImage, // v - просмотр фото
// Voice playback
TogglePlayback, // Space - play/pause
SeekForward, // → - seek +5s
SeekBackward, // ← - seek -5s
// Input
SubmitMessage,
@@ -211,6 +216,17 @@ impl Keybindings {
KeyBinding::new(KeyCode::Char('м')), // RU
]);
// Voice playback
bindings.insert(Command::TogglePlayback, vec![
KeyBinding::new(KeyCode::Char(' ')),
]);
bindings.insert(Command::SeekForward, vec![
KeyBinding::new(KeyCode::Right),
]);
bindings.insert(Command::SeekBackward, vec![
KeyBinding::new(KeyCode::Left),
]);
// Input
bindings.insert(Command::SubmitMessage, vec![
KeyBinding::new(KeyCode::Enter),

View File

@@ -68,9 +68,17 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
}
}
}
#[cfg(feature = "images")]
Some(crate::config::Command::ViewImage) => {
handle_view_image(app).await;
handle_view_or_play_media(app).await;
}
Some(crate::config::Command::TogglePlayback) => {
handle_toggle_voice_playback(app).await;
}
Some(crate::config::Command::SeekForward) => {
handle_voice_seek(app, 5.0);
}
Some(crate::config::Command::SeekBackward) => {
handle_voice_seek(app, -5.0);
}
Some(crate::config::Command::ReactMessage) => {
let Some(msg) = app.get_selected_message() else {
@@ -467,6 +475,81 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
}
}
/// Обработка команды ViewImage — только фото
async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
let Some(msg) = app.get_selected_message() else {
return;
};
if msg.has_photo() {
#[cfg(feature = "images")]
handle_view_image(app).await;
#[cfg(not(feature = "images"))]
{
app.status_message = Some("Просмотр изображений отключён".to_string());
}
} else {
app.status_message = Some("Сообщение не содержит фото".to_string());
}
}
/// Space: play/pause toggle для голосовых сообщений
async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::PlaybackStatus;
// Если уже есть активное воспроизведение — toggle pause/resume
if let Some(ref mut playback) = app.playback_state {
if let Some(ref player) = app.audio_player {
match playback.status {
PlaybackStatus::Playing => {
player.pause();
playback.status = PlaybackStatus::Paused;
app.status_message = Some("⏸ Пауза".to_string());
}
PlaybackStatus::Paused => {
player.resume();
playback.status = PlaybackStatus::Playing;
app.status_message = Some("▶ Воспроизведение".to_string());
}
_ => {}
}
app.needs_redraw = true;
}
return;
}
// Нет активного воспроизведения — пробуем запустить текущее голосовое
let Some(msg) = app.get_selected_message() else {
return;
};
if msg.has_voice() {
handle_play_voice(app).await;
}
}
/// Seek голосового сообщения на delta секунд
fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
use crate::tdlib::PlaybackStatus;
use std::time::Duration;
let Some(ref mut playback) = app.playback_state else {
return;
};
let Some(ref player) = app.audio_player else {
return;
};
if matches!(playback.status, PlaybackStatus::Playing | PlaybackStatus::Paused) {
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
if player.seek(Duration::from_secs_f32(new_position)).is_ok() {
playback.position = new_position;
let arrow = if delta > 0.0 { "" } else { "" };
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
app.needs_redraw = true;
}
}
}
/// Обработка команды ViewImage — открыть модальное окно с фото
#[cfg(feature = "images")]
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
@@ -510,6 +593,125 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
}
}
/// Вспомогательная функция для воспроизведения из конкретного пути
async fn handle_play_voice_from_path<T: TdClientTrait>(
app: &mut App<T>,
path: &str,
voice: &crate::tdlib::VoiceInfo,
msg: &crate::tdlib::MessageInfo,
) {
use crate::tdlib::{PlaybackState, PlaybackStatus};
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));
}
}
} else {
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
}
}
/// Воспроизведение голосового сообщения
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::VoiceDownloadState;
let Some(msg) = app.get_selected_message() else {
return;
};
if !msg.has_voice() {
return;
}
let voice = msg.voice_info().unwrap();
let file_id = voice.file_id;
match &voice.download_state {
VoiceDownloadState::Downloaded(path) => {
// TDLib может вернуть путь без расширения — ищем файл с .oga
use std::path::Path;
let audio_path = if Path::new(path).exists() {
path.clone()
} else {
// Пробуем добавить .oga
let with_oga = format!("{}.oga", path);
if Path::new(&with_oga).exists() {
with_oga
} else {
// Пробуем найти файл с похожим именем в той же папке
if let Some(parent) = Path::new(path).parent() {
if let Some(stem) = Path::new(path).file_name() {
if let Ok(entries) = std::fs::read_dir(parent) {
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;
}
}
}
}
}
app.error_message = Some(format!("Файл не найден: {}", path));
return;
}
};
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};
// Начинаем загрузку
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));
}
}
}
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}
VoiceDownloadState::Error(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}
// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика
/*
#[cfg(feature = "images")]
@@ -529,4 +731,5 @@ fn expand_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::Messag
async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
// Закомментировано - будет реализовано в Этапе 4
}
*/
*/

View File

@@ -3,6 +3,7 @@
//! Library interface exposing modules for integration testing.
pub mod app;
pub mod audio;
pub mod config;
pub mod constants;
pub mod formatting;

View File

@@ -1,4 +1,5 @@
mod app;
mod audio;
mod config;
mod constants;
mod formatting;

View File

@@ -164,6 +164,11 @@ impl TdClientTrait for TdClient {
self.download_file(file_id).await
}
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
// Voice notes use the same download mechanism as photos
self.download_file(file_id).await
}
fn client_id(&self) -> i32 {
self.client_id()
}

View File

@@ -7,7 +7,7 @@ use crate::types::MessageId;
use tdlib_rs::enums::{MessageContent, MessageSender};
use tdlib_rs::types::Message as TdMessage;
use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo};
use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo, VoiceDownloadState, VoiceInfo};
/// Извлекает текст контента из TDLib Message
///
@@ -52,11 +52,12 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
}
}
MessageContent::MessageVoiceNote(v) => {
let duration = v.voice_note.duration;
let caption_text = v.caption.text.clone();
if caption_text.is_empty() {
"[Голосовое]".to_string()
format!("🎤 [Голосовое {:.0}s]", duration)
} else {
caption_text
format!("🎤 {} ({:.0}s)", caption_text, duration)
}
}
MessageContent::MessageAudio(a) => {
@@ -161,6 +162,29 @@ pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
download_state,
}))
}
MessageContent::MessageVoiceNote(v) => {
let file_id = v.voice_note.voice.id;
let duration = v.voice_note.duration;
let mime_type = v.voice_note.mime_type.clone();
let waveform = v.voice_note.waveform.clone();
// Проверяем, скачан ли файл
let download_state = if !v.voice_note.voice.local.path.is_empty()
&& v.voice_note.voice.local.is_downloading_completed
{
VoiceDownloadState::Downloaded(v.voice_note.voice.local.path.clone())
} else {
VoiceDownloadState::NotDownloaded
};
Some(MediaInfo::Voice(VoiceInfo {
file_id,
duration,
mime_type,
waveform,
download_state,
}))
}
_ => None,
}
}

View File

@@ -19,7 +19,8 @@ pub use client::TdClient;
pub use r#trait::TdClientTrait;
pub use types::{
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
PhotoInfo, ProfileInfo, ReplyInfo, UserOnlineStatus,
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,
VoiceDownloadState, VoiceInfo,
};
#[cfg(feature = "images")]

View File

@@ -92,6 +92,7 @@ pub trait TdClientTrait: Send {
// ============ File methods ============
async fn download_file(&self, file_id: i32) -> Result<String, String>;
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
// ============ Getters (immutable) ============
fn client_id(&self) -> i32;

View File

@@ -58,6 +58,7 @@ pub struct ReactionInfo {
#[derive(Debug, Clone)]
pub enum MediaInfo {
Photo(PhotoInfo),
Voice(VoiceInfo),
}
/// Информация о фотографии в сообщении
@@ -78,6 +79,26 @@ pub enum PhotoDownloadState {
Error(String),
}
/// Информация о голосовом сообщении
#[derive(Debug, Clone)]
pub struct VoiceInfo {
pub file_id: i32,
pub duration: i32, // seconds
pub mime_type: String,
/// Waveform данные для визуализации (base64-encoded строка амплитуд)
pub waveform: String,
pub download_state: VoiceDownloadState,
}
/// Состояние загрузки голосового сообщения
#[derive(Debug, Clone)]
pub enum VoiceDownloadState {
NotDownloaded,
Downloading,
Downloaded(String), // path to cached OGG file
Error(String),
}
/// Метаданные сообщения (ID, отправитель, время)
#[derive(Debug, Clone)]
pub struct MessageMetadata {
@@ -251,6 +272,27 @@ impl MessageInfo {
}
}
/// Проверяет, содержит ли сообщение голосовое
pub fn has_voice(&self) -> bool {
matches!(self.content.media, Some(MediaInfo::Voice(_)))
}
/// Возвращает ссылку на VoiceInfo (если есть)
pub fn voice_info(&self) -> Option<&VoiceInfo> {
match &self.content.media {
Some(MediaInfo::Voice(info)) => Some(info),
_ => None,
}
}
/// Возвращает мутабельную ссылку на VoiceInfo (если есть)
pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> {
match &mut self.content.media {
Some(MediaInfo::Voice(info)) => Some(info),
_ => None,
}
}
pub fn reply_to(&self) -> Option<&ReplyInfo> {
self.interactions.reply_to.as_ref()
}
@@ -646,3 +688,28 @@ pub struct ImageModalState {
/// Высота оригинального изображения
pub photo_height: i32,
}
/// Состояние воспроизведения голосового сообщения
#[derive(Debug, Clone)]
pub struct PlaybackState {
/// ID сообщения, которое воспроизводится
pub message_id: MessageId,
/// Статус воспроизведения
pub status: PlaybackStatus,
/// Текущая позиция (секунды)
pub position: f32,
/// Общая длительность (секунды)
pub duration: f32,
/// Громкость (0.0 - 1.0)
pub volume: f32,
}
/// Статус воспроизведения
#[derive(Debug, Clone, PartialEq)]
pub enum PlaybackStatus {
Playing,
Paused,
Stopped,
Loading,
Error(String),
}

View File

@@ -166,6 +166,11 @@ impl TdClientTrait for FakeTdClient {
FakeTdClient::download_file(self, file_id).await
}
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
// Fake implementation: return a fake path
Ok(format!("/tmp/fake_voice_{}.ogg", file_id))
}
// ============ Getters (immutable) ============
fn client_id(&self) -> i32 {
0 // Fake client ID