Skip to content

Instantly share code, notes, and snippets.

@stevenbdf
Last active August 10, 2022 23:55
Show Gist options
  • Save stevenbdf/073e483c663940d7693fdc4fdafbd406 to your computer and use it in GitHub Desktop.
Save stevenbdf/073e483c663940d7693fdc4fdafbd406 to your computer and use it in GitHub Desktop.
CameraPage.js - Uses react-native-vision-camera
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',
},
});
import React, { useCallback, useMemo, useRef } from 'react';
import { StyleSheet, View, ViewProps } from 'react-native';
import {
PanGestureHandler,
PanGestureHandlerGestureEvent,
State,
TapGestureHandler,
TapGestureHandlerStateChangeEvent,
} from 'react-native-gesture-handler';
import Reanimated, {
cancelAnimation,
Easing,
Extrapolate,
interpolate,
useAnimatedStyle,
withSpring,
withTiming,
useAnimatedGestureHandler,
useSharedValue,
withRepeat,
} from 'react-native-reanimated';
import { CAPTURE_BUTTON_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH } from './Constants';
const PAN_GESTURE_HANDLER_FAIL_X = [-SCREEN_WIDTH, SCREEN_WIDTH];
const PAN_GESTURE_HANDLER_ACTIVE_Y = [-2, 2];
const START_RECORDING_DELAY = 200;
const BORDER_WIDTH = CAPTURE_BUTTON_SIZE * 0.1;
const _CaptureButton = ({
camera,
onMediaCaptured,
minZoom,
maxZoom,
cameraZoom,
flash,
enabled,
setIsPressingButton,
style,
...props
}) => {
const pressDownDate = useRef(undefined);
const isRecording = useRef(false);
const recordingProgress = useSharedValue(0);
const takePhotoOptions = useMemo(
() => ({
photoCodec: 'jpeg',
qualityPrioritization: 'speed',
flash: flash,
quality: 90,
skipMetadata: true,
}),
[flash],
);
const isPressingButton = useSharedValue(false);
//#region Camera Capture
const takePhoto = useCallback(async () => {
try {
if (camera.current == null) throw new Error('Camera ref is null!');
console.log('Taking photo...');
const photo = await camera.current.takePhoto(takePhotoOptions);
onMediaCaptured(photo, 'photo');
} catch (e) {
console.error('Failed to take photo!', e);
}
}, [camera, onMediaCaptured, takePhotoOptions]);
const onStoppedRecording = useCallback(() => {
isRecording.current = false;
cancelAnimation(recordingProgress);
console.log('stopped recording video!');
}, [recordingProgress]);
const stopRecording = useCallback(async () => {
try {
if (camera.current == null) throw new Error('Camera ref is null!');
console.log('calling stopRecording()...');
await camera.current.stopRecording();
console.log('called stopRecording()!');
} catch (e) {
console.error('failed to stop recording!', e);
}
}, [camera]);
const startRecording = useCallback(() => {
try {
if (camera.current == null) throw new Error('Camera ref is null!');
console.log('calling startRecording()...');
camera.current.startRecording({
flash: flash,
onRecordingError: (error) => {
console.error('Recording failed!', error);
onStoppedRecording();
},
onRecordingFinished: (video) => {
console.log(`Recording successfully finished! ${video.path}`);
onMediaCaptured(video, 'video');
onStoppedRecording();
},
});
// TODO: wait until startRecording returns to actually find out if the recording has successfully started
console.log('called startRecording()!');
isRecording.current = true;
} catch (e) {
console.error('failed to start recording!', e, 'camera');
}
}, [camera, flash, onMediaCaptured, onStoppedRecording]);
//#endregion
//#region Tap handler
const tapHandler = useRef();
const onHandlerStateChanged = useCallback(
async ({ nativeEvent: event }) => {
// This is the gesture handler for the circular "shutter" button.
// Once the finger touches the button (State.BEGAN), a photo is being taken and "capture mode" is entered. (disabled tab bar)
// Also, we set `pressDownDate` to the time of the press down event, and start a 200ms timeout. If the `pressDownDate` hasn't changed
// after the 200ms, the user is still holding down the "shutter" button. In that case, we start recording.
//
// Once the finger releases the button (State.END/FAILED/CANCELLED), we leave "capture mode" (enable tab bar) and check the `pressDownDate`,
// if `pressDownDate` was less than 200ms ago, we know that the intention of the user is to take a photo. We check the `takePhotoPromise` if
// there already is an ongoing (or already resolved) takePhoto() call (remember that we called takePhoto() when the user pressed down), and
// if yes, use that. If no, we just try calling takePhoto() again
console.debug(`state: ${Object.keys(State)[event.state]}`);
switch (event.state) {
case State.BEGAN: {
// enter "recording mode"
recordingProgress.value = 0;
isPressingButton.value = true;
const now = new Date();
pressDownDate.current = now;
setTimeout(() => {
if (pressDownDate.current === now) {
// user is still pressing down after 200ms, so his intention is to create a video
startRecording();
}
}, START_RECORDING_DELAY);
setIsPressingButton(true);
return;
}
case State.END:
case State.FAILED:
case State.CANCELLED: {
// exit "recording mode"
try {
if (pressDownDate.current == null) throw new Error('PressDownDate ref .current was null!');
const now = new Date();
const diff = now.getTime() - pressDownDate.current.getTime();
pressDownDate.current = undefined;
if (diff < START_RECORDING_DELAY) {
// user has released the button within 200ms, so his intention is to take a single picture.
await takePhoto();
} else {
// user has held the button for more than 200ms, so he has been recording this entire time.
await stopRecording();
}
} finally {
setTimeout(() => {
isPressingButton.value = false;
setIsPressingButton(false);
}, 500);
}
return;
}
default:
break;
}
},
[isPressingButton, recordingProgress, setIsPressingButton, startRecording, stopRecording, takePhoto],
);
//#endregion
//#region Pan handler
const panHandler = useRef();
const onPanGestureEvent = useAnimatedGestureHandler({
onStart: (event, context) => {
context.startY = event.absoluteY;
const yForFullZoom = context.startY * 0.7;
const offsetYForFullZoom = context.startY - yForFullZoom;
// extrapolate [0 ... 1] zoom -> [0 ... Y_FOR_FULL_ZOOM] finger position
context.offsetY = interpolate(cameraZoom.value, [minZoom, maxZoom], [0, offsetYForFullZoom], Extrapolate.CLAMP);
},
onActive: (event, context) => {
const offset = context.offsetY ?? 0;
const startY = context.startY ?? SCREEN_HEIGHT;
const yForFullZoom = startY * 0.7;
cameraZoom.value = interpolate(event.absoluteY - offset, [yForFullZoom, startY], [maxZoom, minZoom], Extrapolate.CLAMP);
},
});
//#endregion
const shadowStyle = useAnimatedStyle(
() => ({
transform: [
{
scale: withSpring(isPressingButton.value ? 1 : 0, {
mass: 1,
damping: 35,
stiffness: 300,
}),
},
],
}),
[isPressingButton],
);
const buttonStyle = useAnimatedStyle(() => {
let scale;
if (enabled) {
if (isPressingButton.value) {
scale = withRepeat(
withSpring(1, {
stiffness: 100,
damping: 1000,
}),
-1,
true,
);
} else {
scale = withSpring(0.9, {
stiffness: 500,
damping: 300,
});
}
} else {
scale = withSpring(0.6, {
stiffness: 500,
damping: 300,
});
}
return {
opacity: withTiming(enabled ? 1 : 0.3, {
duration: 100,
easing: Easing.linear,
}),
transform: [
{
scale: scale,
},
],
};
}, [enabled, isPressingButton]);
return (
<TapGestureHandler
enabled={enabled}
ref={tapHandler}
onHandlerStateChange={onHandlerStateChanged}
shouldCancelWhenOutside={false}
maxDurationMs={99999999} // <-- this prevents the TapGestureHandler from going to State.FAILED when the user moves his finger outside of the child view (to zoom)
simultaneousHandlers={panHandler}>
<Reanimated.View {...props} style={[buttonStyle, style]}>
<PanGestureHandler
enabled={enabled}
ref={panHandler}
failOffsetX={PAN_GESTURE_HANDLER_FAIL_X}
activeOffsetY={PAN_GESTURE_HANDLER_ACTIVE_Y}
onGestureEvent={onPanGestureEvent}
simultaneousHandlers={tapHandler}>
<Reanimated.View style={styles.flex}>
<Reanimated.View style={[styles.shadow, shadowStyle]} />
<View style={styles.button} />
</Reanimated.View>
</PanGestureHandler>
</Reanimated.View>
</TapGestureHandler>
);
};
export const CaptureButton = React.memo(_CaptureButton);
const styles = StyleSheet.create({
flex: {
flex: 1,
},
shadow: {
position: 'absolute',
width: CAPTURE_BUTTON_SIZE,
height: CAPTURE_BUTTON_SIZE,
borderRadius: CAPTURE_BUTTON_SIZE / 2,
backgroundColor: '#e34077',
},
button: {
width: CAPTURE_BUTTON_SIZE,
height: CAPTURE_BUTTON_SIZE,
borderRadius: CAPTURE_BUTTON_SIZE / 2,
borderWidth: BORDER_WIDTH,
borderColor: 'white',
},
});
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;
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,
},
});
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