Skip to main content

a2ui_tui/components/
audio_player.rs

1//! AudioPlayer component.
2//!
3//! By default (no `audio` feature) it renders a text placeholder:
4//! `[♫ description]`. The terminal cannot decode audio without extra native
5//! dependencies.
6//!
7//! With the **`audio`** Cargo feature enabled, it becomes a real, interactive
8//! player: playback starts from a LOCAL file path (resolved from the `url`
9//! binding) and is controlled live — play/pause, volume, and replay after the
10//! track ends — via keyboard when the component has focus. It also fixes the
11//! earlier "re-trigger playback every frame" bug: each instance's `rodio`
12//! handles live in a per-instance cache ([`player::HANDLES`]), created once on
13//! first render and reused.
14//!
15//! # Interaction (requires `audio` feature + focus)
16//! | Key | Action |
17//! |-----|--------|
18//! | `Space` | Play / Pause (or Replay when finished) |
19//! | `↑` / `↓` | Volume ±10 % |
20//!
21//! Seeking is intentionally **not** supported: rodio's bundled decoders only
22//! support forward seeks reliably (WAV/MP3/FLAC reject backward seeks with
23//! `RandomAccessNotSupported`), so a consistent seek UX isn't achievable.
24//!
25//! Because the component is a stateless `&self` singleton (rendered every
26//! frame), live playback state cannot live on the struct — it lives in the
27//! handle cache keyed by `surface_id:component_id`. The bound data model is
28//! only used for `url` / `description`, as before.
29
30use 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// Event types are only needed by the feature-gated `handle_event`.
43#[cfg(feature = "audio")]
44use a2ui_base::event::{EventResult, InputEvent, InputKey};
45
46/// Render the standard text placeholder into `inner`.
47fn 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
60/// AudioPlayer component.
61///
62/// Placeholder-only without the `audio` feature; an interactive player with it.
63/// Applies a default 1-cell margin.
64pub 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        // Apply default 1-cell margin on all sides.
85        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        // Resolve url + description.
97        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        // Real interactive player (feature-gated). Falls back to the
114        // placeholder when playback can't start (non-local url, missing file,
115        // no audio device, decode error).
116        #[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        // The full player UI draws 4 rows (state / progress / volume / hints),
137        // and `render` wraps it in the standard 1-cell margin (+2 rows). So the
138        // rendered footprint is 6 — the measured natural height must match it,
139        // otherwise the layout (which trusts this number) under-sizes the root
140        // and the margin leaves <4 inner rows, collapsing the player to its
141        // degraded single-line status. When forced into less, `draw` degrades
142        // gracefully on its own.
143        Some(6)
144    }
145
146    /// Keyboard control of the live player (feature-gated). Without the
147    /// `audio` feature the trait's default (`None`) is used.
148    #[cfg(feature = "audio")]
149    fn handle_event(
150        &self,
151        ctx: &ComponentContext,
152        event: &InputEvent,
153    ) -> Option<EventResult> {
154        // `InputEvent` has a single variant, so this destructure is
155        // irrefutable; `key` binds by reference via match ergonomics.
156        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        // Playback state lives in the handle cache, not the data model, so
166        // there is nothing to write back — just signal that we consumed it.
167        Some(EventResult::Consumed)
168    }
169}
170
171// ---------------------------------------------------------------------------
172// Live playback (only compiled under `feature = "audio"`)
173// ---------------------------------------------------------------------------
174
175#[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    /// One live playback session for a component instance.
193    struct Handle {
194        // Order matters for drop: `player` drops before `sink`. The sink is
195        // held only to keep the audio device alive (never read here).
196        #[allow(dead_code)]
197        sink: MixerDeviceSink,
198        player: Player,
199        url: String,
200        total: Option<Duration>,
201    }
202
203    thread_local! {
204        /// Per-instance live handles, keyed by `surface_id:component_id`.
205        /// `thread_local` + `RefCell` because the TUI is single-threaded and
206        /// this avoids requiring the rodio handles to be `Send`.
207        static HANDLES: RefCell<HashMap<String, Handle>> = RefCell::new(HashMap::new());
208    }
209
210    /// A cheap point-in-time read of a session; copied out of the borrow so
211    /// it can be used while drawing without holding the `RefCell`.
212    #[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    /// A control operation requested by a key press.
222    pub(crate) enum Op {
223        Toggle,
224        VolUp,
225        VolDown,
226    }
227
228    /// Stable per-instance cache key.
229    pub(crate) fn key(surface_id: &str, component_id: &str) -> String {
230        format!("{surface_id}:{component_id}")
231    }
232
233    /// Open the device, decode `url`, and build a live `Handle`. Local file
234    /// paths only (no HTTP fetch).
235    fn open(url: &str) -> Result<Handle, ()> {
236        let mut sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|_| ())?;
237        // Silence the "Dropping DeviceSink" stderr notice — this app uses
238        // stderr as the TUI backend and stray output corrupts the screen.
239        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    /// Ensure a session exists for `key` playing `url`. Creates one if absent
254    /// or if the URL changed (server swapped the track). Returns `false` if
255    /// playback could not start, so the caller falls back to the placeholder.
256    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    /// Read the current playback state, if a session exists for `key`.
281    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    /// Apply a control operation to the session for `key` (no-op if absent).
294    /// `Toggle` resumes when paused, pauses when playing, and — when the track
295    /// has finished — replays it from the start (re-decode + append to the
296    /// same player).
297    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    /// Draw the player UI into `area` from a `Snapshot`.
328    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        // Degrade to a single status line when there isn't room for gauges.
338        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), // state
348                Constraint::Length(1), // progress
349                Constraint::Length(1), // volume
350                Constraint::Length(1), // hints
351            ])
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}