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 { CameraDeviceFormat, CameraRuntimeError, PhotoFile, sortFormats, useCameraDevices, useFrameProcessor, VideoFile, } from 'react-native-vision-camera'; import { Camera, frameRateIncluded } from 'react-native-vision-camera'; 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'; import type { Routes } from './Routes'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { useIsFocused } from '@react-navigation/core'; import { Skia } from '@shopify/react-native-skia'; import { FACE_SHADER } from './Shaders'; import { examplePlugin } from './frame-processors/ExamplePlugin'; const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera); Reanimated.addWhitelistedNativeProps({ zoom: true, }); const SCALE_FULL_ZOOM = 3; const BUTTON_SIZE = 40; type Props = NativeStackScreenProps; export function CameraPage({ navigation }: Props): React.ReactElement { const camera = useRef(null); const [isCameraInitialized, setIsCameraInitialized] = useState(false); const [hasMicrophonePermission, setHasMicrophonePermission] = useState(false); const zoom = useSharedValue(0); const isPressingButton = useSharedValue(false); // check if camera page is active const isFocussed = useIsFocused(); 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 // This just maps the zoom factor to a percentage value. // so e.g. for [min, neutr., max] values [1, 2, 128] this would result in [0, 0.0081, 1] const minZoom = device?.minZoom ?? 1; const maxZoom = Math.min(device?.maxZoom ?? 1, MAX_ZOOM_FACTOR); const cameraAnimatedProps = useAnimatedProps(() => { const z = Math.max(Math.min(zoom.value, maxZoom), minZoom); return { zoom: z, }; }, [maxZoom, minZoom, 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( (media: PhotoFile | VideoFile, type: 'photo' | 'video') => { console.log(`Media captured! ${JSON.stringify(media)}`); navigation.navigate('MediaPage', { path: media.path, type: type, }); }, [navigation], ); 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(() => { onFlipCameraPressed(); }, [onFlipCameraPressed]); //#endregion //#region Effects const neutralZoom = device?.neutralZoom ?? 1; useEffect(() => { // Run everytime the neutralZoomScaled value changes. (reset zoom when device changes) zoom.value = neutralZoom; }, [neutralZoom, zoom]); useEffect(() => { Camera.getMicrophonePermissionStatus().then((status) => setHasMicrophonePermission(status === 'authorized')); }, []); //#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], [minZoom, startZoom, maxZoom], 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 radius = (format?.videoHeight ?? 1080) * 0.1; const width = radius; const height = radius; const x = (format?.videoHeight ?? 1080) / 2 - radius / 2; const y = (format?.videoWidth ?? 1920) / 2 - radius / 2; const centerX = x + width / 2; const centerY = y + height / 2; const runtimeEffect = Skia.RuntimeEffect.Make(FACE_SHADER); if (runtimeEffect == null) throw new Error('Shader failed to compile!'); const shaderBuilder = Skia.RuntimeShaderBuilder(runtimeEffect); shaderBuilder.setUniform('r', [width]); shaderBuilder.setUniform('x', [centerX]); shaderBuilder.setUniform('y', [centerY]); shaderBuilder.setUniform('resolution', [1920, 1080]); const imageFilter = Skia.ImageFilter.MakeRuntimeShader(shaderBuilder, null, null); const paint = Skia.Paint(); paint.setImageFilter(imageFilter); const frameProcessor = useFrameProcessor((frame) => { 'worklet'; console.log(`Width: ${frame.width}`); const result = examplePlugin(frame); console.log('Example Plugin: ', result); }, []); 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', }, });