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:
@@ -107,6 +107,8 @@ pub struct App<T: TdClientTrait = TdClient> {
|
||||
pub voice_cache: Option<crate::audio::VoiceCache>,
|
||||
/// Состояние текущего воспроизведения
|
||||
pub playback_state: Option<crate::tdlib::PlaybackState>,
|
||||
/// Время последнего тика для обновления позиции воспроизведения
|
||||
pub last_playback_tick: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl<T: TdClientTrait> App<T> {
|
||||
@@ -126,6 +128,8 @@ impl<T: TdClientTrait> App<T> {
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(0));
|
||||
|
||||
let audio_cache_size_mb = config.audio.cache_size_mb;
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
let image_cache = Some(crate::media::cache::ImageCache::new(
|
||||
config.images.cache_size_mb,
|
||||
@@ -169,8 +173,9 @@ impl<T: TdClientTrait> App<T> {
|
||||
last_image_render_time: None,
|
||||
// Voice playback
|
||||
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,
|
||||
last_playback_tick: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +203,7 @@ impl<T: TdClientTrait> App<T> {
|
||||
player.stop();
|
||||
}
|
||||
self.playback_state = None;
|
||||
self.last_playback_tick = None;
|
||||
self.status_message = None;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@ 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,
|
||||
@@ -20,8 +17,8 @@ pub struct VoiceCache {
|
||||
}
|
||||
|
||||
impl VoiceCache {
|
||||
/// Creates a new VoiceCache
|
||||
pub fn new() -> Result<Self, String> {
|
||||
/// Creates a new VoiceCache with the given max size in MB
|
||||
pub fn new(max_size_mb: u64) -> Result<Self, String> {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.ok_or("Failed to get cache directory")?
|
||||
.join("tele-tui")
|
||||
@@ -34,7 +31,7 @@ impl VoiceCache {
|
||||
cache_dir,
|
||||
files: HashMap::new(),
|
||||
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]
|
||||
fn test_voice_cache_creation() {
|
||||
let cache = VoiceCache::new();
|
||||
let cache = VoiceCache::new(100);
|
||||
assert!(cache.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_get_nonexistent() {
|
||||
let mut cache = VoiceCache::new().unwrap();
|
||||
let mut cache = VoiceCache::new(100).unwrap();
|
||||
assert!(cache.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_store_and_get() {
|
||||
let mut cache = VoiceCache::new().unwrap();
|
||||
let mut cache = VoiceCache::new(100).unwrap();
|
||||
|
||||
// Create temporary file
|
||||
let temp_dir = std::env::temp_dir();
|
||||
|
||||
@@ -14,6 +14,10 @@ pub struct AudioPlayer {
|
||||
current_pid: Arc<Mutex<Option<u32>>>,
|
||||
/// Whether the process is currently paused (SIGSTOP)
|
||||
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 {
|
||||
@@ -29,22 +33,38 @@ impl AudioPlayer {
|
||||
Ok(Self {
|
||||
current_pid: Arc::new(Mutex::new(None)),
|
||||
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
|
||||
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();
|
||||
|
||||
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 paused = self.paused.clone();
|
||||
let starting = self.starting.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(mut child) = Command::new("ffplay")
|
||||
.arg("-nodisp")
|
||||
let mut cmd = Command::new("ffplay");
|
||||
cmd.arg("-nodisp")
|
||||
.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)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
@@ -53,11 +73,18 @@ impl AudioPlayer {
|
||||
let pid = child.id();
|
||||
*current_pid.lock().unwrap() = Some(pid);
|
||||
*paused.lock().unwrap() = false;
|
||||
*starting.lock().unwrap() = false;
|
||||
|
||||
let _ = child.wait();
|
||||
|
||||
*current_pid.lock().unwrap() = None;
|
||||
*paused.lock().unwrap() = false;
|
||||
// Обнуляем только если это наш pid (новый play мог уже заменить его)
|
||||
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) {
|
||||
if let Some(pid) = *self.current_pid.lock().unwrap() {
|
||||
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)
|
||||
pub fn stop(&self) {
|
||||
*self.starting.lock().unwrap() = false;
|
||||
if let Some(pid) = self.current_pid.lock().unwrap().take() {
|
||||
// Resume first if paused, then kill
|
||||
let _ = Command::new("kill")
|
||||
@@ -111,9 +149,9 @@ impl AudioPlayer {
|
||||
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 {
|
||||
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) {}
|
||||
@@ -128,6 +166,12 @@ impl AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioPlayer {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -47,6 +47,10 @@ pub struct Config {
|
||||
/// Настройки отображения изображений.
|
||||
#[serde(default)]
|
||||
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 атрибутами)
|
||||
fn default_timezone() -> String {
|
||||
"+03:00".to_string()
|
||||
@@ -197,6 +222,14 @@ fn default_auto_download_images() -> bool {
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self { timezone: default_timezone() }
|
||||
@@ -235,6 +268,7 @@ impl Default for Config {
|
||||
keybindings: Keybindings::default(),
|
||||
notifications: NotificationsConfig::default(),
|
||||
images: ImagesConfig::default(),
|
||||
audio: AudioConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,3 +58,10 @@ pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
|
||||
/// Максимальная ширина inline превью изображений (в символах)
|
||||
#[cfg(feature = "images")]
|
||||
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 => {
|
||||
player.pause();
|
||||
playback.status = PlaybackStatus::Paused;
|
||||
app.last_playback_tick = None;
|
||||
app.status_message = Some("⏸ Пауза".to_string());
|
||||
}
|
||||
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;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||
}
|
||||
_ => {}
|
||||
@@ -612,6 +622,7 @@ async fn handle_play_voice_from_path<T: TdClientTrait>(
|
||||
duration: voice.duration as f32,
|
||||
volume: player.volume(),
|
||||
});
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
@@ -658,7 +669,12 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
VoiceDownloadState::Downloading => {
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
}
|
||||
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());
|
||||
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));
|
||||
}
|
||||
}
|
||||
// Кэшируем загруженный файл
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &path, &voice, &msg).await;
|
||||
}
|
||||
Err(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;
|
||||
}
|
||||
|
||||
// Обновляем позицию воспроизведения голосового сообщения
|
||||
{
|
||||
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 {
|
||||
terminal.draw(|f| ui::render(f, app))?;
|
||||
@@ -185,6 +221,9 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
// Graceful shutdown
|
||||
should_stop.store(true, Ordering::Relaxed);
|
||||
|
||||
// Останавливаем воспроизведение голосового (убиваем ffplay)
|
||||
app.stop_playback();
|
||||
|
||||
// Закрываем TDLib клиент
|
||||
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::formatting;
|
||||
use crate::tdlib::MessageInfo;
|
||||
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
|
||||
#[cfg(feature = "images")]
|
||||
use crate::tdlib::PhotoDownloadState;
|
||||
use crate::types::MessageId;
|
||||
@@ -200,6 +200,7 @@ pub fn render_message_bubble(
|
||||
config: &Config,
|
||||
content_width: usize,
|
||||
selected_msg_id: Option<MessageId>,
|
||||
playback_state: Option<&PlaybackState>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
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")]
|
||||
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;
|
||||
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(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
app.playback_state.as_ref(),
|
||||
);
|
||||
|
||||
// Собираем deferred image renders для всех загруженных фото
|
||||
|
||||
Reference in New Issue
Block a user