chore: Cleanup codebase (#137)

* Remove `useCachedState`

* Add pressable opacity

* Update Media.tsx

* f

* Update FormatFilter.ts

* update

* App -> CameraPage, Media -> MediaPage

* Update CameraPage.tsx

* Create 60 FPS switch

* Update CameraPage.tsx
This commit is contained in:
Marc Rousavy 2021-05-11 12:59:05 +02:00 committed by GitHub
parent 3bf4197b17
commit f839bc23ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 86 additions and 322 deletions

View File

@ -1,10 +1,9 @@
import 'react-native-gesture-handler';
import { Navigation } from 'react-native-navigation';
import { gestureHandlerRootHOC } from 'react-native-gesture-handler';
import { App } from './src/App';
import { Settings } from './src/Settings';
import { CameraPage } from './src/CameraPage';
import { Splash } from './src/Splash';
import { Media } from './src/Media';
import { MediaPage } from './src/MediaPage';
import { Camera } from 'react-native-vision-camera';
Navigation.setDefaultOptions({
@ -42,19 +41,14 @@ Navigation.registerComponent(
() => Splash,
);
Navigation.registerComponent(
'Home',
() => gestureHandlerRootHOC(App),
() => App,
'CameraPage',
() => gestureHandlerRootHOC(CameraPage),
() => CameraPage,
);
Navigation.registerComponent(
'Media',
() => gestureHandlerRootHOC(Media),
() => Media,
);
Navigation.registerComponent(
'Settings',
() => gestureHandlerRootHOC(Settings),
() => Settings,
'MediaPage',
() => gestureHandlerRootHOC(MediaPage),
() => MediaPage,
);
Navigation.events().registerNavigationButtonPressedListener((event) => {
@ -67,7 +61,7 @@ Navigation.events().registerAppLaunchedListener(async () => {
Camera.getMicrophonePermissionStatus(),
]);
let rootName = 'Splash';
if (cameraPermission === 'authorized' && microphonePermission === 'authorized') rootName = 'Home';
if (cameraPermission === 'authorized' && microphonePermission === 'authorized') rootName = 'CameraPage';
Navigation.setRoot({
root: {

View File

@ -14,11 +14,11 @@
"@react-native-community/blur": "^3.6.0",
"@react-native-community/cameraroll": "^4.0.2",
"@react-native-community/slider": "^3.0.3",
"pipestate": "^1.0.2",
"react": "17.0.2",
"react-native": "0.64",
"react-native-gesture-handler": "^1.10.3",
"react-native-navigation": "7.8.4-snapshot.1439",
"react-native-pressable-opacity": "^1.0.4",
"react-native-reanimated": "^2.1.0",
"react-native-static-safe-area-insets": "^2.1.1",
"react-native-vector-icons": "^8.0.0",

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { useRef, useState, useMemo, useCallback } from 'react';
import { StyleSheet, View } from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import {
PinchGestureHandler,
PinchGestureHandlerGestureEvent,
@ -11,18 +11,16 @@ import {
import { Navigation, NavigationFunctionComponent } from 'react-native-navigation';
import type { CameraDeviceFormat, CameraRuntimeError, PhotoFile, VideoFile } from 'react-native-vision-camera';
import { Camera, frameRateIncluded, sortFormatsByResolution, filterFormatsByAspectRatio } from 'react-native-vision-camera';
import { useIsScreenFocused } from './hooks/useIsScreenFocused';
import { useIsScreenFocussed } from './hooks/useIsScreenFocused';
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 './views/PressableOpacity';
import { PressableOpacity } from 'react-native-pressable-opacity';
import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons';
import IonIcon from 'react-native-vector-icons/Ionicons';
import { useSelector } from 'pipestate';
import { FpsSelector } from './state/selectors';
import { useCameraDevice } from './hooks/useCameraDevice';
const ReanimatedCamera = Reanimated.createAnimatedComponent(Camera);
@ -33,14 +31,14 @@ Reanimated.addWhitelistedNativeProps({
const SCALE_FULL_ZOOM = 3;
const BUTTON_SIZE = 40;
export const App: NavigationFunctionComponent = ({ componentId }) => {
export const CameraPage: NavigationFunctionComponent = ({ componentId }) => {
const camera = useRef<Camera>(null);
const [isCameraInitialized, setIsCameraInitialized] = useState(false);
const zoom = useSharedValue(0);
const isPressingButton = useSharedValue(false);
// check if camera page is active
const isFocussed = useIsScreenFocused(componentId);
const isFocussed = useIsScreenFocussed(componentId);
const isForeground = useIsForeground();
const isActive = isFocussed && isForeground;
@ -59,32 +57,34 @@ export const App: NavigationFunctionComponent = ({ componentId }) => {
}, [device?.formats]);
//#region Memos
const [targetFps] = useSelector(FpsSelector);
console.log(`Target FPS: ${targetFps}`);
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 supportsHdrAtHighFps = formats.some((f) => f.supportsVideoHDR && f.frameRateRanges.some((r) => frameRateIncluded(r, targetFps)));
if (enableHdr && !supportsHdrAtHighFps) {
// User has enabled HDR, but HDR is not supported at targetFps.
const supportsHdrAt60Fps = formats.some((f) => f.supportsVideoHDR && f.frameRateRanges.some((r) => frameRateIncluded(r, 60)));
if (enableHdr && !supportsHdrAt60Fps) {
// User has enabled HDR, but HDR is not supported at 60 FPS.
return 30;
}
const supportsHighFps = formats.some((f) => f.frameRateRanges.some((r) => frameRateIncluded(r, targetFps)));
if (!supportsHighFps) {
// targetFps is not supported by any format.
const supports60Fps = formats.some((f) => f.frameRateRanges.some((r) => frameRateIncluded(r, 60)));
if (!supports60Fps) {
// 60 FPS is not supported by any format.
return 30;
}
// If nothing blocks us from using it, we default to targetFps.
return targetFps;
}, [device?.supportsLowLightBoost, enableHdr, enableNightMode, formats, targetFps]);
// 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), [formats]);
const supports60Fps = useMemo(() => formats.some((f) => f.frameRateRanges.some((rate) => frameRateIncluded(rate, 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
@ -136,7 +136,7 @@ export const App: NavigationFunctionComponent = ({ componentId }) => {
console.log(`Media captured! ${JSON.stringify(media)}`);
await Navigation.showModal({
component: {
name: 'Media',
name: 'MediaPage',
passProps: {
type: type,
path: media.path,
@ -147,18 +147,9 @@ export const App: NavigationFunctionComponent = ({ componentId }) => {
const onFlipCameraPressed = useCallback(() => {
setCameraPosition((p) => (p === 'back' ? 'front' : 'back'));
}, []);
const onHdrSwitchPressed = useCallback(() => {
setEnableHdr((h) => !h);
}, []);
const onFlashPressed = useCallback(() => {
setFlash((f) => (f === 'off' ? 'on' : 'off'));
}, []);
const onNightModePressed = useCallback(() => {
setEnableNightMode((n) => !n);
}, []);
const onSettingsPressed = useCallback(() => {
Navigation.push(componentId, { component: { name: 'Settings' } });
}, [componentId]);
//#endregion
//#region Tap Gesture
@ -269,19 +260,24 @@ export const App: NavigationFunctionComponent = ({ componentId }) => {
<IonIcon name={flash === 'on' ? 'flash' : 'flash-off'} color="white" size={24} />
</PressableOpacity>
)}
{canToggleNightMode && (
<PressableOpacity style={styles.button} onPress={onNightModePressed} disabledOpacity={0.4}>
<IonIcon name={enableNightMode ? 'moon' : 'moon-outline'} color="white" size={24} />
{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={onHdrSwitchPressed}>
<PressableOpacity style={styles.button} onPress={() => setEnableHdr((h) => !h)}>
<MaterialIcon name={enableHdr ? 'hdr' : 'hdr-off'} color="white" size={24} />
</PressableOpacity>
)}
<PressableOpacity style={styles.button} onPress={onSettingsPressed}>
<IonIcon name="settings-outline" 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>
);
@ -311,4 +307,10 @@ const styles = StyleSheet.create({
right: SAFE_AREA_PADDING.paddingRight,
top: SAFE_AREA_PADDING.paddingTop,
},
text: {
color: 'white',
fontSize: 11,
fontWeight: 'bold',
textAlign: 'center',
},
});

View File

@ -4,8 +4,8 @@ import { Navigation, NavigationFunctionComponent, OptionsModalPresentationStyle
import Video, { LoadError, OnLoadData } from 'react-native-video';
import { SAFE_AREA_PADDING } from './Constants';
import { useIsForeground } from './hooks/useIsForeground';
import { useIsScreenFocused } from './hooks/useIsScreenFocused';
import { PressableOpacity } from './views/PressableOpacity';
import { useIsScreenFocussed } from './hooks/useIsScreenFocused';
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-community/cameraroll';
@ -34,10 +34,10 @@ const requestSavePermission = async (): Promise<boolean> => {
const isVideoOnLoadEvent = (event: OnLoadData | NativeSyntheticEvent<ImageLoadEventData>): event is OnLoadData =>
'duration' in event && 'naturalSize' in event;
export const Media: NavigationFunctionComponent<MediaProps> = ({ componentId, type, path }) => {
export const MediaPage: NavigationFunctionComponent<MediaProps> = ({ componentId, type, path }) => {
const [hasMediaLoaded, setHasMediaLoaded] = useState(false);
const isForeground = useIsForeground();
const isScreenFocused = useIsScreenFocused(componentId);
const isScreenFocused = useIsScreenFocussed(componentId);
const isVideoPaused = !isForeground || !isScreenFocused;
const [savingState, setSavingState] = useState<'none' | 'saving' | 'saved'>('none');
@ -126,7 +126,7 @@ export const Media: NavigationFunctionComponent<MediaProps> = ({ componentId, ty
);
};
Media.options = {
MediaPage.options = {
modal: {
swipeToDismiss: false,
},

View File

@ -1,110 +0,0 @@
import React, { useCallback } from 'react';
import { StyleSheet, View, Text, Linking } from 'react-native';
import { Navigation, NavigationFunctionComponent } from 'react-native-navigation';
import Slider from '@react-native-community/slider';
import { useState } from 'react';
import { useEffect } from 'react';
import { Camera } from 'react-native-vision-camera';
import { SAFE_AREA_PADDING } from './Constants';
import { useSelector } from 'pipestate';
import { FpsSelector } from './state/selectors';
export const Settings: NavigationFunctionComponent = ({ componentId }) => {
const [fpsSelector, setFpsSelector] = useSelector(FpsSelector);
const [fps, setFps] = useState(fpsSelector);
const [minFps, setMinFps] = useState<number>();
const [maxFps, setMaxFps] = useState<number>();
const onCuventPressed = useCallback(() => {
Linking.openURL('https://cuvent.com');
}, []);
useEffect(() => {
const loadFormats = async (): Promise<void> => {
const devices = await Camera.getAvailableCameraDevices();
const formats = devices.flatMap((d) => d.formats);
let max = 0,
min = 0;
formats.forEach((format) => {
const frameRates = format.frameRateRanges.map((f) => f.maxFrameRate).sort((left, right) => left - right);
const highest = frameRates[frameRates.length - 1] as number;
const lowest = frameRates[0] as number;
if (highest > max) max = highest;
if (lowest < min) min = lowest;
});
setMaxFps(max);
setMinFps(min);
};
loadFormats();
}, []);
useEffect(() => {
const listener = Navigation.events().registerScreenPoppedListener((event) => {
if (event.componentId === componentId) setFpsSelector(Math.round(fps));
});
return () => {
listener.remove();
};
}, [componentId, fps, setFpsSelector]);
return (
<View style={styles.container}>
<View style={styles.vControl}>
<Text>Frame Rate (FPS): {Math.round(fps)}</Text>
{minFps != null && maxFps != null && <Slider minimumValue={minFps} maximumValue={maxFps} value={fps} onValueChange={setFps} />}
</View>
<View style={styles.spacer} />
<Text style={styles.aboutText}>
Vision Camera is powered by{' '}
<Text style={styles.hyperlink} onPress={onCuventPressed}>
Cuvent
</Text>
.
</Text>
</View>
);
};
Settings.options = {
topBar: {
visible: true,
title: {
text: 'Settings',
},
backButton: {
id: 'back',
showTitle: true,
},
},
};
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'stretch',
justifyContent: 'center',
backgroundColor: 'white',
...SAFE_AREA_PADDING,
},
aboutText: {
alignSelf: 'center',
fontSize: 14,
fontWeight: 'bold',
color: '#A9A9A9',
maxWidth: '50%',
textAlign: 'center',
},
hyperlink: {
color: '#007aff',
fontWeight: 'bold',
},
vControl: {
width: '100%',
},
spacer: {
flex: 1,
},
});

View File

@ -1,26 +0,0 @@
import { useCallback, useRef, useState } from 'react';
/**
* Same as `useState`, but swallow all calls to `setState` if the value didn't change (uses `===` comparator per default)
* @param initialValue The initial state
* @param comparator A custom comparator, useful if you want to only round numbers or use string locale for comparison. Make sure this function is memoized!
*/
export const useCachedState = <T>(initialValue: T, comparator?: (oldState: T, newState: T) => boolean): [T, (newState: T) => void] => {
const [state, setState] = useState(initialValue);
const cachedState = useRef(initialValue);
const dispatchState = useCallback(
(newState: T) => {
const areEqual = comparator == null ? cachedState.current === newState : comparator(cachedState.current, newState);
if (areEqual) {
return;
} else {
cachedState.current = newState;
setState(newState);
}
},
[comparator],
);
return [state, dispatchState];
};

View File

@ -1,9 +1,9 @@
import { useState } from 'react';
import { useEffect } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { useCachedState } from './useCachedState';
export const useIsForeground = (): boolean => {
const [isForeground, setIsForeground] = useCachedState(true);
const [isForeground, setIsForeground] = useState(true);
useEffect(() => {
const onChange = (state: AppStateStatus): void => {

View File

@ -1,57 +1,39 @@
import { useEffect, useMemo, useReducer } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Navigation } from 'react-native-navigation';
type Action =
| {
action: 'push';
componentId: string;
}
| {
action: 'pop';
componentId: string;
};
export const useIsScreenFocussed = (componentId: string): boolean => {
const componentStack = useRef<string[]>(['componentId']);
const [isFocussed, setIsFocussed] = useState(true);
const reducer = (stack: string[], action: Action): string[] => {
switch (action.action) {
case 'push': {
stack.push(action.componentId);
break;
}
case 'pop': {
const index = stack.indexOf(action.componentId);
if (index > -1) stack.splice(index, 1);
break;
}
}
return [...stack];
};
export const useIsScreenFocused = (componentId: string): boolean => {
const [componentStack, dispatch] = useReducer(reducer, [componentId]);
const isFocussed = useMemo(() => componentStack[componentStack.length - 1] === componentId, [componentStack, componentId]);
const invalidate = useCallback(() => {
const last = componentStack.current[componentStack.current.length - 1];
setIsFocussed(last === componentId);
}, [componentId, setIsFocussed]);
useEffect(() => {
const listener = Navigation.events().registerComponentDidAppearListener((event) => {
if (event.componentType !== 'Component') return;
dispatch({
action: 'push',
componentId: event.componentId,
});
componentStack.current.push(event.componentId);
invalidate();
});
return () => listener.remove();
}, []);
}, [invalidate]);
useEffect(() => {
const listener = Navigation.events().registerComponentDidDisappearListener((event) => {
if (event.componentType !== 'Component') return;
dispatch({
action: 'pop',
componentId: event.componentId,
});
// we can't simply use .pop() here because the component might be popped deeper down in the hierarchy.
for (let i = componentStack.current.length - 1; i >= 0; i--) {
if (componentStack.current[i] === event.componentId) {
componentStack.current.splice(i, 1);
break;
}
}
invalidate();
});
return () => listener.remove();
}, []);
}, [invalidate]);
return isFocussed;
};

View File

@ -1,11 +0,0 @@
import { atom } from 'pipestate';
interface FormatSettings {
fps: number;
}
export const FormatSettingsAtom = atom<FormatSettings>({
default: {
fps: 60,
},
});

View File

@ -1,13 +0,0 @@
import { selector } from 'pipestate';
import { FormatSettingsAtom } from './atoms';
export const FpsSelector = selector<number, []>({
get: ({ get }) => {
return get(FormatSettingsAtom).fps;
},
set: ({ set, get }, newFps) => {
const formatSettings = get(FormatSettingsAtom);
set(FormatSettingsAtom, { ...formatSettings, fps: newFps });
},
dependencies: [FormatSettingsAtom],
});

View File

@ -1,50 +0,0 @@
import React, { useCallback } from 'react';
import { PressableProps, Pressable, PressableStateCallbackType, StyleProp, ViewStyle } from 'react-native';
export interface PressableOpacityProps extends PressableProps {
/**
* The opacity to use when `disabled={true}`
*
* @default 1
*/
disabledOpacity?: number;
/**
* The opacity to animate to when the user presses the button
*
* @default 0.2
*/
activeOpacity?: number;
}
export type StyleType = (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
/**
* A Pressable component that lowers opacity when in pressed state. Uses the JS Pressability API.
*/
export const PressableOpacity = ({
style,
disabled = false,
disabledOpacity = 1,
activeOpacity = 0.2,
...passThroughProps
}: PressableOpacityProps): React.ReactElement => {
const getOpacity = useCallback(
(pressed: boolean) => {
if (disabled) {
return disabledOpacity;
} else {
if (pressed) return activeOpacity;
else return 1;
}
},
[activeOpacity, disabled, disabledOpacity],
);
const _style = useCallback<StyleType>(({ pressed }) => [style as ViewStyle, { opacity: getOpacity(pressed) }], [getOpacity, style]);
return <Pressable style={_style} disabled={disabled} {...passThroughProps} />;
};
// Fallback implementation using TouchableOpacity:
// export default function PressableOpacity(props: TouchableOpacityProps & { children?: React.ReactNode }): React.ReactElement {
// return <TouchableOpacity delayPressIn={0} {...props} />;
// }

View File

@ -4598,11 +4598,6 @@ pify@^4.0.1:
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pipestate@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/pipestate/-/pipestate-1.0.2.tgz#5b859ce947eb9180395199a44e7622b5700a0151"
integrity sha512-1ZaIMyWESR2cb76AZfUosRBSWPQ/2s29naiV72N1iKixzLY1qdPUgZYV990L/pEt/9T/nZDQU7zXSy/aq1ttYw==
pirates@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87"
@ -4783,6 +4778,11 @@ react-native-navigation@7.8.4-snapshot.1439:
react-lifecycles-compat "2.0.0"
tslib "1.9.3"
react-native-pressable-opacity@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/react-native-pressable-opacity/-/react-native-pressable-opacity-1.0.4.tgz#391f33fdc25cb84551f2743a25eced892b9f30f7"
integrity sha512-DBIg7UoRiuBYiFEvx+XNMqH0OEx64WrSksXhT6Kq9XuyyKsThMNDqZ9G5QV7vfu7dU2/IctwIz5c0Xwkp4K3tA==
react-native-reanimated@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-2.1.0.tgz#b9ad04aee490e1e030d0a6cdaa43a14895d9a54d"

View File

@ -43,10 +43,12 @@ export type Size = {
*/
height: number;
};
const SCREEN_SIZE: Size = {
width: Dimensions.get('window').width,
height: Dimensions.get('window').height,
};
const applyScaledMask = (
clippedElementDimensions: Size, // 12 x 12
maskDimensions: Size, // 6 x 12
@ -54,17 +56,11 @@ const applyScaledMask = (
const wScale = maskDimensions.width / clippedElementDimensions.width; // 0.5
const hScale = maskDimensions.height / clippedElementDimensions.height; // 1.0
if (wScale > hScale) {
return {
width: maskDimensions.width / hScale,
height: maskDimensions.height / hScale,
};
} else {
return {
width: maskDimensions.width / wScale,
height: maskDimensions.height / wScale,
};
}
const scale = Math.min(wScale, hScale);
return {
width: maskDimensions.width / scale,
height: maskDimensions.height / scale,
};
};
const getFormatAspectRatioOverflow = (format: CameraDeviceFormat, size: Size): number => {