diff --git a/packages/public/camera-web/src/Camera/Camera.tsx b/packages/public/camera-web/src/Camera/Camera.tsx index db92e0d33..1a6b1b4e4 100644 --- a/packages/public/camera-web/src/Camera/Camera.tsx +++ b/packages/public/camera-web/src/Camera/Camera.tsx @@ -19,7 +19,7 @@ import { CameraEventHandlers, CameraHUDComponent } from './CameraHUD.types'; * Props given to the Camera component. */ export interface CameraProps - extends Partial, + extends Partial>, Partial, CameraEventHandlers { /** @@ -43,9 +43,7 @@ export interface CameraProps * component works. */ export function Camera({ - facingMode = CameraFacingMode.ENVIRONMENT, resolution = CameraResolution.UHD_4K, - deviceId, format = CompressionFormat.JPEG, quality = 0.8, HUDComponent, @@ -58,7 +56,7 @@ export function Camera({ error, retry, isLoading: isPreviewLoading, - } = useCameraPreview({ facingMode, resolution, deviceId }); + } = useCameraPreview({ resolution, facingMode: CameraFacingMode.ENVIRONMENT }); const { ref: canvasRef } = useCameraCanvas({ dimensions }); const { takeScreenshot } = useCameraScreenshot({ videoRef, canvasRef, dimensions }); const { compress } = useCompression({ canvasRef, options: { format, quality } }); @@ -76,7 +74,7 @@ export function Camera({ style={styles['cameraPreview']} ref={videoRef} autoPlay - playsInline + playsInline={true} controls={false} data-testid='camera-video-preview' /> diff --git a/packages/public/camera-web/src/Camera/hooks/useUserMedia.ts b/packages/public/camera-web/src/Camera/hooks/useUserMedia.ts index 9501c425c..8fc4239c9 100644 --- a/packages/public/camera-web/src/Camera/hooks/useUserMedia.ts +++ b/packages/public/camera-web/src/Camera/hooks/useUserMedia.ts @@ -2,6 +2,8 @@ import { useMonitoring } from '@monkvision/monitoring'; import deepEqual from 'fast-deep-equal'; import { useEffect, useState } from 'react'; import { PixelDimensions } from '@monkvision/types'; +import { isMobileDevice } from '@monkvision/common'; +import { getValidCameraDeviceIds } from './utils'; /** * Enumeration of the different Native error names that can happen when a stream is invalid. @@ -133,6 +135,13 @@ function getStreamDimensions(stream: MediaStream): PixelDimensions { return { width, height }; } +function swapWidthAndHeight(dimensions: PixelDimensions): PixelDimensions { + return { + width: dimensions.height, + height: dimensions.width, + }; +} + /** * React hook that wraps the `navigator.mediaDevices.getUserMedia` browser function in order to add React logic layers * and utility tools : @@ -202,10 +211,20 @@ export function useUserMedia(constraints: MediaStreamConstraints): UserMediaResu stream.removeEventListener('inactive', onStreamInactive); stream.getTracks().forEach((track) => track.stop()); } - const str = await navigator.mediaDevices.getUserMedia(constraints); + const cameraDeviceIds = await getValidCameraDeviceIds(constraints); + const updatedConstraints = { + ...constraints, + video: { + ...(constraints ? (constraints.video as MediaStreamConstraints) : null), + deviceId: { exact: cameraDeviceIds }, + }, + }; + const str = await navigator.mediaDevices.getUserMedia(updatedConstraints); str?.addEventListener('inactive', onStreamInactive); setStream(str); - setDimensions(getStreamDimensions(str)); + + const dimensionsStr = getStreamDimensions(str); + setDimensions(isMobileDevice() ? swapWidthAndHeight(dimensionsStr) : dimensionsStr); setIsLoading(false); } catch (err) { handleGetUserMediaError(err); @@ -215,5 +234,21 @@ export function useUserMedia(constraints: MediaStreamConstraints): UserMediaResu getUserMedia().catch((err) => handleError(err)); }, [constraints, stream, error, isLoading, lastConstraintsApplied, onStreamInactive]); + useEffect(() => { + const portrait = window.matchMedia('(orientation: portrait)'); + + const handleOrientationChange = () => { + if (stream) { + const dimensionsStr = getStreamDimensions(stream); + setDimensions(isMobileDevice() ? swapWidthAndHeight(dimensionsStr) : dimensionsStr); + } + }; + portrait.addEventListener('change', handleOrientationChange); + + return () => { + portrait.removeEventListener('change', handleOrientationChange); + }; + }, [stream]); + return { stream, dimensions, error, retry, isLoading }; } diff --git a/packages/public/camera-web/src/Camera/hooks/utils/getMediaContraints.ts b/packages/public/camera-web/src/Camera/hooks/utils/getMediaContraints.ts index cbc4073a6..72bb6f4bc 100644 --- a/packages/public/camera-web/src/Camera/hooks/utils/getMediaContraints.ts +++ b/packages/public/camera-web/src/Camera/hooks/utils/getMediaContraints.ts @@ -82,12 +82,6 @@ export interface CameraConfig { * @default `CameraResolution.UHD_4K` */ resolution: CameraResolution; - /** - * The ID of the camera device to use. This ID can be fetched using the native - * `navigator.mediaDevices?.enumerateDevices` function. If this ID is specified, it will prevail over the `facingMode` - * property. - */ - deviceId?: string; } /** @@ -108,10 +102,6 @@ export function getMediaConstraints(config: CameraConfig): MediaStreamConstraint facingMode: config.facingMode, }; - if (config.deviceId) { - video.deviceId = config.deviceId; - } - return { audio: false, video, diff --git a/packages/public/camera-web/src/Camera/hooks/utils/getValidCameraDeviceIds.ts b/packages/public/camera-web/src/Camera/hooks/utils/getValidCameraDeviceIds.ts new file mode 100644 index 000000000..279730125 --- /dev/null +++ b/packages/public/camera-web/src/Camera/hooks/utils/getValidCameraDeviceIds.ts @@ -0,0 +1,25 @@ +function isValidCamera(device: MediaDeviceInfo) { + return ( + device.kind === 'videoinput' && + !device.label.includes('Wide') && + !device.label.includes('Telephoto') && + !device.label.includes('Triple') && + !device.label.includes('Dual') && + !device.label.includes('Ultra') + ); +} +/** + * Retrieves the valid camera device IDs based on the specified constraints. + */ +export async function getValidCameraDeviceIds( + constraints: MediaStreamConstraints, +): Promise { + const str = await navigator.mediaDevices.getUserMedia(constraints); + const devices = await navigator.mediaDevices.enumerateDevices(); + const validCameraDeviceIds = devices + .filter((device) => isValidCamera(device)) + .map((device) => device.deviceId); + + str.getTracks().forEach((track) => track.stop()); + return validCameraDeviceIds; +} diff --git a/packages/public/camera-web/src/Camera/hooks/utils/index.ts b/packages/public/camera-web/src/Camera/hooks/utils/index.ts index c60321beb..dba253f72 100644 --- a/packages/public/camera-web/src/Camera/hooks/utils/index.ts +++ b/packages/public/camera-web/src/Camera/hooks/utils/index.ts @@ -1,2 +1,3 @@ export * from './getCanvasHandle'; export * from './getMediaContraints'; +export * from './getValidCameraDeviceIds'; diff --git a/packages/public/camera-web/test/Camera/Camera.test.tsx b/packages/public/camera-web/test/Camera/Camera.test.tsx index bb46db895..5024d7062 100644 --- a/packages/public/camera-web/test/Camera/Camera.test.tsx +++ b/packages/public/camera-web/test/Camera/Camera.test.tsx @@ -44,15 +44,12 @@ describe('Camera component', () => { jest.clearAllMocks(); }); - it('should pass the facingMode, resolution and deviceId props to the useCameraPreview hook', () => { - const facingMode = CameraFacingMode.USER; + it('should pass the resolution props to the useCameraPreview hook', () => { + const facingMode = CameraFacingMode.ENVIRONMENT; const resolution = CameraResolution.HD_720P; - const deviceId = 'test-device-id'; - const { unmount } = render( - , - ); + const { unmount } = render(); - expect(useCameraPreview).toHaveBeenCalledWith({ facingMode, resolution, deviceId }); + expect(useCameraPreview).toHaveBeenCalledWith({ facingMode, resolution }); unmount(); }); @@ -78,17 +75,6 @@ describe('Camera component', () => { unmount(); }); - it('should not use any deviceId if not provided', () => { - const { unmount } = render(); - - expect(useCameraPreview).toHaveBeenCalledWith( - expect.objectContaining({ - deviceId: undefined, - }), - ); - unmount(); - }); - it('should pass the stream dimensions to the useCameraCanvas hook', () => { const { unmount } = render(); diff --git a/packages/public/camera-web/test/Camera/hooks/useCameraPreview.test.tsx b/packages/public/camera-web/test/Camera/hooks/useCameraPreview.test.tsx index 928800283..f59532aee 100644 --- a/packages/public/camera-web/test/Camera/hooks/useCameraPreview.test.tsx +++ b/packages/public/camera-web/test/Camera/hooks/useCameraPreview.test.tsx @@ -31,7 +31,6 @@ describe('useCameraPreview hook', () => { expect(getMediaConstraints).toHaveBeenCalledWith(undefined); }); const options: CameraConfig = { - deviceId: 'test-id', facingMode: CameraFacingMode.USER, resolution: CameraResolution.QHD_2K, }; diff --git a/packages/public/camera-web/test/Camera/hooks/useUserMedia.test.ts b/packages/public/camera-web/test/Camera/hooks/useUserMedia.test.ts index db937c66a..58e153df5 100644 --- a/packages/public/camera-web/test/Camera/hooks/useUserMedia.test.ts +++ b/packages/public/camera-web/test/Camera/hooks/useUserMedia.test.ts @@ -1,12 +1,17 @@ import { useMonitoring } from '@monkvision/monitoring'; jest.mock('@monkvision/monitoring'); +jest.mock('@monkvision/common', () => ({ + ...jest.requireActual('@monkvision/common'), + isMobileDevice: jest.fn(() => false), +})); import { act, waitFor } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import { UserMediaErrorType } from '../../../src'; import { InvalidStreamErrorName, useUserMedia } from '../../../src/Camera/hooks'; import { GetUserMediaMock, mockGetUserMedia } from '../../mocks'; +import { isMobileDevice } from '@monkvision/common'; describe('useUserMedia hook', () => { let gumMock: GetUserMediaMock | null = null; @@ -125,11 +130,13 @@ describe('useUserMedia hook', () => { kind: 'video', applyConstraints: jest.fn(() => Promise.resolve(undefined)), getSettings: jest.fn(() => ({ width: 456, height: 123 })), + stop: jest.fn(), }, { kind: 'video', applyConstraints: jest.fn(() => Promise.resolve(undefined)), getSettings: jest.fn(() => ({ width: 456, height: 123 })), + stop: jest.fn(), }, ] as unknown as MediaStreamTrack[]; mockGetUserMedia({ tracks }); @@ -159,6 +166,7 @@ describe('useUserMedia hook', () => { kind: 'video', applyConstraints: jest.fn(() => Promise.resolve(undefined)), getSettings: jest.fn(() => invalidSettings[i]), + stop: jest.fn(), }, ] as unknown as MediaStreamTrack[]; mockGetUserMedia({ tracks }); @@ -233,7 +241,7 @@ describe('useUserMedia hook', () => { await waitFor(() => { expect(result.current.error).toBeNull(); expect(result.current.stream).toEqual(mock.stream); - expect(mock.getUserMediaSpy).toHaveBeenCalledTimes(2); + expect(mock.getUserMediaSpy).toHaveBeenCalledTimes(3); }); unmount(); }); @@ -271,4 +279,54 @@ describe('useUserMedia hook', () => { }); unmount(); }); + + it('should switch the dimensions if the device is mobile', async () => { + const userAgentGetter = jest.spyOn(window.navigator, 'userAgent', 'get'); + userAgentGetter.mockReturnValue('iphone'); + const isMobileDeviceMock = isMobileDevice as jest.Mock; + isMobileDeviceMock.mockReturnValue(true); + const constraints: MediaStreamConstraints = { + audio: false, + video: { width: 123, height: 456 }, + }; + const { result, unmount } = renderHook(useUserMedia, { + initialProps: constraints, + }); + await waitFor(() => { + expect(result.current.dimensions).toEqual({ + height: 456, + width: 123, + }); + }); + unmount(); + }); + + it('should filter the video constraints by removing: Telephoto and wide camera', async () => { + const userAgentGetter = jest.spyOn(window.navigator, 'userAgent', 'get'); + userAgentGetter.mockReturnValue('iphone'); + const constraints: MediaStreamConstraints = { + audio: false, + video: { width: 123, height: 456 }, + }; + gumMock?.enumerateDevicesSpy.mockResolvedValue([ + { kind: 'videoinput', label: 'Front Camera', deviceId: 'frontDeviceId' }, + { kind: 'videoinput', label: 'Rear Camera', deviceId: 'rearDeviceId' }, + { kind: 'videoinput', label: 'Wide Angle Camera', deviceId: 'wideDeviceId' }, + { kind: 'videoinput', label: 'Telephoto Angle Camera', deviceId: 'wideDeviceId' }, + ]); + const { unmount } = renderHook(useUserMedia, { + initialProps: constraints, + }); + await waitFor(() => { + expect(gumMock?.getUserMediaSpy).toHaveBeenCalledWith({ + audio: false, + video: { + width: 123, + height: 456, + deviceId: { exact: ['frontDeviceId', 'rearDeviceId'] }, + }, + }); + }); + unmount(); + }); }); diff --git a/packages/public/camera-web/test/Camera/hooks/utils/getMediaConstraints.test.ts b/packages/public/camera-web/test/Camera/hooks/utils/getMediaConstraints.test.ts index 41fd2a07f..79a11cee7 100644 --- a/packages/public/camera-web/test/Camera/hooks/utils/getMediaConstraints.test.ts +++ b/packages/public/camera-web/test/Camera/hooks/utils/getMediaConstraints.test.ts @@ -20,21 +20,6 @@ const EXPECTED_CAMERA_RESOLUTION_SIZES: { }; describe('useMediaConstraints hook', () => { - it('should properly map the deviceId option', () => { - const deviceId = 'test-id'; - const constraints = getMediaConstraints({ - deviceId, - facingMode: CameraFacingMode.ENVIRONMENT, - resolution: CameraResolution.UHD_4K, - }); - - expect(constraints).toEqual( - expect.objectContaining({ - video: expect.objectContaining({ deviceId }), - }), - ); - }); - Object.values(CameraFacingMode).forEach((facingMode) => it(`should properly map the '${facingMode}' facingMode option`, () => { const constraints = getMediaConstraints({ facingMode, resolution: CameraResolution.UHD_4K }); diff --git a/packages/public/camera-web/test/Camera/hooks/utils/getValidCameraDeviceIds.test.ts b/packages/public/camera-web/test/Camera/hooks/utils/getValidCameraDeviceIds.test.ts new file mode 100644 index 000000000..17d5c307a --- /dev/null +++ b/packages/public/camera-web/test/Camera/hooks/utils/getValidCameraDeviceIds.test.ts @@ -0,0 +1,24 @@ +import { getValidCameraDeviceIds } from '../../../../src/Camera/hooks/utils'; +import { mockGetUserMedia } from '../../../mocks'; + +describe('getValidCameraDeviceIds util function', () => { + it('should return valid camera device IDs based on constraints', async () => { + const gumMock = mockGetUserMedia(); + const devices = [ + { kind: 'videoinput', label: 'Front Camera', deviceId: 'frontDeviceId' }, + { kind: 'videoinput', label: 'Rear Camera', deviceId: 'rearDeviceId' }, + { kind: 'videoinput', label: 'Wide Angle Camera', deviceId: 'wideDeviceId' }, + { kind: 'videoinput', label: 'Telephoto Angle Camera', deviceId: 'telephotoDeviceId' }, + ]; + gumMock?.enumerateDevicesSpy.mockResolvedValue(devices); + const constraints: MediaStreamConstraints = { + audio: false, + video: { width: 123, height: 456 }, + }; + const validDeviceIds = await getValidCameraDeviceIds(constraints); + + expect(validDeviceIds).toEqual([devices[0].deviceId, devices[1].deviceId]); // Adjust this expectation based on your logic + expect(gumMock.getUserMediaSpy).toHaveBeenCalledWith(constraints); + expect(gumMock.enumerateDevicesSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/public/camera-web/test/mocks/getUserMedia.mock.ts b/packages/public/camera-web/test/mocks/getUserMedia.mock.ts index 2e9cdc620..c5970466b 100644 --- a/packages/public/camera-web/test/mocks/getUserMedia.mock.ts +++ b/packages/public/camera-web/test/mocks/getUserMedia.mock.ts @@ -9,6 +9,8 @@ export interface GetUserMediaMock { tracks: MediaStreamTrack[]; stream: MediaStream; getUserMediaSpy: jest.SpyInstance; + enumerateDevicesSpy: jest.SpyInstance; + matchMedia: jest.SpyInstance; } const defaultMockTrack = { @@ -38,13 +40,27 @@ export function mockGetUserMedia(params?: MockGetUserMediaParams): GetUserMediaM getUserMedia: params?.createMock ? params.createMock(stream) : jest.fn(() => Promise.resolve(stream)), + enumerateDevices: jest.fn(() => Promise.resolve([])), }, configurable: true, writable: true, }); + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: true, // Set the default value as needed + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); return { tracks, stream, getUserMediaSpy: jest.spyOn(global.navigator.mediaDevices, 'getUserMedia'), + enumerateDevicesSpy: jest.spyOn(global.navigator.mediaDevices, 'enumerateDevices'), + matchMedia: jest.spyOn(global.window, 'matchMedia'), }; } diff --git a/packages/public/common/src/utils/browser.utils.ts b/packages/public/common/src/utils/browser.utils.ts new file mode 100644 index 000000000..d66e40934 --- /dev/null +++ b/packages/public/common/src/utils/browser.utils.ts @@ -0,0 +1,13 @@ +/** + * Checks if the current device is a mobile device. + */ +export function isMobileDevice(): boolean { + const userAgent = navigator.userAgent.toLowerCase(); + return ( + userAgent.includes('mobile') || + userAgent.includes('android') || + userAgent.includes('iphone') || + userAgent.includes('ipad') || + userAgent.includes('windows phone') + ); +} diff --git a/packages/public/common/src/utils/index.ts b/packages/public/common/src/utils/index.ts index b814162a5..365c37496 100644 --- a/packages/public/common/src/utils/index.ts +++ b/packages/public/common/src/utils/index.ts @@ -2,3 +2,4 @@ export * from './string.utils'; export * from './array.utils'; export * from './color.utils'; export * from './zlib.utils'; +export * from './browser.utils'; diff --git a/packages/public/common/test/utils/browser.utils.test.ts b/packages/public/common/test/utils/browser.utils.test.ts new file mode 100644 index 000000000..77518c99a --- /dev/null +++ b/packages/public/common/test/utils/browser.utils.test.ts @@ -0,0 +1,17 @@ +import { isMobileDevice } from '../../src'; + +describe('isMobileDevice', () => { + it('should return true for mobile user agents', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('iphone'); + + const result = isMobileDevice(); + expect(result).toBe(true); + }); + + it('should return false for non-mobile user agents', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('safari'); + + const result = isMobileDevice(); + expect(result).toBe(false); + }); +}); diff --git a/packages/public/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/public/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index 8fe56c373..b06850a78 100644 --- a/packages/public/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/public/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -34,7 +34,6 @@ export const PhotoCapture = i18nWrap(({ sights }: PhotoCaptureProps) => {
{ it('should pass states, hud and handlePictureTaken to Camera component', () => { const CameraMock = Camera as jest.Mock; const state = { - facingMode: CameraFacingMode.ENVIRONMENT, resolution: CameraResolution.UHD_4K, compressionFormat: CompressionFormat.JPEG, quality: '0.8', @@ -58,7 +52,6 @@ describe('PhotoCapture component', () => { }); const { HUDComponent } = CameraMock.mock.calls[0][0]; HUDComponent({ sights, cameraPreview: <> , handle: jest.fn() }); - expect(CameraMock.mock.calls[0][0].facingMode).toEqual(state.facingMode); expect(CameraMock.mock.calls[0][0].resolution).toEqual(state.resolution); expect(CameraMock.mock.calls[0][0].format).toEqual(state.compressionFormat); expect(CameraMock.mock.calls[0][0].quality).toEqual(Number(state.quality)); diff --git a/yarn.lock b/yarn.lock index 896245bbe..55991d2bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3721,7 +3721,7 @@ __metadata: languageName: unknown linkType: soft -"@monkvision/network@4.0.0, @monkvision/network@workspace:packages/public/network": +"@monkvision/network@workspace:packages/public/network": version: 0.0.0-use.local resolution: "@monkvision/network@workspace:packages/public/network" dependencies: @@ -15215,7 +15215,6 @@ __metadata: "@monkvision/common-ui-web": 4.0.0 "@monkvision/inspection-capture-web": 4.0.0 "@monkvision/monitoring": 4.0.0 - "@monkvision/network": 4.0.0 "@monkvision/sentry": 4.0.0 "@monkvision/sights": 4.0.0 "@monkvision/types": 4.0.0