From 6d08300daa41ececbe46f9599ad7d8225bb431e5 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Mon, 9 Feb 2026 18:51:45 +0300 Subject: [PATCH] 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 --- CONTEXT.md | 16 +++++----- ROADMAP.md | 60 ++++++++++++++++++++------------------ src/input/handlers/chat.rs | 28 +++++++++++++----- 3 files changed, 60 insertions(+), 44 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index a22d79d..71ce360 100644 --- a/CONTEXT.md +++ b/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 ### Ключевая архитектура diff --git a/ROADMAP.md b/ROADMAP.md index e09829a..61b3c58 100644 --- a/ROADMAP.md +++ b/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) diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index b47af58..d986921 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -74,10 +74,10 @@ pub async fn handle_message_selection(app: &mut App, _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(app: &mut App) { /// Seek голосового сообщения на delta секунд fn handle_voice_seek(app: &mut App, delta: f32) { use crate::tdlib::PlaybackStatus; - use std::time::Duration; let Some(ref mut playback) = app.playback_state else { return; @@ -549,14 +548,27 @@ fn handle_voice_seek(app: &mut App, 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; } + + let arrow = if delta > 0.0 { "→" } else { "←" }; + app.status_message = Some(format!("{} {:.0}s", arrow, new_position)); + app.needs_redraw = true; } }