euv_ui/component/camera/hook/
impl.rs1use super::*;
2
3thread_local! {
4 static DETECT_FN_CACHE: RefCell<Option<Function>> = const { RefCell::new(None) };
11}
12
13const DETECT_FN_KEY: &str = "detect";
14
15impl UseEuvCamera {
17 pub fn use_camera_state() -> UseEuvCamera {
23 UseEuvCamera::new(
24 App::use_signal(|| false),
25 App::use_signal(|| false),
26 App::use_signal(String::new),
27 App::use_signal(EuvCameraFacing::default),
28 App::use_signal(String::new),
29 App::use_signal(|| None),
30 )
31 }
32
33 pub(crate) fn open(video_selector: &str, facing: EuvCameraFacing) -> Result<(), String> {
50 let Some(window_value) = window() else {
51 return Err("no global window exists".to_string());
52 };
53 let navigator: Navigator = window_value.navigator();
54 let media_devices: MediaDevices = navigator
55 .media_devices()
56 .map_err(|error: JsValue| format!("{error:?}"))?;
57 let constraints: MediaStreamConstraints = MediaStreamConstraints::new();
58 let facing_mode: &str = match facing {
59 EuvCameraFacing::User => CAMERA_FACING_MODE_USER,
60 EuvCameraFacing::Environment => CAMERA_FACING_MODE_ENVIRONMENT,
61 };
62 let video_constraint: Object = Object::new();
63 let _: Result<bool, JsValue> = Reflect::set(
64 &video_constraint,
65 &JsValue::from_str("facingMode"),
66 &JsValue::from_str(facing_mode),
67 );
68 constraints.set_video(&video_constraint);
69 constraints.set_audio(&JsValue::from_bool(false));
70 let promise: Promise = media_devices
71 .get_user_media_with_constraints(&constraints)
72 .map_err(|error: JsValue| format!("{error:?}"))?;
73 let selector: String = video_selector.to_string();
74 let on_fulfilled: Closure<dyn FnMut(JsValue)> =
75 Closure::wrap(Box::new(move |stream_value: JsValue| {
76 let stream: MediaStream = stream_value.unchecked_into();
77 let Some(window_value) = window() else {
78 return;
79 };
80 let Some(document) = window_value.document() else {
81 return;
82 };
83 if let Some(element) = document.query_selector(&selector).ok().flatten() {
84 let video_element: HtmlVideoElement = element.unchecked_into();
85 video_element.set_src_object(Some(&stream));
86 let _: Result<Promise, JsValue> = video_element.play();
87 }
88 }));
89 let on_rejected: Closure<dyn FnMut(JsValue)> =
90 Closure::wrap(Box::new(move |error: JsValue| {
91 web_sys::console::log_2(&wasm_bindgen::JsValue::from_str("[euv-camera]"), &error);
92 }));
93 let _: Promise = promise.then(&on_fulfilled).catch(&on_rejected);
94 on_fulfilled.forget();
95 on_rejected.forget();
96 Ok(())
97 }
98
99 pub(crate) fn close(video_selector: &str) {
109 let Some(window_value) = window() else {
110 return;
111 };
112 let Some(document) = window_value.document() else {
113 return;
114 };
115 if let Some(element) = document.query_selector(video_selector).ok().flatten() {
116 let video_element: HtmlVideoElement = element.unchecked_into();
117 if let Some(stream) = video_element.src_object() {
118 let stream: MediaStream = stream.unchecked_into();
119 let tracks: Array = stream.get_tracks();
120 for track_value in tracks.iter() {
121 let track: MediaStreamTrack = track_value.unchecked_into();
122 track.stop();
123 }
124 }
125 video_element.set_src_object(None);
126 }
127 }
128
129 pub(crate) fn open_and_scan(self, config: Option<&EuvCameraConfig>) {
138 let cfg: EuvCameraConfig =
139 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
140 self.get_camera_loading().set(true);
141 self.get_error_message().set(String::new());
142 self.get_scan_result().set(String::new());
143 let facing: EuvCameraFacing = self.get_facing().get();
144 let result: Result<(), String> = Self::open(cfg.video_selector, facing);
145 match result {
146 Ok(()) => {
147 self.get_camera_open().set(true);
148 self.get_camera_loading().set(false);
149 if cfg.auto_scan {
150 self.start_qr_scan(config);
151 }
152 }
153 Err(error) => {
154 self.get_error_message().set(error);
155 self.get_camera_loading().set(false);
156 if let Some(ref on_error) = cfg.on_error {
157 on_error(self.get_error_message().get());
158 }
159 }
160 }
161 }
162
163 pub(crate) fn switch(self, config: Option<&EuvCameraConfig>) {
173 let cfg: EuvCameraConfig =
174 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
175 self.stop_qr_scan();
176 Self::close(cfg.video_selector);
177 self.get_camera_open().set(false);
178 let new_facing: EuvCameraFacing = match self.get_facing().get() {
179 EuvCameraFacing::User => EuvCameraFacing::Environment,
180 EuvCameraFacing::Environment => EuvCameraFacing::User,
181 };
182 self.get_facing().set(new_facing);
183 self.get_camera_loading().set(true);
184 self.get_error_message().set(String::new());
185 let result: Result<(), String> = Self::open(cfg.video_selector, new_facing);
186 match result {
187 Ok(()) => {
188 self.get_camera_open().set(true);
189 self.get_camera_loading().set(false);
190 if cfg.auto_scan {
191 self.start_qr_scan(config);
192 }
193 }
194 Err(error) => {
195 self.get_error_message().set(error);
196 self.get_camera_loading().set(false);
197 }
198 }
199 }
200
201 fn cached_detect_fn(detector: &JsValue) -> Function {
218 DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
219 if let Some(function) = cache.borrow().as_ref() {
220 return function.clone();
221 }
222 let function: Function = Reflect::get(detector, &JsValue::from_str(DETECT_FN_KEY))
223 .ok()
224 .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
225 .unwrap_or_else(|| Function::new_no_args("return Promise.resolve([])"));
226 *cache.borrow_mut() = Some(function.clone());
227 function
228 })
229 }
230
231 pub(crate) fn start_qr_scan(self, config: Option<&EuvCameraConfig>) {
250 let cfg: EuvCameraConfig =
251 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
252 let Some(window_value) = window() else {
253 return;
254 };
255 let barcode_detector_key: JsValue = JsValue::from_str("BarcodeDetector");
256 let barcode_detector_constructor: Function =
257 match Reflect::get(&window_value, &barcode_detector_key) {
258 Ok(value) if !value.is_undefined() && !value.is_null() => value.unchecked_into(),
259 _ => {
260 self.get_error_message()
261 .set("BarcodeDetector API is not supported in this browser".to_string());
262 return;
263 }
264 };
265 let formats_array: Array = Array::new();
266 formats_array.push(&JsValue::from_str("qr_code"));
267 let init_object: Object = Object::new();
268 let _: Result<bool, JsValue> =
269 Reflect::set(&init_object, &JsValue::from_str("formats"), &formats_array);
270 let args_array: Array = Array::new();
271 args_array.push(&init_object.into());
272 let detector: JsValue = match Reflect::construct(&barcode_detector_constructor, &args_array)
273 {
274 Ok(value) => value,
275 Err(error) => {
276 self.get_error_message()
277 .set(format!("Failed to create BarcodeDetector: {error:?}"));
278 return;
279 }
280 };
281 let video_selector: Rc<String> = Rc::new(cfg.video_selector.to_string());
282 let on_qr_detected: Option<QrDetectedCallback> = cfg.on_qr_detected.clone();
283 let self_for_closure: UseEuvCamera = self;
284 let video_selector_for_closure: Rc<String> = video_selector.clone();
285 let on_qr_detected_for_closure: Option<QrDetectedCallback> = on_qr_detected.clone();
286 let on_detected: Closure<dyn FnMut(JsValue)> =
287 Closure::wrap(Box::new(move |barcodes_value: JsValue| {
288 let barcodes: Array = match barcodes_value.dyn_into::<Array>() {
289 Ok(array) => array,
290 Err(_) => return,
291 };
292 if barcodes.length() == 0 {
293 return;
294 }
295 let text: Option<String> = barcodes.get(0).as_string().or_else(|| {
296 Reflect::get(&barcodes.get(0), &JsValue::from_str("rawValue"))
297 .ok()
298 .and_then(|v: JsValue| v.as_string())
299 });
300 if let Some(text) = text {
301 self_for_closure.get_scan_result().set(text.clone());
302 if let Some(ref callback) = on_qr_detected_for_closure {
303 callback(&text);
304 }
305 if Self::is_valid_qr_url(&text) {
306 self_for_closure.stop_qr_scan();
307 Self::close(&video_selector_for_closure);
308 self_for_closure.get_camera_open().set(false);
309 Self::navigate_qr_url(&text);
310 }
311 }
312 }));
313 let on_scan_error: Closure<dyn FnMut(JsValue)> =
314 Closure::wrap(Box::new(move |_error: JsValue| {}));
315 let detect_fn: Function = Self::cached_detect_fn(&detector);
316 let video_element_cache: Rc<RefCell<Option<HtmlVideoElement>>> =
321 Rc::new(RefCell::new(None));
322 let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
323 let on_detected: &Closure<dyn FnMut(JsValue)> = &on_detected;
324 let on_scan_error: &Closure<dyn FnMut(JsValue)> = &on_scan_error;
325 let detect_fn: &Function = &detect_fn;
326 let Some(window_value) = window() else {
327 return;
328 };
329 let Some(document) = window_value.document() else {
330 return;
331 };
332 let video_element: HtmlVideoElement = {
333 let mut cache: std::cell::RefMut<'_, Option<HtmlVideoElement>> =
334 video_element_cache.borrow_mut();
335 match cache.as_ref() {
336 Some(cached) if cached.is_connected() => cached.clone(),
337 _ => {
338 let Some(element) = document.query_selector(&video_selector).ok().flatten()
339 else {
340 return;
341 };
342 let resolved: HtmlVideoElement = element.unchecked_into();
343 *cache = Some(resolved.clone());
344 resolved
345 }
346 }
347 };
348 if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
349 return;
350 }
351 let promise: Promise = match detect_fn.call1(&detector, &video_element) {
352 Ok(result) => result.into(),
353 Err(_) => return,
354 };
355 let _: Promise = promise.then(on_detected).catch(on_scan_error);
356 });
357 self_for_closure.get_scan_handle().set(Some(handle));
358 }
359
360 pub(crate) fn stop_qr_scan(self) {
362 if let Some(handle) = self.get_scan_handle().get() {
363 handle.clear();
364 self.get_scan_handle().set(None);
365 }
366 DETECT_FN_CACHE.with(|cache: &RefCell<Option<Function>>| {
367 *cache.borrow_mut() = None;
368 });
369 }
370
371 pub(crate) fn is_valid_qr_url(text: &str) -> bool {
384 text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
385 }
386
387 pub(crate) fn extract_hostname(url: &str) -> String {
402 let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
403 stripped
404 } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
405 stripped
406 } else {
407 return String::new();
408 };
409 let authority: &str = rest.split('/').next().unwrap_or("");
410 let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
411 if let Some(stripped) = host_with_brackets.strip_prefix('[')
412 && let Some(inner) = stripped.strip_suffix(']')
413 {
414 return inner.to_string();
415 }
416 host_with_brackets.to_string()
417 }
418
419 pub(crate) fn is_private_host(hostname: &str) -> bool {
434 if hostname.is_empty() {
435 return false;
436 }
437 if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
438 return true;
439 }
440 let octets: Vec<&str> = hostname.split('.').collect();
441 if octets.len() != 4 {
442 return false;
443 }
444 let Ok(first) = octets[0].parse::<u8>() else {
445 return false;
446 };
447 let Ok(second) = octets[1].parse::<u8>() else {
448 return false;
449 };
450 if first == 127 {
451 return true;
452 }
453 if first == 10 {
454 return true;
455 }
456 if first == 172 && (16..=31).contains(&second) {
457 return true;
458 }
459 if first == 192 && second == 168 {
460 return true;
461 }
462 if first == 169 && second == 254 {
463 return true;
464 }
465 false
466 }
467
468 pub(crate) fn navigate_qr_url(url: &str) {
481 let Some(window_value) = window() else {
482 return;
483 };
484 let location: Location = window_value.location();
485 let current_hostname: String = location.hostname().unwrap_or_default();
486 let url_hostname: String = Self::extract_hostname(url);
487 if url_hostname == current_hostname
488 && let Some(fragment) = url.split('#').nth(1)
489 {
490 let route: &str = if fragment.is_empty() { "/" } else { fragment };
491 Router::navigate(route);
492 return;
493 }
494 if Self::is_private_host(&url_hostname) {
495 let _: Result<(), JsValue> = window_value.location().set_href(url);
496 return;
497 }
498 if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
499 .and_then(|value: JsValue| value.dyn_into::<Function>())
500 {
501 let _: Result<JsValue, JsValue> = open_fn.call2(
502 &window_value,
503 &JsValue::from_str(url),
504 &JsValue::from_str(SYSTEM_BROWSER_TARGET),
505 );
506 }
507 }
508
509 pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
520 let cfg: EuvCameraConfig =
521 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
522 Some(Rc::new(move |_: Event| {
523 self.stop_qr_scan();
524 Self::close(cfg.video_selector);
525 self.get_camera_open().set(false);
526 self.get_scan_result().set(String::new());
527 }))
528 }
529
530 pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
540 let cfg: EuvCameraConfig =
541 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
542 Some(Rc::new(move |_: Event| {
543 self.switch(Some(&cfg));
544 }))
545 }
546
547 pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
557 let cfg: EuvCameraConfig =
558 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
559 Some(Rc::new(move |_: Event| {
560 self.open_and_scan(Some(&cfg));
561 }))
562 }
563
564 pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
572 let cfg: EuvCameraConfig =
573 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
574 App::use_cleanup(move || {
575 self.stop_qr_scan();
576 Self::close(cfg.video_selector);
577 self.get_camera_open().set(false);
578 self.get_camera_loading().set(false);
579 self.get_error_message().set(String::new());
580 self.get_scan_result().set(String::new());
581 });
582 }
583}
584
585impl Default for EuvCameraConfig {
587 fn default() -> Self {
589 EuvCameraConfig {
590 video_selector: CAMERA_VIDEO_SELECTOR,
591 scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
592 auto_scan: true,
593 on_qr_detected: None,
594 on_error: None,
595 }
596 }
597}