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