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; const BORDER_WIDTH = CAPTURE_BUTTON_SIZE * 0.1; interface Props extends ViewProps { camera: React.RefObject; onMediaCaptured: ( media: PhotoFile | VideoFile, type: "photo" | "video" ) => void; cameraZoom: Reanimated.SharedValue; flash: "off" | "on" | "auto"; enabled: boolean; setIsPressingButton: (isPressingButton: boolean) => void; } const _CaptureButton: React.FC = ({ camera, onMediaCaptured, cameraZoom, flash, enabled, setIsPressingButton, style, ...props }): React.ReactElement => { 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!"); // 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(); 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(); 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 : 0, { mass: 1, damping: 35, stiffness: 300, }), }, ], }), [isPressingButton] ); const buttonStyle = useAnimatedStyle( () => { return ({ 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 ( ); }; 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", }, });