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