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::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
29pub 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 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 assert!(!component.playing.load(Ordering::Relaxed));
337 }
338}