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:
@@ -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