euv_ui/component/camera/hook/
impl.rs1use crate::*;
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 window_value: Window = window().expect("no global window exists");
39 let navigator: Navigator = window_value.navigator();
40 let media_devices: MediaDevices = navigator
41 .media_devices()
42 .map_err(|error: JsValue| format!("{error:?}"))?;
43 let constraints: MediaStreamConstraints = MediaStreamConstraints::new();
44 let facing_mode: &str = match facing {
45 EuvCameraFacing::User => CAMERA_FACING_MODE_USER,
46 EuvCameraFacing::Environment => CAMERA_FACING_MODE_ENVIRONMENT,
47 };
48 let video_constraint: Object = Object::new();
49 let _ = Reflect::set(
50 &video_constraint,
51 &JsValue::from_str("facingMode"),
52 &JsValue::from_str(facing_mode),
53 );
54 constraints.set_video(&video_constraint);
55 constraints.set_audio(&JsValue::from_bool(false));
56 let promise: Promise = media_devices
57 .get_user_media_with_constraints(&constraints)
58 .map_err(|error: JsValue| format!("{error:?}"))?;
59 let selector: String = video_selector.to_string();
60 let on_fulfilled: Closure<dyn FnMut(JsValue)> =
61 Closure::wrap(Box::new(move |stream_value: JsValue| {
62 let stream: MediaStream = stream_value.unchecked_into();
63 let document: Document = window()
64 .expect("no global window exists")
65 .document()
66 .expect("should have a document");
67 if let Some(element) = document.query_selector(&selector).ok().flatten() {
68 let video_element: HtmlVideoElement = element.unchecked_into();
69 video_element.set_src_object(Some(&stream));
70 let _ = video_element.play();
71 }
72 }));
73 let on_rejected: Closure<dyn FnMut(JsValue)> =
74 Closure::wrap(Box::new(move |error: JsValue| {
75 web_sys::console::log_2(&wasm_bindgen::JsValue::from_str("[euv-camera]"), &error);
76 }));
77 let _ = promise.then(&on_fulfilled).catch(&on_rejected);
78 on_fulfilled.forget();
79 on_rejected.forget();
80 Ok(())
81 }
82
83 pub(crate) fn close(video_selector: &str) {
93 let window_value: Window = window().expect("no global window exists");
94 let document: Document = window_value.document().expect("should have a document");
95 if let Some(element) = document.query_selector(video_selector).ok().flatten() {
96 let video_element: HtmlVideoElement = element.unchecked_into();
97 if let Some(stream) = video_element.src_object() {
98 let stream: MediaStream = stream.unchecked_into();
99 let tracks: Array = stream.get_tracks();
100 for track_value in tracks.iter() {
101 let track: MediaStreamTrack = track_value.unchecked_into();
102 track.stop();
103 }
104 }
105 video_element.set_src_object(None);
106 }
107 }
108
109 pub(crate) fn open_and_scan(self, config: Option<&EuvCameraConfig>) {
118 let cfg: EuvCameraConfig =
119 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
120 self.get_camera_loading().set(true);
121 self.get_error_message().set(String::new());
122 self.get_scan_result().set(String::new());
123 let facing: EuvCameraFacing = self.get_facing().get();
124 let result: Result<(), String> = Self::open(cfg.video_selector, facing);
125 match result {
126 Ok(()) => {
127 self.get_camera_open().set(true);
128 self.get_camera_loading().set(false);
129 if cfg.auto_scan {
130 self.start_qr_scan(config);
131 }
132 }
133 Err(error) => {
134 self.get_error_message().set(error);
135 self.get_camera_loading().set(false);
136 if let Some(ref on_error) = cfg.on_error {
137 on_error(self.get_error_message().get());
138 }
139 }
140 }
141 }
142
143 pub(crate) fn switch(self, config: Option<&EuvCameraConfig>) {
153 let cfg: EuvCameraConfig =
154 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
155 self.stop_qr_scan();
156 Self::close(cfg.video_selector);
157 self.get_camera_open().set(false);
158 let new_facing: EuvCameraFacing = match self.get_facing().get() {
159 EuvCameraFacing::User => EuvCameraFacing::Environment,
160 EuvCameraFacing::Environment => EuvCameraFacing::User,
161 };
162 self.get_facing().set(new_facing);
163 self.get_camera_loading().set(true);
164 self.get_error_message().set(String::new());
165 let result: Result<(), String> = Self::open(cfg.video_selector, new_facing);
166 match result {
167 Ok(()) => {
168 self.get_camera_open().set(true);
169 self.get_camera_loading().set(false);
170 if cfg.auto_scan {
171 self.start_qr_scan(config);
172 }
173 }
174 Err(error) => {
175 self.get_error_message().set(error);
176 self.get_camera_loading().set(false);
177 }
178 }
179 }
180
181 pub(crate) fn start_qr_scan(self, config: Option<&EuvCameraConfig>) {
193 let cfg: EuvCameraConfig =
194 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
195 let window_value: Window = window().expect("no global window exists");
196 let barcode_detector_key: JsValue = JsValue::from_str("BarcodeDetector");
197 if Reflect::get(&window_value, &barcode_detector_key).is_err() {
198 self.get_error_message()
199 .set("BarcodeDetector API is not supported in this browser".to_string());
200 return;
201 }
202 let detector_result: Result<JsValue, JsValue> =
203 Function::new_no_args("return new BarcodeDetector({ formats: ['qr_code'] })")
204 .call0(&JsValue::NULL);
205 let detector: JsValue = match detector_result {
206 Ok(value) => value,
207 Err(error) => {
208 self.get_error_message()
209 .set(format!("Failed to create BarcodeDetector: {error:?}"));
210 return;
211 }
212 };
213 let video_selector: Rc<String> = Rc::new(cfg.video_selector.to_string());
214 let on_qr_detected: Option<QrDetectedCallback> = cfg.on_qr_detected.clone();
215 let handle: IntervalHandle = App::use_interval(cfg.scan_interval_millis, move || {
216 let document: Document = window()
217 .expect("no global window exists")
218 .document()
219 .expect("should have a document");
220 let Some(element) = document.query_selector(&video_selector).ok().flatten() else {
221 return;
222 };
223 let video_element: HtmlVideoElement = element.unchecked_into();
224 if video_element.ready_state() != HtmlMediaElement::HAVE_ENOUGH_DATA {
225 return;
226 }
227 let detect_fn: Function = Reflect::get(&detector, &JsValue::from_str("detect"))
228 .ok()
229 .and_then(|value: JsValue| value.dyn_into::<Function>().ok())
230 .unwrap_or_else(|| Function::new_no_args("return Promise.resolve([])"));
231 let promise: Promise = match detect_fn.call1(&detector, &video_element) {
232 Ok(result) => result.into(),
233 Err(_) => return,
234 };
235 let on_qr_detected_clone: Option<QrDetectedCallback> = on_qr_detected.clone();
236 let video_selector_clone: Rc<String> = video_selector.clone();
237 let on_detected: Closure<dyn FnMut(JsValue)> =
238 Closure::wrap(Box::new(move |barcodes_value: JsValue| {
239 let barcodes: Array = match barcodes_value.dyn_into::<Array>() {
240 Ok(array) => array,
241 Err(_) => return,
242 };
243 if barcodes.length() == 0 {
244 return;
245 }
246 let text: Option<String> = barcodes.get(0).as_string().or_else(|| {
247 Reflect::get(&barcodes.get(0), &JsValue::from_str("rawValue"))
248 .ok()
249 .and_then(|v: JsValue| v.as_string())
250 });
251 if let Some(text) = text {
252 self.get_scan_result().set(text.clone());
253 if let Some(ref callback) = on_qr_detected_clone {
254 callback(&text);
255 }
256 if Self::is_valid_qr_url(&text) {
257 self.stop_qr_scan();
258 Self::close(&video_selector_clone);
259 self.get_camera_open().set(false);
260 Self::navigate_qr_url(&text);
261 }
262 }
263 }));
264 let on_scan_error: Closure<dyn FnMut(JsValue)> =
265 Closure::wrap(Box::new(move |_error: JsValue| {}));
266 let _ = promise.then(&on_detected).catch(&on_scan_error);
267 on_detected.forget();
268 on_scan_error.forget();
269 });
270 self.get_scan_handle().set(Some(handle));
271 }
272
273 pub(crate) fn stop_qr_scan(self) {
275 if let Some(handle) = self.get_scan_handle().get() {
276 handle.clear();
277 self.get_scan_handle().set(None);
278 }
279 }
280
281 pub(crate) fn is_valid_qr_url(text: &str) -> bool {
294 text.starts_with(CAMERA_URL_PREFIX_HTTP) || text.starts_with(CAMERA_URL_PREFIX_HTTPS)
295 }
296
297 pub(crate) fn extract_hostname(url: &str) -> String {
312 let rest: &str = if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTPS) {
313 stripped
314 } else if let Some(stripped) = url.strip_prefix(CAMERA_URL_PREFIX_HTTP) {
315 stripped
316 } else {
317 return String::new();
318 };
319 let authority: &str = rest.split('/').next().unwrap_or("");
320 let host_with_brackets: &str = authority.split(':').next().unwrap_or("");
321 if let Some(stripped) = host_with_brackets.strip_prefix('[')
322 && let Some(inner) = stripped.strip_suffix(']')
323 {
324 return inner.to_string();
325 }
326 host_with_brackets.to_string()
327 }
328
329 pub(crate) fn is_private_host(hostname: &str) -> bool {
344 if hostname.is_empty() {
345 return false;
346 }
347 if hostname.eq_ignore_ascii_case(CAMERA_LOCALHOST_HOSTNAME) {
348 return true;
349 }
350 let octets: Vec<&str> = hostname.split('.').collect();
351 if octets.len() != 4 {
352 return false;
353 }
354 let Ok(first) = octets[0].parse::<u8>() else {
355 return false;
356 };
357 let Ok(second) = octets[1].parse::<u8>() else {
358 return false;
359 };
360 if first == 127 {
361 return true;
362 }
363 if first == 10 {
364 return true;
365 }
366 if first == 172 && (16..=31).contains(&second) {
367 return true;
368 }
369 if first == 192 && second == 168 {
370 return true;
371 }
372 if first == 169 && second == 254 {
373 return true;
374 }
375 false
376 }
377
378 pub(crate) fn navigate_qr_url(url: &str) {
391 let window_value: Window = window().expect("no global window exists");
392 let location: Location = window_value.location();
393 let current_hostname: String = location.hostname().unwrap_or_default();
394 let url_hostname: String = Self::extract_hostname(url);
395 if url_hostname == current_hostname
396 && let Some(fragment) = url.split('#').nth(1)
397 {
398 let route: &str = if fragment.is_empty() { "/" } else { fragment };
399 Router::navigate(route);
400 return;
401 }
402 if Self::is_private_host(&url_hostname) {
403 let _ = window_value.location().set_href(url);
404 return;
405 }
406 if let Ok(open_fn) = Reflect::get(&window_value, &JsValue::from_str("open"))
407 .and_then(|value: JsValue| value.dyn_into::<Function>())
408 {
409 let _ = open_fn.call2(
410 &window_value,
411 &JsValue::from_str(url),
412 &JsValue::from_str(SYSTEM_BROWSER_TARGET),
413 );
414 }
415 }
416
417 pub fn on_close(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
428 let cfg: EuvCameraConfig =
429 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
430 Some(Rc::new(move |_: Event| {
431 self.stop_qr_scan();
432 Self::close(cfg.video_selector);
433 self.get_camera_open().set(false);
434 self.get_scan_result().set(String::new());
435 }))
436 }
437
438 pub fn on_switch(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
448 let cfg: EuvCameraConfig =
449 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
450 Some(Rc::new(move |_: Event| {
451 self.switch(Some(&cfg));
452 }))
453 }
454
455 pub fn on_open(self, config: Option<&EuvCameraConfig>) -> Option<Rc<dyn Fn(Event)>> {
465 let cfg: EuvCameraConfig =
466 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
467 Some(Rc::new(move |_: Event| {
468 self.open_and_scan(Some(&cfg));
469 }))
470 }
471
472 pub fn cleanup(self, config: Option<&EuvCameraConfig>) {
480 let cfg: EuvCameraConfig =
481 config.map_or_else(EuvCameraConfig::default, |c: &EuvCameraConfig| c.clone());
482 App::use_cleanup(move || {
483 self.stop_qr_scan();
484 Self::close(cfg.video_selector);
485 self.get_camera_open().set(false);
486 self.get_camera_loading().set(false);
487 self.get_error_message().set(String::new());
488 self.get_scan_result().set(String::new());
489 });
490 }
491}
492
493impl Default for EuvCameraConfig {
495 fn default() -> Self {
496 EuvCameraConfig {
497 video_selector: CAMERA_VIDEO_SELECTOR,
498 scan_interval_millis: CAMERA_SCAN_INTERVAL_MILLIS,
499 auto_scan: false,
500 on_qr_detected: None,
501 on_error: None,
502 }
503 }
504}