update a whole lotta stuff
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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,
|
||||
} 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];
|
||||
|
||||
const IS_ANDROID = Platform.OS === "android"
|
||||
|
||||
const START_RECORDING_DELAY = 200;
|
||||
|
||||
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 &&
|
||||
IS_ANDROID &&
|
||||
takePhotoOptions.flash === "off"
|
||||
? "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: [
|
||||
{
|
||||
scale: withSpring(isPressingButton.value ? 1.1 : 1, {
|
||||
mass: 0.5,
|
||||
damping: 35,
|
||||
stiffness: 300,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
[isPressingButton]
|
||||
);
|
||||
const buttonStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
opacity: withTiming(enabled ? 1 : 0.3, {
|
||||
duration: 100,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
transform: [
|
||||
{
|
||||
scale: withSpring(
|
||||
enabled ? (isPressingButton.value ? 1 : 0.9) : 0.6,
|
||||
{
|
||||
stiffness: 500,
|
||||
damping: 300,
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
[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,
|
||||
borderWidth: 3,
|
||||
borderColor: "rgba(225, 48, 108, 0.7)",
|
||||
},
|
||||
button: {
|
||||
width: CAPTURE_BUTTON_SIZE,
|
||||
height: CAPTURE_BUTTON_SIZE,
|
||||
borderRadius: CAPTURE_BUTTON_SIZE / 2,
|
||||
borderWidth: CAPTURE_BUTTON_SIZE * 0.1,
|
||||
borderColor: "white",
|
||||
},
|
||||
});
|
||||
|
Reference in New Issue
Block a user