1/* eslint-env browser */
2import * as React from 'react';
3
4import {
5  CameraReadyListener,
6  CameraType,
7  MountErrorListener,
8  WebCameraSettings,
9} from './Camera.types';
10import * as Utils from './WebCameraUtils';
11import { FacingModeToCameraType } from './WebConstants';
12
13const VALID_SETTINGS_KEYS = [
14  'autoFocus',
15  'flashMode',
16  'exposureCompensation',
17  'colorTemperature',
18  'iso',
19  'brightness',
20  'contrast',
21  'saturation',
22  'sharpness',
23  'focusDistance',
24  'whiteBalance',
25  'zoom',
26];
27
28function useLoadedVideo(video: HTMLVideoElement | null, onLoaded: () => void) {
29  React.useEffect(() => {
30    if (video) {
31      video.addEventListener('loadedmetadata', () => {
32        // without this async block the constraints aren't properly applied to the camera,
33        // this means that if you were to turn on the torch and swap to the front camera,
34        // then swap back to the rear camera the torch setting wouldn't be applied.
35        requestAnimationFrame(() => {
36          onLoaded();
37        });
38      });
39    }
40  }, [video]);
41}
42
43export function useWebCameraStream(
44  video: React.MutableRefObject<HTMLVideoElement | null>,
45  preferredType: CameraType,
46  settings: Record<string, any>,
47  {
48    onCameraReady,
49    onMountError,
50  }: { onCameraReady?: CameraReadyListener; onMountError?: MountErrorListener }
51): {
52  type: CameraType | null;
53  mediaTrackSettings: MediaTrackSettings | null;
54} {
55  const isStartingCamera = React.useRef<boolean | null>(false);
56  const activeStreams = React.useRef<MediaStream[]>([]);
57  const capabilities = React.useRef<WebCameraSettings>({
58    autoFocus: 'continuous',
59    flashMode: 'off',
60    whiteBalance: 'continuous',
61    zoom: 1,
62  });
63  const [stream, setStream] = React.useState<MediaStream | null>(null);
64
65  const mediaTrackSettings = React.useMemo(() => {
66    return stream ? stream.getTracks()[0].getSettings() : null;
67  }, [stream]);
68
69  // The actual camera type - this can be different from the incoming camera type.
70  const type = React.useMemo(() => {
71    if (!mediaTrackSettings) {
72      return null;
73    }
74    // On desktop no value will be returned, in this case we should assume the cameraType is 'front'
75    const { facingMode = 'user' } = mediaTrackSettings;
76    return FacingModeToCameraType[facingMode];
77  }, [mediaTrackSettings]);
78
79  const getStreamDeviceAsync = React.useCallback(async (): Promise<MediaStream | null> => {
80    try {
81      return await Utils.getPreferredStreamDevice(preferredType);
82    } catch (nativeEvent) {
83      if (__DEV__) {
84        console.warn(`Error requesting UserMedia for type "${preferredType}":`, nativeEvent);
85      }
86      if (onMountError) {
87        onMountError({ nativeEvent });
88      }
89      return null;
90    }
91  }, [preferredType, onMountError]);
92
93  const resumeAsync = React.useCallback(async (): Promise<boolean> => {
94    const nextStream = await getStreamDeviceAsync();
95    if (Utils.compareStreams(nextStream, stream)) {
96      // Do nothing if the streams are the same.
97      // This happens when the device only supports one camera (i.e. desktop) and the mode was toggled between front/back while already active.
98      // Without this check there is a screen flash while the video switches.
99      return false;
100    }
101
102    // Save a history of all active streams (usually 2+) so we can close them later.
103    // Keeping them open makes swapping camera types much faster.
104    if (!activeStreams.current.some((value) => value.id === nextStream?.id)) {
105      activeStreams.current.push(nextStream!);
106    }
107
108    // Set the new stream -> update the video, settings, and actual camera type.
109    setStream(nextStream);
110    if (onCameraReady) {
111      onCameraReady();
112    }
113    return false;
114  }, [getStreamDeviceAsync, setStream, onCameraReady, stream, activeStreams.current]);
115
116  React.useEffect(() => {
117    // Restart the camera and guard concurrent actions.
118    if (isStartingCamera.current) {
119      return;
120    }
121    isStartingCamera.current = true;
122
123    resumeAsync()
124      .then((isStarting) => {
125        isStartingCamera.current = isStarting;
126      })
127      .catch(() => {
128        // ensure the camera can be started again.
129        isStartingCamera.current = false;
130      });
131  }, [preferredType]);
132
133  // Update the native camera with any custom capabilities.
134  React.useEffect(() => {
135    const changes: WebCameraSettings = {};
136
137    for (const key of Object.keys(settings)) {
138      if (!VALID_SETTINGS_KEYS.includes(key)) {
139        continue;
140      }
141      const nextValue = settings[key];
142      if (nextValue !== capabilities.current[key]) {
143        changes[key] = nextValue;
144      }
145    }
146
147    // Only update the native camera if changes were found
148    const hasChanges = !!Object.keys(changes).length;
149
150    const nextWebCameraSettings = { ...capabilities.current, ...changes };
151    if (hasChanges) {
152      Utils.syncTrackCapabilities(preferredType, stream, changes);
153    }
154
155    capabilities.current = nextWebCameraSettings;
156  }, [
157    settings.autoFocus,
158    settings.flashMode,
159    settings.exposureCompensation,
160    settings.colorTemperature,
161    settings.iso,
162    settings.brightness,
163    settings.contrast,
164    settings.saturation,
165    settings.sharpness,
166    settings.focusDistance,
167    settings.whiteBalance,
168    settings.zoom,
169  ]);
170
171  React.useEffect(() => {
172    // set or unset the video source.
173    if (!video.current) {
174      return;
175    }
176    Utils.setVideoSource(video.current, stream);
177  }, [video.current, stream]);
178
179  React.useEffect(() => {
180    return () => {
181      // Clean up on dismount, this is important for making sure the camera light goes off when the component is removed.
182      for (const stream of activeStreams.current) {
183        // Close all open streams.
184        Utils.stopMediaStream(stream);
185      }
186      if (video.current) {
187        // Invalidate the video source.
188        Utils.setVideoSource(video.current, stream);
189      }
190    };
191  }, []);
192
193  // Update props when the video loads.
194  useLoadedVideo(video.current, () => {
195    Utils.syncTrackCapabilities(preferredType, stream, capabilities.current);
196  });
197
198  return {
199    type,
200    mediaTrackSettings,
201  };
202}
203