350 lines
11 KiB
TypeScript
Raw Normal View History

2021-02-19 19:06:28 +01:00
import React, { useCallback, useMemo, useRef } from "react";
import { Platform, 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,
2021-02-19 19:54:30 +01:00
withRepeat,
2021-02-19 19:06:28 +01:00
} from "react-native-reanimated";
import type { Camera, PhotoFile, TakePhotoOptions, TakeSnapshotOptions, VideoFile } from "react-native-vision-camera";
import { CAPTURE_BUTTON_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH, USE_SNAPSHOT_ON_ANDROID } from "./../Constants";
const PAN_GESTURE_HANDLER_FAIL_X = [-SCREEN_WIDTH, SCREEN_WIDTH];
const PAN_GESTURE_HANDLER_ACTIVE_Y = [-2, 2];
2021-02-19 19:51:59 +01:00
const IS_ANDROID = Platform.OS === "android";
2021-02-19 19:06:28 +01:00
const START_RECORDING_DELAY = 200;
2021-02-19 19:51:59 +01:00
const BORDER_WIDTH = CAPTURE_BUTTON_SIZE * 0.1;
2021-02-19 19:06:28 +01:00
interface Props extends ViewProps {
camera: React.RefObject<Camera>;
onMediaCaptured: (
media: PhotoFile | VideoFile,
type: "photo" | "video"
) => void;
cameraZoom: Reanimated.SharedValue<number>;
flash: "off" | "on" | "auto";
enabled: boolean;
setIsPressingButton: (isPressingButton: boolean) => void;
}
const _CaptureButton: React.FC<Props> = ({
camera,
onMediaCaptured,
cameraZoom,
flash,
enabled,
setIsPressingButton,
style,
...props
}): React.ReactElement => {
const pressDownDate = useRef<Date | undefined>(undefined);
const isRecording = useRef(false);
const recordingProgress = useSharedValue(0);
const takePhotoOptions = useMemo<TakePhotoOptions & TakeSnapshotOptions>(
() => ({
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!");
// If we're on Android and flash is disabled, we can use the "snapshot" method.
// this will take a snapshot of the current SurfaceView, which results in faster image
// capture rate at the cost of greatly reduced quality.
const photoMethod =
USE_SNAPSHOT_ON_ANDROID &&
2021-02-19 19:51:59 +01:00
IS_ANDROID &&
takePhotoOptions.flash === "off"
2021-02-19 19:06:28 +01:00
? "snapshot"
: "photo";
console.log(`Taking ${photoMethod}...`);
const photo =
photoMethod === "snapshot"
? await camera.current.takeSnapshot(takePhotoOptions)
: 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,
recordingProgress,
stopRecording,
]);
//#endregion
//#region Tap handler
const tapHandler = useRef<TapGestureHandler>();
const onHandlerStateChanged = useCallback(
async ({ nativeEvent: event }: TapGestureHandlerStateChangeEvent) => {
// 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<PanGestureHandler>();
const onPanGestureEvent = useAnimatedGestureHandler<
PanGestureHandlerGestureEvent,
{ offsetY?: number; startY?: number }
>({
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(
Math.sqrt(cameraZoom.value),
[0, 1],
[0, offsetYForFullZoom],
Extrapolate.CLAMP
);
},
onActive: (event, context) => {
const offset = context.offsetY ?? 0;
const startY = context.startY ?? SCREEN_HEIGHT;
const yForFullZoom = startY * 0.7;
const zoom = interpolate(
event.absoluteY - offset,
[yForFullZoom, startY],
[1, 0],
Extrapolate.CLAMP
);
cameraZoom.value = zoom ** 2;
},
});
//#endregion
const shadowStyle = useAnimatedStyle(
() => ({
transform: [
{
2021-02-19 19:51:59 +01:00
scale: withSpring(isPressingButton.value ? 1 : 0, {
mass: 1,
2021-02-19 19:06:28 +01:00
damping: 35,
stiffness: 300,
}),
},
],
}),
[isPressingButton]
);
const buttonStyle = useAnimatedStyle(
2021-02-19 19:51:59 +01:00
() => {
2021-02-19 19:54:30 +01:00
let scale: number;
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,
});
}
2021-02-19 19:51:59 +01:00
return ({
opacity: withTiming(enabled ? 1 : 0.3, {
duration: 100,
easing: Easing.linear,
}),
transform: [
{
2021-02-19 19:54:30 +01:00
scale: scale
2021-02-19 19:51:59 +01:00
},
],
})
},
2021-02-19 19:06:28 +01:00
[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,
2021-02-19 19:51:59 +01:00
backgroundColor: "#e34077",
2021-02-19 19:06:28 +01:00
},
button: {
width: CAPTURE_BUTTON_SIZE,
height: CAPTURE_BUTTON_SIZE,
borderRadius: CAPTURE_BUTTON_SIZE / 2,
2021-02-19 19:51:59 +01:00
borderWidth: BORDER_WIDTH,
2021-02-19 19:06:28 +01:00
borderColor: "white",
},
});