feat: implement audio seeking with arrow keys via ffplay restart
Seek now works by restarting ffplay with -ss offset instead of the broken player.seek() stub. MoveLeft/MoveRight added as aliases for SeekBackward/SeekForward to fix HashMap non-deterministic iteration order causing Left arrow to resolve to MoveLeft instead of SeekBackward. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
CONTEXT.md
16
CONTEXT.md
@@ -65,16 +65,16 @@ Feature-gated (`images`), 2-tier архитектура:
|
||||
- **VoiceCache**: LRU кэш OGG файлов в `~/.cache/tele-tui/voice/` (max 100 MB)
|
||||
- **Типы**: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
|
||||
- **TDLib интеграция**: `download_voice_note()`, конвертация `MessageVoiceNote`
|
||||
- **Хоткеи**: Space (play/pause), ←/→ (seek ±5s)
|
||||
- **Хоткеи**: Space (play/pause), ←/→ (seek ±5s via ffplay restart с `-ss`)
|
||||
- **Автостоп**: при навигации на другое сообщение воспроизведение останавливается
|
||||
|
||||
**Доделано в этой сессии:**
|
||||
- **Ticker**: `last_playback_tick` в App + обновление position в event loop каждые 16ms
|
||||
- **VoiceCache интеграция**: проверка кэша перед загрузкой, кэширование после download
|
||||
- **Ticker**: `last_playback_tick` в App + обновление position в event loop (1 FPS redraw)
|
||||
- **VoiceCache**: проверка кэша перед загрузкой, кэширование после download
|
||||
- **AudioConfig**: `[audio]` секция в config.toml (cache_size_mb, auto_download_voice)
|
||||
|
||||
**Не реализовано (optional):**
|
||||
- UI индикаторы в сообщениях (🎤, progress bar, waveform) — начаты в diff, не подключены
|
||||
- **UI**: progress bar (━●─) + waveform (▁▂▃▄▅▆▇█) + иконки статуса в `message_bubble.rs`
|
||||
- **Race condition fixes**: `starting` flag + pid ownership guard в потоках AudioPlayer
|
||||
- **Seek**: `resume_from()` перезапускает ffplay с `-ss` offset; MoveLeft/MoveRight как alias для SeekBackward/SeekForward
|
||||
- **Resume with rewind**: пауза→продолжение откатывает на 1 секунду назад
|
||||
- **Graceful shutdown**: `stop_playback()` + Drop impl для AudioPlayer
|
||||
|
||||
### Ключевая архитектура
|
||||
|
||||
|
||||
60
ROADMAP.md
60
ROADMAP.md
@@ -15,14 +15,14 @@
|
||||
| 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 |
|
||||
| 12 | Голосовые сообщения | ffplay player, pause/resume with seek, VoiceCache, AudioConfig, progress bar + waveform UI |
|
||||
| 13 | Глубокий рефакторинг | 5 файлов (4582->модули), 5 traits, shared components, docs |
|
||||
|
||||
---
|
||||
|
||||
## Фаза 11: Inline просмотр фото в чате [DONE ✅]
|
||||
## Фаза 11: Inline просмотр фото в чате [DONE]
|
||||
|
||||
**UX**: Always-show inline preview (50 chars, Halfblocks) → `v`/`м` открывает fullscreen modal (iTerm2/Sixel) → `←`/`→` навигация между фото.
|
||||
**UX**: Always-show inline preview (50 chars, Halfblocks) -> `v`/`м` открывает fullscreen modal (iTerm2/Sixel) -> `←`/`→` навигация между фото.
|
||||
|
||||
### Реализовано:
|
||||
- [x] **Dual renderer архитектура**:
|
||||
@@ -36,7 +36,7 @@
|
||||
- [x] **UX улучшения**:
|
||||
- Always-show inline preview (фикс. ширина 50 chars)
|
||||
- Fullscreen modal на `v`/`м` с aspect ratio
|
||||
- Loading indicator "⏳ Загрузка..." в модалке
|
||||
- Loading indicator в модалке
|
||||
- Navigation hotkeys: `←`/`→` между фото, `Esc`/`q` закрыть
|
||||
- [x] **Типы и API**:
|
||||
- `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState`
|
||||
@@ -49,43 +49,47 @@
|
||||
- `modals/image_viewer.rs`: fullscreen modal
|
||||
- `messages.rs`: throttled second-pass rendering
|
||||
|
||||
### Результат:
|
||||
- ✅ 10x faster navigation (lazy loading)
|
||||
- ✅ Smooth 60 FPS text, 15 FPS images
|
||||
- ✅ Quality modal viewing (iTerm2/Sixel)
|
||||
- ✅ No flickering/shrinking
|
||||
|
||||
---
|
||||
|
||||
## Фаза 12: Прослушивание голосовых сообщений [IN PROGRESS]
|
||||
## Фаза 12: Прослушивание голосовых сообщений [DONE]
|
||||
|
||||
### Этап 1: Инфраструктура аудио [DONE ✅]
|
||||
### Этап 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()
|
||||
- `cache.rs` — VoiceCache (LRU, configurable size, `~/.cache/tele-tui/voice/`)
|
||||
- [x] AudioPlayer API: play(), play_from(ss), pause() (SIGSTOP), resume(), resume_from(ss), stop()
|
||||
- [x] Race condition fix: `starting` flag + pid ownership guard в потоках
|
||||
- [x] Drop impl для AudioPlayer (убивает ffplay при выходе)
|
||||
|
||||
### Этап 2: Интеграция с TDLib [DONE ✅]
|
||||
### Этап 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]
|
||||
- [ ] Индикатор в сообщении (🎤, duration, progress bar)
|
||||
- [ ] Waveform визуализация (символы ▁▂▃▄▅▆▇█)
|
||||
### Этап 3: UI для воспроизведения [DONE]
|
||||
- [x] Progress bar (━●─) с позицией и длительностью
|
||||
- [x] Waveform визуализация (▁▂▃▄▅▆▇█) из base64-encoded TDLib данных
|
||||
- [x] Иконки статуса: ▶ Playing, ⏸ Paused, ⏹ Stopped
|
||||
- [x] Throttled redraw: обновление UI только при смене секунды (не 60 FPS)
|
||||
|
||||
### Этап 4: Хоткеи [DONE ✅]
|
||||
- [x] Space — play/pause toggle (запуск + пауза/возобновление)
|
||||
- [x] ←/→ — seek ±5 сек
|
||||
### Этап 4: Хоткеи [DONE]
|
||||
- [x] Space — play/pause toggle (запуск + пауза/возобновление с откатом 1s)
|
||||
- [x] ←/→ — seek ±5 сек (через `resume_from()` — перезапуск ffplay с `-ss`)
|
||||
- [x] Seek работает и при воспроизведении, и на паузе (на паузе двигает позицию, при resume стартует с неё)
|
||||
- [x] MoveLeft/MoveRight как alias для SeekBackward/SeekForward (HashMap non-deterministic order fix)
|
||||
- [x] Автоматическая остановка при навигации на другое сообщение
|
||||
- [x] Остановка ffplay при выходе из приложения (Ctrl+C)
|
||||
|
||||
### Этап 5: TODO
|
||||
- [ ] AudioConfig в config.toml
|
||||
- [ ] Ticker для progress bar (каждые 100ms)
|
||||
- [ ] Интеграция VoiceCache в handlers
|
||||
### Этап 5: Конфигурация и кэш [DONE]
|
||||
- [x] `AudioConfig` в config.toml (`cache_size_mb`, `auto_download_voice`)
|
||||
- [x] `DEFAULT_AUDIO_CACHE_SIZE_MB` константа (100 MB)
|
||||
- [x] Ticker для progress bar в event loop (delta-based position tracking)
|
||||
- [x] VoiceCache интеграция: проверка кэша перед загрузкой, кэширование после download
|
||||
|
||||
### Технические детали
|
||||
- **Аудио:** ffplay (subprocess), pause/resume через SIGSTOP/SIGCONT
|
||||
- **Аудио:** ffplay (subprocess), resume/seek через перезапуск с `-ss` offset
|
||||
- **Race conditions:** `starting` flag предотвращает false `is_stopped()` при старте ffplay; pid ownership guard в потоках предотвращает затирание pid нового процесса старым
|
||||
- **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения
|
||||
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
|
||||
- **Хоткеи:** Space (play/pause), ←/→ (seek)
|
||||
- **Хоткеи:** Space (play/pause), ←/→ (seek ±5s)
|
||||
|
||||
@@ -74,10 +74,10 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
|
||||
Some(crate::config::Command::TogglePlayback) => {
|
||||
handle_toggle_voice_playback(app).await;
|
||||
}
|
||||
Some(crate::config::Command::SeekForward) => {
|
||||
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
|
||||
handle_voice_seek(app, 5.0);
|
||||
}
|
||||
Some(crate::config::Command::SeekBackward) => {
|
||||
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
|
||||
handle_voice_seek(app, -5.0);
|
||||
}
|
||||
Some(crate::config::Command::ReactMessage) => {
|
||||
@@ -540,7 +540,6 @@ async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
||||
/// 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;
|
||||
@@ -549,15 +548,28 @@ fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
||||
return;
|
||||
};
|
||||
|
||||
if matches!(playback.status, PlaybackStatus::Playing | PlaybackStatus::Paused) {
|
||||
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
||||
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
||||
|
||||
if was_playing || was_paused {
|
||||
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
||||
if player.seek(Duration::from_secs_f32(new_position)).is_ok() {
|
||||
|
||||
if was_playing {
|
||||
// Перезапускаем ffplay с новой позиции
|
||||
if player.resume_from(new_position).is_ok() {
|
||||
playback.position = new_position;
|
||||
app.last_playback_tick = Some(std::time::Instant::now());
|
||||
}
|
||||
} else {
|
||||
// На паузе — только двигаем позицию, воспроизведение начнётся при resume
|
||||
player.stop();
|
||||
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 — открыть модальное окно с фото
|
||||
|
||||
Reference in New Issue
Block a user