1use ratatui::{
31 Frame,
32 layout::Rect,
33 style::{Color, Style},
34 text::{Line, Span},
35 widgets::Paragraph,
36};
37
38use a2ui_base::model::component_context::ComponentContext;
39use a2ui_base::protocol::common_types::DynamicString;
40use crate::component_impl::TuiComponent;
41
42#[cfg(feature = "audio")]
44use a2ui_base::event::{EventResult, InputEvent, InputKey};
45
46fn render_placeholder(description: &str, display_text: &str, inner: Rect, frame: &mut Frame) {
48 let placeholder = if description.is_empty() {
49 format!("[\u{266B} {}]", display_text)
50 } else {
51 format!("[\u{266B} {} \u{2014} {}]", description, display_text)
52 };
53 let paragraph = Paragraph::new(Line::from(Span::styled(
54 placeholder,
55 Style::default().fg(Color::DarkGray),
56 )));
57 frame.render_widget(paragraph, inner);
58}
59
60pub struct AudioPlayerComponent;
65
66impl TuiComponent for AudioPlayerComponent {
67 fn name(&self) -> &'static str {
68 "AudioPlayer"
69 }
70
71 fn render(
72 &self,
73 ctx: &ComponentContext,
74 area: Rect,
75 frame: &mut Frame,
76 _render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
77 _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
78 ) {
79 let comp_model = match ctx.components.get(&ctx.component_id) {
80 Some(m) => m,
81 None => return,
82 };
83
84 let inner = Rect {
86 x: area.x + 1,
87 y: area.y + 1,
88 width: area.width.saturating_sub(2),
89 height: area.height.saturating_sub(2),
90 };
91
92 if inner.width == 0 || inner.height == 0 {
93 return;
94 }
95
96 let url = match comp_model.get_property::<DynamicString>("url") {
98 Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
99 None => String::new(),
100 };
101 let description = comp_model
102 .get_property::<DynamicString>("description")
103 .map(|ds| ctx.data_context.resolve_dynamic_string(&ds))
104 .unwrap_or_default();
105 let display = if !description.is_empty() {
106 description.clone()
107 } else if !url.is_empty() {
108 url.clone()
109 } else {
110 "audio".to_string()
111 };
112
113 #[cfg(feature = "audio")]
117 {
118 let key = player::key(&ctx.surface_id, &ctx.component_id);
119 if player::ensure_started(&key, &url) {
120 if let Some(snap) = player::snapshot(&key) {
121 player::draw(frame, inner, &display, &snap);
122 return;
123 }
124 }
125 }
126
127 render_placeholder(&description, &display, inner, frame);
128 }
129
130 fn natural_height(
131 &self,
132 _ctx: &ComponentContext,
133 _available_width: u16,
134 _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
135 ) -> Option<u16> {
136 Some(6)
144 }
145
146 #[cfg(feature = "audio")]
149 fn handle_event(
150 &self,
151 ctx: &ComponentContext,
152 event: &InputEvent,
153 ) -> Option<EventResult> {
154 let InputEvent::KeyPress { key } = event;
157 let op = match key {
158 InputKey::Space => player::Op::Toggle,
159 InputKey::Up => player::Op::VolUp,
160 InputKey::Down => player::Op::VolDown,
161 _ => return None,
162 };
163 let key = player::key(&ctx.surface_id, &ctx.component_id);
164 player::control(&key, op);
165 Some(EventResult::Consumed)
168 }
169}
170
171#[cfg(feature = "audio")]
176mod player {
177 use std::cell::RefCell;
178 use std::collections::HashMap;
179 use std::fs::File;
180 use std::io::BufReader;
181 use std::time::Duration;
182
183 use ratatui::{
184 Frame,
185 layout::{Constraint, Direction, Layout, Rect},
186 style::{Color, Modifier, Style},
187 text::{Line, Span},
188 widgets::{Gauge, Paragraph},
189 };
190 use rodio::{Decoder, MixerDeviceSink, Player, Source};
191
192 struct Handle {
194 #[allow(dead_code)]
197 sink: MixerDeviceSink,
198 player: Player,
199 url: String,
200 total: Option<Duration>,
201 }
202
203 thread_local! {
204 static HANDLES: RefCell<HashMap<String, Handle>> = RefCell::new(HashMap::new());
208 }
209
210 #[derive(Clone, Copy, Default)]
213 pub(crate) struct Snapshot {
214 paused: bool,
215 ended: bool,
216 pos: Duration,
217 vol: f32,
218 total: Option<Duration>,
219 }
220
221 pub(crate) enum Op {
223 Toggle,
224 VolUp,
225 VolDown,
226 }
227
228 pub(crate) fn key(surface_id: &str, component_id: &str) -> String {
230 format!("{surface_id}:{component_id}")
231 }
232
233 fn open(url: &str) -> Result<Handle, ()> {
236 let mut sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|_| ())?;
237 sink.log_on_drop(false);
240 let file = File::open(url).map_err(|_| ())?;
241 let decoder = Decoder::new(BufReader::new(file)).map_err(|_| ())?;
242 let total = decoder.total_duration();
243 let player = Player::connect_new(sink.mixer());
244 player.append(decoder);
245 Ok(Handle {
246 sink,
247 player,
248 url: url.to_string(),
249 total,
250 })
251 }
252
253 pub(crate) fn ensure_started(key: &str, url: &str) -> bool {
257 if url.is_empty() || url.starts_with("http://") || url.starts_with("https://") {
258 return false;
259 }
260 if !std::path::Path::new(url).is_file() {
261 return false;
262 }
263 HANDLES.with(|m| -> bool {
264 let mut m = m.borrow_mut();
265 let needs = m.get(key).map_or(true, |h| h.url != url);
266 if needs {
267 match open(url) {
268 Ok(h) => {
269 m.insert(key.to_string(), h);
270 true
271 }
272 Err(()) => false,
273 }
274 } else {
275 true
276 }
277 })
278 }
279
280 pub(crate) fn snapshot(key: &str) -> Option<Snapshot> {
282 HANDLES.with(|m| {
283 m.borrow().get(key).map(|h| Snapshot {
284 paused: h.player.is_paused(),
285 ended: h.player.empty(),
286 pos: h.player.get_pos(),
287 vol: h.player.volume(),
288 total: h.total,
289 })
290 })
291 }
292
293 pub(crate) fn control(key: &str, op: Op) {
298 HANDLES.with(|m| {
299 let mut m = m.borrow_mut();
300 let Some(h) = m.get_mut(key) else { return };
301 match op {
302 Op::Toggle => {
303 if h.player.empty() {
304 if let Ok(file) = File::open(&h.url) {
305 if let Ok(dec) = Decoder::new(BufReader::new(file)) {
306 h.player.append(dec);
307 }
308 }
309 h.player.play();
310 } else if h.player.is_paused() {
311 h.player.play();
312 } else {
313 h.player.pause();
314 }
315 }
316 Op::VolUp => h.player.set_volume((h.player.volume() + 0.1).min(1.0)),
317 Op::VolDown => h.player.set_volume((h.player.volume() - 0.1).max(0.0)),
318 }
319 });
320 }
321
322 fn fmt_dur(d: Duration) -> String {
323 let s = d.as_secs();
324 format!("{}:{:02}", s / 60, s % 60)
325 }
326
327 pub(crate) fn draw(frame: &mut Frame, area: Rect, display: &str, snap: &Snapshot) {
329 let (icon, label, color) = if snap.ended {
330 ("\u{25A0}", "Ended", Color::DarkGray)
331 } else if snap.paused {
332 ("\u{23F8}", "Paused", Color::Yellow)
333 } else {
334 ("\u{25B6}", "Playing", Color::Green)
335 };
336
337 if area.height < 4 || area.width < 12 {
339 let p = Paragraph::new(format!("{icon} {label} \u{2014} {display}"));
340 frame.render_widget(p, area);
341 return;
342 }
343
344 let chunks = Layout::default()
345 .direction(Direction::Vertical)
346 .constraints([
347 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
352 .split(area);
353
354 let state = Paragraph::new(Line::from(vec![
355 Span::styled(
356 format!("{icon} "),
357 Style::default().fg(color).add_modifier(Modifier::BOLD),
358 ),
359 Span::raw(label),
360 Span::raw(format!(" \u{2014} {display}")),
361 ]));
362 frame.render_widget(state, chunks[0]);
363
364 match snap.total {
365 Some(t) => {
366 let pct =
367 ((snap.pos.as_secs_f64() / t.as_secs_f64()) * 100.0).clamp(0.0, 100.0) as u16;
368 let g = Gauge::default()
369 .gauge_style(Style::default().fg(Color::Cyan))
370 .percent(pct)
371 .label(format!("{} / {}", fmt_dur(snap.pos), fmt_dur(t)));
372 frame.render_widget(g, chunks[1]);
373 }
374 None => {
375 let p = Paragraph::new(format!("{} (duration unknown)", fmt_dur(snap.pos)));
376 frame.render_widget(p, chunks[1]);
377 }
378 }
379
380 let vpct = (snap.vol * 100.0).round().clamp(0.0, 100.0) as u16;
381 let vg = Gauge::default()
382 .gauge_style(Style::default().fg(Color::Magenta))
383 .percent(vpct)
384 .label(format!("Vol {}%", vpct));
385 frame.render_widget(vg, chunks[2]);
386
387 let hint = Paragraph::new(Line::from(
388 " Space:play/pause/replay \u{2191}/\u{2193}:volume",
389 ))
390 .style(Style::default().fg(Color::DarkGray));
391 frame.render_widget(hint, chunks[3]);
392 }
393}