chore: Move everything into package/
(#1745)
* Move everything into package * Remove .DS_Store * Move scripts and eslintrc to package * Create CODE_OF_CONDUCT.md * fix some links * Update all links (I think) * Update generated docs * Update notice-yarn-changes.yml * Update validate-android.yml * Update validate-cpp.yml * Delete notice-yarn-changes.yml * Update validate-cpp.yml * Update validate-cpp.yml * Update validate-js.yml * Update validate-cpp.yml * Update validate-cpp.yml * wrong c++ style * Revert "wrong c++ style" This reverts commit 55a3575589c6f13f8b05134d83384f55e0601ab2.
This commit is contained in:
54
package/example/src/App.tsx
Normal file
54
package/example/src/App.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { PermissionsPage } from './PermissionsPage';
|
||||
import { MediaPage } from './MediaPage';
|
||||
import { CameraPage } from './CameraPage';
|
||||
import type { Routes } from './Routes';
|
||||
import { Camera, CameraPermissionStatus } from 'react-native-vision-camera';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
|
||||
const Stack = createNativeStackNavigator<Routes>();
|
||||
|
||||
export function App(): React.ReactElement | null {
|
||||
const [cameraPermission, setCameraPermission] = useState<CameraPermissionStatus>();
|
||||
const [microphonePermission, setMicrophonePermission] = useState<CameraPermissionStatus>();
|
||||
|
||||
useEffect(() => {
|
||||
Camera.getCameraPermissionStatus().then(setCameraPermission);
|
||||
Camera.getMicrophonePermissionStatus().then(setMicrophonePermission);
|
||||
}, []);
|
||||
|
||||
console.log(`Re-rendering Navigator. Camera: ${cameraPermission} | Microphone: ${microphonePermission}`);
|
||||
|
||||
if (cameraPermission == null || microphonePermission == null) {
|
||||
// still loading
|
||||
return null;
|
||||
}
|
||||
|
||||
const showPermissionsPage = cameraPermission !== 'granted' || microphonePermission === 'not-determined';
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
statusBarStyle: 'dark',
|
||||
animationTypeForReplace: 'push',
|
||||
}}
|
||||
initialRouteName={showPermissionsPage ? 'PermissionsPage' : 'CameraPage'}>
|
||||
<Stack.Screen name="PermissionsPage" component={PermissionsPage} />
|
||||
<Stack.Screen name="CameraPage" component={CameraPage} />
|
||||
<Stack.Screen
|
||||
name="MediaPage"
|
||||
component={MediaPage}
|
||||
options={{
|
||||
animation: 'none',
|
||||
presentation: 'transparentModal',
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</GestureHandlerRootView>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
315
package/example/src/CameraPage.tsx
Normal file
315
package/example/src/CameraPage.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import * as React from 'react';
|
||||
import { useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { PinchGestureHandler, PinchGestureHandlerGestureEvent, TapGestureHandler } from 'react-native-gesture-handler';
|
||||
import {
|
||||
CameraDeviceFormat,
|
||||
CameraRuntimeError,
|
||||
PhotoFile,
|
||||
sortFormats,
|
||||
useCameraDevices,
|
||||
useFrameProcessor,
|
||||
VideoFile,
|
||||
} from 'react-native-vision-camera';
|
||||
import { Camera } from 'react-native-vision-camera';
|
||||
import { CONTENT_SPACING, MAX_ZOOM_FACTOR, SAFE_AREA_PADDING } from './Constants';
|
||||
import Reanimated, { Extrapolate, interpolate, useAnimatedGestureHandler, useAnimatedProps, useSharedValue } from 'react-native-reanimated';
|
||||
import { useEffect } from 'react';
|
||||
import { useIsForeground } from './hooks/useIsForeground';
|
||||
import { StatusBarBlurBackground } from './views/StatusBarBlurBackground';
|
||||
import { CaptureButton } from './views/CaptureButton';
|
||||
import { PressableOpacity } from 'react-native-pressable-opacity';
|
||||
import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import IonIcon from 'react-native-vector-icons/Ionicons';
|
||||
import type { Routes } from './Routes';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { useIsFocused } from '@react-navigation/core';
|
||||
import { examplePlugin } from './frame-processors/ExamplePlugin';
|
||||
|
||||
const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera);
|
||||
Reanimated.addWhitelistedNativeProps({
|
||||
zoom: true,
|
||||
});
|
||||
|
||||
const SCALE_FULL_ZOOM = 3;
|
||||
const BUTTON_SIZE = 40;
|
||||
|
||||
type Props = NativeStackScreenProps<Routes, 'CameraPage'>;
|
||||
export function CameraPage({ navigation }: Props): React.ReactElement {
|
||||
const camera = useRef<Camera>(null);
|
||||
const [isCameraInitialized, setIsCameraInitialized] = useState(false);
|
||||
const [hasMicrophonePermission, setHasMicrophonePermission] = useState(false);
|
||||
const zoom = useSharedValue(0);
|
||||
const isPressingButton = useSharedValue(false);
|
||||
|
||||
// check if camera page is active
|
||||
const isFocussed = useIsFocused();
|
||||
const isForeground = useIsForeground();
|
||||
const isActive = isFocussed && isForeground;
|
||||
|
||||
const [cameraPosition, setCameraPosition] = useState<'front' | 'back'>('back');
|
||||
const [enableHdr, setEnableHdr] = useState(false);
|
||||
const [flash, setFlash] = useState<'off' | 'on'>('off');
|
||||
const [enableNightMode, setEnableNightMode] = useState(false);
|
||||
|
||||
// camera format settings
|
||||
const devices = useCameraDevices();
|
||||
const device = devices[cameraPosition];
|
||||
const formats = useMemo<CameraDeviceFormat[]>(() => {
|
||||
if (device?.formats == null) return [];
|
||||
return device.formats.sort(sortFormats);
|
||||
}, [device?.formats]);
|
||||
|
||||
//#region Memos
|
||||
const [is60Fps, setIs60Fps] = useState(true);
|
||||
const fps = useMemo(() => {
|
||||
if (!is60Fps) return 30;
|
||||
|
||||
if (enableNightMode && !device?.supportsLowLightBoost) {
|
||||
// User has enabled Night Mode, but Night Mode is not natively supported, so we simulate it by lowering the frame rate.
|
||||
return 30;
|
||||
}
|
||||
|
||||
const supportsHdrAt60Fps = formats.some((f) => f.supportsVideoHDR && f.maxFps >= 60);
|
||||
if (enableHdr && !supportsHdrAt60Fps) {
|
||||
// User has enabled HDR, but HDR is not supported at 60 FPS.
|
||||
return 30;
|
||||
}
|
||||
|
||||
const supports60Fps = formats.some((f) => f.maxFps >= 60);
|
||||
if (!supports60Fps) {
|
||||
// 60 FPS is not supported by any format.
|
||||
return 30;
|
||||
}
|
||||
// If nothing blocks us from using it, we default to 60 FPS.
|
||||
return 60;
|
||||
}, [device?.supportsLowLightBoost, enableHdr, enableNightMode, formats, is60Fps]);
|
||||
|
||||
const supportsCameraFlipping = useMemo(() => devices.back != null && devices.front != null, [devices.back, devices.front]);
|
||||
const supportsFlash = device?.hasFlash ?? false;
|
||||
const supportsHdr = useMemo(() => formats.some((f) => f.supportsVideoHDR || f.supportsPhotoHDR), [formats]);
|
||||
const supports60Fps = useMemo(() => formats.some((f) => f.maxFps >= 60), [formats]);
|
||||
const canToggleNightMode = enableNightMode
|
||||
? true // it's enabled so you have to be able to turn it off again
|
||||
: (device?.supportsLowLightBoost ?? false) || fps > 30; // either we have native support, or we can lower the FPS
|
||||
//#endregion
|
||||
|
||||
const format = useMemo(() => {
|
||||
let result = formats;
|
||||
if (enableHdr) {
|
||||
// We only filter by HDR capable formats if HDR is set to true.
|
||||
// Otherwise we ignore the `supportsVideoHDR` property and accept formats which support HDR `true` or `false`
|
||||
result = result.filter((f) => f.supportsVideoHDR || f.supportsPhotoHDR);
|
||||
}
|
||||
|
||||
// find the first format that includes the given FPS
|
||||
return result.find((f) => f.maxFps >= fps);
|
||||
}, [formats, fps, enableHdr]);
|
||||
|
||||
//#region Animated Zoom
|
||||
// This just maps the zoom factor to a percentage value.
|
||||
// so e.g. for [min, neutr., max] values [1, 2, 128] this would result in [0, 0.0081, 1]
|
||||
const minZoom = device?.minZoom ?? 1;
|
||||
const maxZoom = Math.min(device?.maxZoom ?? 1, MAX_ZOOM_FACTOR);
|
||||
|
||||
const cameraAnimatedProps = useAnimatedProps(() => {
|
||||
const z = Math.max(Math.min(zoom.value, maxZoom), minZoom);
|
||||
return {
|
||||
zoom: z,
|
||||
};
|
||||
}, [maxZoom, minZoom, zoom]);
|
||||
//#endregion
|
||||
|
||||
//#region Callbacks
|
||||
const setIsPressingButton = useCallback(
|
||||
(_isPressingButton: boolean) => {
|
||||
isPressingButton.value = _isPressingButton;
|
||||
},
|
||||
[isPressingButton],
|
||||
);
|
||||
// Camera callbacks
|
||||
const onError = useCallback((error: CameraRuntimeError) => {
|
||||
console.error(error);
|
||||
}, []);
|
||||
const onInitialized = useCallback(() => {
|
||||
console.log('Camera initialized!');
|
||||
setIsCameraInitialized(true);
|
||||
}, []);
|
||||
const onMediaCaptured = useCallback(
|
||||
(media: PhotoFile | VideoFile, type: 'photo' | 'video') => {
|
||||
console.log(`Media captured! ${JSON.stringify(media)}`);
|
||||
navigation.navigate('MediaPage', {
|
||||
path: media.path,
|
||||
type: type,
|
||||
});
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
const onFlipCameraPressed = useCallback(() => {
|
||||
setCameraPosition((p) => (p === 'back' ? 'front' : 'back'));
|
||||
}, []);
|
||||
const onFlashPressed = useCallback(() => {
|
||||
setFlash((f) => (f === 'off' ? 'on' : 'off'));
|
||||
}, []);
|
||||
//#endregion
|
||||
|
||||
//#region Tap Gesture
|
||||
const onDoubleTap = useCallback(() => {
|
||||
onFlipCameraPressed();
|
||||
}, [onFlipCameraPressed]);
|
||||
//#endregion
|
||||
|
||||
//#region Effects
|
||||
const neutralZoom = device?.neutralZoom ?? 1;
|
||||
useEffect(() => {
|
||||
// Run everytime the neutralZoomScaled value changes. (reset zoom when device changes)
|
||||
zoom.value = neutralZoom;
|
||||
}, [neutralZoom, zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
Camera.getMicrophonePermissionStatus().then((status) => setHasMicrophonePermission(status === 'granted'));
|
||||
}, []);
|
||||
//#endregion
|
||||
|
||||
//#region Pinch to Zoom Gesture
|
||||
// The gesture handler maps the linear pinch gesture (0 - 1) to an exponential curve since a camera's zoom
|
||||
// function does not appear linear to the user. (aka zoom 0.1 -> 0.2 does not look equal in difference as 0.8 -> 0.9)
|
||||
const onPinchGesture = useAnimatedGestureHandler<PinchGestureHandlerGestureEvent, { startZoom?: number }>({
|
||||
onStart: (_, context) => {
|
||||
context.startZoom = zoom.value;
|
||||
},
|
||||
onActive: (event, context) => {
|
||||
// we're trying to map the scale gesture to a linear zoom here
|
||||
const startZoom = context.startZoom ?? 0;
|
||||
const scale = interpolate(event.scale, [1 - 1 / SCALE_FULL_ZOOM, 1, SCALE_FULL_ZOOM], [-1, 0, 1], Extrapolate.CLAMP);
|
||||
zoom.value = interpolate(scale, [-1, 0, 1], [minZoom, startZoom, maxZoom], Extrapolate.CLAMP);
|
||||
},
|
||||
});
|
||||
//#endregion
|
||||
|
||||
if (device != null && format != null) {
|
||||
console.log(
|
||||
`Re-rendering camera page with ${isActive ? 'active' : 'inactive'} camera. ` +
|
||||
`Device: "${device.name}" (${format.photoWidth}x${format.photoHeight} photo / ${format.videoWidth}x${format.videoHeight} video @ ${fps}fps)`,
|
||||
);
|
||||
} else {
|
||||
console.log('re-rendering camera page without active camera');
|
||||
}
|
||||
|
||||
const frameProcessor = useFrameProcessor((frame) => {
|
||||
'worklet';
|
||||
|
||||
console.log(`${frame.timestamp}: ${frame.width}x${frame.height} ${frame.pixelFormat} Frame (${frame.orientation})`);
|
||||
examplePlugin(frame);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{device != null && (
|
||||
<PinchGestureHandler onGestureEvent={onPinchGesture} enabled={isActive}>
|
||||
<Reanimated.View style={StyleSheet.absoluteFill}>
|
||||
<TapGestureHandler onEnded={onDoubleTap} numberOfTaps={2}>
|
||||
<ReanimatedCamera
|
||||
ref={camera}
|
||||
style={StyleSheet.absoluteFill}
|
||||
device={device}
|
||||
format={format}
|
||||
fps={fps}
|
||||
hdr={enableHdr}
|
||||
lowLightBoost={device.supportsLowLightBoost && enableNightMode}
|
||||
isActive={isActive}
|
||||
onInitialized={onInitialized}
|
||||
onError={onError}
|
||||
enableZoomGesture={false}
|
||||
animatedProps={cameraAnimatedProps}
|
||||
enableFpsGraph={true}
|
||||
orientation="portrait"
|
||||
photo={true}
|
||||
video={true}
|
||||
audio={hasMicrophonePermission}
|
||||
frameProcessor={frameProcessor}
|
||||
/>
|
||||
</TapGestureHandler>
|
||||
</Reanimated.View>
|
||||
</PinchGestureHandler>
|
||||
)}
|
||||
|
||||
<CaptureButton
|
||||
style={styles.captureButton}
|
||||
camera={camera}
|
||||
onMediaCaptured={onMediaCaptured}
|
||||
cameraZoom={zoom}
|
||||
minZoom={minZoom}
|
||||
maxZoom={maxZoom}
|
||||
flash={supportsFlash ? flash : 'off'}
|
||||
enabled={isCameraInitialized && isActive}
|
||||
setIsPressingButton={setIsPressingButton}
|
||||
/>
|
||||
|
||||
<StatusBarBlurBackground />
|
||||
|
||||
<View style={styles.rightButtonRow}>
|
||||
{supportsCameraFlipping && (
|
||||
<PressableOpacity style={styles.button} onPress={onFlipCameraPressed} disabledOpacity={0.4}>
|
||||
<IonIcon name="camera-reverse" color="white" size={24} />
|
||||
</PressableOpacity>
|
||||
)}
|
||||
{supportsFlash && (
|
||||
<PressableOpacity style={styles.button} onPress={onFlashPressed} disabledOpacity={0.4}>
|
||||
<IonIcon name={flash === 'on' ? 'flash' : 'flash-off'} color="white" size={24} />
|
||||
</PressableOpacity>
|
||||
)}
|
||||
{supports60Fps && (
|
||||
<PressableOpacity style={styles.button} onPress={() => setIs60Fps(!is60Fps)}>
|
||||
<Text style={styles.text}>
|
||||
{is60Fps ? '60' : '30'}
|
||||
{'\n'}FPS
|
||||
</Text>
|
||||
</PressableOpacity>
|
||||
)}
|
||||
{supportsHdr && (
|
||||
<PressableOpacity style={styles.button} onPress={() => setEnableHdr((h) => !h)}>
|
||||
<MaterialIcon name={enableHdr ? 'hdr' : 'hdr-off'} color="white" size={24} />
|
||||
</PressableOpacity>
|
||||
)}
|
||||
{canToggleNightMode && (
|
||||
<PressableOpacity style={styles.button} onPress={() => setEnableNightMode(!enableNightMode)} disabledOpacity={0.4}>
|
||||
<IonIcon name={enableNightMode ? 'moon' : 'moon-outline'} color="white" size={24} />
|
||||
</PressableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: 'black',
|
||||
},
|
||||
captureButton: {
|
||||
position: 'absolute',
|
||||
alignSelf: 'center',
|
||||
bottom: SAFE_AREA_PADDING.paddingBottom,
|
||||
},
|
||||
button: {
|
||||
marginBottom: CONTENT_SPACING,
|
||||
width: BUTTON_SIZE,
|
||||
height: BUTTON_SIZE,
|
||||
borderRadius: BUTTON_SIZE / 2,
|
||||
backgroundColor: 'rgba(140, 140, 140, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rightButtonRow: {
|
||||
position: 'absolute',
|
||||
right: SAFE_AREA_PADDING.paddingRight,
|
||||
top: SAFE_AREA_PADDING.paddingTop,
|
||||
},
|
||||
text: {
|
||||
color: 'white',
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
28
package/example/src/Constants.ts
Normal file
28
package/example/src/Constants.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Dimensions, Platform } from 'react-native';
|
||||
import StaticSafeAreaInsets from 'react-native-static-safe-area-insets';
|
||||
|
||||
export const CONTENT_SPACING = 15;
|
||||
|
||||
const SAFE_BOTTOM =
|
||||
Platform.select({
|
||||
ios: StaticSafeAreaInsets.safeAreaInsetsBottom,
|
||||
}) ?? 0;
|
||||
|
||||
export const SAFE_AREA_PADDING = {
|
||||
paddingLeft: StaticSafeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING,
|
||||
paddingTop: StaticSafeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING,
|
||||
paddingRight: StaticSafeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING,
|
||||
paddingBottom: SAFE_BOTTOM + CONTENT_SPACING,
|
||||
};
|
||||
|
||||
// The maximum zoom _factor_ you should be able to zoom in
|
||||
export const MAX_ZOOM_FACTOR = 20;
|
||||
|
||||
export const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
export const SCREEN_HEIGHT = Platform.select<number>({
|
||||
android: Dimensions.get('screen').height - StaticSafeAreaInsets.safeAreaInsetsBottom,
|
||||
ios: Dimensions.get('window').height,
|
||||
}) as number;
|
||||
|
||||
// Capture Button
|
||||
export const CAPTURE_BUTTON_SIZE = 78;
|
151
package/example/src/MediaPage.tsx
Normal file
151
package/example/src/MediaPage.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { StyleSheet, View, ActivityIndicator, PermissionsAndroid, Platform } from 'react-native';
|
||||
import Video, { LoadError, OnLoadData } from 'react-native-video';
|
||||
import { SAFE_AREA_PADDING } from './Constants';
|
||||
import { useIsForeground } from './hooks/useIsForeground';
|
||||
import { PressableOpacity } from 'react-native-pressable-opacity';
|
||||
import IonIcon from 'react-native-vector-icons/Ionicons';
|
||||
import { Alert } from 'react-native';
|
||||
import { CameraRoll } from '@react-native-camera-roll/camera-roll';
|
||||
import { StatusBarBlurBackground } from './views/StatusBarBlurBackground';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { Routes } from './Routes';
|
||||
import { useIsFocused } from '@react-navigation/core';
|
||||
import FastImage, { OnLoadEvent } from 'react-native-fast-image';
|
||||
|
||||
const requestSavePermission = async (): Promise<boolean> => {
|
||||
if (Platform.OS !== 'android') return true;
|
||||
|
||||
const permission = PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE;
|
||||
if (permission == null) return false;
|
||||
let hasPermission = await PermissionsAndroid.check(permission);
|
||||
if (!hasPermission) {
|
||||
const permissionRequestResult = await PermissionsAndroid.request(permission);
|
||||
hasPermission = permissionRequestResult === 'granted';
|
||||
}
|
||||
return hasPermission;
|
||||
};
|
||||
|
||||
const isVideoOnLoadEvent = (event: OnLoadData | OnLoadEvent): event is OnLoadData => 'duration' in event && 'naturalSize' in event;
|
||||
|
||||
type Props = NativeStackScreenProps<Routes, 'MediaPage'>;
|
||||
export function MediaPage({ navigation, route }: Props): React.ReactElement {
|
||||
const { path, type } = route.params;
|
||||
const [hasMediaLoaded, setHasMediaLoaded] = useState(false);
|
||||
const isForeground = useIsForeground();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const isVideoPaused = !isForeground || !isScreenFocused;
|
||||
const [savingState, setSavingState] = useState<'none' | 'saving' | 'saved'>('none');
|
||||
|
||||
const onMediaLoad = useCallback((event: OnLoadData | OnLoadEvent) => {
|
||||
if (isVideoOnLoadEvent(event)) {
|
||||
console.log(
|
||||
`Video loaded. Size: ${event.naturalSize.width}x${event.naturalSize.height} (${event.naturalSize.orientation}, ${event.duration} seconds)`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Image loaded. Size: ${event.nativeEvent.width}x${event.nativeEvent.height}`);
|
||||
}
|
||||
}, []);
|
||||
const onMediaLoadEnd = useCallback(() => {
|
||||
console.log('media has loaded.');
|
||||
setHasMediaLoaded(true);
|
||||
}, []);
|
||||
const onMediaLoadError = useCallback((error: LoadError) => {
|
||||
console.log(`failed to load media: ${JSON.stringify(error)}`);
|
||||
}, []);
|
||||
|
||||
const onSavePressed = useCallback(async () => {
|
||||
try {
|
||||
setSavingState('saving');
|
||||
|
||||
const hasPermission = await requestSavePermission();
|
||||
if (!hasPermission) {
|
||||
Alert.alert('Permission denied!', 'Vision Camera does not have permission to save the media to your camera roll.');
|
||||
return;
|
||||
}
|
||||
await CameraRoll.save(`file://${path}`, {
|
||||
type: type,
|
||||
});
|
||||
setSavingState('saved');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : JSON.stringify(e);
|
||||
setSavingState('none');
|
||||
Alert.alert('Failed to save!', `An unexpected error occured while trying to save your ${type}. ${message}`);
|
||||
}
|
||||
}, [path, type]);
|
||||
|
||||
const source = useMemo(() => ({ uri: `file://${path}` }), [path]);
|
||||
|
||||
const screenStyle = useMemo(() => ({ opacity: hasMediaLoaded ? 1 : 0 }), [hasMediaLoaded]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, screenStyle]}>
|
||||
{type === 'photo' && (
|
||||
<FastImage source={source} style={StyleSheet.absoluteFill} resizeMode="cover" onLoadEnd={onMediaLoadEnd} onLoad={onMediaLoad} />
|
||||
)}
|
||||
{type === 'video' && (
|
||||
<Video
|
||||
source={source}
|
||||
style={StyleSheet.absoluteFill}
|
||||
paused={isVideoPaused}
|
||||
resizeMode="cover"
|
||||
posterResizeMode="cover"
|
||||
allowsExternalPlayback={false}
|
||||
automaticallyWaitsToMinimizeStalling={false}
|
||||
disableFocus={true}
|
||||
repeat={true}
|
||||
useTextureView={false}
|
||||
controls={false}
|
||||
playWhenInactive={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
onReadyForDisplay={onMediaLoadEnd}
|
||||
onLoad={onMediaLoad}
|
||||
onError={onMediaLoadError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PressableOpacity style={styles.closeButton} onPress={navigation.goBack}>
|
||||
<IonIcon name="close" size={35} color="white" style={styles.icon} />
|
||||
</PressableOpacity>
|
||||
|
||||
<PressableOpacity style={styles.saveButton} onPress={onSavePressed} disabled={savingState !== 'none'}>
|
||||
{savingState === 'none' && <IonIcon name="download" size={35} color="white" style={styles.icon} />}
|
||||
{savingState === 'saved' && <IonIcon name="checkmark" size={35} color="white" style={styles.icon} />}
|
||||
{savingState === 'saving' && <ActivityIndicator color="white" />}
|
||||
</PressableOpacity>
|
||||
|
||||
<StatusBarBlurBackground />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: SAFE_AREA_PADDING.paddingTop,
|
||||
left: SAFE_AREA_PADDING.paddingLeft,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
saveButton: {
|
||||
position: 'absolute',
|
||||
bottom: SAFE_AREA_PADDING.paddingBottom,
|
||||
left: SAFE_AREA_PADDING.paddingLeft,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
icon: {
|
||||
textShadowColor: 'black',
|
||||
textShadowOffset: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
textShadowRadius: 1,
|
||||
},
|
||||
});
|
96
package/example/src/PermissionsPage.tsx
Normal file
96
package/example/src/PermissionsPage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ImageRequireSource, Linking } from 'react-native';
|
||||
|
||||
import { StyleSheet, View, Text, Image } from 'react-native';
|
||||
import { Camera, CameraPermissionStatus } from 'react-native-vision-camera';
|
||||
import { CONTENT_SPACING, SAFE_AREA_PADDING } from './Constants';
|
||||
import type { Routes } from './Routes';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const BANNER_IMAGE = require('../../docs/static/img/11.png') as ImageRequireSource;
|
||||
|
||||
type Props = NativeStackScreenProps<Routes, 'PermissionsPage'>;
|
||||
export function PermissionsPage({ navigation }: Props): React.ReactElement {
|
||||
const [cameraPermissionStatus, setCameraPermissionStatus] = useState<CameraPermissionStatus>('not-determined');
|
||||
const [microphonePermissionStatus, setMicrophonePermissionStatus] = useState<CameraPermissionStatus>('not-determined');
|
||||
|
||||
const requestMicrophonePermission = useCallback(async () => {
|
||||
console.log('Requesting microphone permission...');
|
||||
const permission = await Camera.requestMicrophonePermission();
|
||||
console.log(`Microphone permission status: ${permission}`);
|
||||
|
||||
if (permission === 'denied') await Linking.openSettings();
|
||||
setMicrophonePermissionStatus(permission);
|
||||
}, []);
|
||||
|
||||
const requestCameraPermission = useCallback(async () => {
|
||||
console.log('Requesting camera permission...');
|
||||
const permission = await Camera.requestCameraPermission();
|
||||
console.log(`Camera permission status: ${permission}`);
|
||||
|
||||
if (permission === 'denied') await Linking.openSettings();
|
||||
setCameraPermissionStatus(permission);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cameraPermissionStatus === 'granted' && microphonePermissionStatus === 'granted') navigation.replace('CameraPage');
|
||||
}, [cameraPermissionStatus, microphonePermissionStatus, navigation]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Image source={BANNER_IMAGE} style={styles.banner} />
|
||||
<Text style={styles.welcome}>Welcome to{'\n'}Vision Camera.</Text>
|
||||
<View style={styles.permissionsContainer}>
|
||||
{cameraPermissionStatus !== 'granted' && (
|
||||
<Text style={styles.permissionText}>
|
||||
Vision Camera needs <Text style={styles.bold}>Camera permission</Text>.{' '}
|
||||
<Text style={styles.hyperlink} onPress={requestCameraPermission}>
|
||||
Grant
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
{microphonePermissionStatus !== 'granted' && (
|
||||
<Text style={styles.permissionText}>
|
||||
Vision Camera needs <Text style={styles.bold}>Microphone permission</Text>.{' '}
|
||||
<Text style={styles.hyperlink} onPress={requestMicrophonePermission}>
|
||||
Grant
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
welcome: {
|
||||
fontSize: 38,
|
||||
fontWeight: 'bold',
|
||||
maxWidth: '80%',
|
||||
},
|
||||
banner: {
|
||||
position: 'absolute',
|
||||
opacity: 0.4,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: 'white',
|
||||
...SAFE_AREA_PADDING,
|
||||
},
|
||||
permissionsContainer: {
|
||||
marginTop: CONTENT_SPACING * 2,
|
||||
},
|
||||
permissionText: {
|
||||
fontSize: 17,
|
||||
},
|
||||
hyperlink: {
|
||||
color: '#007aff',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bold: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
8
package/example/src/Routes.ts
Normal file
8
package/example/src/Routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type Routes = {
|
||||
PermissionsPage: undefined;
|
||||
CameraPage: undefined;
|
||||
MediaPage: {
|
||||
path: string;
|
||||
type: 'video' | 'photo';
|
||||
};
|
||||
};
|
17
package/example/src/frame-processors/ExamplePlugin.ts
Normal file
17
package/example/src/frame-processors/ExamplePlugin.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { VisionCameraProxy, Frame } from 'react-native-vision-camera';
|
||||
|
||||
const plugin = VisionCameraProxy.getFrameProcessorPlugin('example_plugin');
|
||||
|
||||
export function examplePlugin(frame: Frame): string[] {
|
||||
'worklet';
|
||||
|
||||
if (plugin == null) throw new Error('Failed to load Frame Processor Plugin "example_plugin"!');
|
||||
|
||||
return plugin.call(frame, {
|
||||
someString: 'hello!',
|
||||
someBoolean: true,
|
||||
someNumber: 42,
|
||||
someObject: { test: 0, second: 'test' },
|
||||
someArray: ['another test', 5],
|
||||
}) as string[];
|
||||
}
|
17
package/example/src/hooks/useIsForeground.ts
Normal file
17
package/example/src/hooks/useIsForeground.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
|
||||
export const useIsForeground = (): boolean => {
|
||||
const [isForeground, setIsForeground] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = (state: AppStateStatus): void => {
|
||||
setIsForeground(state === 'active');
|
||||
};
|
||||
const listener = AppState.addEventListener('change', onChange);
|
||||
return () => listener.remove();
|
||||
}, [setIsForeground]);
|
||||
|
||||
return isForeground;
|
||||
};
|
308
package/example/src/views/CaptureButton.tsx
Normal file
308
package/example/src/views/CaptureButton.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { 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, VideoFile } from 'react-native-vision-camera';
|
||||
import { CAPTURE_BUTTON_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH } from './../Constants';
|
||||
|
||||
const PAN_GESTURE_HANDLER_FAIL_X = [-SCREEN_WIDTH, SCREEN_WIDTH];
|
||||
const PAN_GESTURE_HANDLER_ACTIVE_Y = [-2, 2];
|
||||
|
||||
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;
|
||||
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
cameraZoom: Reanimated.SharedValue<number>;
|
||||
|
||||
flash: 'off' | 'on';
|
||||
|
||||
enabled: boolean;
|
||||
|
||||
setIsPressingButton: (isPressingButton: boolean) => void;
|
||||
}
|
||||
|
||||
const _CaptureButton: React.FC<Props> = ({
|
||||
camera,
|
||||
onMediaCaptured,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
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>(
|
||||
() => ({
|
||||
photoCodec: 'jpeg',
|
||||
qualityPrioritization: 'speed',
|
||||
flash: flash,
|
||||
quality: 90,
|
||||
enableShutterSound: false,
|
||||
}),
|
||||
[flash],
|
||||
);
|
||||
const isPressingButton = useSharedValue(false);
|
||||
|
||||
//#region Camera Capture
|
||||
const takePhoto = useCallback(async () => {
|
||||
try {
|
||||
if (camera.current == null) throw new Error('Camera ref is null!');
|
||||
|
||||
console.log('Taking photo...');
|
||||
const photo = 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]);
|
||||
//#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(cameraZoom.value, [minZoom, maxZoom], [0, offsetYForFullZoom], Extrapolate.CLAMP);
|
||||
},
|
||||
onActive: (event, context) => {
|
||||
const offset = context.offsetY ?? 0;
|
||||
const startY = context.startY ?? SCREEN_HEIGHT;
|
||||
const yForFullZoom = startY * 0.7;
|
||||
|
||||
cameraZoom.value = interpolate(event.absoluteY - offset, [yForFullZoom, startY], [maxZoom, minZoom], Extrapolate.CLAMP);
|
||||
},
|
||||
});
|
||||
//#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',
|
||||
},
|
||||
});
|
32
package/example/src/views/StatusBarBlurBackground.tsx
Normal file
32
package/example/src/views/StatusBarBlurBackground.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { BlurView, BlurViewProps } from '@react-native-community/blur';
|
||||
import React from 'react';
|
||||
import { Platform, StyleSheet } from 'react-native';
|
||||
import StaticSafeAreaInsets from 'react-native-static-safe-area-insets';
|
||||
|
||||
const FALLBACK_COLOR = 'rgba(140, 140, 140, 0.3)';
|
||||
|
||||
const StatusBarBlurBackgroundImpl = ({ style, ...props }: BlurViewProps): React.ReactElement | null => {
|
||||
if (Platform.OS !== 'ios') return null;
|
||||
|
||||
return (
|
||||
<BlurView
|
||||
style={[styles.statusBarBackground, style]}
|
||||
blurAmount={25}
|
||||
blurType="light"
|
||||
reducedTransparencyFallbackColor={FALLBACK_COLOR}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatusBarBlurBackground = React.memo(StatusBarBlurBackgroundImpl);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
statusBarBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: StaticSafeAreaInsets.safeAreaInsetsTop,
|
||||
},
|
||||
});
|
Reference in New Issue
Block a user