euv_ui/component/camera/hook/
impl.rs1use super::*;
2
3impl UseEuvCamera {
5 pub fn use_camera_state() -> UseEuvCamera {
11 UseEuvCamera::new(
12 App::use_signal(|| false),
13 App::use_signal(|| false),
14 App::use_signal(String::new),
15 App::use_signal(EuvCameraFacing::default),
16 App::use_signal(String::new),
17 App::use_signal(|| None),
18 )
19 }
20
21 pub(crate) fn open(video_selector: &str, facing: EuvCameraFacing) -> Result<(), String> {
38 let Some(window_value) = window() else {
39 return Err("no global window exists".to_string());
40 };
41 let navigator: Navigator = window_value.navigator();
42 let media_devices: MediaDevices = navigator
43 .media_devices()
44 .map_err(|error: JsValue| format!("{error:?}"))?;
45 let constraints: MediaStreamConstraints = MediaStreamConstraints::new();
46 let facing_mode: &str = match facing {
47 EuvCameraFacing::User => CAMERA_FACING_MODE_USER,
48 EuvCameraFacing::Environment => CAMERA_FACING_MODE_ENVIRONMENT,
49 };
50 let video_constraint: Object = Object::new();
51 let _: Result<bool, JsValue> = Reflect::set(
52 &video_constraint,
53 &JsValue::from_str("facingMode"),
54 &JsValue::from_str(facing_mode),
55 );
56 constraints.set_video(&video_constraint);
57 constraints.set_audio(&JsValue::from_bool(false));
58 let promise: Promise = media_devices
59 .get_user_media_with_constraints(&constraints)
60 .map_err(|error: JsValue| format!("{error:?}"))?;
61 let selector: String = video_selector.to_string();
62 let on_fulfilled: Closure<dyn FnMut(JsValue)> =
63 Closure::wrap(Box::new(move |stream_value: JsValue| {
64 let stream: MediaStream = stream_value.unchecked_into();
65 let Some(window_value) = window() else {
66 return;
67 };
68 let Some(document) = window_value.document() else {
69 return;
70 };
71 if let Some(element) = document.query_selector(&selector).ok().flatten() {
72 let video_element: HtmlVideoElement = element.unchecked_into();
73 video_element.set_src_object(Some(&stream));
74 let _: Result<Promise, JsValue> = video_element.play();
75 }
76 }));
77 let on_rejected: Closure<dyn FnMut(JsValue)> =
78 Closure::wrap(Box::new(move |error: JsValue| {
79 web_sys::console::log_2(&wasm_bindgen::JsValue::from_str("[euv-camera]"), &error);
80 }));
81 let _: Promise = promise.then(&on_fulfilled).catch(&on_rejected);
82 on_fulfilled.forget();
83 on_rejected.forget();
84 Ok(())
85 }
86
87 pub(crate) fn close(video_selector: &str) {
97 let Some(window_value) = window() else {
98 return;
99 };
100 let Some(document) = window_value.document() else {
101 return;
102 };
103 if let Some(element) = document.query_selector(video_selector).ok().flatten() {
104 let video_element: HtmlVideoElement = element.unchecked_into();
105 if let Some(stream) = video_element.src_object() {
106 let stream: MediaStream = stream.unchecked_into();
107 let tracks: Array = stream.get_tracks();
108 for track_value in tracks.iter() {
109 let track: MediaStreamTrack = track_value.unchecked_into();
110 track.stop();
111 }
112 }
113 video_element.set_src_object(None);
114 }
115 }
116
117 pub(crate) fn open_and_scan(self, config: Option<&EuvCameraConfig>) {
126 let cfg: EuvCameraConfig =
127 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
128 self.get_camera_loading().set(true);
129 self.get_error_message().set(String::new());
130 self.get_scan_result().set(String::new());
131 let facing: EuvCameraFacing = self.get_facing().get();
132 let result: Result<(), String> = Self::open(cfg.video_selector, facing);
133 match result {
134 Ok(()) => {
135 self.get_camera_open().set(true);
136 self.get_camera_loading().set(false);
137 if cfg.auto_scan {
138 self.start_qr_scan(config);
139 }
140 }
141 Err(error) => {
142 self.get_error_message().set(error);
143 self.get_camera_loading().set(false);
144 if let Some(ref on_error) = cfg.on_error {
145 on_error(self.get_error_message().get());
146 }
147 }
148 }
149 }
150
151 pub(crate) fn switch(self, config: Option<&EuvCameraConfig>) {
161 let cfg: EuvCameraConfig =
162 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
163 self.stop_qr_scan();
164 Self::close(cfg.video_selector);
165 self.get_camera_open().set(false);
166 let new_facing: EuvCameraFacing = match self.get_facing().get() {
167 EuvCameraFacing::User => EuvCameraFacing::Environment,
168 EuvCameraFacing::Environment => EuvCameraFacing::User,
169 };
170 self.get_facing().set(new_facing);
171 self.get_camera_loading().set(true);
172 self.get_error_message().set(String::new());
173 let result: Result<(), String> = Self::open(cfg.video_selector, new_facing);
174 match result {
175 Ok(()) => {
176 self.get_camera_open().set(true);
177 self.get_camera_loading().set(false);
178 if cfg.auto_scan {
179 self.start_qr_scan(config);
180 }
181 }
182 Err(error) => {
183 self.get_error_message().set(error);
184 self.get_camera_loading().set(false);
185 }
186 }
187 }
188
189 pub(crate) fn start_qr_scan(self, config: Option<&EuvCameraConfig>) {
201 let cfg: EuvCameraConfig =
202 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
203 let Some(window_value) = window() else {
204 return;
205 };
206 let barcode_detector_key: JsValue = JsValue::from_str("BarcodeDetector");
207 let barcode_detector_constructor: Function =
208 match Reflect::get(&window_value, &barcode_detector_key) {
209 Ok(value) if !value.is_undefined() && !value.is_null() => value.unchecked_into(),
210 _ => {
211 self.get_error_message()
212 .set("BarcodeDetector API is not supported in this browser".to_string());
213 return;
214 }
215 };
216 let formats_array: Array = Array::new();
217 formats_array.push(&JsValue::from_str("qr_code"));
218 let init_object: Object = Object::new();
219 let _: Result<bool, JsValue> =
220 Reflect::set(&init_object, &JsValue::from_str("formats"), &formats_array);
221 let args_array: Array = Array::new();
222 args_array.push(&init_object.into());
223 let detector: JsValue = match Reflect::construct(&barcode_detector_constructor, &args_array)
224 {
225 Ok(value) => value,
226 Err(error) => {
227 self.get_error_message()
228 .set(format!("Failed to create BarcodeDetector: {error:?}"));
229 return;
230 }
231 };
232 let video_selector: Rc<String> = Rc::new(cfg.video_selector.to_string());
233 let on_qr_detected: Option<QrDetectedCallback> = cfg.on_qr_detected.clone();
234 let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
235 let Some(window_value) = window() else {
236 return;
237 };
238 let Some(document) = window_value.document() else {
239 return;
240 };
241 let Some(element) = document.query_selector(&video_selector).ok().flatten() else {
242 return;
243 };
244 let video_element: HtmlVideoElement = element.unchecked_into();
245 if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
246 return;
247 }
248 let detect_fn: Function = Reflect::get(&detector, &JsValue::from_str("detect"))
249 .ok()
250 .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
251 .unwrap_or_else(|| Function::new_no_args("return Promise.resolve([])"));
252 let promise: Promise = match detect_fn.call1(&detector, &video_element) {
253 Ok(result) => result.into(),
254 Err(_) => return,
255 };
256 let on_qr_detected_clone: Option<QrDetectedCallback> = on_qr_detected.clone();
257 let video_selector_clone: Rc<String> = video_selector.clone();
258 let on_detected: Closure<dyn FnMut(JsValue)> =
259 Closure::wrap(Box::new(move |barcodes_value: JsValue| {
260 let barcodes: Array = match barcodes_value.dyn_into::<Array>() {
261 Ok(array) => array,
262 Err(_) => return,
263 };
264 if barcodes.length() == 0 {
265 return;
266 }
267 let text: Option<String> = barcodes.get(0).as_string().or_else(|| {
268 Reflect::get(&barcodes.get(0), &JsValue::from_str("rawValue"))
269 .ok()
270 .and_then(|v: JsValue| v.as_string())
271 });
272 if let Some(text) = text {
273 self.get_scan_result().set(text.clone());
274 if let Some(ref callback) = on_qr_detected_clone {
275 callback(&text);
276 }
277 if Self::is_valid_qr_url(&text) {
278 self.stop_qr_scan();
279 Self::close(&video_selector_clone);
280 self.get_camera_open().set(false);
281 Self::navigate_qr_url(&text);
282 }
283 }
284 }));
285 let on_scan_error: Closure<dyn FnMut(JsValue)> =
286 Closure::wrap(Box::new(move |_error: JsValue| {}));
287 let _: Promise = promise.then(&on_detected).catch(&on_scan_error);
288 on_detected.forget();
289 on_scan_error.forget();
290 });
291 self.get_scan_handle().set(Some(handle));
292 }
293
294 pub(crate) fn stop_qr_scan(self) {
296 if let Some(handle) = self.get_scan_handle().get() {
297 handle.clear();
298 self.get_scan_handle().set(None);
299 }
300 }
301
302 pub(crate) fn is_valid_qr_url(text: &str) -> bool {
315 text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
316 }
317
318 pub(crate) fn extract_hostname(url: &str) -> String {
333 let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
334 stripped
335 } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
336 stripped
337 } else {
338 return String::new();
339 };
340 let authority: &str = rest.split('/').next().unwrap_or("");
341 let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
342 if let Some(stripped) = host_with_brackets.strip_prefix('[')
343 && let Some(inner) = stripped.strip_suffix(']')
344 {
345 return inner.to_string();
346 }
347 host_with_brackets.to_string()
348 }
349
350 pub(crate) fn is_private_host(hostname: &str) -> bool {
365 if hostname.is_empty() {
366 return false;
367 }
368 if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
369 return true;
370 }
371 let octets: Vec<&str> = hostname.split('.').collect();
372 if octets.len() != 4 {
373 return false;
374 }
375 let Ok(first) = octets[0].parse::<u8>() else {
376 return false;
377 };
378 let Ok(second) = octets[1].parse::<u8>() else {
379 return false;
380 };
381 if first == 127 {
382 return true;
383 }
384 if first == 10 {
385 return true;
386 }
387 if first == 172 && (16..=31).contains(&second) {
388 return true;
389 }
390 if first == 192 && second == 168 {
391 return true;
392 }
393 if first == 169 && second == 254 {
394 return true;
395 }
396 false
397 }
398
399 pub(crate) fn navigate_qr_url(url: &str) {
412 let Some(window_value) = window() else {
413 return;
414 };
415 let location: Location = window_value.location();
416 let current_hostname: String = location.hostname().unwrap_or_default();
417 let url_hostname: String = Self::extract_hostname(url);
418 if url_hostname == current_hostname
419 && let Some(fragment) = url.split('#').nth(1)
420 {
421 let route: &str = if fragment.is_empty() { "/" } else { fragment };
422 Router::navigate(route);
423 return;
424 }
425 if Self::is_private_host(&url_hostname) {
426 let _: Result<(), JsValue> = window_value.location().set_href(url);
427 return;
428 }
429 if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
430 .and_then(|value: JsValue| value.dyn_into::<Function>())
431 {
432 let _: Result<JsValue, JsValue> = open_fn.call2(
433 &window_value,
434 &JsValue::from_str(url),
435 &JsValue::from_str(SYSTEM_BROWSER_TARGET),
436 );
437 }
438 }
439
440 pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
451 let cfg: EuvCameraConfig =
452 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
453 Some(Rc::new(move |_: Event| {
454 self.stop_qr_scan();
455 Self::close(cfg.video_selector);
456 self.get_camera_open().set(false);
457 self.get_scan_result().set(String::new());
458 }))
459 }
460
461 pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
471 let cfg: EuvCameraConfig =
472 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
473 Some(Rc::new(move |_: Event| {
474 self.switch(Some(&cfg));
475 }))
476 }
477
478 pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
488 let cfg: EuvCameraConfig =
489 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
490 Some(Rc::new(move |_: Event| {
491 self.open_and_scan(Some(&cfg));
492 }))
493 }
494
495 pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
503 let cfg: EuvCameraConfig =
504 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
505 App::use_cleanup(move || {
506 self.stop_qr_scan();
507 Self::close(cfg.video_selector);
508 self.get_camera_open().set(false);
509 self.get_camera_loading().set(false);
510 self.get_error_message().set(String::new());
511 self.get_scan_result().set(String::new());
512 });
513 }
514}
515
516impl Default for EuvCameraConfig {
518 fn default() -> Self {
520 EuvCameraConfig {
521 video_selector: CAMERA_VIDEO_SELECTOR,
522 scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
523 auto_scan: true,
524 on_qr_detected: None,
525 on_error: None,
526 }
527 }
528}