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(3)
139 }
140
141 #[cfg(feature = "audio")]
144 fn handle_event(
145 &self,
146 ctx: &ComponentContext,
147 event: &InputEvent,
148 ) -> Option<EventResult> {
149 let InputEvent::KeyPress { key } = event;
152 let op = match key {
153 InputKey::Space => player::Op::Toggle,
154 InputKey::Up => player::Op::VolUp,
155 InputKey::Down => player::Op::VolDown,
156 _ => return None,
157 };
158 let key = player::key(&ctx.surface_id, &ctx.component_id);
159 player::control(&key, op);
160 Some(EventResult::Consumed)
163 }
164}
165
166#[cfg(feature = "audio")]
171mod player {
172 use std::cell::RefCell;
173 use std::collections::HashMap;
174 use std::fs::File;
175 use std::io::BufReader;
176 use std::time::Duration;
177
178 use ratatui::{
179 Frame,
180 layout::{Constraint, Direction, Layout, Rect},
181 style::{Color, Modifier, Style},
182 text::{Line, Span},
183 widgets::{Gauge, Paragraph},
184 };
185 use rodio::{Decoder, MixerDeviceSink, Player, Source};
186
187 struct Handle {
189 #[allow(dead_code)]
192 sink: MixerDeviceSink,
193 player: Player,
194 url: String,
195 total: Option<Duration>,
196 }
197
198 thread_local! {
199 static HANDLES: RefCell<HashMap<String, Handle>> = RefCell::new(HashMap::new());
203 }
204
205 #[derive(Clone, Copy, Default)]
208 pub(crate) struct Snapshot {
209 paused: bool,
210 ended: bool,
211 pos: Duration,
212 vol: f32,
213 total: Option<Duration>,
214 }
215
216 pub(crate) enum Op {
218 Toggle,
219 VolUp,
220 VolDown,
221 }
222
223 pub(crate) fn key(surface_id: &str, component_id: &str) -> String {
225 format!("{surface_id}:{component_id}")
226 }
227
228 fn open(url: &str) -> Result<Handle, ()> {
231 let mut sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|_| ())?;
232 sink.log_on_drop(false);
235 let file = File::open(url).map_err(|_| ())?;
236 let decoder = Decoder::new(BufReader::new(file)).map_err(|_| ())?;
237 let total = decoder.total_duration();
238 let player = Player::connect_new(sink.mixer());
239 player.append(decoder);
240 Ok(Handle {
241 sink,
242 player,
243 url: url.to_string(),
244 total,
245 })
246 }
247
248 pub(crate) fn ensure_started(key: &str, url: &str) -> bool {
252 if url.is_empty() || url.starts_with("http://") || url.starts_with("https://") {
253 return false;
254 }
255 if !std::path::Path::new(url).is_file() {
256 return false;
257 }
258 HANDLES.with(|m| -> bool {
259 let mut m = m.borrow_mut();
260 let needs = m.get(key).map_or(true, |h| h.url != url);
261 if needs {
262 match open(url) {
263 Ok(h) => {
264 m.insert(key.to_string(), h);
265 true
266 }
267 Err(()) => false,
268 }
269 } else {
270 true
271 }
272 })
273 }
274
275 pub(crate) fn snapshot(key: &str) -> Option<Snapshot> {
277 HANDLES.with(|m| {
278 m.borrow().get(key).map(|h| Snapshot {
279 paused: h.player.is_paused(),
280 ended: h.player.empty(),
281 pos: h.player.get_pos(),
282 vol: h.player.volume(),
283 total: h.total,
284 })
285 })
286 }
287
288 pub(crate) fn control(key: &str, op: Op) {
293 HANDLES.with(|m| {
294 let mut m = m.borrow_mut();
295 let Some(h) = m.get_mut(key) else { return };
296 match op {
297 Op::Toggle => {
298 if h.player.empty() {
299 if let Ok(file) = File::open(&h.url) {
300 if let Ok(dec) = Decoder::new(BufReader::new(file)) {
301 h.player.append(dec);
302 }
303 }
304 h.player.play();
305 } else if h.player.is_paused() {
306 h.player.play();
307 } else {
308 h.player.pause();
309 }
310 }
311 Op::VolUp => h.player.set_volume((h.player.volume() + 0.1).min(1.0)),
312 Op::VolDown => h.player.set_volume((h.player.volume() - 0.1).max(0.0)),
313 }
314 });
315 }
316
317 fn fmt_dur(d: Duration) -> String {
318 let s = d.as_secs();
319 format!("{}:{:02}", s / 60, s % 60)
320 }
321
322 pub(crate) fn draw(frame: &mut Frame, area: Rect, display: &str, snap: &Snapshot) {
324 let (icon, label, color) = if snap.ended {
325 ("\u{25A0}", "Ended", Color::DarkGray)
326 } else if snap.paused {
327 ("\u{23F8}", "Paused", Color::Yellow)
328 } else {
329 ("\u{25B6}", "Playing", Color::Green)
330 };
331
332 if area.height < 4 || area.width < 12 {
334 let p = Paragraph::new(format!("{icon} {label} \u{2014} {display}"));
335 frame.render_widget(p, area);
336 return;
337 }
338
339 let chunks = Layout::default()
340 .direction(Direction::Vertical)
341 .constraints([
342 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
347 .split(area);
348
349 let state = Paragraph::new(Line::from(vec![
350 Span::styled(
351 format!("{icon} "),
352 Style::default().fg(color).add_modifier(Modifier::BOLD),
353 ),
354 Span::raw(label),
355 Span::raw(format!(" \u{2014} {display}")),
356 ]));
357 frame.render_widget(state, chunks[0]);
358
359 match snap.total {
360 Some(t) => {
361 let pct =
362 ((snap.pos.as_secs_f64() / t.as_secs_f64()) * 100.0).clamp(0.0, 100.0) as u16;
363 let g = Gauge::default()
364 .gauge_style(Style::default().fg(Color::Cyan))
365 .percent(pct)
366 .label(format!("{} / {}", fmt_dur(snap.pos), fmt_dur(t)));
367 frame.render_widget(g, chunks[1]);
368 }
369 None => {
370 let p = Paragraph::new(format!("{} (duration unknown)", fmt_dur(snap.pos)));
371 frame.render_widget(p, chunks[1]);
372 }
373 }
374
375 let vpct = (snap.vol * 100.0).round().clamp(0.0, 100.0) as u16;
376 let vg = Gauge::default()
377 .gauge_style(Style::default().fg(Color::Magenta))
378 .percent(vpct)
379 .label(format!("Vol {}%", vpct));
380 frame.render_widget(vg, chunks[2]);
381
382 let hint = Paragraph::new(Line::from(
383 " Space:play/pause/replay \u{2191}/\u{2193}:volume",
384 ))
385 .style(Style::default().fg(Color::DarkGray));
386 frame.render_widget(hint, chunks[3]);
387 }
388}