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

@@ -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),
}