Skip to main content

orinium_browser/engine/ui/components/
audio.rs

1//! Engine-rendered controls for the HTML `<audio>` element.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, LazyLock, Mutex};
5use std::time::{Duration, Instant};
6
7use ui_layout::Style;
8
9use crate::engine::bridge::audio::AudioSink;
10use crate::engine::layouter::types::{Color, TextFlowStyle, TextStyle};
11use crate::engine::renderer_model::{Brush, DrawCommand, FillRule, Image, Paint, rect_path};
12use crate::engine::ui::custom_node::{ContentSize, CustomNode, PointerEvent};
13
14const PLAYER_WIDTH: f32 = 170.0;
15const PLAYER_HEIGHT: f32 = 32.0;
16const BUTTON_WIDTH: f32 = 32.0;
17const ICON_SIZE: f32 = 18.0;
18const ICON_RASTER_SIZE: u32 = 64;
19const TIMER_REPAINT_INTERVAL: Duration = Duration::from_millis(100);
20
21const PLAY_SVG: &[u8] = include_bytes!("../../../../resource/icons/audio_play.svg");
22const STOP_SVG: &[u8] = include_bytes!("../../../../resource/icons/audio_stop.svg");
23
24static PLAY_ICON: LazyLock<Result<Image, String>> =
25    LazyLock::new(|| rasterize_svg(PLAY_SVG).map_err(|error| error.to_string()));
26static STOP_ICON: LazyLock<Result<Image, String>> =
27    LazyLock::new(|| rasterize_svg(STOP_SVG).map_err(|error| error.to_string()));
28
29/// The compact play/stop control shown for an HTML `<audio>` element.
30pub struct AudioComponent {
31    source: String,
32    data: Option<Arc<[u8]>>,
33    sink: Arc<dyn AudioSink>,
34    loaded: AtomicBool,
35    playing: AtomicBool,
36    hovered: AtomicBool,
37    pressed: AtomicBool,
38    dirty: AtomicBool,
39    last_timer_repaint: Mutex<Instant>,
40}
41
42impl std::fmt::Debug for AudioComponent {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("AudioComponent")
45            .field("source", &self.source)
46            .field("loaded", &self.loaded)
47            .field("playing", &self.playing)
48            .finish_non_exhaustive()
49    }
50}
51
52impl AudioComponent {
53    /// Builds the control around a sink obtained from the host's
54    /// [`AudioSinkFactory`](crate::engine::bridge::audio::AudioSinkFactory).
55    ///
56    /// When `data` is present it is decoded eagerly so the first click has
57    /// something to play.
58    pub fn new(
59        source: impl Into<String>,
60        data: Option<Arc<[u8]>>,
61        sink: Arc<dyn AudioSink>,
62    ) -> Self {
63        let loaded = data.as_ref().is_some_and(|data| {
64            let result = sink.preload(data);
65            if let Err(error) = result {
66                log::error!("Failed to decode <audio> data: {error}");
67                false
68            } else {
69                true
70            }
71        });
72        Self {
73            source: source.into(),
74            data,
75            sink,
76            loaded: AtomicBool::new(loaded),
77            playing: AtomicBool::new(false),
78            hovered: AtomicBool::new(false),
79            pressed: AtomicBool::new(false),
80            dirty: AtomicBool::new(true),
81            last_timer_repaint: Mutex::new(Instant::now()),
82        }
83    }
84
85    fn toggle_playback(&self) {
86        if self.source.is_empty() {
87            log::warn!("Cannot play <audio> without a media source");
88            return;
89        }
90
91        let result = if !self.loaded.load(Ordering::Relaxed) || self.sink.is_finished() {
92            self.sink.play(&self.source, self.data.as_deref())
93        } else if self.playing.load(Ordering::Relaxed) {
94            self.sink.pause()
95        } else {
96            self.sink.resume()
97        };
98
99        match result {
100            Ok(()) => {
101                if !self.loaded.load(Ordering::Relaxed) || self.sink.is_finished() {
102                    self.loaded.store(true, Ordering::Relaxed);
103                }
104                self.playing.fetch_xor(true, Ordering::Relaxed);
105            }
106            Err(error) => log::error!("Failed to toggle <audio> playback: {error}"),
107        }
108        self.dirty.store(true, Ordering::Relaxed);
109    }
110
111    fn playback_times(&self) -> (f32, f32) {
112        (self.sink.current_seconds(), self.sink.duration_seconds())
113    }
114
115    fn update_finished_state(&self) {
116        if !self.playing.load(Ordering::Relaxed) {
117            return;
118        }
119        if self.sink.is_finished() && self.playing.swap(false, Ordering::Relaxed) {
120            self.dirty.store(true, Ordering::Relaxed);
121        }
122    }
123}
124
125impl CustomNode for AudioComponent {
126    fn draw_sized(
127        &self,
128        cmd_buf: &mut Vec<DrawCommand>,
129        text_style: &TextStyle,
130        text_flow_style: &TextFlowStyle,
131        _style: &Style,
132        size: ContentSize,
133    ) {
134        self.update_finished_state();
135
136        let button_color = if self.pressed.load(Ordering::Relaxed) {
137            Color(45, 45, 45, 255)
138        } else if self.hovered.load(Ordering::Relaxed) {
139            Color(85, 85, 85, 255)
140        } else {
141            Color(65, 65, 65, 255)
142        };
143        cmd_buf.push(solid_fill(
144            rect_path(0.0, 0.0, BUTTON_WIDTH.min(size.width), size.height),
145            button_color,
146        ));
147
148        let icon = if self.playing.load(Ordering::Relaxed) {
149            STOP_ICON.as_ref()
150        } else {
151            PLAY_ICON.as_ref()
152        };
153        if let Ok(icon) = icon {
154            let icon_size = ICON_SIZE.min(size.height);
155            cmd_buf.push(DrawCommand::Fill {
156                path: rect_path(
157                    (BUTTON_WIDTH - icon_size) * 0.5,
158                    (size.height - icon_size) * 0.5,
159                    icon_size,
160                    icon_size,
161                ),
162                paint: Paint {
163                    brush: Brush::Image(icon.clone()),
164                    opacity: 1.0,
165                },
166                rule: FillRule::NonZero,
167            });
168        }
169
170        let mut time_style = text_style.clone();
171        time_style.color = Color(40, 40, 40, 255);
172        let (current, duration) = self.playback_times();
173        cmd_buf.push(DrawCommand::DrawText {
174            x: BUTTON_WIDTH + 10.0,
175            y: ((size.height - text_flow_style.font_size) * 0.5).max(0.0),
176            text: format!(
177                "{} / {}",
178                format_media_time(current),
179                format_media_time(duration)
180            )
181            .into(),
182            style: time_style,
183            flow_style: *text_flow_style,
184        });
185    }
186
187    fn intrinsic_size(&self) -> ContentSize {
188        ContentSize {
189            width: PLAYER_WIDTH,
190            height: PLAYER_HEIGHT,
191        }
192    }
193
194    fn on_pointer_event(&self, event: PointerEvent) -> bool {
195        match event {
196            PointerEvent::Move { x, .. } => {
197                self.set_hovered(x < BUTTON_WIDTH);
198                x < BUTTON_WIDTH
199            }
200            PointerEvent::Down { x, .. } if x < BUTTON_WIDTH => {
201                self.pressed.store(true, Ordering::Relaxed);
202                self.dirty.store(true, Ordering::Relaxed);
203                true
204            }
205            PointerEvent::Up { x, .. } => {
206                let clicked = self.pressed.swap(false, Ordering::Relaxed) && x < BUTTON_WIDTH;
207                if clicked {
208                    self.toggle_playback();
209                }
210                self.dirty.store(true, Ordering::Relaxed);
211                clicked
212            }
213            PointerEvent::Leave => {
214                self.set_hovered(false);
215                self.pressed.store(false, Ordering::Relaxed);
216                false
217            }
218            PointerEvent::Down { .. } => false,
219        }
220    }
221
222    fn set_hovered(&self, hovered: bool) {
223        if self.hovered.swap(hovered, Ordering::Relaxed) != hovered {
224            self.dirty.store(true, Ordering::Relaxed);
225        }
226    }
227
228    fn is_hovered(&self) -> bool {
229        self.hovered.load(Ordering::Relaxed)
230    }
231
232    fn needs_repaint(&self) -> bool {
233        self.update_finished_state();
234        if self.playing.load(Ordering::Relaxed) {
235            let mut last_repaint = self
236                .last_timer_repaint
237                .lock()
238                .unwrap_or_else(|e| e.into_inner());
239            if last_repaint.elapsed() >= TIMER_REPAINT_INTERVAL {
240                *last_repaint = Instant::now();
241                return true;
242            }
243        }
244        self.dirty.swap(false, Ordering::Relaxed)
245    }
246
247    fn role(&self) -> Option<&'static str> {
248        Some("group")
249    }
250
251    fn label(&self) -> Option<String> {
252        Some("Audio player".to_string())
253    }
254}
255
256fn solid_fill(path: crate::engine::renderer_model::Path, color: Color) -> DrawCommand {
257    DrawCommand::Fill {
258        path,
259        paint: Paint {
260            brush: Brush::Solid(color),
261            opacity: 1.0,
262        },
263        rule: FillRule::NonZero,
264    }
265}
266
267fn format_media_time(seconds: f32) -> String {
268    let total_seconds = seconds.max(0.0).floor() as u64;
269    let hours = total_seconds / 3600;
270    let minutes = (total_seconds % 3600) / 60;
271    let seconds = total_seconds % 60;
272    if hours > 0 {
273        format!("{hours}:{minutes:02}:{seconds:02}")
274    } else {
275        format!("{minutes}:{seconds:02}")
276    }
277}
278
279fn rasterize_svg(svg: &[u8]) -> anyhow::Result<Image> {
280    let raster =
281        crate::engine::svg::rasterize_from_bytes(svg, Some((ICON_RASTER_SIZE, ICON_RASTER_SIZE)))
282            .map_err(anyhow::Error::msg)?;
283    Image::from_rgba(raster.width, raster.height, raster.rgba)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::engine::bridge::audio::{AudioSinkFactory, NullAudioSinkFactory};
290
291    fn component(source: &str) -> AudioComponent {
292        AudioComponent::new(source, None, NullAudioSinkFactory.create())
293    }
294
295    #[test]
296    fn svg_assets_decode_to_renderer_images() {
297        assert!(PLAY_ICON.is_ok());
298        assert!(STOP_ICON.is_ok());
299    }
300
301    #[test]
302    fn audio_control_draws_button_icon_and_seconds() {
303        let component = component("resource:///audio/birds.mp3");
304        let mut commands = Vec::new();
305        component.draw(
306            &mut commands,
307            &TextStyle::default(),
308            &TextFlowStyle::default(),
309        );
310        assert!(matches!(commands.first(), Some(DrawCommand::Fill { .. })));
311        assert!(commands.iter().any(
312            |command| matches!(command, DrawCommand::DrawText { text, .. } if text == "0:00 / 0:00")
313        ));
314    }
315
316    #[test]
317    fn media_time_uses_minutes_and_hours() {
318        assert_eq!(format_media_time(0.0), "0:00");
319        assert_eq!(format_media_time(83.9), "1:23");
320        assert_eq!(format_media_time(3_661.0), "1:01:01");
321    }
322
323    #[test]
324    fn only_left_button_accepts_pointer_down() {
325        let component = component("");
326        assert!(!component.on_pointer_event(PointerEvent::Down { x: 80.0, y: 10.0 }));
327        assert!(component.on_pointer_event(PointerEvent::Down { x: 10.0, y: 10.0 }));
328    }
329
330    #[test]
331    fn playback_without_a_source_is_refused() {
332        let component = component("");
333        component.toggle_playback();
334        // No sink call can succeed without a media source, so the widget
335        // never enters the playing state.
336        assert!(!component.playing.load(Ordering::Relaxed));
337    }
338}