Last active
August 10, 2022 23:55
-
-
Save stevenbdf/073e483c663940d7693fdc4fdafbd406 to your computer and use it in GitHub Desktop.
CameraPage.js - Uses react-native-vision-camera
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
VideoFile, | |
} from 'react-native-vision-camera'; | |
import { Camera, frameRateIncluded } from 'react-native-vision-camera'; | |
import { CONTENT_SPACING, MAX_ZOOM_FACTOR, SAFE_AREA_PADDING } from '../../components/Common/Camera/Constants'; | |
import Reanimated, { Extrapolate, interpolate, useAnimatedGestureHandler, useAnimatedProps, useSharedValue } from 'react-native-reanimated'; | |
import { useEffect } from 'react'; | |
import { useIsForeground } from '../../hooks/useIsForeground'; | |
import { StatusBarBlurBackground } from '../../components/Common/Camera/StatusBarBlurBackground'; | |
import { CaptureButton } from '../../components/Common/Camera/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 { useIsFocused } from '@react-navigation/core'; | |
const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera); | |
Reanimated.addWhitelistedNativeProps({ | |
zoom: true, | |
}); | |
const SCALE_FULL_ZOOM = 3; | |
const BUTTON_SIZE = 40; | |
export function CameraPage({ navigation }) { | |
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('back'); | |
const [enableHdr, setEnableHdr] = useState(false); | |
const [flash, setFlash] = useState('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) => { | |
isPressingButton.value = _isPressingButton; | |
}, | |
[isPressingButton], | |
); | |
// Camera callbacks | |
const onError = useCallback((error) => { | |
console.error(error); | |
}, []); | |
const onInitialized = useCallback(() => { | |
console.log('Camera initialized!'); | |
setIsCameraInitialized(true); | |
}, []); | |
const onMediaCaptured = useCallback( | |
(media, type) => { | |
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(() => { | |
const initializeCamera = async () => { | |
const cameraPermission = await Camera.getCameraPermissionStatus() | |
const microphonePermission = await Camera.getMicrophonePermissionStatus() | |
console.log({ cameraPermission, microphonePermission }) | |
try { | |
await Camera.requestCameraPermission() | |
await Camera.requestMicrophonePermission() | |
} catch (error) { | |
console.log('Error requesting permissions', error) | |
} | |
} | |
initializeCamera(); | |
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'); | |
} | |
return ( | |
<View style={styles.container}> | |
{device != null && ( | |
<PinchGestureHandler onGestureEvent={onPinchGesture} enabled={isActive}> | |
<Reanimated.View style={StyleSheet.absoluteFill}> | |
<TapGestureHandler onEnded={onDoubleTap} numberOfTaps={2}> | |
<ReanimatedCamera | |
ref={camera} | |
style={StyleSheet.absoluteFill} | |
device={device} | |
format={format} | |
fps={fps} | |
hdr={enableHdr} | |
lowLightBoost={device.supportsLowLightBoost && enableNightMode} | |
isActive={isActive} | |
onInitialized={onInitialized} | |
onError={onError} | |
enableZoomGesture={false} | |
animatedProps={cameraAnimatedProps} | |
photo={true} | |
video={true} | |
audio={hasMicrophonePermission} | |
orientation="portrait" | |
frameProcessorFps={1} | |
/> | |
</TapGestureHandler> | |
</Reanimated.View> | |
</PinchGestureHandler> | |
)} | |
<PressableOpacity style={styles.closeButton} onPress={navigation.goBack}> | |
<IonIcon name="close" size={35} color="white" style={styles.icon} /> | |
</PressableOpacity> | |
<CaptureButton | |
style={styles.captureButton} | |
camera={camera} | |
onMediaCaptured={onMediaCaptured} | |
cameraZoom={zoom} | |
minZoom={minZoom} | |
maxZoom={maxZoom} | |
flash={supportsFlash ? flash : 'off'} | |
enabled={isCameraInitialized && isActive} | |
setIsPressingButton={setIsPressingButton} | |
/> | |
<StatusBarBlurBackground /> | |
<View style={styles.rightButtonRow}> | |
{supportsCameraFlipping && ( | |
<PressableOpacity style={styles.button} onPress={onFlipCameraPressed} disabledOpacity={0.4}> | |
<IonIcon name="camera-reverse" color="white" size={24} /> | |
</PressableOpacity> | |
)} | |
{supportsFlash && ( | |
<PressableOpacity style={styles.button} onPress={onFlashPressed} disabledOpacity={0.4}> | |
<IonIcon name={flash === 'on' ? 'flash' : 'flash-off'} color="white" size={24} /> | |
</PressableOpacity> | |
)} | |
{supports60Fps && ( | |
<PressableOpacity style={styles.button} onPress={() => setIs60Fps(!is60Fps)}> | |
<Text style={styles.text}> | |
{is60Fps ? '60' : '30'} | |
{'\n'}FPS | |
</Text> | |
</PressableOpacity> | |
)} | |
{supportsHdr && ( | |
<PressableOpacity style={styles.button} onPress={() => setEnableHdr((h) => !h)}> | |
<MaterialIcon name={enableHdr ? 'hdr' : 'hdr-off'} color="white" size={24} /> | |
</PressableOpacity> | |
)} | |
{canToggleNightMode && ( | |
<PressableOpacity style={styles.button} onPress={() => setEnableNightMode(!enableNightMode)} disabledOpacity={0.4}> | |
<IonIcon name={enableNightMode ? 'moon' : 'moon-outline'} color="white" size={24} /> | |
</PressableOpacity> | |
)} | |
</View> | |
</View> | |
); | |
} | |
const styles = StyleSheet.create({ | |
container: { | |
flex: 1, | |
backgroundColor: 'black', | |
}, | |
closeButton: { | |
position: 'absolute', | |
top: SAFE_AREA_PADDING.paddingTop, | |
left: SAFE_AREA_PADDING.paddingLeft, | |
width: 40, | |
height: 40, | |
}, | |
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', | |
}, | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Dimensions, Platform } from 'react-native'; | |
import StaticSafeAreaInsets from 'react-native-static-safe-area-insets'; | |
export const CONTENT_SPACING = 15; | |
const SAFE_BOTTOM = | |
Platform.select({ | |
ios: StaticSafeAreaInsets.safeAreaInsetsBottom, | |
}) ?? 0; | |
export const SAFE_AREA_PADDING = { | |
paddingLeft: StaticSafeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING, | |
paddingTop: StaticSafeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING, | |
paddingRight: StaticSafeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING, | |
paddingBottom: SAFE_BOTTOM + CONTENT_SPACING, | |
}; | |
// The maximum zoom _factor_ you should be able to zoom in | |
export const MAX_ZOOM_FACTOR = 20; | |
export const SCREEN_WIDTH = Dimensions.get('window').width; | |
export const SCREEN_HEIGHT = Platform.select({ | |
android: Dimensions.get('screen').height - StaticSafeAreaInsets.safeAreaInsetsBottom, | |
ios: Dimensions.get('window').height, | |
}); | |
// Capture Button | |
export const CAPTURE_BUTTON_SIZE = 78; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useCallback, useMemo, useState } from 'react'; | |
import { StyleSheet, View, Image, ActivityIndicator, PermissionsAndroid, Platform } from 'react-native'; | |
import Video, { LoadError, OnLoadData } from 'react-native-video'; | |
import { SAFE_AREA_PADDING } from '../../components/Common/Camera/Constants'; | |
import { useIsForeground } from '../../hooks/useIsForeground'; | |
import { PressableOpacity } from 'react-native-pressable-opacity'; | |
import IonIcon from 'react-native-vector-icons/Ionicons'; | |
import { Alert } from 'react-native'; | |
import CameraRoll from '@react-native-community/cameraroll'; | |
import { StatusBarBlurBackground } from '../../components/Common/Camera/StatusBarBlurBackground'; | |
import { useIsFocused } from '@react-navigation/core'; | |
const requestSavePermission = async () => { | |
if (Platform.OS !== 'android') return true; | |
const permission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE; | |
if (permission == null) return false; | |
let hasPermission = await PermissionsAndroid.check(permission); | |
if (!hasPermission) { | |
const permissionRequestResult = await PermissionsAndroid.request(permission); | |
hasPermission = permissionRequestResult === 'granted'; | |
} | |
return hasPermission; | |
}; | |
const isVideoOnLoadEvent = (event) => | |
'duration' in event && 'naturalSize' in event; | |
export function MediaPage({ navigation, route }) { | |
const { path, type } = route.params; | |
const [hasMediaLoaded, setHasMediaLoaded] = useState(false); | |
const isForeground = useIsForeground(); | |
const isScreenFocused = useIsFocused(); | |
const isVideoPaused = !isForeground || !isScreenFocused; | |
const [savingState, setSavingState] = useState('none'); | |
const onMediaLoad = useCallback((event) => { | |
if (isVideoOnLoadEvent(event)) { | |
console.log( | |
`Video loaded. Size: ${event.naturalSize.width}x${event.naturalSize.height} (${event.naturalSize.orientation}, ${event.duration} seconds)`, | |
); | |
} else { | |
console.log(`Image loaded. Size: ${event.nativeEvent.source.width}x${event.nativeEvent.source.height}`); | |
} | |
}, []); | |
const onMediaLoadEnd = useCallback(() => { | |
console.log('media has loaded.'); | |
setHasMediaLoaded(true); | |
}, []); | |
const onMediaLoadError = useCallback((error) => { | |
console.log(`failed to load media: ${JSON.stringify(error)}`); | |
}, []); | |
const onSavePressed = useCallback(async () => { | |
try { | |
setSavingState('saving'); | |
const hasPermission = await requestSavePermission(); | |
if (!hasPermission) { | |
Alert.alert('Permission denied!', 'Vision Camera does not have permission to save the media to your camera roll.'); | |
return; | |
} | |
await CameraRoll.save(`file://${path}`, { | |
type: type, | |
}); | |
setSavingState('saved'); | |
} catch (e) { | |
const message = e instanceof Error ? e.message : JSON.stringify(e); | |
setSavingState('none'); | |
Alert.alert('Failed to save!', `An unexpected error occured while trying to save your ${type}. ${message}`); | |
} | |
}, [path, type]); | |
const source = useMemo(() => ({ uri: `file://${path}` }), [path]); | |
const screenStyle = useMemo(() => ({ opacity: hasMediaLoaded ? 1 : 0 }), [hasMediaLoaded]); | |
return ( | |
<View style={[styles.container, screenStyle]}> | |
{type === 'photo' && ( | |
<Image source={source} style={StyleSheet.absoluteFill} resizeMode="cover" onLoadEnd={onMediaLoadEnd} onLoad={onMediaLoad} /> | |
)} | |
{type === 'video' && ( | |
<Video | |
source={source} | |
style={StyleSheet.absoluteFill} | |
paused={isVideoPaused} | |
resizeMode="cover" | |
posterResizeMode="cover" | |
allowsExternalPlayback={false} | |
automaticallyWaitsToMinimizeStalling={false} | |
disableFocus={true} | |
repeat={true} | |
useTextureView={false} | |
controls={false} | |
playWhenInactive={true} | |
ignoreSilentSwitch="ignore" | |
onReadyForDisplay={onMediaLoadEnd} | |
onLoad={onMediaLoad} | |
onError={onMediaLoadError} | |
/> | |
)} | |
<PressableOpacity style={styles.closeButton} onPress={navigation.goBack}> | |
<IonIcon name="close" size={35} color="white" style={styles.icon} /> | |
</PressableOpacity> | |
<PressableOpacity style={styles.saveButton} onPress={onSavePressed} disabled={savingState !== 'none'}> | |
{savingState === 'none' && <IonIcon name="download" size={35} color="white" style={styles.icon} />} | |
{savingState === 'saved' && <IonIcon name="checkmark" size={35} color="white" style={styles.icon} />} | |
{savingState === 'saving' && <ActivityIndicator color="white" />} | |
</PressableOpacity> | |
<StatusBarBlurBackground /> | |
</View> | |
); | |
} | |
const styles = StyleSheet.create({ | |
container: { | |
flex: 1, | |
alignItems: 'center', | |
justifyContent: 'center', | |
backgroundColor: 'white', | |
}, | |
closeButton: { | |
position: 'absolute', | |
top: SAFE_AREA_PADDING.paddingTop, | |
left: SAFE_AREA_PADDING.paddingLeft, | |
width: 40, | |
height: 40, | |
}, | |
saveButton: { | |
position: 'absolute', | |
bottom: SAFE_AREA_PADDING.paddingBottom, | |
left: SAFE_AREA_PADDING.paddingLeft, | |
width: 40, | |
height: 40, | |
}, | |
icon: { | |
textShadowColor: 'black', | |
textShadowOffset: { | |
height: 0, | |
width: 0, | |
}, | |
textShadowRadius: 1, | |
}, | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { BlurView } from '@react-native-community/blur'; | |
import React from 'react'; | |
import { Platform, StyleSheet } from 'react-native'; | |
import StaticSafeAreaInsets from 'react-native-static-safe-area-insets'; | |
const FALLBACK_COLOR = 'rgba(140, 140, 140, 0.3)'; | |
const StatusBarBlurBackgroundImpl = ({ style, ...props }) => { | |
if (Platform.OS !== 'ios') return null; | |
return ( | |
<BlurView | |
style={[styles.statusBarBackground, style]} | |
blurAmount={25} | |
blurType="light" | |
reducedTransparencyFallbackColor={FALLBACK_COLOR} | |
{...props} | |
/> | |
); | |
}; | |
export const StatusBarBlurBackground = React.memo(StatusBarBlurBackgroundImpl); | |
const styles = StyleSheet.create({ | |
statusBarBackground: { | |
position: 'absolute', | |
top: 0, | |
left: 0, | |
right: 0, | |
height: StaticSafeAreaInsets.safeAreaInsetsTop, | |
}, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment