diff --git a/native/media/camera-modal.react.js b/native/media/camera-modal.react.js --- a/native/media/camera-modal.react.js +++ b/native/media/camera-modal.react.js @@ -13,7 +13,6 @@ Animated, Easing, } from 'react-native'; -import { RNCamera } from 'react-native-camera'; import filesystem from 'react-native-fs'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; @@ -30,11 +29,18 @@ runOnJS, interpolate, Extrapolate, + useAnimatedProps, } from 'react-native-reanimated'; import { SafeAreaView, useSafeAreaInsets, } from 'react-native-safe-area-context'; +import { + Camera, + useCameraPermission, + useCameraDevice, + type CameraProps, +} from 'react-native-vision-camera'; import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils.js'; import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils.js'; @@ -53,19 +59,12 @@ import type { NativeMethods } from '../types/react-native.js'; import { clamp } from '../utils/animation-utils.js'; +Reanimated.addWhitelistedNativeProps({ + zoom: true, +}); +const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera); + const maxZoom = 16; -const zoomUpdateFactor = (() => { - if (Platform.OS === 'ios') { - return 0.002; - } - if (Platform.OS === 'android' && Platform.Version > 26) { - return 0.005; - } - if (Platform.OS === 'android' && Platform.Version > 23) { - return 0.01; - } - return 0.03; -})(); const stagingModeAnimationConfig = { duration: 150, @@ -96,19 +95,11 @@ } catch (e) {} } -type RNCameraStatus = 'READY' | 'PENDING_AUTHORIZATION' | 'NOT_AUTHORIZED'; - type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; -type AutoFocusPointOfInterest = ?{ - +x: number, - +y: number, - +autoExposure?: boolean, -}; - type Dimensions = { +x: number, +y: number, @@ -276,7 +267,6 @@ const CameraModal: React.ComponentType = React.memo( function CameraModal(props: Props) { - const dimensions = useSelector(state => state.dimensions); const deviceCameraInfo = useSelector(state => state.deviceCameraInfo); const deviceOrientation = useSelector(state => state.deviceOrientation); const foreground = useIsAppForegrounded(); @@ -301,18 +291,16 @@ }; }, []); - const [flashMode, setFlashMode] = React.useState( - RNCamera.Constants.FlashMode.off, - ); + const [flashMode, setFlashMode] = React.useState('off'); const changeFlashMode = React.useCallback(() => { setFlashMode(prevFlashMode => { - if (prevFlashMode === RNCamera.Constants.FlashMode.on) { - return RNCamera.Constants.FlashMode.off; - } else if (prevFlashMode === RNCamera.Constants.FlashMode.off) { - return RNCamera.Constants.FlashMode.auto; + if (prevFlashMode === 'on') { + return 'off'; + } else if (prevFlashMode === 'off') { + return 'auto'; } - return RNCamera.Constants.FlashMode.on; + return 'on'; }); }, []); @@ -330,135 +318,89 @@ const cameraIDsFetched = React.useRef(false); - const fetchCameraIDs = React.useCallback( - async (camera: RNCamera) => { - if (cameraIDsFetched.current) { - return; - } - cameraIDsFetched.current = true; - - const deviceCameras = await camera.getCameraIdsAsync(); - - let hasFront = false, - hasBack = false, - i = 0; - while ((!hasFront || !hasBack) && i < deviceCameras.length) { - const deviceCamera = deviceCameras[i]; - if (deviceCamera.type === RNCamera.Constants.Type.front) { - hasFront = true; - } else if (deviceCamera.type === RNCamera.Constants.Type.back) { - hasBack = true; - } - i++; + const fetchCameraIDs = React.useCallback(async () => { + if (cameraIDsFetched.current) { + return; + } + cameraIDsFetched.current = true; + + const deviceCameras = Camera.getAvailableCameraDevices(); + + let hasFront = false, + hasBack = false, + i = 0; + while ((!hasFront || !hasBack) && i < deviceCameras.length) { + const deviceCamera = deviceCameras[i]; + if (deviceCamera.position === 'front') { + hasFront = true; + } else if (deviceCamera.position === 'back') { + hasBack = true; } + i++; + } - const nextHasCamerasOnBothSides = hasFront && hasBack; - const defaultUseFrontCamera = !hasBack && hasFront; - if (nextHasCamerasOnBothSides !== hasCamerasOnBothSides) { - setHasCamerasOnBothSides(nextHasCamerasOnBothSides); - } - const { - hasCamerasOnBothSides: oldHasCamerasOnBothSides, - defaultUseFrontCamera: oldDefaultUseFrontCamera, - } = deviceCameraInfo; - if ( - nextHasCamerasOnBothSides !== oldHasCamerasOnBothSides || - defaultUseFrontCamera !== oldDefaultUseFrontCamera - ) { - dispatch({ - type: updateDeviceCameraInfoActionType, - payload: { - hasCamerasOnBothSides: nextHasCamerasOnBothSides, - defaultUseFrontCamera, - }, - }); - } - }, - [deviceCameraInfo, dispatch, hasCamerasOnBothSides], - ); + const nextHasCamerasOnBothSides = hasFront && hasBack; + const defaultUseFrontCamera = !hasBack && hasFront; + if (nextHasCamerasOnBothSides !== hasCamerasOnBothSides) { + setHasCamerasOnBothSides(nextHasCamerasOnBothSides); + } + const { + hasCamerasOnBothSides: oldHasCamerasOnBothSides, + defaultUseFrontCamera: oldDefaultUseFrontCamera, + } = deviceCameraInfo; + if ( + nextHasCamerasOnBothSides !== oldHasCamerasOnBothSides || + defaultUseFrontCamera !== oldDefaultUseFrontCamera + ) { + dispatch({ + type: updateDeviceCameraInfoActionType, + payload: { + hasCamerasOnBothSides: nextHasCamerasOnBothSides, + defaultUseFrontCamera, + }, + }); + } + }, [deviceCameraInfo, dispatch, hasCamerasOnBothSides]); - const [autoFocusPointOfInterest, setAutoFocusPointOfInterest] = - React.useState(); + const cameraRef = React.useRef(); const focusOnPoint = React.useCallback( ([inputX, inputY]: [number, number]) => { - const { width: screenWidth, height: screenHeight } = dimensions; - const relativeX = inputX / screenWidth; - const relativeY = inputY / screenHeight; - - // react-native-camera's autoFocusPointOfInterest prop is based on a - // LANDSCAPE-LEFT orientation, so we need to map to that - let x, y; - if (deviceOrientation === 'LANDSCAPE-LEFT') { - x = relativeX; - y = relativeY; - } else if (deviceOrientation === 'LANDSCAPE-RIGHT') { - x = 1 - relativeX; - y = 1 - relativeY; - } else if (deviceOrientation === 'PORTRAIT-UPSIDEDOWN') { - x = 1 - relativeY; - y = relativeX; - } else { - x = relativeY; - y = 1 - relativeX; - } - setAutoFocusPointOfInterest( - Platform.OS === 'ios' ? { x, y, autoExposure: true } : { x, y }, - ); + const camera = cameraRef.current; + invariant(camera, 'camera ref should be set'); + void camera.focus({ x: inputX, y: inputY }); }, - [deviceOrientation, dimensions], + [], ); - const [zoom, setZoom] = React.useState(0); - - const updateZoom = React.useCallback((nextZoom: number) => { - setZoom(nextZoom); - }, []); - const [stagingMode, setStagingMode] = React.useState(false); const [pendingPhotoCapture, setPendingPhotoCapture] = React.useState(); - const cameraRef = React.useRef(); - const takePhoto = React.useCallback(async () => { const camera = cameraRef.current; invariant(camera, 'camera ref should be set'); setStagingMode(true); - // We avoid flipping useFrontCamera if we discover we don't - // actually have a back camera since it causes a bit of lag, but this - // means there are cases where it is false but we are actually using the - // front camera - const { - hasCamerasOnBothSides: hasCamerasOnBothSidesFromDeviceInfo, - defaultUseFrontCamera, - } = deviceCameraInfo; - const usingFrontCamera = - useFrontCamera || - (!hasCamerasOnBothSidesFromDeviceInfo && defaultUseFrontCamera); - const startTime = Date.now(); - const photoPromise = camera.takePictureAsync({ - pauseAfterCapture: Platform.OS === 'android', - mirrorImage: usingFrontCamera, - fixOrientation: true, + const photoPromise = camera.takePhoto({ + flash: flashMode, }); - if (Platform.OS === 'ios') { - camera.pausePreview(); - } - const { uri, width, height } = await photoPromise; + const { path: uri, width, height } = await photoPromise; + const filename = filenameFromPathOrURI(uri); invariant( filename, - `unable to parse filename out of react-native-camera URI ${uri}`, + `unable to parse filename out of react-native-vision-camera URI ${uri}`, ); const now = Date.now(); const nextPendingPhotoCapture = { step: 'photo_capture', - uri, + // If you want to consume this file (e.g. for displaying it in an component), you might have to add the file:// prefix. + // https://react-native-vision-camera.com/docs/api/interfaces/PhotoFile#path + uri: uri.startsWith('file://') ? uri : 'file://' + uri, dimensions: { width, height }, filename, time: now - startTime, @@ -467,11 +409,8 @@ sendTime: 0, retries: 0, }; - - setAutoFocusPointOfInterest(undefined); - setZoom(0); setPendingPhotoCapture(nextPendingPhotoCapture); - }, [deviceCameraInfo, useFrontCamera]); + }, [flashMode]); const close = React.useCallback(() => { if (overlayContext && navigation.goBackOnce) { @@ -499,7 +438,6 @@ const clearPendingImage = React.useCallback(() => { invariant(cameraRef.current, 'camera ref should be set'); - cameraRef.current.resumePreview(); setStagingMode(false); setPendingPhotoCapture(); }, []); @@ -702,42 +640,25 @@ ); const zoomBase = useSharedValue(1); - const zoomReported = useSharedValue(1); const currentZoom = useSharedValue(1); + const animatedProps = useAnimatedProps( + () => ({ zoom: currentZoom.value }), + [currentZoom], + ); + const onPinchUpdate = React.useCallback( (pinchScale: number) => { 'worklet'; currentZoom.value = clamp(zoomBase.value * pinchScale, 1, 8); - if ( - Math.abs(currentZoom.value / zoomReported.value - 1) > - zoomUpdateFactor - ) { - zoomReported.value = currentZoom.value; - const cameraZoomFactor = interpolate( - zoomReported.value, - [1, 8], - [0, 1], - Extrapolate.CLAMP, - ); - runOnJS(updateZoom)(cameraZoomFactor); - } }, - [currentZoom, updateZoom, zoomBase.value, zoomReported], + [currentZoom, zoomBase.value], ); const onPinchEnd = React.useCallback(() => { 'worklet'; - zoomReported.value = currentZoom.value; zoomBase.value = currentZoom.value; - const cameraZoomFactor = interpolate( - zoomReported.value, - [1, 8], - [0, 1], - Extrapolate.CLAMP, - ); - runOnJS(updateZoom)(cameraZoomFactor); - }, [currentZoom, updateZoom, zoomBase, zoomReported]); + }, [currentZoom, zoomBase]); const gesture = React.useMemo(() => { const pinchGesture = Gesture.Pinch() @@ -793,18 +714,11 @@ const prevDeviceOrientation = React.useRef(); React.useEffect(() => { if (deviceOrientation !== prevDeviceOrientation.current) { - setAutoFocusPointOfInterest(null); cancelFocusAnimation(); } prevDeviceOrientation.current = deviceOrientation; }, [cancelFocusAnimation, deviceOrientation]); - React.useEffect(() => { - if (foreground && cameraRef.current) { - void cameraRef.current.refreshAuthorizationStatus(); - } - }, [foreground]); - const prevStagingMode = React.useRef(false); React.useEffect(() => { if (stagingMode && !prevStagingMode.current) { @@ -844,16 +758,17 @@ return [styles.container, containerAnimatedStyle]; }, [containerAnimatedStyle, overlayContext]); - const renderCamera = ({ - camera, - status, - }: { - +camera: RNCamera & { +_cameraHandle?: mixed, ... }, - status: RNCameraStatus, - ... - }): React.Node => { - if (camera && camera._cameraHandle) { - void fetchCameraIDs(camera); + const { hasPermission, requestPermission } = useCameraPermission(); + + React.useEffect(() => { + if (foreground && !hasPermission) { + void requestPermission(); + } + }, [foreground, hasPermission, requestPermission]); + + const renderCamera = (): React.Node => { + if (cameraRef.current) { + void fetchCameraIDs(); } if (stagingMode) { return renderStagingView(); @@ -862,7 +777,7 @@ return ( - {renderCameraContent(status)} + {renderCameraContent()} { - if (status === 'PENDING_AUTHORIZATION') { - return ; - } else if (status === 'NOT_AUTHORIZED') { + const renderCameraContent = (): React.Node => { + if (!hasPermission) { return ( @@ -936,9 +849,9 @@ } let flashIcon; - if (flashMode === RNCamera.Constants.FlashMode.on) { + if (flashMode === 'on') { flashIcon = ; - } else if (flashMode === RNCamera.Constants.FlashMode.off) { + } else if (flashMode === 'off') { flashIcon = ; } else { flashIcon = ( @@ -978,25 +891,35 @@ }; const statusBar = isActive ?