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:
@@ -47,6 +47,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||
if *selected_index > 0 {
|
||||
*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 *selected_index < total - 1 {
|
||||
*selected_index += 1;
|
||||
self.stop_playback();
|
||||
} else {
|
||||
// Дошли до самого нового сообщения - выходим из режима выбора
|
||||
self.chat_state = ChatState::Normal;
|
||||
self.stop_playback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,13 @@ pub struct App<T: TdClientTrait = TdClient> {
|
||||
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
|
||||
#[cfg(feature = "images")]
|
||||
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> {
|
||||
@@ -160,6 +167,10 @@ impl<T: TdClientTrait> App<T> {
|
||||
image_modal: None,
|
||||
#[cfg(feature = "images")]
|
||||
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())
|
||||
}
|
||||
|
||||
/// Останавливает воспроизведение голосового и сбрасывает состояние
|
||||
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
|
||||
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||
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,
|
||||
|
||||
// Media
|
||||
ViewImage,
|
||||
ViewImage, // v - просмотр фото
|
||||
|
||||
// Voice playback
|
||||
TogglePlayback, // Space - play/pause
|
||||
SeekForward, // → - seek +5s
|
||||
SeekBackward, // ← - seek -5s
|
||||
|
||||
// Input
|
||||
SubmitMessage,
|
||||
@@ -211,6 +216,17 @@ impl Keybindings {
|
||||
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
|
||||
bindings.insert(Command::SubmitMessage, vec![
|
||||
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) => {
|
||||
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
|
||||
}
|
||||
*/
|
||||
*/
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Library interface exposing modules for integration testing.
|
||||
|
||||
pub mod app;
|
||||
pub mod audio;
|
||||
pub mod config;
|
||||
pub mod constants;
|
||||
pub mod formatting;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod audio;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod formatting;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user