Skip to main content

euv_ui/component/camera/hook/
impl.rs

1use super::*;
2
3thread_local! {
4    /// Cache of the `BarcodeDetector#detect` `Function`. There is at
5    /// most one active `BarcodeDetector` per scan session (created in
6    /// `start_qr_scan`), so a single slot is enough. The `Function` is
7    /// fetched once via `Reflect::get(detector, "detect")` and reused
8    /// across every scan tick — the per-tick `Reflect::get` +
9    /// `JsValue::from_str("detect")` allocation is eliminated.
10    static DETECT_FN_CACHE: RefCell<Option<Function>> = const { RefCell::new(None) };
11}
12
13/// Implementation of camera functionality.
14impl UseEuvCamera {
15    /// Creates camera state for controlling camera stream and QR scanning.
16    ///
17    /// # Returns
18    ///
19    /// - `UseEuvCamera` - The camera state.
20    pub fn use_camera_state() -> UseEuvCamera {
21        UseEuvCamera::new(
22            App::use_signal(|| false),
23            App::use_signal(|| false),
24            App::use_signal(String::new),
25            App::use_signal(EuvCameraFacing::default),
26            App::use_signal(String::new),
27            App::use_signal(|| None),
28        )
29    }
30
31    /// Requests camera access from the browser and binds the resulting
32    /// media stream to the `<video>` element identified by the given CSS selector.
33    ///
34    /// Uses `navigator.mediaDevices.getUserMedia` with a video-only
35    /// constraint. On success the stream is assigned as `srcObject` on
36    /// the target video element and `play()` is called. Errors are
37    /// returned as human-readable strings.
38    ///
39    /// # Arguments
40    ///
41    /// - `&str` - The CSS selector of the `<video>` element to bind the stream to.
42    /// - `EuvCameraFacing` - The desired camera facing direction.
43    ///
44    /// # Returns
45    ///
46    /// - `Result<(), String>` - `Ok(())` on success, or an error message on failure.
47    pub(crate) fn open(video_selector: &str, facing: EuvCameraFacing) -> Result<(), String> {
48        let Some(window_value) = window() else {
49            return Err(CAMERA_NO_WINDOW_ERROR.to_string());
50        };
51        let navigator: Navigator = window_value.navigator();
52        let media_devices: MediaDevices = navigator
53            .media_devices()
54            .map_err(|error: JsValue| format!("{error:?}"))?;
55        let constraints: MediaStreamConstraints = MediaStreamConstraints::new();
56        let facing_mode: &str = match facing {
57            EuvCameraFacing::User => CAMERA_FACING_MODE_USER,
58            EuvCameraFacing::Environment => CAMERA_FACING_MODE_ENVIRONMENT,
59        };
60        let video_constraint: Object = Object::new();
61        let _: Result<bool, JsValue> = Reflect::set(
62            &video_constraint,
63            &JsValue::from_str(CAMERA_FACING_MODE_KEY),
64            &JsValue::from_str(facing_mode),
65        );
66        constraints.set_video(&video_constraint);
67        constraints.set_audio(&JsValue::from_bool(false));
68        let promise: Promise = media_devices
69            .get_user_media_with_constraints(&constraints)
70            .map_err(|error: JsValue| format!("{error:?}"))?;
71        let selector: String = video_selector.to_string();
72        let on_fulfilled: Closure<dyn FnMut(JsValue)> =
73            Closure::wrap(Box::new(move |stream_value: JsValue| {
74                let stream: MediaStream = stream_value.unchecked_into();
75                let Some(window_value) = window() else {
76                    return;
77                };
78                let Some(document) = window_value.document() else {
79                    return;
80                };
81                if let Some(element) = document.query_selector(&selector).ok().flatten() {
82                    let video_element: HtmlVideoElement = element.unchecked_into();
83                    video_element.set_src_object(Some(&stream));
84                    let _: Result<Promise, JsValue> = video_element.play();
85                }
86            }));
87        let on_rejected: Closure<dyn FnMut(JsValue)> =
88            Closure::wrap(Box::new(move |error: JsValue| {
89                console::log_2(&JsValue::from_str(CAMERA_LOG_PREFIX), &error);
90            }));
91        let _: Promise = promise.then(&on_fulfilled).catch(&on_rejected);
92        on_fulfilled.forget();
93        on_rejected.forget();
94        Ok(())
95    }
96
97    /// Stops all tracks on the media stream currently attached to the
98    /// `<video>` element identified by the given CSS selector.
99    ///
100    /// Iterates over `videoElement.srcObject.getTracks()` and calls
101    /// `stop()` on each one, then clears `srcObject`.
102    ///
103    /// # Arguments
104    ///
105    /// - `&str` - The CSS selector of the `<video>` element whose stream should be stopped.
106    pub(crate) fn close(video_selector: &str) {
107        let Some(window_value) = window() else {
108            return;
109        };
110        let Some(document) = window_value.document() else {
111            return;
112        };
113        if let Some(element) = document.query_selector(video_selector).ok().flatten() {
114            let video_element: HtmlVideoElement = element.unchecked_into();
115            if let Some(stream) = video_element.src_object() {
116                let stream: MediaStream = stream.unchecked_into();
117                let tracks: Array = stream.get_tracks();
118                for track_value in tracks.iter() {
119                    let track: MediaStreamTrack = track_value.unchecked_into();
120                    track.stop();
121                }
122            }
123            video_element.set_src_object(None);
124        }
125    }
126
127    /// Opens the camera, starts QR code scanning immediately, and updates
128    /// the state signals accordingly.
129    ///
130    /// If the camera fails to open, the error message signal is set.
131    ///
132    /// # Arguments
133    ///
134    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
135    pub(crate) fn open_and_scan(self, config: Option<&EuvCameraConfig>) {
136        let cfg: EuvCameraConfig =
137            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
138        self.get_camera_loading().set(true);
139        self.get_error_message().set(String::new());
140        self.get_scan_result().set(String::new());
141        let facing: EuvCameraFacing = self.get_facing().get();
142        let result: Result<(), String> = Self::open(cfg.video_selector, facing);
143        match result {
144            Ok(()) => {
145                self.get_camera_open().set(true);
146                self.get_camera_loading().set(false);
147                if cfg.auto_scan {
148                    self.start_qr_scan(config);
149                }
150            }
151            Err(error) => {
152                self.get_error_message().set(error);
153                self.get_camera_loading().set(false);
154                if let Some(ref on_error) = cfg.on_error {
155                    on_error(self.get_error_message().get());
156                }
157            }
158        }
159    }
160
161    /// Switches the camera to the opposite facing direction and restarts
162    /// QR code scanning.
163    ///
164    /// Closes the current camera stream and reopens with the new facing
165    /// mode. On success, QR code scanning is started automatically.
166    ///
167    /// # Arguments
168    ///
169    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
170    pub(crate) fn switch(self, config: Option<&EuvCameraConfig>) {
171        let cfg: EuvCameraConfig =
172            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
173        self.stop_qr_scan();
174        Self::close(cfg.video_selector);
175        self.get_camera_open().set(false);
176        let new_facing: EuvCameraFacing = match self.get_facing().get() {
177            EuvCameraFacing::User => EuvCameraFacing::Environment,
178            EuvCameraFacing::Environment => EuvCameraFacing::User,
179        };
180        self.get_facing().set(new_facing);
181        self.get_camera_loading().set(true);
182        self.get_error_message().set(String::new());
183        let result: Result<(), String> = Self::open(cfg.video_selector, new_facing);
184        match result {
185            Ok(()) => {
186                self.get_camera_open().set(true);
187                self.get_camera_loading().set(false);
188                if cfg.auto_scan {
189                    self.start_qr_scan(config);
190                }
191            }
192            Err(error) => {
193                self.get_error_message().set(error);
194                self.get_camera_loading().set(false);
195            }
196        }
197    }
198
199    /// Returns the cached `BarcodeDetector#detect` `Function`, populating
200    /// the cache on first lookup.
201    ///
202    /// The original code called `Reflect::get(detector, "detect")` every
203    /// scan tick, allocating a fresh `JsValue::from_str("detect")` JS
204    /// string and crossing the FFI boundary. The cached `Function` is
205    /// reused for the lifetime of the scan session.
206    ///
207    /// # Arguments
208    ///
209    /// - `&JsValue` - The `BarcodeDetector` instance.
210    ///
211    /// # Returns
212    ///
213    /// - `Function` - A clone of the cached `detect` function, or a
214    ///   no-op `Promise.resolve([])` fallback when lookup fails.
215    fn cached_detect_fn(detector: &JsValue) -> Function {
216        DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
217            // The read guard here and the write guard below are two separate
218            // statements, so they never overlap: the `if let` scrutinee
219            // temporary is dropped when the `if let` ends, before the
220            // `Reflect::get` lookup. `Reflect::get` on a `BarcodeDetector` can
221            // run a prototype getter installed by page code, which is why
222            // nothing may hold a cache borrow across it.
223            if let Some(function) = cache.borrow().as_ref() {
224                return function.clone();
225            }
226            let function: Function =
227                Reflect::get(detector, &JsValue::from_str(CAMERA_DETECT_FN_KEY))
228                    .ok()
229                    .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
230                    .unwrap_or_else(|| Function::new_no_args(CAMERA_DETECT_FALLBACK_BODY));
231            *cache.borrow_mut() = Some(function.clone());
232            function
233        })
234    }
235
236    /// Starts a periodic QR code scan using the browser `BarcodeDetector` API.
237    ///
238    /// If the browser does not support `BarcodeDetector`, the scan is not
239    /// started and the error signal is set. On each interval tick, captures
240    /// the current video frame and attempts to detect a QR code. If a QR
241    /// code is found, the result is stored in `scan_result`. If the result
242    /// is an HTTP URL, the browser navigates directly to that URL.
243    ///
244    /// The on_detected / on_scan_error `Closure`s are created once per
245    /// scan session and stored in `ON_QR_DETECTED_CLOSURE` /
246    /// `ON_QR_SCAN_ERROR_CLOSURE` thread-locals; per-tick
247    /// `Closure::wrap` + `.forget()` is eliminated (memory leak fix
248    /// from audit #26). The `detect` method is cached in
249    /// `DETECT_FN_CACHE` (audit #26 per-tick `Reflect::get` cost).
250    ///
251    /// # Arguments
252    ///
253    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
254    pub(crate) fn start_qr_scan(self, config: Option<&EuvCameraConfig>) {
255        let cfg: EuvCameraConfig =
256            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
257        let Some(window_value) = window() else {
258            return;
259        };
260        let barcode_detector_key: JsValue = JsValue::from_str(CAMERA_BARCODE_DETECTOR_KEY);
261        let barcode_detector_constructor: Function =
262            match Reflect::get(&window_value, &barcode_detector_key) {
263                Ok(value) if !value.is_undefined() && !value.is_null() => value.unchecked_into(),
264                _ => {
265                    self.get_error_message()
266                        .set(CAMERA_BARCODE_UNSUPPORTED_MESSAGE.to_string());
267                    return;
268                }
269            };
270        let formats_array: Array = Array::new();
271        formats_array.push(&JsValue::from_str(CAMERA_BARCODE_FORMAT_QR_CODE));
272        let init_object: Object = Object::new();
273        let _: Result<bool, JsValue> = Reflect::set(
274            &init_object,
275            &JsValue::from_str(CAMERA_BARCODE_FORMATS_KEY),
276            &formats_array,
277        );
278        let args_array: Array = Array::new();
279        args_array.push(&init_object.into());
280        let detector: JsValue = match Reflect::construct(&barcode_detector_constructor, &args_array)
281        {
282            Ok(value) => value,
283            Err(error) => {
284                self.get_error_message()
285                    .set(format!("Failed to create BarcodeDetector: {error:?}"));
286                return;
287            }
288        };
289        let video_selector: Rc<String> = Rc::new(cfg.video_selector.to_string());
290        let on_qr_detected: Option<QrDetectedCallback> = cfg.on_qr_detected.clone();
291        let self_for_closure: UseEuvCamera = self;
292        let video_selector_for_closure: Rc<String> = video_selector.clone();
293        let on_qr_detected_for_closure: Option<QrDetectedCallback> = on_qr_detected.clone();
294        let on_detected: Closure<dyn FnMut(JsValue)> =
295            Closure::wrap(Box::new(move |barcodes_value: JsValue| {
296                let barcodes: Array = match barcodes_value.dyn_into::<Array>() {
297                    Ok(array) => array,
298                    Err(_) => return,
299                };
300                if barcodes.length() == 0 {
301                    return;
302                }
303                let text: Option<String> = barcodes.get(0).as_string().or_else(|| {
304                    Reflect::get(
305                        &barcodes.get(0),
306                        &JsValue::from_str(CAMERA_BARCODE_RAW_VALUE_KEY),
307                    )
308                    .ok()
309                    .and_then(|v: JsValue| v.as_string())
310                });
311                if let Some(text) = text {
312                    self_for_closure.get_scan_result().set(text.clone());
313                    if let Some(ref callback) = on_qr_detected_for_closure {
314                        callback(&text);
315                    }
316                    if Self::is_valid_qr_url(&text) {
317                        self_for_closure.stop_qr_scan();
318                        Self::close(&video_selector_for_closure);
319                        self_for_closure.get_camera_open().set(false);
320                        Self::navigate_qr_url(&text);
321                    }
322                }
323            }));
324        let on_scan_error: Closure<dyn FnMut(JsValue)> =
325            Closure::wrap(Box::new(move |_error: JsValue| {}));
326        let detect_fn: Function = Self::cached_detect_fn(&detector);
327        // Cache the video element across scan ticks: `query_selector` costs
328        // one JS crossing plus a JS-side selector parse per tick; the element
329        // is stable for a scan session and is re-validated cheaply via
330        // `is_connected` (re-resolved if the DOM node was swapped).
331        let video_element_cache: Rc<RefCell<Option<HtmlVideoElement>>> =
332            Rc::new(RefCell::new(None));
333        let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
334            let on_detected: &Closure<dyn FnMut(JsValue)> = &on_detected;
335            let on_scan_error: &Closure<dyn FnMut(JsValue)> = &on_scan_error;
336            let detect_fn: &Function = &detect_fn;
337            let Some(window_value) = window() else {
338                return;
339            };
340            let Some(document) = window_value.document() else {
341                return;
342            };
343            let video_element: HtmlVideoElement = {
344                // `is_connected` and `query_selector` cross into the DOM, and a
345                // `Node.prototype.isConnected` getter installed by page code runs
346                // arbitrary JS in between. Holding a `RefMut` across either lets a
347                // re-entrant scan tick reach `borrow_mut` under a live guard and
348                // abort the instance, so the cached element is cloned out under a
349                // read borrow that is dropped before any DOM call. A contended
350                // cell reports "no cache" and the element is simply re-resolved.
351                let cached: Option<HtmlVideoElement> = video_element_cache
352                    .try_borrow()
353                    .map(|cache: Ref<'_, Option<HtmlVideoElement>>| cache.clone())
354                    .unwrap_or_default();
355                match cached {
356                    Some(element) if element.is_connected() => element,
357                    _ => {
358                        let Some(element) = document.query_selector(&video_selector).ok().flatten()
359                        else {
360                            return;
361                        };
362                        let resolved: HtmlVideoElement = element.unchecked_into();
363                        // Best-effort store: the element is only an optimisation
364                        // and is re-validated by `is_connected` on the next tick.
365                        // Skipping a contended write costs one `query_selector`;
366                        // panicking would cost the whole instance.
367                        if let Ok(mut cache) = video_element_cache.try_borrow_mut() {
368                            *cache = Some(resolved.clone());
369                        }
370                        resolved
371                    }
372                }
373            };
374            if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
375                return;
376            }
377            let promise: Promise = match detect_fn.call1(&detector, &video_element) {
378                Ok(result) => result.into(),
379                Err(_) => return,
380            };
381            let _: Promise = promise.then(on_detected).catch(on_scan_error);
382        });
383        self_for_closure.get_scan_handle().set(Some(handle));
384    }
385
386    /// Stops the periodic QR code scan timer if it is running.
387    pub(crate) fn stop_qr_scan(self) {
388        if let Some(handle) = self.get_scan_handle().get() {
389            handle.clear();
390            self.get_scan_handle().set(None);
391        }
392        DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
393            *cache.borrow_mut() = None;
394        });
395    }
396
397    /// Checks whether the given string is a valid QR code URL that the
398    /// camera scanner should navigate to.
399    ///
400    /// A valid URL must start with `http://` or `https://`.
401    ///
402    /// # Arguments
403    ///
404    /// - `&str` - The string to check.
405    ///
406    /// # Returns
407    ///
408    /// - `bool` - `true` if the string is a valid HTTP or HTTPS URL.
409    pub(crate) fn is_valid_qr_url(text: &str) -> bool {
410        text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
411    }
412
413    /// Extracts the hostname from an absolute URL string using pure Rust
414    /// string parsing.
415    ///
416    /// Supports `http://` and `https://` schemes, strips IPv6 brackets,
417    /// and ignores the port portion. Returns an empty string if the URL
418    /// format is not recognised.
419    ///
420    /// # Arguments
421    ///
422    /// - `&str` - The absolute URL to parse.
423    ///
424    /// # Returns
425    ///
426    /// - `String` - The extracted hostname, or an empty string on failure.
427    pub(crate) fn extract_hostname(url: &str) -> String {
428        let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
429            stripped
430        } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
431            stripped
432        } else {
433            return String::new();
434        };
435        let authority: &str = rest.split('/').next().unwrap_or("");
436        let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
437        if let Some(stripped) = host_with_brackets.strip_prefix('[')
438            && let Some(inner) = stripped.strip_suffix(']')
439        {
440            return inner.to_string();
441        }
442        host_with_brackets.to_string()
443    }
444
445    /// Checks whether the given hostname is a private or loopback IP
446    /// address.
447    ///
448    /// Recognises loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`),
449    /// RFC 1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`,
450    /// `192.168.0.0/16`), and the `localhost` hostname.
451    ///
452    /// # Arguments
453    ///
454    /// - `&str` - The hostname to inspect.
455    ///
456    /// # Returns
457    ///
458    /// - `bool` - `true` if the hostname is a private/internal address.
459    pub(crate) fn is_private_host(hostname: &str) -> bool {
460        if hostname.is_empty() {
461            return false;
462        }
463        if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
464            return true;
465        }
466        let octets: Vec<&str> = hostname.split('.').collect();
467        if octets.len() != 4 {
468            return false;
469        }
470        let Ok(first) = octets[0].parse::<u8>() else {
471            return false;
472        };
473        let Ok(second) = octets[1].parse::<u8>() else {
474            return false;
475        };
476        if first == 127 {
477            return true;
478        }
479        if first == 10 {
480            return true;
481        }
482        if first == 172 && (16..=31).contains(&second) {
483            return true;
484        }
485        if first == 192 && second == 168 {
486            return true;
487        }
488        if first == 169 && second == 254 {
489            return true;
490        }
491        false
492    }
493
494    /// Navigates to the URL detected from a QR code.
495    ///
496    /// If the URL points to the same origin (current host), extracts the
497    /// hash fragment route and navigates internally using `navigate`.
498    /// If the URL host is a private/internal IP address, performs a full
499    /// page navigation via `location.href` within the current browser.
500    /// Otherwise (external public URL), opens the link in the system
501    /// browser via `window.open` so the user stays in the app.
502    ///
503    /// # Arguments
504    ///
505    /// - `&str` - The URL to navigate to.
506    pub(crate) fn navigate_qr_url(url: &str) {
507        let Some(window_value) = window() else {
508            return;
509        };
510        let location: Location = window_value.location();
511        let current_hostname: String = location.hostname().unwrap_or_default();
512        let url_hostname: String = Self::extract_hostname(url);
513        if url_hostname == current_hostname
514            && let Some(fragment) = url.split('#').nth(1)
515        {
516            let route: &str = if fragment.is_empty() { "/" } else { fragment };
517            Router::navigate(route);
518            return;
519        }
520        if Self::is_private_host(&url_hostname) {
521            let _: Result<(), JsValue> = window_value.location().set_href(url);
522            return;
523        }
524        if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str(CAMERA_WINDOW_OPEN_KEY))
525            .and_then(|value: JsValue| value.dyn_into::<Function>())
526        {
527            let _: Result<JsValue, JsValue> = open_fn.call2(
528                &window_value,
529                &JsValue::from_str(url),
530                &JsValue::from_str(SYSTEM_BROWSER_TARGET),
531            );
532        }
533    }
534
535    /// Creates a click event handler that closes the camera stream and
536    /// stops the QR code scan.
537    ///
538    /// # Arguments
539    ///
540    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
541    ///
542    /// # Returns
543    ///
544    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
545    pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
546        let cfg: EuvCameraConfig =
547            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
548        Some(Rc::new(move |_: Event| {
549            self.stop_qr_scan();
550            Self::close(cfg.video_selector);
551            self.get_camera_open().set(false);
552            self.get_scan_result().set(String::new());
553        }))
554    }
555
556    /// Creates a click event handler that switches the camera facing direction.
557    ///
558    /// # Arguments
559    ///
560    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
561    ///
562    /// # Returns
563    ///
564    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
565    pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
566        let cfg: EuvCameraConfig =
567            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
568        Some(Rc::new(move |_: Event| {
569            self.switch(Some(&cfg));
570        }))
571    }
572
573    /// Creates a click event handler that opens the camera and starts QR scanning.
574    ///
575    /// # Arguments
576    ///
577    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
578    ///
579    /// # Returns
580    ///
581    /// - `Option<Rc<dyn Fn(Event)>>` - A click event handler.
582    pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
583        let cfg: EuvCameraConfig =
584            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
585        Some(Rc::new(move |_: Event| {
586            self.open_and_scan(Some(&cfg));
587        }))
588    }
589
590    /// Registers a cleanup callback that closes the camera stream and
591    /// stops the QR code scan timer when the component unmounts or
592    /// the page route switches away.
593    ///
594    /// # Arguments
595    ///
596    /// - `Option<&EuvCameraConfig>` - Optional camera configuration.
597    pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
598        let cfg: EuvCameraConfig =
599            config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
600        App::use_cleanup(move || {
601            self.stop_qr_scan();
602            Self::close(cfg.video_selector);
603            self.get_camera_open().set(false);
604            self.get_camera_loading().set(false);
605            self.get_error_message().set(String::new());
606            self.get_scan_result().set(String::new());
607        });
608    }
609}
610
611/// Default implementation for `EuvCameraConfig`.
612impl Default for EuvCameraConfig {
613    /// Constructs a default [`EuvCameraConfig`] value.
614    fn default() -> Self {
615        EuvCameraConfig {
616            video_selector: CAMERA_VIDEO_SELECTOR,
617            scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
618            auto_scan: true,
619            on_qr_detected: None,
620            on_error: None,
621        }
622    }
623}