import * as React from 'react'; import { useRef, useState, useMemo, useCallback } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { PinchGestureHandler, PinchGestureHandlerGestureEvent, TapGestureHandler } from 'react-native-gesture-handler'; import { Navigation, NavigationFunctionComponent } from 'react-native-navigation'; import { CameraDeviceFormat, CameraRuntimeError, PhotoFile, sortFormats, useCameraDevices, VideoFile } from 'react-native-vision-camera'; import { Camera, frameRateIncluded } from 'react-native-vision-camera'; import { useIsScreenFocussed } from './hooks/useIsScreenFocused'; import { CONTENT_SPACING, MAX_ZOOM_FACTOR, SAFE_AREA_PADDING } from './Constants'; import Reanimated, { Extrapolate, interpolate, useAnimatedGestureHandler, useAnimatedProps, useSharedValue } from 'react-native-reanimated'; import { useEffect } from 'react'; import { useIsForeground } from './hooks/useIsForeground'; import { StatusBarBlurBackground } from './views/StatusBarBlurBackground'; import { CaptureButton } from './views/CaptureButton'; import { PressableOpacity } from 'react-native-pressable-opacity'; import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons'; import IonIcon from 'react-native-vector-icons/Ionicons'; const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera); Reanimated.addWhitelistedNativeProps({ zoom: true, }); const SCALE_FULL_ZOOM = 3; const BUTTON_SIZE = 40; export const CameraPage: NavigationFunctionComponent = ({ componentId }) => { const camera = useRef(null); const [isCameraInitialized, setIsCameraInitialized] = useState(false); const zoom = useSharedValue(0); const isPressingButton = useSharedValue(false); // check if camera page is active const isFocussed = useIsScreenFocussed(componentId); const isForeground = useIsForeground(); const isActive = isFocussed && isForeground; const [cameraPosition, setCameraPosition] = useState<'front' | 'back'>('back'); const [enableHdr, setEnableHdr] = useState(false); const [flash, setFlash] = useState<'off' | 'on'>('off'); const [enableNightMode, setEnableNightMode] = useState(false); // camera format settings const devices = useCameraDevices(); const device = devices[cameraPosition]; const formats = useMemo(() => { if (device?.formats == null) return []; return device.formats.sort(sortFormats); }, [device?.formats]); //#region Memos const [is60Fps, setIs60Fps] = useState(true); const fps = useMemo(() => { if (!is60Fps) return 30; if (enableNightMode && !device?.supportsLowLightBoost) { // User has enabled Night Mode, but Night Mode is not natively supported, so we simulate it by lowering the frame rate. return 30; } const supportsHdrAt60Fps = formats.some((f) => f.supportsVideoHDR && f.frameRateRanges.some((r) => frameRateIncluded(r, 60))); if (enableHdr && !supportsHdrAt60Fps) { // User has enabled HDR, but HDR is not supported at 60 FPS. return 30; } const supports60Fps = formats.some((f) => f.frameRateRanges.some((r) => frameRateIncluded(r, 60))); if (!supports60Fps) { // 60 FPS is not supported by any format. return 30; } // If nothing blocks us from using it, we default to 60 FPS. return 60; }, [device?.supportsLowLightBoost, enableHdr, enableNightMode, formats, is60Fps]); const supportsCameraFlipping = useMemo(() => devices.back != null && devices.front != null, [devices.back, devices.front]); const supportsFlash = device?.hasFlash ?? false; const supportsHdr = useMemo(() => formats.some((f) => f.supportsVideoHDR || f.supportsPhotoHDR), [formats]); const supports60Fps = useMemo(() => formats.some((f) => f.frameRateRanges.some((rate) => frameRateIncluded(rate, 60))), [formats]); const canToggleNightMode = enableNightMode ? true // it's enabled so you have to be able to turn it off again : (device?.supportsLowLightBoost ?? false) || fps > 30; // either we have native support, or we can lower the FPS //#endregion const format = useMemo(() => { let result = formats; if (enableHdr) { // We only filter by HDR capable formats if HDR is set to true. // Otherwise we ignore the `supportsVideoHDR` property and accept formats which support HDR `true` or `false` result = result.filter((f) => f.supportsVideoHDR || f.supportsPhotoHDR); } // find the first format that includes the given FPS return result.find((f) => f.frameRateRanges.some((r) => frameRateIncluded(r, fps))); }, [formats, fps, enableHdr]); //#region Animated Zoom const formatMaxZoom = format?.maxZoom ?? 1; const maxZoomFactor = Math.min(formatMaxZoom, MAX_ZOOM_FACTOR); const neutralZoom = device?.neutralZoom ?? 0; const neutralZoomScaled = (neutralZoom / maxZoomFactor) * formatMaxZoom; const maxZoomScaled = (1 / formatMaxZoom) * maxZoomFactor; const cameraAnimatedProps = useAnimatedProps( () => ({ zoom: interpolate(zoom.value, [0, neutralZoomScaled, 1], [0, neutralZoom, maxZoomScaled], Extrapolate.CLAMP), }), [maxZoomScaled, neutralZoom, neutralZoomScaled, zoom], ); //#endregion //#region Callbacks const setIsPressingButton = useCallback( (_isPressingButton: boolean) => { isPressingButton.value = _isPressingButton; }, [isPressingButton], ); // Camera callbacks const onError = useCallback((error: CameraRuntimeError) => { console.error(error); }, []); const onInitialized = useCallback(() => { console.log('Camera initialized!'); setIsCameraInitialized(true); }, []); const onMediaCaptured = useCallback(async (media: PhotoFile | VideoFile, type: 'photo' | 'video') => { console.log(`Media captured! ${JSON.stringify(media)}`); await Navigation.showModal({ component: { name: 'MediaPage', passProps: { type: type, path: media.path, }, }, }); }, []); const onFlipCameraPressed = useCallback(() => { setCameraPosition((p) => (p === 'back' ? 'front' : 'back')); }, []); const onFlashPressed = useCallback(() => { setFlash((f) => (f === 'off' ? 'on' : 'off')); }, []); //#endregion //#region Tap Gesture const onDoubleTap = useCallback(() => { // TODO: (MARC) Allow switching camera (back <-> front) while recording and stich videos together! if (isPressingButton.value) return; onFlipCameraPressed(); }, [isPressingButton, onFlipCameraPressed]); //#endregion //#region Effects useEffect(() => { // Run everytime the neutralZoomScaled value changes. (reset zoom when device changes) zoom.value = neutralZoomScaled; }, [neutralZoomScaled, zoom]); //#endregion //#region Pinch to Zoom Gesture // The gesture handler maps the linear pinch gesture (0 - 1) to an exponential curve since a camera's zoom // function does not appear linear to the user. (aka zoom 0.1 -> 0.2 does not look equal in difference as 0.8 -> 0.9) const onPinchGesture = useAnimatedGestureHandler({ onStart: (_, context) => { context.startZoom = zoom.value; }, onActive: (event, context) => { // we're trying to map the scale gesture to a linear zoom here const startZoom = context.startZoom ?? 0; const scale = interpolate(event.scale, [1 - 1 / SCALE_FULL_ZOOM, 1, SCALE_FULL_ZOOM], [-1, 0, 1], Extrapolate.CLAMP); zoom.value = interpolate(scale, [-1, 0, 1], [0, startZoom, 1], Extrapolate.CLAMP); }, }); //#endregion if (device != null && format != null) { console.log( `Re-rendering camera page with ${isActive ? 'active' : 'inactive'} camera. ` + `Device: "${device.name}" (${format.photoWidth}x${format.photoHeight} @ ${fps}fps)`, ); } else { console.log('re-rendering camera page without active camera'); } // const frameProcessor = useFrameProcessor((frame) => { // 'worklet'; // const codes = scanQRCodesObjC(frame); // _log(`Codes: ${JSON.stringify(codes)}`); // }, []); return ( {device != null && ( )} {supportsCameraFlipping && ( )} {supportsFlash && ( )} {supports60Fps && ( setIs60Fps(!is60Fps)}> {is60Fps ? '60' : '30'} {'\n'}FPS )} {supportsHdr && ( setEnableHdr((h) => !h)}> )} {canToggleNightMode && ( setEnableNightMode(!enableNightMode)} disabledOpacity={0.4}> )} ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: 'black', }, captureButton: { position: 'absolute', alignSelf: 'center', bottom: SAFE_AREA_PADDING.paddingBottom, }, button: { marginBottom: CONTENT_SPACING, width: BUTTON_SIZE, height: BUTTON_SIZE, borderRadius: BUTTON_SIZE / 2, backgroundColor: 'rgba(140, 140, 140, 0.3)', justifyContent: 'center', alignItems: 'center', }, rightButtonRow: { position: 'absolute', right: SAFE_AREA_PADDING.paddingRight, top: SAFE_AREA_PADDING.paddingTop, }, text: { color: 'white', fontSize: 11, fontWeight: 'bold', textAlign: 'center', }, });