1import { CodedError } from 'expo-modules-core';
2import * as React from 'react';
3import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
4import createElement from 'react-native-web/dist/exports/createElement';
5
6import {
7  CameraCapturedPicture,
8  CameraNativeProps,
9  CameraPictureOptions,
10  CameraType,
11} from './Camera.types';
12import CameraManager from './ExponentCameraManager.web';
13import { capture } from './WebCameraUtils';
14import { PictureSizes } from './WebConstants';
15import { useWebCameraStream } from './useWebCameraStream';
16import { useWebQRScanner } from './useWebQRScanner';
17
18export interface ExponentCameraRef {
19  getAvailablePictureSizes: (ratio: string) => Promise<string[]>;
20  takePicture: (options: CameraPictureOptions) => Promise<CameraCapturedPicture>;
21  resumePreview: () => Promise<void>;
22  pausePreview: () => Promise<void>;
23}
24
25const ExponentCamera = React.forwardRef(
26  (
27    { type, pictureSize, poster, ...props }: CameraNativeProps & { children?: React.ReactNode },
28    ref: React.Ref<ExponentCameraRef>
29  ) => {
30    const video = React.useRef<HTMLVideoElement | null>(null);
31
32    const native = useWebCameraStream(video, type as CameraType, props, {
33      onCameraReady() {
34        if (props.onCameraReady) {
35          props.onCameraReady();
36        }
37      },
38      onMountError: props.onMountError,
39    });
40
41    const isQRScannerEnabled = React.useMemo<boolean>(() => {
42      return !!(
43        props.barCodeScannerSettings?.barCodeTypes?.includes('qr') && !!props.onBarCodeScanned
44      );
45    }, [props.barCodeScannerSettings?.barCodeTypes, props.onBarCodeScanned]);
46
47    useWebQRScanner(video, {
48      interval: props.barCodeScannerSettings?.interval,
49      isEnabled: isQRScannerEnabled,
50      captureOptions: { scale: 1, isImageMirror: native.type === CameraType.front },
51      onScanned(event) {
52        if (props.onBarCodeScanned) {
53          props.onBarCodeScanned(event);
54        }
55      },
56      // onError: props.onMountError,
57    });
58
59    // const [pause, setPaused]
60
61    React.useImperativeHandle(
62      ref,
63      () => ({
64        async getAvailablePictureSizes(ratio: string): Promise<string[]> {
65          return PictureSizes;
66        },
67        async takePicture(options: CameraPictureOptions): Promise<CameraCapturedPicture> {
68          if (!video.current || video.current?.readyState !== video.current?.HAVE_ENOUGH_DATA) {
69            throw new CodedError(
70              'ERR_CAMERA_NOT_READY',
71              'HTMLVideoElement does not have enough camera data to construct an image yet.'
72            );
73          }
74          const settings = native.mediaTrackSettings;
75          if (!settings) {
76            throw new CodedError('ERR_CAMERA_NOT_READY', 'MediaStream is not ready yet.');
77          }
78
79          return capture(video.current, settings, {
80            ...options,
81            // This will always be defined, the option gets added to a queue in the upper-level. We should replace the original so it isn't called twice.
82            onPictureSaved(picture) {
83              if (options.onPictureSaved) {
84                options.onPictureSaved(picture);
85              }
86              if (props.onPictureSaved) {
87                props.onPictureSaved({ nativeEvent: { data: picture, id: -1 } });
88              }
89            },
90          });
91        },
92        async resumePreview(): Promise<void> {
93          if (video.current) {
94            video.current.play();
95          }
96        },
97        async pausePreview(): Promise<void> {
98          if (video.current) {
99            video.current.pause();
100          }
101        },
102      }),
103      [native.mediaTrackSettings, props.onPictureSaved]
104    );
105
106    // TODO(Bacon): Create a universal prop, on native the microphone is only used when recording videos.
107    // Because we don't support recording video in the browser we don't need the user to give microphone permissions.
108    const isMuted = true;
109
110    const style = React.useMemo<StyleProp<ViewStyle>>(() => {
111      const isFrontFacingCamera = native.type === CameraManager.Type.front;
112      return [
113        StyleSheet.absoluteFill,
114        styles.video,
115        {
116          // Flip the camera
117          transform: isFrontFacingCamera ? [{ scaleX: -1 }] : undefined,
118        },
119      ];
120    }, [native.type]);
121
122    return (
123      <View pointerEvents="box-none" style={[styles.videoWrapper, props.style]}>
124        <Video
125          autoPlay
126          playsInline
127          muted={isMuted}
128          poster={poster}
129          // webkitPlaysinline
130          pointerEvents={props.pointerEvents}
131          ref={video}
132          style={style}
133        />
134        {props.children}
135      </View>
136    );
137  }
138);
139
140export default ExponentCamera;
141
142const Video = React.forwardRef(
143  (
144    props: React.ComponentProps<typeof View> & {
145      autoPlay?: boolean;
146      playsInline?: boolean;
147      muted?: boolean;
148      poster?: string;
149    },
150    ref: React.Ref<HTMLVideoElement>
151  ) => createElement('video', { ...props, ref })
152);
153
154const styles = StyleSheet.create({
155  videoWrapper: {
156    flex: 1,
157    alignItems: 'stretch',
158  },
159  video: {
160    width: '100%',
161    height: '100%',
162    objectFit: 'cover',
163  },
164});
165