Skip to content

Commit f4f2d9b

Browse files
authored
MN-469 Fixing portrait camera + prevent zoom/wide camera (#676)
* fixing camera portrait and prevent zoom/wide camera * Fixed PR / deviceId and facingMode removed from camera props
1 parent 90e4523 commit f4f2d9b

File tree

17 files changed

+202
-63
lines changed

17 files changed

+202
-63
lines changed

packages/public/camera-web/src/Camera/Camera.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { CameraEventHandlers, CameraHUDComponent } from './CameraHUD.types';
1919
* Props given to the Camera component.
2020
*/
2121
export interface CameraProps
22-
extends Partial<CameraConfig>,
22+
extends Partial<Pick<CameraConfig, 'resolution'>>,
2323
Partial<CompressionOptions>,
2424
CameraEventHandlers {
2525
/**
@@ -43,9 +43,7 @@ export interface CameraProps
4343
* component works.
4444
*/
4545
export function Camera({
46-
facingMode = CameraFacingMode.ENVIRONMENT,
4746
resolution = CameraResolution.UHD_4K,
48-
deviceId,
4947
format = CompressionFormat.JPEG,
5048
quality = 0.8,
5149
HUDComponent,
@@ -58,7 +56,7 @@ export function Camera({
5856
error,
5957
retry,
6058
isLoading: isPreviewLoading,
61-
} = useCameraPreview({ facingMode, resolution, deviceId });
59+
} = useCameraPreview({ resolution, facingMode: CameraFacingMode.ENVIRONMENT });
6260
const { ref: canvasRef } = useCameraCanvas({ dimensions });
6361
const { takeScreenshot } = useCameraScreenshot({ videoRef, canvasRef, dimensions });
6462
const { compress } = useCompression({ canvasRef, options: { format, quality } });
@@ -76,7 +74,7 @@ export function Camera({
7674
style={styles['cameraPreview']}
7775
ref={videoRef}
7876
autoPlay
79-
playsInline
77+
playsInline={true}
8078
controls={false}
8179
data-testid='camera-video-preview'
8280
/>

packages/public/camera-web/src/Camera/hooks/useUserMedia.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useMonitoring } from '@monkvision/monitoring';
22
import deepEqual from 'fast-deep-equal';
33
import { useEffect, useState } from 'react';
44
import { PixelDimensions } from '@monkvision/types';
5+
import { isMobileDevice } from '@monkvision/common';
6+
import { getValidCameraDeviceIds } from './utils';
57

68
/**
79
* Enumeration of the different Native error names that can happen when a stream is invalid.
@@ -133,6 +135,13 @@ function getStreamDimensions(stream: MediaStream): PixelDimensions {
133135
return { width, height };
134136
}
135137

138+
function swapWidthAndHeight(dimensions: PixelDimensions): PixelDimensions {
139+
return {
140+
width: dimensions.height,
141+
height: dimensions.width,
142+
};
143+
}
144+
136145
/**
137146
* React hook that wraps the `navigator.mediaDevices.getUserMedia` browser function in order to add React logic layers
138147
* and utility tools :
@@ -202,10 +211,20 @@ export function useUserMedia(constraints: MediaStreamConstraints): UserMediaResu
202211
stream.removeEventListener('inactive', onStreamInactive);
203212
stream.getTracks().forEach((track) => track.stop());
204213
}
205-
const str = await navigator.mediaDevices.getUserMedia(constraints);
214+
const cameraDeviceIds = await getValidCameraDeviceIds(constraints);
215+
const updatedConstraints = {
216+
...constraints,
217+
video: {
218+
...(constraints ? (constraints.video as MediaStreamConstraints) : null),
219+
deviceId: { exact: cameraDeviceIds },
220+
},
221+
};
222+
const str = await navigator.mediaDevices.getUserMedia(updatedConstraints);
206223
str?.addEventListener('inactive', onStreamInactive);
207224
setStream(str);
208-
setDimensions(getStreamDimensions(str));
225+
226+
const dimensionsStr = getStreamDimensions(str);
227+
setDimensions(isMobileDevice() ? swapWidthAndHeight(dimensionsStr) : dimensionsStr);
209228
setIsLoading(false);
210229
} catch (err) {
211230
handleGetUserMediaError(err);
@@ -215,5 +234,21 @@ export function useUserMedia(constraints: MediaStreamConstraints): UserMediaResu
215234
getUserMedia().catch((err) => handleError(err));
216235
}, [constraints, stream, error, isLoading, lastConstraintsApplied, onStreamInactive]);
217236

237+
useEffect(() => {
238+
const portrait = window.matchMedia('(orientation: portrait)');
239+
240+
const handleOrientationChange = () => {
241+
if (stream) {
242+
const dimensionsStr = getStreamDimensions(stream);
243+
setDimensions(isMobileDevice() ? swapWidthAndHeight(dimensionsStr) : dimensionsStr);
244+
}
245+
};
246+
portrait.addEventListener('change', handleOrientationChange);
247+
248+
return () => {
249+
portrait.removeEventListener('change', handleOrientationChange);
250+
};
251+
}, [stream]);
252+
218253
return { stream, dimensions, error, retry, isLoading };
219254
}

packages/public/camera-web/src/Camera/hooks/utils/getMediaContraints.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,6 @@ export interface CameraConfig {
8282
* @default `CameraResolution.UHD_4K`
8383
*/
8484
resolution: CameraResolution;
85-
/**
86-
* The ID of the camera device to use. This ID can be fetched using the native
87-
* `navigator.mediaDevices?.enumerateDevices` function. If this ID is specified, it will prevail over the `facingMode`
88-
* property.
89-
*/
90-
deviceId?: string;
9185
}
9286

9387
/**
@@ -108,10 +102,6 @@ export function getMediaConstraints(config: CameraConfig): MediaStreamConstraint
108102
facingMode: config.facingMode,
109103
};
110104

111-
if (config.deviceId) {
112-
video.deviceId = config.deviceId;
113-
}
114-
115105
return {
116106
audio: false,
117107
video,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
function isValidCamera(device: MediaDeviceInfo) {
2+
return (
3+
device.kind === 'videoinput' &&
4+
!device.label.includes('Wide') &&
5+
!device.label.includes('Telephoto') &&
6+
!device.label.includes('Triple') &&
7+
!device.label.includes('Dual') &&
8+
!device.label.includes('Ultra')
9+
);
10+
}
11+
/**
12+
* Retrieves the valid camera device IDs based on the specified constraints.
13+
*/
14+
export async function getValidCameraDeviceIds(
15+
constraints: MediaStreamConstraints,
16+
): Promise<string[]> {
17+
const str = await navigator.mediaDevices.getUserMedia(constraints);
18+
const devices = await navigator.mediaDevices.enumerateDevices();
19+
const validCameraDeviceIds = devices
20+
.filter((device) => isValidCamera(device))
21+
.map((device) => device.deviceId);
22+
23+
str.getTracks().forEach((track) => track.stop());
24+
return validCameraDeviceIds;
25+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './getCanvasHandle';
22
export * from './getMediaContraints';
3+
export * from './getValidCameraDeviceIds';

packages/public/camera-web/test/Camera/Camera.test.tsx

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,12 @@ describe('Camera component', () => {
4444
jest.clearAllMocks();
4545
});
4646

47-
it('should pass the facingMode, resolution and deviceId props to the useCameraPreview hook', () => {
48-
const facingMode = CameraFacingMode.USER;
47+
it('should pass the resolution props to the useCameraPreview hook', () => {
48+
const facingMode = CameraFacingMode.ENVIRONMENT;
4949
const resolution = CameraResolution.HD_720P;
50-
const deviceId = 'test-device-id';
51-
const { unmount } = render(
52-
<Camera facingMode={facingMode} resolution={resolution} deviceId={deviceId} />,
53-
);
50+
const { unmount } = render(<Camera resolution={resolution} />);
5451

55-
expect(useCameraPreview).toHaveBeenCalledWith({ facingMode, resolution, deviceId });
52+
expect(useCameraPreview).toHaveBeenCalledWith({ facingMode, resolution });
5653
unmount();
5754
});
5855

@@ -78,17 +75,6 @@ describe('Camera component', () => {
7875
unmount();
7976
});
8077

81-
it('should not use any deviceId if not provided', () => {
82-
const { unmount } = render(<Camera />);
83-
84-
expect(useCameraPreview).toHaveBeenCalledWith(
85-
expect.objectContaining({
86-
deviceId: undefined,
87-
}),
88-
);
89-
unmount();
90-
});
91-
9278
it('should pass the stream dimensions to the useCameraCanvas hook', () => {
9379
const { unmount } = render(<Camera />);
9480

packages/public/camera-web/test/Camera/hooks/useCameraPreview.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ describe('useCameraPreview hook', () => {
3131
expect(getMediaConstraints).toHaveBeenCalledWith(undefined);
3232
});
3333
const options: CameraConfig = {
34-
deviceId: 'test-id',
3534
facingMode: CameraFacingMode.USER,
3635
resolution: CameraResolution.QHD_2K,
3736
};

packages/public/camera-web/test/Camera/hooks/useUserMedia.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { useMonitoring } from '@monkvision/monitoring';
22

33
jest.mock('@monkvision/monitoring');
4+
jest.mock('@monkvision/common', () => ({
5+
...jest.requireActual('@monkvision/common'),
6+
isMobileDevice: jest.fn(() => false),
7+
}));
48

59
import { act, waitFor } from '@testing-library/react';
610
import { renderHook } from '@testing-library/react-hooks';
711
import { UserMediaErrorType } from '../../../src';
812
import { InvalidStreamErrorName, useUserMedia } from '../../../src/Camera/hooks';
913
import { GetUserMediaMock, mockGetUserMedia } from '../../mocks';
14+
import { isMobileDevice } from '@monkvision/common';
1015

1116
describe('useUserMedia hook', () => {
1217
let gumMock: GetUserMediaMock | null = null;
@@ -125,11 +130,13 @@ describe('useUserMedia hook', () => {
125130
kind: 'video',
126131
applyConstraints: jest.fn(() => Promise.resolve(undefined)),
127132
getSettings: jest.fn(() => ({ width: 456, height: 123 })),
133+
stop: jest.fn(),
128134
},
129135
{
130136
kind: 'video',
131137
applyConstraints: jest.fn(() => Promise.resolve(undefined)),
132138
getSettings: jest.fn(() => ({ width: 456, height: 123 })),
139+
stop: jest.fn(),
133140
},
134141
] as unknown as MediaStreamTrack[];
135142
mockGetUserMedia({ tracks });
@@ -159,6 +166,7 @@ describe('useUserMedia hook', () => {
159166
kind: 'video',
160167
applyConstraints: jest.fn(() => Promise.resolve(undefined)),
161168
getSettings: jest.fn(() => invalidSettings[i]),
169+
stop: jest.fn(),
162170
},
163171
] as unknown as MediaStreamTrack[];
164172
mockGetUserMedia({ tracks });
@@ -233,7 +241,7 @@ describe('useUserMedia hook', () => {
233241
await waitFor(() => {
234242
expect(result.current.error).toBeNull();
235243
expect(result.current.stream).toEqual(mock.stream);
236-
expect(mock.getUserMediaSpy).toHaveBeenCalledTimes(2);
244+
expect(mock.getUserMediaSpy).toHaveBeenCalledTimes(3);
237245
});
238246
unmount();
239247
});
@@ -271,4 +279,54 @@ describe('useUserMedia hook', () => {
271279
});
272280
unmount();
273281
});
282+
283+
it('should switch the dimensions if the device is mobile', async () => {
284+
const userAgentGetter = jest.spyOn(window.navigator, 'userAgent', 'get');
285+
userAgentGetter.mockReturnValue('iphone');
286+
const isMobileDeviceMock = isMobileDevice as jest.Mock;
287+
isMobileDeviceMock.mockReturnValue(true);
288+
const constraints: MediaStreamConstraints = {
289+
audio: false,
290+
video: { width: 123, height: 456 },
291+
};
292+
const { result, unmount } = renderHook(useUserMedia, {
293+
initialProps: constraints,
294+
});
295+
await waitFor(() => {
296+
expect(result.current.dimensions).toEqual({
297+
height: 456,
298+
width: 123,
299+
});
300+
});
301+
unmount();
302+
});
303+
304+
it('should filter the video constraints by removing: Telephoto and wide camera', async () => {
305+
const userAgentGetter = jest.spyOn(window.navigator, 'userAgent', 'get');
306+
userAgentGetter.mockReturnValue('iphone');
307+
const constraints: MediaStreamConstraints = {
308+
audio: false,
309+
video: { width: 123, height: 456 },
310+
};
311+
gumMock?.enumerateDevicesSpy.mockResolvedValue([
312+
{ kind: 'videoinput', label: 'Front Camera', deviceId: 'frontDeviceId' },
313+
{ kind: 'videoinput', label: 'Rear Camera', deviceId: 'rearDeviceId' },
314+
{ kind: 'videoinput', label: 'Wide Angle Camera', deviceId: 'wideDeviceId' },
315+
{ kind: 'videoinput', label: 'Telephoto Angle Camera', deviceId: 'wideDeviceId' },
316+
]);
317+
const { unmount } = renderHook(useUserMedia, {
318+
initialProps: constraints,
319+
});
320+
await waitFor(() => {
321+
expect(gumMock?.getUserMediaSpy).toHaveBeenCalledWith({
322+
audio: false,
323+
video: {
324+
width: 123,
325+
height: 456,
326+
deviceId: { exact: ['frontDeviceId', 'rearDeviceId'] },
327+
},
328+
});
329+
});
330+
unmount();
331+
});
274332
});

packages/public/camera-web/test/Camera/hooks/utils/getMediaConstraints.test.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,6 @@ const EXPECTED_CAMERA_RESOLUTION_SIZES: {
2020
};
2121

2222
describe('useMediaConstraints hook', () => {
23-
it('should properly map the deviceId option', () => {
24-
const deviceId = 'test-id';
25-
const constraints = getMediaConstraints({
26-
deviceId,
27-
facingMode: CameraFacingMode.ENVIRONMENT,
28-
resolution: CameraResolution.UHD_4K,
29-
});
30-
31-
expect(constraints).toEqual(
32-
expect.objectContaining({
33-
video: expect.objectContaining({ deviceId }),
34-
}),
35-
);
36-
});
37-
3823
Object.values(CameraFacingMode).forEach((facingMode) =>
3924
it(`should properly map the '${facingMode}' facingMode option`, () => {
4025
const constraints = getMediaConstraints({ facingMode, resolution: CameraResolution.UHD_4K });
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { getValidCameraDeviceIds } from '../../../../src/Camera/hooks/utils';
2+
import { mockGetUserMedia } from '../../../mocks';
3+
4+
describe('getValidCameraDeviceIds util function', () => {
5+
it('should return valid camera device IDs based on constraints', async () => {
6+
const gumMock = mockGetUserMedia();
7+
const devices = [
8+
{ kind: 'videoinput', label: 'Front Camera', deviceId: 'frontDeviceId' },
9+
{ kind: 'videoinput', label: 'Rear Camera', deviceId: 'rearDeviceId' },
10+
{ kind: 'videoinput', label: 'Wide Angle Camera', deviceId: 'wideDeviceId' },
11+
{ kind: 'videoinput', label: 'Telephoto Angle Camera', deviceId: 'telephotoDeviceId' },
12+
];
13+
gumMock?.enumerateDevicesSpy.mockResolvedValue(devices);
14+
const constraints: MediaStreamConstraints = {
15+
audio: false,
16+
video: { width: 123, height: 456 },
17+
};
18+
const validDeviceIds = await getValidCameraDeviceIds(constraints);
19+
20+
expect(validDeviceIds).toEqual([devices[0].deviceId, devices[1].deviceId]); // Adjust this expectation based on your logic
21+
expect(gumMock.getUserMediaSpy).toHaveBeenCalledWith(constraints);
22+
expect(gumMock.enumerateDevicesSpy).toHaveBeenCalled();
23+
});
24+
});

0 commit comments

Comments
 (0)