import * as React from 'react' import { useRef, useState, useCallback, useMemo } from 'react' import { StyleSheet, Text, View } from 'react-native' import { PinchGestureHandler, PinchGestureHandlerGestureEvent, TapGestureHandler } from 'react-native-gesture-handler' import { CameraRuntimeError, PhotoFile, useCameraDevice, useCameraFormat, useFrameProcessor, VideoFile } from 'react-native-vision-camera' import { Camera } from 'react-native-vision-camera' import { CONTENT_SPACING, MAX_ZOOM_FACTOR, SAFE_AREA_PADDING, SCREEN_HEIGHT, SCREEN_WIDTH } 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 { 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 device = useCameraDevice(cameraPosition, { physicalDevices: ['ultra-wide-angle-camera', 'wide-angle-camera', 'telephoto-camera'], }) const [targetFps, setTargetFps] = useState(60) const screenAspectRatio = SCREEN_HEIGHT / SCREEN_WIDTH const format = useCameraFormat(device, [ { fps: targetFps }, { videoAspectRatio: screenAspectRatio }, { videoResolution: 'max' }, { photoAspectRatio: screenAspectRatio }, { photoResolution: 'max' }, ]) const fps = Math.min(format?.maxFps ?? 1, targetFps) const supportsFlash = device?.hasFlash ?? false const supportsHdr = format?.supportsPhotoHDR const supports60Fps = useMemo(() => device?.formats.some((f) => f.maxFps >= 60), [device?.formats]) const canToggleNightMode = device?.supportsLowLightBoost ?? false //#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 === 'granted')) }, []) //#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} photo / ${format.videoWidth}x${format.videoHeight} video @ ${fps}fps)`, ) } else { console.log('re-rendering camera page without active camera') } const frameProcessor = useFrameProcessor((frame) => { 'worklet' console.log(`${frame.timestamp}: ${frame.width}x${frame.height} ${frame.pixelFormat} Frame (${frame.orientation})`) examplePlugin(frame) }, []) return ( {device != null && ( )} {supportsFlash && ( )} {supports60Fps && ( setTargetFps((t) => (t === 30 ? 60 : 30))}> {`${targetFps}\nFPS`} )} {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', }, })