Skip to content

🐛 Blank video after recording video on some specific android(Xioami/Redmi) #3692

@tdammy92

Description

@tdammy92

What's happening?

Currently users on some certain android device get blank videos after recording a video from the camera using react-native-vision-camera.
the videos was made the log from sentry shows a valid file path and dimensions, but the video is blank

device experincing the crash:
Redmi Note 12 (6gb/128gb)

Reproduceable Code

import {
  Alert,
  Dimensions,
  Image,
  Linking,
  Platform,
  Pressable,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from 'react-native';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {
  Camera,
  CameraCaptureError,
  TakePhotoOptions,
  Templates,
  useCameraDevice,
  useCameraFormat,
  useCameraPermission,
  useMicrophonePermission,
} from 'react-native-vision-camera';
import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native';
import {useAppState} from '@react-native-community/hooks';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {runOnJS} from 'react-native-reanimated';
import {Point} from 'react-native-gesture-handler/lib/typescript/web/interfaces';
import {useSafeAreaInsets} from 'react-native-safe-area-context';

import {getPermission} from '@utils/getPermission';
import AppText from '@components/app-text';
import {logThis} from '@utils/logThis';
import {luupliGreenLightColor} from '@constants/colors';
import {useRunAfterInteraction} from '@hooks/useRunAfterInteraction';
import {openLinkInDefaultApp} from '@utils/device';
import {Button} from '@components/Button';
import CircularProgressWithImage from './components/circularProgressWithImage';
import {Camera as CameraSvgIcon, CloseIcon, Upload, Video} from '@assets/svg';
import {ScreenWrapper} from '@components/ScreenWrapper';
import {
  CameraIcon,
  CameraSwitcherIcon,
  CancelIcon,
  CircledUploadIcon,
  FlashIcon,
} from '@assets/svg/svgComponents';
import {colors} from '@resources/colors';
import {launchImageLibrary} from 'react-native-image-picker';
import {checkFileType, testlogger} from '@utils/helper';
import {useAddPostContext} from './Model/AddPostContextProvider';
import useMusicTrackPlayerForSingleSong from '@hooks/useMusicTrackPlayer';
import TrackPlayer from 'react-native-track-player';
import {SCREEN_HEIGHT} from '@resources/config';
import {luupliMaxVideoDuration} from '@constants/staticData';
import AlertModal from '@components/alert-modal';
import useWeeklyChallengeHandler from '@hooks/useWeeklyChallengeHandler';
import * as Sentry from '@sentry/react-native';

type PostMediaOption = 'photo' | 'video' | 'textpost';
const postMediaOptions: {title: string; type: PostMediaOption}[] = [
  {
    type: 'video',
    title: 'Video',
  },
  {
    type: 'photo',
    title: 'Photo',
  },
  {
    type: 'textpost',
    title: 'Text',
  },
];

const AppCamera = () => {
  const [cameraOption, setCameraOption] =
    useState<Exclude<PostMediaOption, 'textpost'>>('photo');
  const [cameraPosition, setCameraPosition] = useState<'front' | 'back'>(
    'back'
  );
  const [flash, setFlash] = useState<TakePhotoOptions['flash']>('off');
  const [seconds, setSeconds] = useState(0);
  const [isRecordingVideo, setIsRecordingVideo] = useState(false);
  const [showGrantMicPrompt, setShowGrantMicPrompt] = useState(false);
  const {removeWeeklyChallengeDetail} = useWeeklyChallengeHandler();
  const [isCameraReady, setIsCameraReady] = useState(false);

  const cameraRef = useRef<Camera>(null);
  const device = useCameraDevice(cameraPosition);
  const {
    hasPermission: hasCameraPermission,
    requestPermission: requestCameraPermission,
  } = useCameraPermission();
  const {
    hasPermission: hasMicPermission,
    requestPermission: requestMicPermission,
  } = useMicrophonePermission();
  const format = useCameraFormat(device, Templates.Snapchat);

  const navigation = useNavigation();
  const focused = useIsFocused();
  const appState = useAppState();
  const {top, bottom} = useSafeAreaInsets();

  const isActive = useMemo(
    () => focused && appState === 'active',
    [appState, focused]
  );

  const {runTaskAfterInteractions} = useRunAfterInteraction();

  const {setCapturedMediaUri, setCapturedMediaData, setMediaType} =
    useAddPostContext();

  const route = useRoute();
  const {feedItemForAudio = {}, musicInfo: musicInfoForAudioReuse = {}} =
    route.params ?? ({} as unknown as any);

  const {playSong, pauseSong, isPlaying} = useMusicTrackPlayerForSingleSong({
    songUrl: musicInfoForAudioReuse?.url,
    onRepeat: true,
  });

  const videoTimerIntervalId = useRef<NodeJS.Timeout>(null);

  const resetTrackPlayer = async () => {
    await TrackPlayer.reset();
  };

  useEffect(() => {
    const init = async () => {
      try {
        if (!hasCameraPermission) await requestCameraPermission();
        if (!hasMicPermission) await requestMicPermission();
      } catch (err) {
        logThis('error when requesting mic and camera permission', err);
      }
    };
    init();
  }, [hasCameraPermission, hasMicPermission]);

  let micPermStatus = Camera.getMicrophonePermissionStatus();

  useEffect(() => {
    if (micPermStatus === 'denied') {
      setShowGrantMicPrompt(true);
    } else {
      setShowGrantMicPrompt(false);
    }
    // logThis({hasMicPermission, micPermStatus, showGrantMicPrompt});
  }, [micPermStatus]);

  useEffect(() => {
    return () => {
      resetTrackPlayer();
    };
  }, []);

  useEffect(() => {
    if (isRecordingVideo) {
      videoTimerIntervalId.current = setInterval(() => {
        setSeconds(prevSeconds => prevSeconds + 1);
      }, 1000);
    }

    return () => clearInterval(videoTimerIntervalId.current);
  }, [isRecordingVideo]);

  useEffect(() => {
    if (seconds >= luupliMaxVideoDuration) {
      endCaptureVideo();
    }
  }, [seconds]);

  const openAppSettings = () => {
    if (Platform.OS === 'ios') {
      const url = 'app-settings:';
      openLinkInDefaultApp(url);
    } else {
      Linking.openSettings();
    }
  };

  const goToSettingsForPermissions = async () => {
    Alert.alert(
      'Permissions Required',
      'You need to allow camera and microphone permissions to use this feature.',
      [
        {
          text: 'Cancel',
          onPress: () => {
            navigation.goBack();
          },
        },
        {
          text: 'Open Settings',
          onPress: () => {
            openAppSettings();
          },
        },
      ]
    );
  };

  /** Navigate to Edit Media Screen */
  const goToEditMedia = () => {
    navigation.navigate('EditMediaScreen' as unknown as never);
  };

  /** Upload image from gallery */
  const uploadImageFormGallery = async () => {
    try {
      const newFiles = await launchImageLibrary({
        mediaType: device == null ? 'mixed' : cameraOption,
        presentationStyle: 'currentContext',
        quality: 1,
        videoQuality: 'high',
        assetRepresentationMode: 'current',
      });

      const selectedImage = newFiles.assets?.[0];

      let imageUrlPath = selectedImage.uri;
      setCapturedMediaUri(imageUrlPath);
      setCapturedMediaData(selectedImage);

      let dataType = checkFileType(selectedImage.uri);

      setMediaType(dataType);


      goToEditMedia();
    } catch (error) {
      console.log(error);
    }
  };

  const snapPhoto = async () => {
    if (cameraRef.current) {
      try {
        const data = await cameraRef.current.takePhoto({
          flash,
        });

        setCapturedMediaUri(
          Platform.OS === 'ios' ? data.path : `file://${data.path}`
        );
        let dataType = checkFileType(data.path);
        setMediaType(dataType);
        setCapturedMediaData({
          ...data,
          path: Platform.OS === 'ios' ? data.path : `file://${data.path}`,
        });


        goToEditMedia();
      } catch (error) {
        console.error('Failed to capture picture:', error);
      }
    }
  };

  const resetAndClearProgressTimer = () => {
    setSeconds(0);
    clearInterval(videoTimerIntervalId.current);
  };

  const handleVideoError = (err: CameraCaptureError) => {
    switch (err.code) {
      case 'capture/recording-canceled':
        setIsRecordingVideo(false);
        resetAndClearProgressTimer();
        break;
      case 'capture/focus-canceled':
        setIsRecordingVideo(false);
        Sentry.captureMessage(JSON.stringify(err, null, 3), 'error');
        break;
      default:
        break;
    }
  };

  const captureVideo = async () => {
    if (cameraRef.current && isCameraReady) {
      setIsRecordingVideo(true);
      try {
        cameraRef.current.startRecording({
          flash: flash === 'on' ? 'on' : 'off',
          videoCodec: 'h264', // Use H.264 codec for MP4
          fileType: 'mp4', // Explicitly set file type to MP4

          onRecordingFinished: video => {
            Sentry.captureMessage(JSON.stringify(video, null, 3), 'error');

            // const response = {...video, path: `file://${video?.path}`};
            // setCapturedMediaUri(`file://${video?.path}`); // Update the capturedMediaUri state
            setCapturedMediaUri(video?.path); // Update the capturedMediaUri state
            // setCapturedMediaUri(
            //   Platform.OS === 'ios' ? video?.path : `file://${video?.path}`
            // ); // Update the capturedMediaUri state
            // setCapturedMediaData({
            //   ...video,
            //   path:
            //     Platform.OS === 'ios' ? video?.path : `file://${video?.path}`,
            // });
            setCapturedMediaData(video);
            setMediaType('Video');

            setIsRecordingVideo(false);

            // logThis('Video recorded:', video);
          },
          onRecordingError: error => {
            handleVideoError(error);
            logThis('error when recording Video', error);
          },
        });
      } catch (error) {
        console.log('Failed to record video:', error);
      }
    }
  };

  const endCaptureVideo = async () => {
    if (cameraRef.current) {
      try {
        await cameraRef.current.stopRecording();

        resetAndClearProgressTimer();
      } catch (error) {
        logThis('Failed to stop video recording:', error);
      }
    }
    setIsRecordingVideo(false);
    if (isPlaying) {
      pauseSong();
      await TrackPlayer.seekTo(0);
    }

    goToEditMedia();
  };

  /** Focus gesture to focus camera to wherever the user taps in the screen */
  const focus = useCallback((point: Point) => {
    const c = cameraRef.current;
    if (c == null) return;
    c.focus(point);
  }, []);

  const gesture = Gesture.Tap().onEnd(({x, y}) => {
    runOnJS(focus)({x, y});
  });

  const onCancelPress = async () => {
    if (isRecordingVideo) {
      await cameraRef.current.cancelRecording();
    } else {
      removeWeeklyChallengeDetail();
      navigation.goBack();
    }
  };

  if (!hasCameraPermission) {
    return (
      <View
        style={[
          StyleSheet.absoluteFill,
          {
            backgroundColor: luupliGreenLightColor,
            justifyContent: 'center',
            alignItems: 'center',
          },
        ]}
      >
        <AppText align={'center'}>
          Camera App requires Microphone and Camera permission.
        </AppText>

        <Button
          label="Grant Permission"
          marginTop={24}
          labelTextType={'button'}
          onPress={async () => {
            await goToSettingsForPermissions();
          }}
        />
      </View>
    );
  }

  Sentry.captureMessage(
    JSON.stringify(device, (k, v) => (k === 'formats' ? [] : v), 2),
    'error'
  );

  if (device == null) {
    return (
      <View
        style={[
          StyleSheet.absoluteFill,
          {
            backgroundColor: colors.luupli_light_green_100,
            justifyContent: 'center',
            alignItems: 'center',
          },
        ]}
      >
        <View
          style={{
            position: 'absolute',
            top: top + 30,
            left: 30,
          }}
        >
          <TouchableOpacity hitSlop={10} onPress={navigation.goBack}>
            <CancelIcon
              width={20}
              height={20}
              strokeWidth={1}
              strokeColor={colors.luupli_black}
            />
          </TouchableOpacity>
        </View>
        <Image
          source={require('@assets/gifs/errorGif.gif')}
          style={{width: 200, height: 150}}
        />

        <View style={{display: 'flex', flexDirection: 'row'}}>
          <CameraIcon strokeColor={colors.luupli_green_500} />
          <AppText align={'center'}>No camera device Available.</AppText>
        </View>

        <Button
          label="Upload A text"
          marginTop={24}
          onPress={() => {
            navigation.navigate('TextPreviewScreen' as unknown as never);
          }}
        />

        <View
          style={{
            position: 'absolute',
            bottom: 30,
            left: 10,
          }}
        >
          <TouchableOpacity onPress={uploadImageFormGallery}>
            <Upload width={140} height={40} stroke={colors.primary_black} />
          </TouchableOpacity>
          <AppText align={'center'} color={'primary_black'}>
            Upload
          </AppText>
        </View>
      </View>
    );
  }

  return (
    <ScreenWrapper
      edges={['left', 'right']}
      customStyles={{
        paddingTop: top,
        paddingBottom: bottom,
        backgroundColor: colors.luupli_light_green_100,
      }}
    >
      <GestureDetector gesture={gesture}>
        {/* <ScreenWrapper edges={['bottom']}> */}
        <View
          collapsable={false}
          style={{
            flex: 1,
            // backgroundColor: 'black',
            // position: 'relative',
            width: '100%',
            height: '100%',
          }}
        >
          <View style={styles.topContainerStyle}>
            <TouchableOpacity hitSlop={10} onPress={onCancelPress}>
              <CancelIcon
                width={16}
                height={16}
                strokeWidth={1}
                strokeColor={colors.plain_white}
              />
            </TouchableOpacity>

            {device?.hasFlash && (
              <TouchableOpacity
                hitSlop={10}
                onPress={() =>
                  setFlash(prevFlash => (prevFlash !== 'on' ? 'on' : 'off'))
                }
              >
                <FlashIcon
                  fillColor={
                    flash === 'on' || flash === 'auto'
                      ? colors.luupli_light_green_300
                      : colors.plain_white
                  }
                />
              </TouchableOpacity>
            )}

            <TouchableOpacity
              hitSlop={10}
              onPress={() => {
                setCameraPosition(prev => (prev === 'back' ? 'front' : 'back'));
              }}
            >
              <CameraSwitcherIcon />
            </TouchableOpacity>
          </View>

          <Camera
            ref={cameraRef}
            style={[
              StyleSheet.absoluteFill,
              {
                top: 0,
                height: SCREEN_HEIGHT,
              },
            ]}
            device={device}
            isActive={isActive}
            enableZoomGesture
            format={format}
            photoQualityBalance={'quality'}
            photo={cameraOption === 'photo'}
            video={cameraOption === 'video'}
            audio={hasMicPermission && cameraOption === 'video'}
            //enableBufferCompression
            // audio={cameraOption === 'video'}
            onError={error => {
              logThis('Camera error', error);
            }}
            onInitialized={() => {
              logThis('Camera initialized');
              setIsCameraReady(true);
            }}
            onPreviewStarted={() => logThis('Preview started!')}
            onPreviewStopped={() => logThis('Preview stopped!')}
          />

          <View style={styles.bottomContainerStyle}>
            {!isRecordingVideo ? (
              <View style={styles.optionsContainerStyle}>
                {postMediaOptions.map(option => (
                  <TouchableOpacity
                    key={option.type}
                    activeOpacity={0.7}
                    style={styles.optionItemStyle}
                    onPress={() => {
                      if (option.type === 'textpost') {
                        navigation.navigate(
                          'TextPreviewScreen' as unknown as never
                        );
                      } else {
                        setCameraOption(option.type);
                      }
                    }}
                  >
                    <AppText
                      style={{
                        ...styles.bottomTextItemStyle,
                        color:
                          cameraOption === option.type
                            ? colors.luupli_light_green_300
                            : colors.grey_200,
                      }}
                    >
                      {option.title}
                    </AppText>
                  </TouchableOpacity>
                ))}
              </View>
            ) : null}

            <View
              style={[
                styles.mediaSwitcherContainer,
                {
                  justifyContent: isRecordingVideo
                    ? 'flex-end'
                    : 'space-between',
                },
              ]}
            >
              {!isRecordingVideo ? (
                <View style={styles.uploadBtnContainer}>
                  <TouchableOpacity onPress={uploadImageFormGallery}>
                    <CircledUploadIcon />
                  </TouchableOpacity>
                  <AppText style={styles.uploadTextStyle}>Upload</AppText>
                </View>
              ) : null}

              <TouchableOpacity
                style={[
                  styles.shutterIndicatorContainerStyle,
                  isRecordingVideo ? {alignItems: 'center'} : {},
                ]}
                onPress={() => {
                  if (cameraOption === 'photo') {
                    snapPhoto();
                  } else {
                    if (isRecordingVideo) {
                      endCaptureVideo();
                    } else {
                      captureVideo();
                    }
                  }
                }}
              >
                {/* <View
                style={{
                  justifyContent: 'center',
                  alignItems: 'center',
                  width: 80,
                  height: 80,
                  padding: 20,
                }}
              > */}
                {cameraOption === 'photo' ? (
                  <CameraSvgIcon style={styles.photoShutterIndicator} />
                ) : (
                  <CircularProgressWithImage
                    seconds={seconds}
                    maxSeconds={luupliMaxVideoDuration}
                    isRecordingVideo={isRecordingVideo}
                    imageSourceComponent={
                      <Video style={styles.videoShutterIndicator} />
                    }
                  />
                )}
                {/* </View> */}
              </TouchableOpacity>
            </View>
          </View>
        </View>
        {/* </ScreenWrapper> */}
      </GestureDetector>

      {!hasMicPermission && showGrantMicPrompt && (
        <AlertModal
          isVisible={showGrantMicPrompt}
          noTitle="Ignore"
          yesTitle="Open Settings"
          title="Ohh hi!"
          description={`Luups are more fun with sound 🎙️👌🏾 \nAllow microphone access in settings to add sound`}
          onPressYes={goToSettingsForPermissions}
          onPressNo={() => setShowGrantMicPrompt(false)}
        />
      )}
    </ScreenWrapper>
  );
};

export default AppCamera;

const styles = StyleSheet.create({
  topContainerStyle: {
    position: 'absolute',
    width: '100%',
    display: 'flex',
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    zIndex: 2,
    top: 20,
    paddingHorizontal: 20,
  },
  bottomTextItemStyle: {
    color: colors.luupli_white,
    fontFamily: 'Inter',
    fontSize: 17,
  },
  bottomContainerStyle: {
    position: 'absolute',
    width: '100%',
    bottom: 0,
    paddingBottom: 25,
    zIndex: 2,
  },
  optionsContainerStyle: {
    flexDirection: 'row',
    backgroundColor: 'transparent',
    justifyContent: 'center',
    alignItems: 'center',
    gap: 14,
  },
  optionItemStyle: {
    backgroundColor: 'rgba(0,0,0,0.3)',
    justifyContent: 'center',
    alignItems: 'center',
    height: 30,
    paddingHorizontal: 8,
    borderRadius: 10,
    fontFamily: 'Inter',
    fontSize: 17,
  },
  mediaSwitcherContainer: {
    display: 'flex',
    flexDirection: 'row',
    alignItems: 'center',
    gap: 20,
    width: '65%',
    marginHorizontal: 10,
  },
  shutterIndicatorContainerStyle: {
    position: 'relative',
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'flex-end',
    width: 120,
    height: 120,
    padding: 20,
  },
  photoShutterIndicator: {
    position: 'absolute',
    width: 80,
    height: 80,
    borderRadius: 80 / 2,
  },
  videoShutterIndicator: {
    position: 'absolute',
    width: 80,
    height: 80,
    borderRadius: 80 / 2,
  },
  flex2Style: {
    // flex: 2,
    justifyContent: 'center',
    alignItems: 'center',
  },
  uploadBtnContainer: {
    display: 'flex',
    alignItems: 'center',
  },
  uploadTextStyle: {
    color: colors.luupli_white,
    fontFamily: 'Inter',
    fontSize: 15,
    marginTop: 5,
  },
});

Relevant log output

log is not available. This the data after user finished recording, 
 {
   "height": 1080,
   "width": 1920,
   "duration": 4.58,
   "path": "/data/user/0/com.dev.luupli/cache/mrousavy3823717656105929792.mp4"
}

Camera Device

{
  "formats": [],
  "sensorOrientation": "landscape-left",
  "hardwareLevel": "full",
  "maxZoom": 10,
  "minZoom": 1,
  "maxExposure": 24,
  "supportsLowLightBoost": false,
  "neutralZoom": 1,
  "physicalDevices": [
    "wide-angle-camera"
  ],
  "supportsFocus": true,
  "supportsRawCapture": false,
  "isMultiCam": false,
  "minFocusDistance": 0,
  "minExposure": -24,
  "name": "0 (BACK) androidx.camera.camera2",
  "hasFlash": true,
  "hasTorch": true,
  "position": "back",
  "id": "0"
}

Device

Redmi Note 12(Android)

VisionCamera Version

^4.7.2/4.7.2

Can you reproduce this issue in the VisionCamera Example app?

Yes, I can reproduce the same issue in the Example app here

Additional information

Metadata

Metadata

Assignees

No one assigned

    Labels

    🐛 bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions