350 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			350 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
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,
 | 
						|
  withRepeat,
 | 
						|
} 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<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 : 0, {
 | 
						|
            mass: 1,
 | 
						|
            damping: 35,
 | 
						|
            stiffness: 300,
 | 
						|
          }),
 | 
						|
        },
 | 
						|
      ],
 | 
						|
    }),
 | 
						|
    [isPressingButton]
 | 
						|
  );
 | 
						|
  const buttonStyle = useAnimatedStyle(
 | 
						|
    () => {
 | 
						|
      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,
 | 
						|
        });
 | 
						|
      }
 | 
						|
 | 
						|
      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",
 | 
						|
  },
 | 
						|
});
 |