Skip to content

Commit c557aa2

Browse files
authored
Merge pull request #220 from monkvision/fix/taking-picture
Rework camera for web
2 parents 24ada1e + abb140f commit c557aa2

File tree

16 files changed

+414
-178
lines changed

16 files changed

+414
-178
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
"react-responsive": "^9.0.0-beta.6",
107107
"release-it": "^14.12.5",
108108
"screenfull": "^5.2.0",
109+
"webrtc-adapter": "^8.1.1",
109110
"yup": "^0.32.11"
110111
},
111112
"devDependencies": {

packages/camera/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@
4646
"expo-camera": "^12.1.2",
4747
"expo-image-manipulator": "^10.2.1",
4848
"expo-screen-orientation": "^4.1.1",
49+
"react-native-canvas": "^0.1.38",
4950
"react-native-svg": "^12.1.1",
5051
"react-responsive": "^9.0.0-beta.6",
51-
"screenfull": "^5.2.0"
52+
"screenfull": "^5.2.0",
53+
"webrtc-adapter": "^8.1.1"
5254
},
5355
"devDependencies": {
5456
"eslint": "^7.32.0",
Lines changed: 215 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,243 @@
1-
import React, { useCallback } from 'react';
1+
import React, { forwardRef, useCallback, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
2+
import createElement from 'react-native-web/dist/exports/createElement';
3+
import adapter from 'webrtc-adapter';
24
import PropTypes from 'prop-types';
35

4-
import { Text, View } from 'react-native';
5-
import { Camera as ExpoCamera } from 'expo-camera';
6-
6+
import { Text, useWindowDimensions, View } from 'react-native';
77
import { utils } from '@monkvision/toolkit';
8-
import log from '../../utils/log';
9-
import useAvailable from '../../hooks/useAvailable';
10-
import usePermissions from '../../hooks/usePermissions';
11-
import useWindowDimensions from '../../hooks/useWindowDimensions';
128

139
import styles from './styles';
1410

1511
const { getSize } = utils.styles;
1612

17-
export default function Camera({
18-
children,
19-
containerStyle,
20-
onRef,
21-
ratio,
22-
style,
23-
title,
24-
...passThroughProps
25-
}) {
26-
const available = useAvailable();
27-
const permissions = usePermissions();
28-
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
29-
const size = getSize(ratio, { windowHeight, windowWidth });
30-
31-
const handleError = useCallback((error) => {
32-
log([error], 'error');
13+
const Video = React.forwardRef((props, ref) => createElement('video', { ...props, ref }));
14+
15+
const tests = [
16+
// {
17+
// label: '4K(UHD) 4:3',
18+
// width: 3840,
19+
// height: 2880,
20+
// ratio: '4:3',
21+
// }, {
22+
// label: '4K(UHD) 16:9',
23+
// width: 3840,
24+
// height: 2160,
25+
// ratio: '16:9',
26+
// },
27+
{
28+
label: 'FHD 4:3',
29+
width: 1920,
30+
height: 1440,
31+
ratio: '4:3',
32+
}, {
33+
label: 'FHD 16:9',
34+
width: 1920,
35+
height: 1080,
36+
ratio: '16:9',
37+
}, {
38+
label: 'UXGA',
39+
width: 1600,
40+
height: 1200,
41+
ratio: '4:3',
42+
}, {
43+
label: 'HD(720p)',
44+
width: 1280,
45+
height: 720,
46+
ratio: '16:9',
47+
}, {
48+
label: 'SVGA',
49+
width: 800,
50+
height: 600,
51+
ratio: '4:3',
52+
}, {
53+
label: 'VGA',
54+
width: 640,
55+
height: 480,
56+
ratio: '4:3',
57+
}, {
58+
label: 'CIF',
59+
width: 352,
60+
height: 288,
61+
ratio: '4:3',
62+
}, {
63+
label: 'QVGA',
64+
width: 320,
65+
height: 240,
66+
ratio: '4:3',
67+
}, {
68+
label: 'QCIF',
69+
width: 176,
70+
height: 144,
71+
ratio: '4:3',
72+
}, {
73+
label: 'QQVGA',
74+
width: 160,
75+
height: 120,
76+
ratio: '4:3',
77+
}];
78+
79+
const { getUserMedia } = navigator.mediaDevices || navigator.mozGetUserMedia;
80+
81+
const Camera = ({ children, containerStyle, onCameraReady, title }, ref) => {
82+
const windowDimensions = useWindowDimensions();
83+
const videoEl = useRef();
84+
const canvasEl = useRef();
85+
const [candidate, setCandidate] = useState();
86+
const [loading, setLoading] = useState(false);
87+
88+
const { width: videoWith, height: videoHeight } = useMemo(() => {
89+
if (!candidate) { return { width: 0, height: 0 }; }
90+
return getSize(candidate.test.ratio, windowDimensions, 'number');
91+
}, [candidate, windowDimensions]);
92+
93+
const gum = useCallback(async (test, device) => {
94+
if (window.stream) { window.stream.getTracks().forEach((track) => track.stop()); }
95+
96+
const OS = utils.getOS();
97+
const facingMode = ['iOS', 'Android'].includes(OS) ? { exact: 'environment' } : 'environment';
98+
99+
const constraints = {
100+
audio: false,
101+
video: {
102+
facingMode,
103+
deviceId: device.deviceId ? { exact: device.deviceId } : undefined,
104+
width: { exact: test.width },
105+
height: { exact: test.height },
106+
},
107+
};
108+
109+
let result;
110+
try {
111+
result = await getUserMedia(constraints);
112+
} catch (error) { result = { error }; }
113+
114+
return [test, result, constraints, device];
115+
}, []);
116+
117+
const findBestCandidate = useCallback(async (devices) => {
118+
const testResults = devices
119+
.map((device) => tests.map(async (test) => gum(test, device)))
120+
.flat();
121+
122+
const [test, stream, constraint] = await testResults
123+
.reduce(async (resultA, resultB) => {
124+
const [testA, streamA] = await resultA;
125+
const [testB, streamB] = await resultB;
126+
127+
if (streamA.error) { return resultB; }
128+
if (streamB.error) { return resultA; }
129+
130+
return testA.width > testB.width ? resultA : resultB;
131+
});
132+
133+
return {
134+
test,
135+
stream,
136+
constraint,
137+
ask: `${test.width}x${test.height}`,
138+
browserVer: `${adapter.browserDetails.browser} ${adapter.browserDetails.version}`,
139+
};
140+
}, [gum]);
141+
142+
const findDevices = useCallback(async () => {
143+
const constraints = { audio: false, video: { facingMode: 'environment' } };
144+
const mediaDevices = await navigator.mediaDevices.enumerateDevices();
145+
window.stream = await getUserMedia(constraints);
146+
147+
return mediaDevices.filter(({ kind }) => kind === 'videoinput');
33148
}, []);
34149

35-
if (permissions.isGranted === null) {
36-
return <View />;
37-
}
150+
useImperativeHandle(
151+
ref,
152+
() => ({
153+
async takePicture() {
154+
if (!videoEl.current || videoEl.current?.readyState !== videoEl.current?.HAVE_ENOUGH_DATA) {
155+
throw new Error(
156+
'ERR_CAMERA_NOT_READY',
157+
'HTMLVideoElement does not have enough camera data to construct an image yet.',
158+
);
159+
}
160+
161+
canvasEl.current
162+
.getContext('2d')
163+
.drawImage(videoEl.current, 0, 0, canvasEl.current.width, canvasEl.current.height);
164+
165+
const imageType = utils.getOS() === 'ios' ? 'image/png' : 'image/webp';
166+
return { uri: canvasEl.current.toDataURL(imageType) };
167+
},
168+
async resumePreview() {
169+
if (videoEl.current) {
170+
videoEl.current.play();
171+
}
172+
},
173+
async pausePreview() {
174+
if (videoEl.current) {
175+
videoEl.current.pause();
176+
}
177+
},
178+
}),
179+
[videoEl],
180+
);
181+
182+
useLayoutEffect(() => {
183+
(async () => {
184+
if (videoEl && !candidate && !loading) {
185+
const video = videoEl.current;
186+
187+
setLoading(true);
38188

39-
if (permissions.isGranted === false || !available) {
40-
return <Text>No access to camera</Text>;
41-
}
189+
const canvas = canvasEl.current;
190+
const devices = await findDevices();
191+
const bestCandidate = await findBestCandidate(devices);
192+
193+
setCandidate(bestCandidate);
194+
195+
if ('srcObject' in video) {
196+
video.srcObject = bestCandidate.stream;
197+
} else {
198+
video.src = window.URL.createObjectURL(bestCandidate.stream);
199+
}
200+
201+
video.onloadedmetadata = () => { video.play(); };
202+
203+
canvas.width = bestCandidate.test.width;
204+
canvas.height = bestCandidate.test.height;
205+
206+
setLoading(false);
207+
onCameraReady();
208+
}
209+
})();
210+
}, [candidate, findBestCandidate, findDevices, loading, onCameraReady, videoEl]);
42211

43212
return (
44213
<View
45214
accessibilityLabel="Camera container"
46-
style={[containerStyle, size]}
215+
style={[styles.container, containerStyle]}
47216
>
48-
<ExpoCamera
49-
ref={onRef}
50-
ratio={ratio}
51-
onMountError={handleError}
52-
{...passThroughProps}
53-
>
54-
{children}
55-
</ExpoCamera>
56-
{title !== '' && <Text style={styles.title}>{title}</Text>}
217+
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
218+
<Video
219+
autoPlay
220+
playsInline
221+
ref={videoEl}
222+
width={videoWith}
223+
height={videoHeight}
224+
/>
225+
<canvas ref={canvasEl} />
226+
{children}
227+
{(title !== '' && candidate) && <Text style={styles.title}>{title}</Text>}
57228
</View>
58229
);
59-
}
230+
};
231+
232+
export default forwardRef(Camera);
60233

61234
Camera.propTypes = {
62-
children: PropTypes.element,
63235
containerStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
64-
onRef: PropTypes.func.isRequired,
65-
ratio: PropTypes.string.isRequired,
236+
onCameraReady: PropTypes.func.isRequired,
66237
title: PropTypes.string,
67238
};
68239

69240
Camera.defaultProps = {
70-
children: null,
71241
containerStyle: null,
72242
title: '',
73243
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React, { forwardRef, useCallback } from 'react';
2+
import PropTypes from 'prop-types';
3+
4+
import { Text, View } from 'react-native';
5+
import { Camera as ExpoCamera } from 'expo-camera';
6+
7+
import { utils } from '@monkvision/toolkit';
8+
import log from '../../utils/log';
9+
import useAvailable from '../../hooks/useAvailable';
10+
import usePermissions from '../../hooks/usePermissions';
11+
import useWindowDimensions from '../../hooks/useWindowDimensions';
12+
13+
import styles from './styles';
14+
15+
const { getSize } = utils.styles;
16+
17+
function Camera({
18+
children,
19+
containerStyle,
20+
ratio,
21+
style,
22+
title,
23+
...passThroughProps
24+
}, ref) {
25+
const available = useAvailable();
26+
const permissions = usePermissions();
27+
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
28+
const size = getSize(ratio, { windowHeight, windowWidth });
29+
30+
const handleError = useCallback((error) => {
31+
log([error], 'error');
32+
}, []);
33+
34+
if (permissions.isGranted === null) {
35+
return <View />;
36+
}
37+
38+
if (permissions.isGranted === false || !available) {
39+
return <Text>No access to camera</Text>;
40+
}
41+
42+
return (
43+
<View
44+
accessibilityLabel="Camera container"
45+
style={[containerStyle, size]}
46+
>
47+
<ExpoCamera
48+
ref={ref}
49+
ratio={ratio}
50+
onMountError={handleError}
51+
{...passThroughProps}
52+
>
53+
{children}
54+
</ExpoCamera>
55+
{title !== '' && <Text style={styles.title}>{title}</Text>}
56+
</View>
57+
);
58+
}
59+
60+
export default forwardRef(Camera);
61+
62+
Camera.propTypes = {
63+
children: PropTypes.element,
64+
containerStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
65+
ratio: PropTypes.string.isRequired,
66+
title: PropTypes.string,
67+
};
68+
69+
Camera.defaultProps = {
70+
children: null,
71+
containerStyle: null,
72+
title: '',
73+
};

packages/camera/src/components/Camera/styles.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { StyleSheet } from 'react-native';
22

33
export default StyleSheet.create({
4+
container: {
5+
display: 'flex',
6+
alignItems: 'center',
7+
},
48
title: {
59
alignSelf: 'center',
610
backgroundColor: 'rgba(0,0,0,0.75)',

0 commit comments

Comments
 (0)