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

@@ -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
}
*/
*/