use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Paragraph,
};
use a2ui_base::model::component_context::ComponentContext;
use a2ui_base::protocol::common_types::DynamicString;
use crate::component_impl::TuiComponent;
#[cfg(feature = "audio")]
use a2ui_base::event::{EventResult, InputEvent, InputKey};
fn render_placeholder(description: &str, display_text: &str, inner: Rect, frame: &mut Frame) {
let placeholder = if description.is_empty() {
format!("[\u{266B} {}]", display_text)
} else {
format!("[\u{266B} {} \u{2014} {}]", description, display_text)
};
let paragraph = Paragraph::new(Line::from(Span::styled(
placeholder,
Style::default().fg(Color::DarkGray),
)));
frame.render_widget(paragraph, inner);
}
pub struct AudioPlayerComponent;
impl TuiComponent for AudioPlayerComponent {
fn name(&self) -> &'static str {
"AudioPlayer"
}
fn render(
&self,
ctx: &ComponentContext,
area: Rect,
frame: &mut Frame,
_render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
_measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
) {
let comp_model = match ctx.components.get(&ctx.component_id) {
Some(m) => m,
None => return,
};
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
if inner.width == 0 || inner.height == 0 {
return;
}
let url = match comp_model.get_property::<DynamicString>("url") {
Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
None => String::new(),
};
let description = comp_model
.get_property::<DynamicString>("description")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
.unwrap_or_default();
let display = if !description.is_empty() {
description.clone()
} else if !url.is_empty() {
url.clone()
} else {
"audio".to_string()
};
#[cfg(feature = "audio")]
{
let key = player::key(&ctx.surface_id, &ctx.component_id);
if player::ensure_started(&key, &url) {
if let Some(snap) = player::snapshot(&key) {
player::draw(frame, inner, &display, &snap);
return;
}
}
}
render_placeholder(&description, &display, inner, frame);
}
fn natural_height(
&self,
_ctx: &ComponentContext,
_available_width: u16,
_measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
) -> Option<u16> {
Some(3)
}
#[cfg(feature = "audio")]
fn handle_event(
&self,
ctx: &ComponentContext,
event: &InputEvent,
) -> Option<EventResult> {
let InputEvent::KeyPress { key } = event;
let op = match key {
InputKey::Space => player::Op::Toggle,
InputKey::Up => player::Op::VolUp,
InputKey::Down => player::Op::VolDown,
_ => return None,
};
let key = player::key(&ctx.surface_id, &ctx.component_id);
player::control(&key, op);
Some(EventResult::Consumed)
}
}
#[cfg(feature = "audio")]
mod player {
use std::cell::RefCell;
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::time::Duration;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Gauge, Paragraph},
};
use rodio::{Decoder, MixerDeviceSink, Player, Source};
struct Handle {
#[allow(dead_code)]
sink: MixerDeviceSink,
player: Player,
url: String,
total: Option<Duration>,
}
thread_local! {
static HANDLES: RefCell<HashMap<String, Handle>> = RefCell::new(HashMap::new());
}
#[derive(Clone, Copy, Default)]
pub(crate) struct Snapshot {
paused: bool,
ended: bool,
pos: Duration,
vol: f32,
total: Option<Duration>,
}
pub(crate) enum Op {
Toggle,
VolUp,
VolDown,
}
pub(crate) fn key(surface_id: &str, component_id: &str) -> String {
format!("{surface_id}:{component_id}")
}
fn open(url: &str) -> Result<Handle, ()> {
let mut sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|_| ())?;
sink.log_on_drop(false);
let file = File::open(url).map_err(|_| ())?;
let decoder = Decoder::new(BufReader::new(file)).map_err(|_| ())?;
let total = decoder.total_duration();
let player = Player::connect_new(sink.mixer());
player.append(decoder);
Ok(Handle {
sink,
player,
url: url.to_string(),
total,
})
}
pub(crate) fn ensure_started(key: &str, url: &str) -> bool {
if url.is_empty() || url.starts_with("http://") || url.starts_with("https://") {
return false;
}
if !std::path::Path::new(url).is_file() {
return false;
}
HANDLES.with(|m| -> bool {
let mut m = m.borrow_mut();
let needs = m.get(key).map_or(true, |h| h.url != url);
if needs {
match open(url) {
Ok(h) => {
m.insert(key.to_string(), h);
true
}
Err(()) => false,
}
} else {
true
}
})
}
pub(crate) fn snapshot(key: &str) -> Option<Snapshot> {
HANDLES.with(|m| {
m.borrow().get(key).map(|h| Snapshot {
paused: h.player.is_paused(),
ended: h.player.empty(),
pos: h.player.get_pos(),
vol: h.player.volume(),
total: h.total,
})
})
}
pub(crate) fn control(key: &str, op: Op) {
HANDLES.with(|m| {
let mut m = m.borrow_mut();
let Some(h) = m.get_mut(key) else { return };
match op {
Op::Toggle => {
if h.player.empty() {
if let Ok(file) = File::open(&h.url) {
if let Ok(dec) = Decoder::new(BufReader::new(file)) {
h.player.append(dec);
}
}
h.player.play();
} else if h.player.is_paused() {
h.player.play();
} else {
h.player.pause();
}
}
Op::VolUp => h.player.set_volume((h.player.volume() + 0.1).min(1.0)),
Op::VolDown => h.player.set_volume((h.player.volume() - 0.1).max(0.0)),
}
});
}
fn fmt_dur(d: Duration) -> String {
let s = d.as_secs();
format!("{}:{:02}", s / 60, s % 60)
}
pub(crate) fn draw(frame: &mut Frame, area: Rect, display: &str, snap: &Snapshot) {
let (icon, label, color) = if snap.ended {
("\u{25A0}", "Ended", Color::DarkGray)
} else if snap.paused {
("\u{23F8}", "Paused", Color::Yellow)
} else {
("\u{25B6}", "Playing", Color::Green)
};
if area.height < 4 || area.width < 12 {
let p = Paragraph::new(format!("{icon} {label} \u{2014} {display}"));
frame.render_widget(p, area);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
let state = Paragraph::new(Line::from(vec![
Span::styled(
format!("{icon} "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::raw(label),
Span::raw(format!(" \u{2014} {display}")),
]));
frame.render_widget(state, chunks[0]);
match snap.total {
Some(t) => {
let pct =
((snap.pos.as_secs_f64() / t.as_secs_f64()) * 100.0).clamp(0.0, 100.0) as u16;
let g = Gauge::default()
.gauge_style(Style::default().fg(Color::Cyan))
.percent(pct)
.label(format!("{} / {}", fmt_dur(snap.pos), fmt_dur(t)));
frame.render_widget(g, chunks[1]);
}
None => {
let p = Paragraph::new(format!("{} (duration unknown)", fmt_dur(snap.pos)));
frame.render_widget(p, chunks[1]);
}
}
let vpct = (snap.vol * 100.0).round().clamp(0.0, 100.0) as u16;
let vg = Gauge::default()
.gauge_style(Style::default().fg(Color::Magenta))
.percent(vpct)
.label(format!("Vol {}%", vpct));
frame.render_widget(vg, chunks[2]);
let hint = Paragraph::new(Line::from(
" Space:play/pause/replay \u{2191}/\u{2193}:volume",
))
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(hint, chunks[3]);
}
}