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::layouter::types::{Color, TextFlowStyle, TextStyle};
10use crate::engine::renderer_model::{Brush, DrawCommand, FillRule, Image, Paint, rect_path};
11use crate::engine::ui::custom_node::{ContentSize, CustomNode, PointerEvent};
12use crate::platform::audio::SoundManager;
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    sound: Arc<Mutex<SoundManager>>,
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    pub fn new(source: impl Into<String>, data: Option<Arc<[u8]>>) -> Self {
54        let sound = SoundManager::init().expect("SoundManager initialization cannot fail");
55        let loaded = data.as_ref().is_some_and(|data| {
56            let result = sound
57                .lock()
58                .unwrap_or_else(|e| e.into_inner())
59                .load_from_bytes(data);
60            if let Err(error) = result {
61                log::error!("Failed to decode <audio> data: {error}");
62                false
63            } else {
64                true
65            }
66        });
67        Self {
68            source: source.into(),
69            data,
70            sound,
71            loaded: AtomicBool::new(loaded),
72            playing: AtomicBool::new(false),
73            hovered: AtomicBool::new(false),
74            pressed: AtomicBool::new(false),
75            dirty: AtomicBool::new(true),
76            last_timer_repaint: Mutex::new(Instant::now()),
77        }
78    }
79
80    fn toggle_playback(&self) {
81        if self.source.is_empty() {
82            log::warn!("Cannot play <audio> without a media source");
83            return;
84        }
85
86        let mut sound = self.sound.lock().unwrap_or_else(|e| e.into_inner());
87        let result = if !self.loaded.load(Ordering::Relaxed) || sound.is_finished() {
88            if let Some(data) = &self.data {
89                sound.play_from_bytes(data)
90            } else {
91                sound.play_from_local_uri(&self.source)
92            }
93        } else if self.playing.load(Ordering::Relaxed) {
94            sound.pause()
95        } else {
96            sound.resume()
97        };
98
99        match result {
100            Ok(()) => {
101                if !self.loaded.load(Ordering::Relaxed) || sound.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        let sound = self.sound.lock().unwrap_or_else(|e| e.into_inner());
113        (sound.current_seconds(), sound.duration_seconds())
114    }
115
116    fn update_finished_state(&self) {
117        if !self.playing.load(Ordering::Relaxed) {
118            return;
119        }
120        let finished = self
121            .sound
122            .lock()
123            .unwrap_or_else(|e| e.into_inner())
124            .is_finished();
125        if finished && self.playing.swap(false, Ordering::Relaxed) {
126            self.dirty.store(true, Ordering::Relaxed);
127        }
128    }
129}
130
131impl CustomNode for AudioComponent {
132    fn draw_sized(
133        &self,
134        cmd_buf: &mut Vec<DrawCommand>,
135        text_style: &TextStyle,
136        text_flow_style: &TextFlowStyle,
137        _style: &Style,
138        size: ContentSize,
139    ) {
140        self.update_finished_state();
141
142        let button_color = if self.pressed.load(Ordering::Relaxed) {
143            Color(45, 45, 45, 255)
144        } else if self.hovered.load(Ordering::Relaxed) {
145            Color(85, 85, 85, 255)
146        } else {
147            Color(65, 65, 65, 255)
148        };
149        cmd_buf.push(solid_fill(
150            rect_path(0.0, 0.0, BUTTON_WIDTH.min(size.width), size.height),
151            button_color,
152        ));
153
154        let icon = if self.playing.load(Ordering::Relaxed) {
155            STOP_ICON.as_ref()
156        } else {
157            PLAY_ICON.as_ref()
158        };
159        if let Ok(icon) = icon {
160            let icon_size = ICON_SIZE.min(size.height);
161            cmd_buf.push(DrawCommand::Fill {
162                path: rect_path(
163                    (BUTTON_WIDTH - icon_size) * 0.5,
164                    (size.height - icon_size) * 0.5,
165                    icon_size,
166                    icon_size,
167                ),
168                paint: Paint {
169                    brush: Brush::Image(icon.clone()),
170                    opacity: 1.0,
171                },
172                rule: FillRule::NonZero,
173            });
174        }
175
176        let mut time_style = text_style.clone();
177        time_style.color = Color(40, 40, 40, 255);
178        let (current, duration) = self.playback_times();
179        cmd_buf.push(DrawCommand::DrawText {
180            x: BUTTON_WIDTH + 10.0,
181            y: ((size.height - text_flow_style.font_size) * 0.5).max(0.0),
182            text: format!(
183                "{} / {}",
184                format_media_time(current),
185                format_media_time(duration)
186            )
187            .into(),
188            style: time_style,
189            flow_style: *text_flow_style,
190        });
191    }
192
193    fn intrinsic_size(&self) -> ContentSize {
194        ContentSize {
195            width: PLAYER_WIDTH,
196            height: PLAYER_HEIGHT,
197        }
198    }
199
200    fn on_pointer_event(&self, event: PointerEvent) -> bool {
201        match event {
202            PointerEvent::Move { x, .. } => {
203                self.set_hovered(x < BUTTON_WIDTH);
204                x < BUTTON_WIDTH
205            }
206            PointerEvent::Down { x, .. } if x < BUTTON_WIDTH => {
207                self.pressed.store(true, Ordering::Relaxed);
208                self.dirty.store(true, Ordering::Relaxed);
209                true
210            }
211            PointerEvent::Up { x, .. } => {
212                let clicked = self.pressed.swap(false, Ordering::Relaxed) && x < BUTTON_WIDTH;
213                if clicked {
214                    self.toggle_playback();
215                }
216                self.dirty.store(true, Ordering::Relaxed);
217                clicked
218            }
219            PointerEvent::Leave => {
220                self.set_hovered(false);
221                self.pressed.store(false, Ordering::Relaxed);
222                false
223            }
224            PointerEvent::Down { .. } => false,
225        }
226    }
227
228    fn set_hovered(&self, hovered: bool) {
229        if self.hovered.swap(hovered, Ordering::Relaxed) != hovered {
230            self.dirty.store(true, Ordering::Relaxed);
231        }
232    }
233
234    fn is_hovered(&self) -> bool {
235        self.hovered.load(Ordering::Relaxed)
236    }
237
238    fn needs_repaint(&self) -> bool {
239        self.update_finished_state();
240        if self.playing.load(Ordering::Relaxed) {
241            let mut last_repaint = self
242                .last_timer_repaint
243                .lock()
244                .unwrap_or_else(|e| e.into_inner());
245            if last_repaint.elapsed() >= TIMER_REPAINT_INTERVAL {
246                *last_repaint = Instant::now();
247                return true;
248            }
249        }
250        self.dirty.swap(false, Ordering::Relaxed)
251    }
252
253    fn role(&self) -> Option<&'static str> {
254        Some("group")
255    }
256
257    fn label(&self) -> Option<String> {
258        Some("Audio player".to_string())
259    }
260}
261
262fn solid_fill(path: crate::engine::renderer_model::Path, color: Color) -> DrawCommand {
263    DrawCommand::Fill {
264        path,
265        paint: Paint {
266            brush: Brush::Solid(color),
267            opacity: 1.0,
268        },
269        rule: FillRule::NonZero,
270    }
271}
272
273fn format_media_time(seconds: f32) -> String {
274    let total_seconds = seconds.max(0.0).floor() as u64;
275    let hours = total_seconds / 3600;
276    let minutes = (total_seconds % 3600) / 60;
277    let seconds = total_seconds % 60;
278    if hours > 0 {
279        format!("{hours}:{minutes:02}:{seconds:02}")
280    } else {
281        format!("{minutes}:{seconds:02}")
282    }
283}
284
285fn rasterize_svg(svg: &[u8]) -> anyhow::Result<Image> {
286    let options = resvg::usvg::Options::default();
287    let tree = resvg::usvg::Tree::from_data(svg, &options)?;
288    let mut pixmap = resvg::tiny_skia::Pixmap::new(ICON_RASTER_SIZE, ICON_RASTER_SIZE)
289        .ok_or_else(|| anyhow::anyhow!("failed to allocate SVG icon pixmap"))?;
290    let size = tree.size();
291    let transform = resvg::tiny_skia::Transform::from_scale(
292        ICON_RASTER_SIZE as f32 / size.width(),
293        ICON_RASTER_SIZE as f32 / size.height(),
294    );
295    resvg::render(&tree, transform, &mut pixmap.as_mut());
296
297    // tiny-skia stores premultiplied RGBA, while the renderer model accepts
298    // straight-alpha RGBA. Convert once when the static icon is initialized.
299    let mut rgba = pixmap.data().to_vec();
300    for pixel in rgba.as_chunks_mut::<4>().0 {
301        let alpha = pixel[3] as u16;
302        if alpha == 0 {
303            continue;
304        }
305        for channel in &mut pixel[..3] {
306            *channel = ((*channel as u16 * 255 + alpha / 2) / alpha).min(255) as u8;
307        }
308    }
309    Image::from_rgba(ICON_RASTER_SIZE, ICON_RASTER_SIZE, rgba)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn svg_assets_decode_to_renderer_images() {
318        assert!(PLAY_ICON.is_ok());
319        assert!(STOP_ICON.is_ok());
320    }
321
322    #[test]
323    fn audio_control_draws_button_icon_and_seconds() {
324        let component = AudioComponent::new("resource:///audio/birds.mp3", None);
325        let mut commands = Vec::new();
326        component.draw(
327            &mut commands,
328            &TextStyle::default(),
329            &TextFlowStyle::default(),
330        );
331        assert!(matches!(commands.first(), Some(DrawCommand::Fill { .. })));
332        assert!(commands.iter().any(
333            |command| matches!(command, DrawCommand::DrawText { text, .. } if text == "0:00 / 0:00")
334        ));
335    }
336
337    #[test]
338    fn media_time_uses_minutes_and_hours() {
339        assert_eq!(format_media_time(0.0), "0:00");
340        assert_eq!(format_media_time(83.9), "1:23");
341        assert_eq!(format_media_time(3_661.0), "1:01:01");
342    }
343
344    #[test]
345    fn only_left_button_accepts_pointer_down() {
346        let component = AudioComponent::new("", None);
347        assert!(!component.on_pointer_event(PointerEvent::Down { x: 80.0, y: 10.0 }));
348        assert!(component.on_pointer_event(PointerEvent::Down { x: 10.0, y: 10.0 }));
349    }
350}