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        // Deterministic floor; the full player UI only draws >=4 rows when
137        // there is room, so 3 is a safe intrinsic floor.
138        Some(3)
139    }
140
141    /// Keyboard control of the live player (feature-gated). Without the
142    /// `audio` feature the trait's default (`None`) is used.
143    #[cfg(feature = "audio")]
144    fn handle_event(
145        &self,
146        ctx: &ComponentContext,
147        event: &InputEvent,
148    ) -> Option<EventResult> {
149        // `InputEvent` has a single variant, so this destructure is
150        // irrefutable; `key` binds by reference via match ergonomics.
151        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        // Playback state lives in the handle cache, not the data model, so
161        // there is nothing to write back — just signal that we consumed it.
162        Some(EventResult::Consumed)
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Live playback (only compiled under `feature = "audio"`)
168// ---------------------------------------------------------------------------
169
170#[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    /// One live playback session for a component instance.
188    struct Handle {
189        // Order matters for drop: `player` drops before `sink`. The sink is
190        // held only to keep the audio device alive (never read here).
191        #[allow(dead_code)]
192        sink: MixerDeviceSink,
193        player: Player,
194        url: String,
195        total: Option<Duration>,
196    }
197
198    thread_local! {
199        /// Per-instance live handles, keyed by `surface_id:component_id`.
200        /// `thread_local` + `RefCell` because the TUI is single-threaded and
201        /// this avoids requiring the rodio handles to be `Send`.
202        static HANDLES: RefCell<HashMap<String, Handle>> = RefCell::new(HashMap::new());
203    }
204
205    /// A cheap point-in-time read of a session; copied out of the borrow so
206    /// it can be used while drawing without holding the `RefCell`.
207    #[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    /// A control operation requested by a key press.
217    pub(crate) enum Op {
218        Toggle,
219        VolUp,
220        VolDown,
221    }
222
223    /// Stable per-instance cache key.
224    pub(crate) fn key(surface_id: &str, component_id: &str) -> String {
225        format!("{surface_id}:{component_id}")
226    }
227
228    /// Open the device, decode `url`, and build a live `Handle`. Local file
229    /// paths only (no HTTP fetch).
230    fn open(url: &str) -> Result<Handle, ()> {
231        let mut sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|_| ())?;
232        // Silence the "Dropping DeviceSink" stderr notice — this app uses
233        // stderr as the TUI backend and stray output corrupts the screen.
234        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    /// Ensure a session exists for `key` playing `url`. Creates one if absent
249    /// or if the URL changed (server swapped the track). Returns `false` if
250    /// playback could not start, so the caller falls back to the placeholder.
251    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    /// Read the current playback state, if a session exists for `key`.
276    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    /// Apply a control operation to the session for `key` (no-op if absent).
289    /// `Toggle` resumes when paused, pauses when playing, and — when the track
290    /// has finished — replays it from the start (re-decode + append to the
291    /// same player).
292    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    /// Draw the player UI into `area` from a `Snapshot`.
323    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        // Degrade to a single status line when there isn't room for gauges.
333        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), // state
343                Constraint::Length(1), // progress
344                Constraint::Length(1), // volume
345                Constraint::Length(1), // hints
346            ])
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}