feat: complete Phase 12 — voice playback ticker, cache, config, and UI
Add playback position ticker in event loop with 1s UI refresh rate, integrate VoiceCache for downloaded voice files, add [audio] config section (cache_size_mb, auto_download_voice), and render progress bar with waveform visualization in message bubbles. Fix race conditions in AudioPlayer: add `starting` flag to prevent false `is_stopped()` during ffplay startup, guard pid cleanup so old threads don't overwrite newer process pids. Implement `resume_from()` with ffplay `-ss` for real audio seek on unpause (-1s rewind). Kill ffplay on app exit via `stop_playback()` in shutdown + Drop impl. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
16
CONTEXT.md
16
CONTEXT.md
@@ -1,6 +1,6 @@
|
|||||||
# Текущий контекст проекта
|
# Текущий контекст проекта
|
||||||
|
|
||||||
## Статус: Фаза 12 — Прослушивание голосовых сообщений (IN PROGRESS)
|
## Статус: Фаза 12 — Прослушивание голосовых сообщений (DONE)
|
||||||
|
|
||||||
### Завершённые фазы (краткий итог)
|
### Завершённые фазы (краткий итог)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
| 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE |
|
| 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE |
|
||||||
| 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) |
|
| 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) |
|
||||||
| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE |
|
| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE |
|
||||||
| 12 | Прослушивание голосовых сообщений (ffplay, play/pause, seek) | IN PROGRESS |
|
| 12 | Прослушивание голосовых сообщений (ffplay, play/pause, seek, ticker, cache, config) | DONE |
|
||||||
| 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE |
|
| 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE |
|
||||||
|
|
||||||
### Фаза 11: Inline фото + оптимизации (подробности)
|
### Фаза 11: Inline фото + оптимизации (подробности)
|
||||||
@@ -68,11 +68,13 @@ Feature-gated (`images`), 2-tier архитектура:
|
|||||||
- **Хоткеи**: Space (play/pause), ←/→ (seek ±5s)
|
- **Хоткеи**: Space (play/pause), ←/→ (seek ±5s)
|
||||||
- **Автостоп**: при навигации на другое сообщение воспроизведение останавливается
|
- **Автостоп**: при навигации на другое сообщение воспроизведение останавливается
|
||||||
|
|
||||||
**Не реализовано:**
|
**Доделано в этой сессии:**
|
||||||
- UI индикаторы в сообщениях (🎤, progress bar, waveform)
|
- **Ticker**: `last_playback_tick` в App + обновление position в event loop каждые 16ms
|
||||||
- AudioConfig в config.toml
|
- **VoiceCache интеграция**: проверка кэша перед загрузкой, кэширование после download
|
||||||
- Ticker для progress bar
|
- **AudioConfig**: `[audio]` секция в config.toml (cache_size_mb, auto_download_voice)
|
||||||
- VoiceCache не интегрирован в handlers
|
|
||||||
|
**Не реализовано (optional):**
|
||||||
|
- UI индикаторы в сообщениях (🎤, progress bar, waveform) — начаты в diff, не подключены
|
||||||
|
|
||||||
### Ключевая архитектура
|
### Ключевая архитектура
|
||||||
|
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3377,6 +3377,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"criterion",
|
"criterion",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dirs = "5.0"
|
|||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = "1.34"
|
insta = "1.34"
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ pub struct App<T: TdClientTrait = TdClient> {
|
|||||||
pub voice_cache: Option<crate::audio::VoiceCache>,
|
pub voice_cache: Option<crate::audio::VoiceCache>,
|
||||||
/// Состояние текущего воспроизведения
|
/// Состояние текущего воспроизведения
|
||||||
pub playback_state: Option<crate::tdlib::PlaybackState>,
|
pub playback_state: Option<crate::tdlib::PlaybackState>,
|
||||||
|
/// Время последнего тика для обновления позиции воспроизведения
|
||||||
|
pub last_playback_tick: Option<std::time::Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: TdClientTrait> App<T> {
|
impl<T: TdClientTrait> App<T> {
|
||||||
@@ -126,6 +128,8 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
let mut state = ListState::default();
|
let mut state = ListState::default();
|
||||||
state.select(Some(0));
|
state.select(Some(0));
|
||||||
|
|
||||||
|
let audio_cache_size_mb = config.audio.cache_size_mb;
|
||||||
|
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
let image_cache = Some(crate::media::cache::ImageCache::new(
|
let image_cache = Some(crate::media::cache::ImageCache::new(
|
||||||
config.images.cache_size_mb,
|
config.images.cache_size_mb,
|
||||||
@@ -169,8 +173,9 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
last_image_render_time: None,
|
last_image_render_time: None,
|
||||||
// Voice playback
|
// Voice playback
|
||||||
audio_player: crate::audio::AudioPlayer::new().ok(),
|
audio_player: crate::audio::AudioPlayer::new().ok(),
|
||||||
voice_cache: crate::audio::VoiceCache::new().ok(),
|
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(),
|
||||||
playback_state: None,
|
playback_state: None,
|
||||||
|
last_playback_tick: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +203,7 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
player.stop();
|
player.stop();
|
||||||
}
|
}
|
||||||
self.playback_state = None;
|
self.playback_state = None;
|
||||||
|
self.last_playback_tick = None;
|
||||||
self.status_message = None;
|
self.status_message = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
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
|
/// Cache for voice message files
|
||||||
pub struct VoiceCache {
|
pub struct VoiceCache {
|
||||||
cache_dir: PathBuf,
|
cache_dir: PathBuf,
|
||||||
@@ -20,8 +17,8 @@ pub struct VoiceCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VoiceCache {
|
impl VoiceCache {
|
||||||
/// Creates a new VoiceCache
|
/// Creates a new VoiceCache with the given max size in MB
|
||||||
pub fn new() -> Result<Self, String> {
|
pub fn new(max_size_mb: u64) -> Result<Self, String> {
|
||||||
let cache_dir = dirs::cache_dir()
|
let cache_dir = dirs::cache_dir()
|
||||||
.ok_or("Failed to get cache directory")?
|
.ok_or("Failed to get cache directory")?
|
||||||
.join("tele-tui")
|
.join("tele-tui")
|
||||||
@@ -34,7 +31,7 @@ impl VoiceCache {
|
|||||||
cache_dir,
|
cache_dir,
|
||||||
files: HashMap::new(),
|
files: HashMap::new(),
|
||||||
access_counter: 0,
|
access_counter: 0,
|
||||||
max_size_bytes: MAX_CACHE_SIZE_BYTES,
|
max_size_bytes: max_size_mb * 1024 * 1024,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,19 +120,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_voice_cache_creation() {
|
fn test_voice_cache_creation() {
|
||||||
let cache = VoiceCache::new();
|
let cache = VoiceCache::new(100);
|
||||||
assert!(cache.is_ok());
|
assert!(cache.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cache_get_nonexistent() {
|
fn test_cache_get_nonexistent() {
|
||||||
let mut cache = VoiceCache::new().unwrap();
|
let mut cache = VoiceCache::new(100).unwrap();
|
||||||
assert!(cache.get("nonexistent").is_none());
|
assert!(cache.get("nonexistent").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cache_store_and_get() {
|
fn test_cache_store_and_get() {
|
||||||
let mut cache = VoiceCache::new().unwrap();
|
let mut cache = VoiceCache::new(100).unwrap();
|
||||||
|
|
||||||
// Create temporary file
|
// Create temporary file
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ pub struct AudioPlayer {
|
|||||||
current_pid: Arc<Mutex<Option<u32>>>,
|
current_pid: Arc<Mutex<Option<u32>>>,
|
||||||
/// Whether the process is currently paused (SIGSTOP)
|
/// Whether the process is currently paused (SIGSTOP)
|
||||||
paused: Arc<Mutex<bool>>,
|
paused: Arc<Mutex<bool>>,
|
||||||
|
/// Path to the currently playing file (for restart with seek)
|
||||||
|
current_path: Arc<Mutex<Option<std::path::PathBuf>>>,
|
||||||
|
/// True between play_from() call and ffplay actually starting (race window)
|
||||||
|
starting: Arc<Mutex<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioPlayer {
|
impl AudioPlayer {
|
||||||
@@ -29,22 +33,38 @@ impl AudioPlayer {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
current_pid: Arc::new(Mutex::new(None)),
|
current_pid: Arc::new(Mutex::new(None)),
|
||||||
paused: Arc::new(Mutex::new(false)),
|
paused: Arc::new(Mutex::new(false)),
|
||||||
|
current_path: Arc::new(Mutex::new(None)),
|
||||||
|
starting: Arc::new(Mutex::new(false)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Plays an audio file from the given path
|
/// Plays an audio file from the given path
|
||||||
pub fn play<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
|
pub fn play<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
|
||||||
|
self.play_from(path, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays an audio file starting from the given position (seconds)
|
||||||
|
pub fn play_from<P: AsRef<Path>>(&self, path: P, start_secs: f32) -> Result<(), String> {
|
||||||
self.stop();
|
self.stop();
|
||||||
|
|
||||||
let path_owned = path.as_ref().to_path_buf();
|
let path_owned = path.as_ref().to_path_buf();
|
||||||
|
*self.current_path.lock().unwrap() = Some(path_owned.clone());
|
||||||
|
*self.starting.lock().unwrap() = true;
|
||||||
let current_pid = self.current_pid.clone();
|
let current_pid = self.current_pid.clone();
|
||||||
let paused = self.paused.clone();
|
let paused = self.paused.clone();
|
||||||
|
let starting = self.starting.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Ok(mut child) = Command::new("ffplay")
|
let mut cmd = Command::new("ffplay");
|
||||||
.arg("-nodisp")
|
cmd.arg("-nodisp")
|
||||||
.arg("-autoexit")
|
.arg("-autoexit")
|
||||||
.arg("-loglevel").arg("quiet")
|
.arg("-loglevel").arg("quiet");
|
||||||
|
|
||||||
|
if start_secs > 0.0 {
|
||||||
|
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut child) = cmd
|
||||||
.arg(&path_owned)
|
.arg(&path_owned)
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::null())
|
.stderr(std::process::Stdio::null())
|
||||||
@@ -53,11 +73,18 @@ impl AudioPlayer {
|
|||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
*current_pid.lock().unwrap() = Some(pid);
|
*current_pid.lock().unwrap() = Some(pid);
|
||||||
*paused.lock().unwrap() = false;
|
*paused.lock().unwrap() = false;
|
||||||
|
*starting.lock().unwrap() = false;
|
||||||
|
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
|
|
||||||
*current_pid.lock().unwrap() = None;
|
// Обнуляем только если это наш pid (новый play мог уже заменить его)
|
||||||
*paused.lock().unwrap() = false;
|
let mut pid_guard = current_pid.lock().unwrap();
|
||||||
|
if *pid_guard == Some(pid) {
|
||||||
|
*pid_guard = None;
|
||||||
|
*paused.lock().unwrap() = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*starting.lock().unwrap() = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +102,7 @@ impl AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resumes playback via SIGCONT
|
/// Resumes playback via SIGCONT (from the same position)
|
||||||
pub fn resume(&self) {
|
pub fn resume(&self) {
|
||||||
if let Some(pid) = *self.current_pid.lock().unwrap() {
|
if let Some(pid) = *self.current_pid.lock().unwrap() {
|
||||||
let _ = Command::new("kill")
|
let _ = Command::new("kill")
|
||||||
@@ -86,8 +113,19 @@ impl AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resumes playback from a specific position (restarts ffplay with -ss)
|
||||||
|
pub fn resume_from(&self, position_secs: f32) -> Result<(), String> {
|
||||||
|
let path = self.current_path.lock().unwrap().clone();
|
||||||
|
if let Some(path) = path {
|
||||||
|
self.play_from(&path, position_secs)
|
||||||
|
} else {
|
||||||
|
Err("No file to resume".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops playback (kills the process)
|
/// Stops playback (kills the process)
|
||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
|
*self.starting.lock().unwrap() = false;
|
||||||
if let Some(pid) = self.current_pid.lock().unwrap().take() {
|
if let Some(pid) = self.current_pid.lock().unwrap().take() {
|
||||||
// Resume first if paused, then kill
|
// Resume first if paused, then kill
|
||||||
let _ = Command::new("kill")
|
let _ = Command::new("kill")
|
||||||
@@ -111,9 +149,9 @@ impl AudioPlayer {
|
|||||||
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
|
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if no active process
|
/// Returns true if no active process and not starting a new one
|
||||||
pub fn is_stopped(&self) -> bool {
|
pub fn is_stopped(&self) -> bool {
|
||||||
self.current_pid.lock().unwrap().is_none()
|
self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_volume(&self, _volume: f32) {}
|
pub fn set_volume(&self, _volume: f32) {}
|
||||||
@@ -128,6 +166,12 @@ impl AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for AudioPlayer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ pub struct Config {
|
|||||||
/// Настройки отображения изображений.
|
/// Настройки отображения изображений.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub images: ImagesConfig,
|
pub images: ImagesConfig,
|
||||||
|
|
||||||
|
/// Настройки аудио (голосовые сообщения).
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio: AudioConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Общие настройки приложения.
|
/// Общие настройки приложения.
|
||||||
@@ -140,6 +144,27 @@ impl Default for ImagesConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Настройки аудио (голосовые сообщения).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AudioConfig {
|
||||||
|
/// Размер кэша голосовых файлов (в МБ)
|
||||||
|
#[serde(default = "default_audio_cache_size_mb")]
|
||||||
|
pub cache_size_mb: u64,
|
||||||
|
|
||||||
|
/// Автоматически загружать голосовые при открытии чата
|
||||||
|
#[serde(default = "default_auto_download_voice")]
|
||||||
|
pub auto_download_voice: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
cache_size_mb: default_audio_cache_size_mb(),
|
||||||
|
auto_download_voice: default_auto_download_voice(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Дефолтные значения (используются serde атрибутами)
|
// Дефолтные значения (используются serde атрибутами)
|
||||||
fn default_timezone() -> String {
|
fn default_timezone() -> String {
|
||||||
"+03:00".to_string()
|
"+03:00".to_string()
|
||||||
@@ -197,6 +222,14 @@ fn default_auto_download_images() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_audio_cache_size_mb() -> u64 {
|
||||||
|
crate::constants::DEFAULT_AUDIO_CACHE_SIZE_MB
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auto_download_voice() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for GeneralConfig {
|
impl Default for GeneralConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { timezone: default_timezone() }
|
Self { timezone: default_timezone() }
|
||||||
@@ -235,6 +268,7 @@ impl Default for Config {
|
|||||||
keybindings: Keybindings::default(),
|
keybindings: Keybindings::default(),
|
||||||
notifications: NotificationsConfig::default(),
|
notifications: NotificationsConfig::default(),
|
||||||
images: ImagesConfig::default(),
|
images: ImagesConfig::default(),
|
||||||
|
audio: AudioConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,10 @@ pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
|
|||||||
/// Максимальная ширина inline превью изображений (в символах)
|
/// Максимальная ширина inline превью изображений (в символах)
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
|
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Audio
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Размер кэша голосовых сообщений по умолчанию (в МБ)
|
||||||
|
pub const DEFAULT_AUDIO_CACHE_SIZE_MB: u64 = 100;
|
||||||
|
|||||||
@@ -504,11 +504,21 @@ async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
PlaybackStatus::Playing => {
|
PlaybackStatus::Playing => {
|
||||||
player.pause();
|
player.pause();
|
||||||
playback.status = PlaybackStatus::Paused;
|
playback.status = PlaybackStatus::Paused;
|
||||||
|
app.last_playback_tick = None;
|
||||||
app.status_message = Some("⏸ Пауза".to_string());
|
app.status_message = Some("⏸ Пауза".to_string());
|
||||||
}
|
}
|
||||||
PlaybackStatus::Paused => {
|
PlaybackStatus::Paused => {
|
||||||
player.resume();
|
// Откатываем на 1 секунду для контекста
|
||||||
|
let resume_pos = (playback.position - 1.0).max(0.0);
|
||||||
|
// Перезапускаем ffplay с нужной позиции (-ss)
|
||||||
|
if player.resume_from(resume_pos).is_ok() {
|
||||||
|
playback.position = resume_pos;
|
||||||
|
} else {
|
||||||
|
// Fallback: простой SIGCONT без перемотки
|
||||||
|
player.resume();
|
||||||
|
}
|
||||||
playback.status = PlaybackStatus::Playing;
|
playback.status = PlaybackStatus::Playing;
|
||||||
|
app.last_playback_tick = Some(Instant::now());
|
||||||
app.status_message = Some("▶ Воспроизведение".to_string());
|
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -612,6 +622,7 @@ async fn handle_play_voice_from_path<T: TdClientTrait>(
|
|||||||
duration: voice.duration as f32,
|
duration: voice.duration as f32,
|
||||||
volume: player.volume(),
|
volume: player.volume(),
|
||||||
});
|
});
|
||||||
|
app.last_playback_tick = Some(Instant::now());
|
||||||
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
@@ -658,7 +669,12 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let entry_name = entry.file_name();
|
let entry_name = entry.file_name();
|
||||||
if entry_name.to_string_lossy().starts_with(&stem.to_string_lossy().to_string()) {
|
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;
|
let found_path = entry.path().to_string_lossy().to_string();
|
||||||
|
// Кэшируем найденный файл
|
||||||
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
|
let _ = cache.store(&file_id.to_string(), Path::new(&found_path));
|
||||||
|
}
|
||||||
|
return handle_play_voice_from_path(app, &found_path, &voice, &msg).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,37 +685,35 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Кэшируем файл если ещё не в кэше
|
||||||
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
|
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
||||||
|
}
|
||||||
|
|
||||||
handle_play_voice_from_path(app, &audio_path, &voice, &msg).await;
|
handle_play_voice_from_path(app, &audio_path, &voice, &msg).await;
|
||||||
}
|
}
|
||||||
VoiceDownloadState::Downloading => {
|
VoiceDownloadState::Downloading => {
|
||||||
app.status_message = Some("Загрузка голосового...".to_string());
|
app.status_message = Some("Загрузка голосового...".to_string());
|
||||||
}
|
}
|
||||||
VoiceDownloadState::NotDownloaded => {
|
VoiceDownloadState::NotDownloaded => {
|
||||||
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
// Проверяем кэш перед загрузкой
|
||||||
|
let cache_key = file_id.to_string();
|
||||||
|
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
||||||
|
let path_str = cached_path.to_string_lossy().to_string();
|
||||||
|
handle_play_voice_from_path(app, &path_str, &voice, &msg).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Начинаем загрузку
|
// Начинаем загрузку
|
||||||
app.status_message = Some("Загрузка голосового...".to_string());
|
app.status_message = Some("Загрузка голосового...".to_string());
|
||||||
match app.td_client.download_voice_note(file_id).await {
|
match app.td_client.download_voice_note(file_id).await {
|
||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
// Пытаемся воспроизвести после загрузки
|
// Кэшируем загруженный файл
|
||||||
if let Some(ref player) = app.audio_player {
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
match player.play(&path) {
|
let _ = cache.store(&cache_key, std::path::Path::new(&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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle_play_voice_from_path(app, &path, &voice, &msg).await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||||
|
|||||||
39
src/main.rs
39
src/main.rs
@@ -167,6 +167,42 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновляем позицию воспроизведения голосового сообщения
|
||||||
|
{
|
||||||
|
let mut stop_playback = false;
|
||||||
|
if let Some(ref mut playback) = app.playback_state {
|
||||||
|
use crate::tdlib::PlaybackStatus;
|
||||||
|
match playback.status {
|
||||||
|
PlaybackStatus::Playing => {
|
||||||
|
let prev_second = playback.position as u32;
|
||||||
|
if let Some(last_tick) = app.last_playback_tick {
|
||||||
|
let delta = last_tick.elapsed().as_secs_f32();
|
||||||
|
playback.position += delta;
|
||||||
|
}
|
||||||
|
app.last_playback_tick = Some(std::time::Instant::now());
|
||||||
|
|
||||||
|
// Проверяем завершение воспроизведения
|
||||||
|
if playback.position >= playback.duration
|
||||||
|
|| app.audio_player.as_ref().map_or(false, |p| p.is_stopped())
|
||||||
|
{
|
||||||
|
stop_playback = true;
|
||||||
|
}
|
||||||
|
// Перерисовка только при смене секунды (не 60 FPS)
|
||||||
|
if playback.position as u32 != prev_second || stop_playback {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
app.last_playback_tick = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stop_playback {
|
||||||
|
app.stop_playback();
|
||||||
|
app.last_playback_tick = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Рендерим только если есть изменения
|
// Рендерим только если есть изменения
|
||||||
if app.needs_redraw {
|
if app.needs_redraw {
|
||||||
terminal.draw(|f| ui::render(f, app))?;
|
terminal.draw(|f| ui::render(f, app))?;
|
||||||
@@ -185,6 +221,9 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
should_stop.store(true, Ordering::Relaxed);
|
should_stop.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Останавливаем воспроизведение голосового (убиваем ffplay)
|
||||||
|
app.stop_playback();
|
||||||
|
|
||||||
// Закрываем TDLib клиент
|
// Закрываем TDLib клиент
|
||||||
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::formatting;
|
use crate::formatting;
|
||||||
use crate::tdlib::MessageInfo;
|
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
use crate::tdlib::PhotoDownloadState;
|
use crate::tdlib::PhotoDownloadState;
|
||||||
use crate::types::MessageId;
|
use crate::types::MessageId;
|
||||||
@@ -200,6 +200,7 @@ pub fn render_message_bubble(
|
|||||||
config: &Config,
|
config: &Config,
|
||||||
content_width: usize,
|
content_width: usize,
|
||||||
selected_msg_id: Option<MessageId>,
|
selected_msg_id: Option<MessageId>,
|
||||||
|
playback_state: Option<&PlaybackState>,
|
||||||
) -> Vec<Line<'static>> {
|
) -> Vec<Line<'static>> {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
let is_selected = selected_msg_id == Some(msg.id());
|
let is_selected = selected_msg_id == Some(msg.id());
|
||||||
@@ -394,6 +395,47 @@ pub fn render_message_bubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отображаем индикатор воспроизведения голосового
|
||||||
|
if msg.has_voice() {
|
||||||
|
if let Some(voice) = msg.voice_info() {
|
||||||
|
let is_this_playing = playback_state
|
||||||
|
.map(|ps| ps.message_id == msg.id())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let status_line = if is_this_playing {
|
||||||
|
let ps = playback_state.unwrap();
|
||||||
|
let icon = match ps.status {
|
||||||
|
PlaybackStatus::Playing => "▶",
|
||||||
|
PlaybackStatus::Paused => "⏸",
|
||||||
|
PlaybackStatus::Loading => "⏳",
|
||||||
|
_ => "⏹",
|
||||||
|
};
|
||||||
|
let bar = render_progress_bar(ps.position, ps.duration, 20);
|
||||||
|
format!(
|
||||||
|
"{} {} {:.0}s/{:.0}s",
|
||||||
|
icon, bar, ps.position, ps.duration
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let waveform = render_waveform(&voice.waveform, 20);
|
||||||
|
format!(" {} {:.0}s", waveform, voice.duration)
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_len = status_line.chars().count();
|
||||||
|
if msg.is_outgoing() {
|
||||||
|
let padding = content_width.saturating_sub(status_len + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(status_line, Style::default().fg(Color::Cyan)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
status_line,
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Отображаем статус фото (если есть)
|
// Отображаем статус фото (если есть)
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
if let Some(photo) = msg.photo_info() {
|
if let Some(photo) = msg.photo_info() {
|
||||||
@@ -469,3 +511,43 @@ pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: us
|
|||||||
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
|
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
|
||||||
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
|
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Рендерит progress bar для воспроизведения
|
||||||
|
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
|
||||||
|
if duration <= 0.0 {
|
||||||
|
return "─".repeat(width);
|
||||||
|
}
|
||||||
|
let ratio = (position / duration).clamp(0.0, 1.0);
|
||||||
|
let filled = (ratio * width as f32) as usize;
|
||||||
|
let empty = width.saturating_sub(filled + 1);
|
||||||
|
format!("{}●{}", "━".repeat(filled), "─".repeat(empty))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит waveform из base64-encoded данных TDLib
|
||||||
|
fn render_waveform(waveform_b64: &str, width: usize) -> String {
|
||||||
|
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
|
||||||
|
if waveform_b64.is_empty() {
|
||||||
|
return "▁".repeat(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Декодируем waveform (каждый байт = амплитуда 0-255)
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(waveform_b64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return "▁".repeat(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сэмплируем до нужной ширины
|
||||||
|
let mut result = String::with_capacity(width * 4);
|
||||||
|
for i in 0..width {
|
||||||
|
let byte_idx = i * bytes.len() / width;
|
||||||
|
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
|
||||||
|
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
|
||||||
|
result.push(BARS[bar_idx]);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
|
|||||||
app.config(),
|
app.config(),
|
||||||
content_width,
|
content_width,
|
||||||
selected_msg_id,
|
selected_msg_id,
|
||||||
|
app.playback_state.as_ref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Собираем deferred image renders для всех загруженных фото
|
// Собираем deferred image renders для всех загруженных фото
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Integration tests for config flow
|
// Integration tests for config flow
|
||||||
|
|
||||||
use tele_tui::config::{Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig};
|
use tele_tui::config::{AudioConfig, Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig};
|
||||||
|
|
||||||
/// Test: Дефолтные значения конфигурации
|
/// Test: Дефолтные значения конфигурации
|
||||||
#[test]
|
#[test]
|
||||||
@@ -35,6 +35,7 @@ fn test_config_custom_values() {
|
|||||||
keybindings: Keybindings::default(),
|
keybindings: Keybindings::default(),
|
||||||
notifications: NotificationsConfig::default(),
|
notifications: NotificationsConfig::default(),
|
||||||
images: ImagesConfig::default(),
|
images: ImagesConfig::default(),
|
||||||
|
audio: AudioConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(config.general.timezone, "+05:00");
|
assert_eq!(config.general.timezone, "+05:00");
|
||||||
@@ -120,6 +121,7 @@ fn test_config_toml_serialization() {
|
|||||||
keybindings: Keybindings::default(),
|
keybindings: Keybindings::default(),
|
||||||
notifications: NotificationsConfig::default(),
|
notifications: NotificationsConfig::default(),
|
||||||
images: ImagesConfig::default(),
|
images: ImagesConfig::default(),
|
||||||
|
audio: AudioConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сериализуем в TOML
|
// Сериализуем в TOML
|
||||||
|
|||||||
Reference in New Issue
Block a user