orinium_browser/engine/ui/components/
audio.rs1use 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
29pub 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 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}