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:
21
CONTEXT.md
21
CONTEXT.md
@@ -1,6 +1,6 @@
|
|||||||
# Текущий контекст проекта
|
# Текущий контекст проекта
|
||||||
|
|
||||||
## Статус: Фаза 11 — Inline просмотр фото (DONE)
|
## Статус: Фаза 12 — Прослушивание голосовых сообщений (IN PROGRESS)
|
||||||
|
|
||||||
### Завершённые фазы (краткий итог)
|
### Завершённые фазы (краткий итог)
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
| 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE |
|
| 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE |
|
||||||
| 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) |
|
| 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) |
|
||||||
| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE |
|
| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE |
|
||||||
|
| 12 | Прослушивание голосовых сообщений (ffplay, play/pause, seek) | IN PROGRESS |
|
||||||
| 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE |
|
| 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE |
|
||||||
|
|
||||||
### Фаза 11: Inline фото + оптимизации (подробности)
|
### Фаза 11: Inline фото + оптимизации (подробности)
|
||||||
@@ -57,6 +58,22 @@ Feature-gated (`images`), 2-tier архитектура:
|
|||||||
- **Очистка дублей**: ~220 строк удалено (shared components, format_user_status, scroll_to_message)
|
- **Очистка дублей**: ~220 строк удалено (shared components, format_user_status, scroll_to_message)
|
||||||
- **Документация**: PROJECT_STRUCTURE.md переписан, 16 файлов получили `//!` docs
|
- **Документация**: 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)
|
├── input/ → роутер + handlers/ (chat, chat_list, compose, modal, search)
|
||||||
├── app/ → App<T: TdClientTrait> + methods/ (5 traits, 67 методов)
|
├── app/ → App<T: TdClientTrait> + methods/ (5 traits, 67 методов)
|
||||||
├── ui/ → рендеринг (messages, chat_list, modals/, compose_bar, components/)
|
├── ui/ → рендеринг (messages, chat_list, modals/, compose_bar, components/)
|
||||||
|
├── audio/ → player.rs (ffplay), cache.rs (VoiceCache)
|
||||||
├── media/ → [feature=images] cache.rs, image_renderer.rs
|
├── media/ → [feature=images] cache.rs, image_renderer.rs
|
||||||
└── tdlib/ → TDLib wrapper (client, auth, chats, messages/, users, reactions, types)
|
└── 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)
|
5. **Конфиг**: TOML `~/.config/tele-tui/config.toml`, credentials с приоритетом (XDG → .env)
|
||||||
6. **Feature-gated images**: `images` feature flag для ratatui-image + image deps
|
6. **Feature-gated images**: `images` feature flag для ratatui-image + image deps
|
||||||
7. **Dual renderer**: inline (Halfblocks, 15 FPS) + modal (iTerm2/Sixel, high quality) для баланса скорости/качества
|
7. **Dual renderer**: inline (Halfblocks, 15 FPS) + modal (iTerm2/Sixel, high quality) для баланса скорости/качества
|
||||||
|
8. **Audio via ffplay**: subprocess с SIGSTOP/SIGCONT для pause/resume, автостоп при навигации
|
||||||
|
|
||||||
### Зависимости (основные)
|
### Зависимости (основные)
|
||||||
|
|
||||||
|
|||||||
150
ROADMAP.md
150
ROADMAP.md
@@ -14,116 +14,78 @@
|
|||||||
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
|
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
|
||||||
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
|
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
|
||||||
| 10 | Desktop уведомления (83%) | notify-rust, muted фильтр, mentions, медиа. TODO: кастомные звуки |
|
| 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 |
|
| 13 | Глубокий рефакторинг | 5 файлов (4582→модули), 5 traits, shared components, docs |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Фаза 11: Inline просмотр фото в чате [IN PROGRESS]
|
## Фаза 11: Inline просмотр фото в чате [DONE ✅]
|
||||||
|
|
||||||
**UX**: `v`/`м` на фото → загрузка → inline превью (~30x15) → Esc/навигация → свернуть обратно в текст.
|
**UX**: Always-show inline preview (50 chars, Halfblocks) → `v`/`м` открывает fullscreen modal (iTerm2/Sixel) → `←`/`→` навигация между фото.
|
||||||
Повторное `v` — мгновенно из кэша. Целевой терминал: iTerm2.
|
|
||||||
|
|
||||||
### Этап 1: Инфраструктура [TODO]
|
### Реализовано:
|
||||||
- [ ] Обновить ratatui 0.29 → 0.30 (требование ratatui-image)
|
- [x] **Dual renderer архитектура**:
|
||||||
- [ ] Добавить зависимости: `ratatui-image`, `image`
|
- `inline_image_renderer`: Halfblocks (быстро, Unicode блоки) для навигации
|
||||||
- [ ] Создать `src/media/` модуль
|
- `modal_image_renderer`: iTerm2/Sixel (медленно, высокое качество) для просмотра
|
||||||
- `cache.rs` — LRU кэш файлов, лимит 500 MB, `~/.cache/tele-tui/images/`
|
- [x] **Performance optimizations**:
|
||||||
- `loader.rs` — загрузка через TDLib downloadFile API
|
- 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)
|
- ✅ 10x faster navigation (lazy loading)
|
||||||
- [ ] Сохранять метаданные фото при конвертации TDLib → MessageInfo
|
- ✅ Smooth 60 FPS text, 15 FPS images
|
||||||
- [ ] Обновить FakeTdClient для тестов
|
- ✅ Quality modal viewing (iTerm2/Sixel)
|
||||||
|
- ✅ No flickering/shrinking
|
||||||
### Этап 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 превью
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Фаза 12: Прослушивание голосовых сообщений [PLANNED]
|
## Фаза 12: Прослушивание голосовых сообщений [IN PROGRESS]
|
||||||
|
|
||||||
### Этап 1: Инфраструктура аудио [TODO]
|
### Этап 1: Инфраструктура аудио [DONE ✅]
|
||||||
- [ ] Модуль src/audio/
|
- [x] Модуль `src/audio/`
|
||||||
- player.rs - AudioPlayer на rodio
|
- `player.rs` — AudioPlayer на ffplay (subprocess)
|
||||||
- cache.rs - VoiceCache для загруженных файлов
|
- `cache.rs` — VoiceCache (LRU, max 100 MB, `~/.cache/tele-tui/voice/`)
|
||||||
- state.rs - PlaybackState (статус, позиция, громкость)
|
- [x] AudioPlayer API: play(), pause() (SIGSTOP), resume() (SIGCONT), stop()
|
||||||
- [ ] Зависимости
|
|
||||||
- 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
|
|
||||||
|
|
||||||
### Этап 2: Интеграция с TDLib [TODO]
|
### Этап 2: Интеграция с TDLib [DONE ✅]
|
||||||
- [ ] Обработка MessageContentVoiceNote
|
- [x] Типы: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
|
||||||
- Добавить VoiceNoteInfo в MessageInfo
|
- [x] Конвертация `MessageVoiceNote` в `message_conversion.rs`
|
||||||
- Извлечение file_id, duration, mime_type, waveform
|
- [x] `download_voice_note()` в TdClientTrait + client_impl + fake
|
||||||
- [ ] Загрузка файлов
|
- [x] Методы `has_voice()`, `voice_info()`, `voice_info_mut()` на `MessageInfo`
|
||||||
- Метод TdClient::download_voice_note(file_id)
|
|
||||||
- Асинхронная загрузка через downloadFile API
|
|
||||||
- Обработка состояний (pending/downloading/ready)
|
|
||||||
- [ ] Кэширование путей к загруженным файлам
|
|
||||||
|
|
||||||
### Этап 3: UI для воспроизведения [TODO]
|
### Этап 3: UI для воспроизведения [TODO]
|
||||||
- [ ] Индикатор в сообщении
|
- [ ] Индикатор в сообщении (🎤, duration, progress bar)
|
||||||
- Иконка 🎤 и длительность голосового
|
- [ ] Waveform визуализация (символы ▁▂▃▄▅▆▇█)
|
||||||
- Progress bar во время воспроизведения
|
|
||||||
- Статус: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading)
|
|
||||||
- Текущее время / общая длительность (0:08 / 0:15)
|
|
||||||
- [ ] Footer с управлением
|
|
||||||
- "[Space] Play/Pause [s] Stop [←/→] Seek [↑/↓] Volume"
|
|
||||||
- [ ] Waveform визуализация (опционально)
|
|
||||||
- Символы ▁▂▃▄▅▆▇█ для визуализации
|
|
||||||
|
|
||||||
### Этап 4: Хоткеи для управления [TODO]
|
### Этап 4: Хоткеи [DONE ✅]
|
||||||
- [ ] Новые команды
|
- [x] Space — play/pause toggle (запуск + пауза/возобновление)
|
||||||
- Space - play/pause, s/ы - stop
|
- [x] ←/→ — seek ±5 сек
|
||||||
- ←/→ - seek ±5 сек, ↑/↓ - volume ±10%
|
- [x] Автоматическая остановка при навигации на другое сообщение
|
||||||
- [ ] Контекстная обработка (управление только во время воспроизведения)
|
|
||||||
- [ ] Поддержка русской раскладки
|
|
||||||
|
|
||||||
### Этап 5: Конфигурация и UX [TODO]
|
### Этап 5: TODO
|
||||||
- [ ] AudioConfig в config.toml
|
- [ ] AudioConfig в config.toml
|
||||||
- enabled, default_volume, seek_step_seconds, autoplay, cache_size_mb, show_waveform
|
- [ ] Ticker для progress bar (каждые 100ms)
|
||||||
- system_player_fallback, system_player (mpv, ffplay)
|
- [ ] Интеграция VoiceCache в handlers
|
||||||
- [ ] Асинхронная загрузка (не блокирует UI)
|
|
||||||
- [ ] Ticker для обновления progress bar (каждые 100ms)
|
|
||||||
|
|
||||||
### Этап 6: Обработка ошибок [TODO]
|
|
||||||
- [ ] Graceful fallback на системный плеер (mpv/ffplay)
|
|
||||||
- [ ] Таймаут загрузки (30 сек), повторная попытка
|
|
||||||
- [ ] Ограничения: максимальный размер файла, автоочистка кэша
|
|
||||||
|
|
||||||
### Этап 7: Дополнительные улучшения [TODO]
|
|
||||||
- [ ] Автоматическая остановка при закрытии чата
|
|
||||||
- [ ] Сохранение позиции при паузе
|
|
||||||
- [ ] Префетчинг следующего голосового (опционально)
|
|
||||||
|
|
||||||
### Технические детали
|
### Технические детали
|
||||||
- **Аудио библиотека:** rodio 0.17 (Pure Rust, кроссплатформенная, OGG Opus)
|
- **Аудио:** ffplay (subprocess), pause/resume через SIGSTOP/SIGCONT
|
||||||
- **Платформы:** Linux (ALSA/PulseAudio), macOS (CoreAudio), Windows (WASAPI)
|
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
|
||||||
- **Fallback:** mpv --no-video, ffplay -nodisp
|
- **Хоткеи:** Space (play/pause), ←/→ (seek)
|
||||||
- **Новые хоткеи:** Space - play/pause, s/ы - stop, ←/→ - seek, ↑/↓ - volume
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
|||||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||||
if *selected_index > 0 {
|
if *selected_index > 0 {
|
||||||
*selected_index -= 1;
|
*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 let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||||
if *selected_index < total - 1 {
|
if *selected_index < total - 1 {
|
||||||
*selected_index += 1;
|
*selected_index += 1;
|
||||||
|
self.stop_playback();
|
||||||
} else {
|
} else {
|
||||||
// Дошли до самого нового сообщения - выходим из режима выбора
|
// Дошли до самого нового сообщения - выходим из режима выбора
|
||||||
self.chat_state = ChatState::Normal;
|
self.chat_state = ChatState::Normal;
|
||||||
|
self.stop_playback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,13 @@ pub struct App<T: TdClientTrait = TdClient> {
|
|||||||
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
|
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
pub last_image_render_time: Option<std::time::Instant>,
|
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> {
|
impl<T: TdClientTrait> App<T> {
|
||||||
@@ -160,6 +167,10 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
image_modal: None,
|
image_modal: None,
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
last_image_render_time: None,
|
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())
|
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
|
/// Get the selected chat info
|
||||||
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||||
self.selected_chat_id
|
self.selected_chat_id
|
||||||
|
|||||||
158
src/audio/cache.rs
Normal file
158
src/audio/cache.rs
Normal 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
11
src/audio/mod.rs
Normal 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
150
src/audio/player.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,12 @@ pub enum Command {
|
|||||||
SelectMessage,
|
SelectMessage,
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
ViewImage,
|
ViewImage, // v - просмотр фото
|
||||||
|
|
||||||
|
// Voice playback
|
||||||
|
TogglePlayback, // Space - play/pause
|
||||||
|
SeekForward, // → - seek +5s
|
||||||
|
SeekBackward, // ← - seek -5s
|
||||||
|
|
||||||
// Input
|
// Input
|
||||||
SubmitMessage,
|
SubmitMessage,
|
||||||
@@ -211,6 +216,17 @@ impl Keybindings {
|
|||||||
KeyBinding::new(KeyCode::Char('м')), // RU
|
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
|
// Input
|
||||||
bindings.insert(Command::SubmitMessage, vec![
|
bindings.insert(Command::SubmitMessage, vec![
|
||||||
KeyBinding::new(KeyCode::Enter),
|
KeyBinding::new(KeyCode::Enter),
|
||||||
|
|||||||
@@ -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) => {
|
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) => {
|
Some(crate::config::Command::ReactMessage) => {
|
||||||
let Some(msg) = app.get_selected_message() else {
|
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 — открыть модальное окно с фото
|
/// Обработка команды ViewImage — открыть модальное окно с фото
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
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): Эти функции будут переписаны для модального просмотрщика
|
// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика
|
||||||
/*
|
/*
|
||||||
#[cfg(feature = "images")]
|
#[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) {
|
async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
|
||||||
// Закомментировано - будет реализовано в Этапе 4
|
// Закомментировано - будет реализовано в Этапе 4
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! Library interface exposing modules for integration testing.
|
//! Library interface exposing modules for integration testing.
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod audio;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod audio;
|
||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod formatting;
|
mod formatting;
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ impl TdClientTrait for TdClient {
|
|||||||
self.download_file(file_id).await
|
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 {
|
fn client_id(&self) -> i32 {
|
||||||
self.client_id()
|
self.client_id()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::types::MessageId;
|
|||||||
use tdlib_rs::enums::{MessageContent, MessageSender};
|
use tdlib_rs::enums::{MessageContent, MessageSender};
|
||||||
use tdlib_rs::types::Message as TdMessage;
|
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
|
/// Извлекает текст контента из TDLib Message
|
||||||
///
|
///
|
||||||
@@ -52,11 +52,12 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageVoiceNote(v) => {
|
MessageContent::MessageVoiceNote(v) => {
|
||||||
|
let duration = v.voice_note.duration;
|
||||||
let caption_text = v.caption.text.clone();
|
let caption_text = v.caption.text.clone();
|
||||||
if caption_text.is_empty() {
|
if caption_text.is_empty() {
|
||||||
"[Голосовое]".to_string()
|
format!("🎤 [Голосовое {:.0}s]", duration)
|
||||||
} else {
|
} else {
|
||||||
caption_text
|
format!("🎤 {} ({:.0}s)", caption_text, duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageAudio(a) => {
|
MessageContent::MessageAudio(a) => {
|
||||||
@@ -161,6 +162,29 @@ pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
|
|||||||
download_state,
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ pub use client::TdClient;
|
|||||||
pub use r#trait::TdClientTrait;
|
pub use r#trait::TdClientTrait;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
||||||
PhotoInfo, ProfileInfo, ReplyInfo, UserOnlineStatus,
|
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,
|
||||||
|
VoiceDownloadState, VoiceInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ pub trait TdClientTrait: Send {
|
|||||||
|
|
||||||
// ============ File methods ============
|
// ============ File methods ============
|
||||||
async fn download_file(&self, file_id: i32) -> Result<String, String>;
|
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) ============
|
// ============ Getters (immutable) ============
|
||||||
fn client_id(&self) -> i32;
|
fn client_id(&self) -> i32;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ pub struct ReactionInfo {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum MediaInfo {
|
pub enum MediaInfo {
|
||||||
Photo(PhotoInfo),
|
Photo(PhotoInfo),
|
||||||
|
Voice(VoiceInfo),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Информация о фотографии в сообщении
|
/// Информация о фотографии в сообщении
|
||||||
@@ -78,6 +79,26 @@ pub enum PhotoDownloadState {
|
|||||||
Error(String),
|
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, отправитель, время)
|
/// Метаданные сообщения (ID, отправитель, время)
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MessageMetadata {
|
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> {
|
pub fn reply_to(&self) -> Option<&ReplyInfo> {
|
||||||
self.interactions.reply_to.as_ref()
|
self.interactions.reply_to.as_ref()
|
||||||
}
|
}
|
||||||
@@ -646,3 +688,28 @@ pub struct ImageModalState {
|
|||||||
/// Высота оригинального изображения
|
/// Высота оригинального изображения
|
||||||
pub photo_height: i32,
|
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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ impl TdClientTrait for FakeTdClient {
|
|||||||
FakeTdClient::download_file(self, file_id).await
|
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) ============
|
// ============ Getters (immutable) ============
|
||||||
fn client_id(&self) -> i32 {
|
fn client_id(&self) -> i32 {
|
||||||
0 // Fake client ID
|
0 // Fake client ID
|
||||||
|
|||||||
Reference in New Issue
Block a user