import { BrowserCompatibility } from "./browserCompatibility"; import { BrowserHelper } from "./browserHelper"; import { Camera } from "./camera"; import { UnsupportedBrowserError } from "./unsupportedBrowserError"; /** * A helper object to interact with cameras. */ export namespace CameraAccess { /** * @hidden * * Handle localized camera labels. Supported languages: * English, German, French, Spanish (spain), Portuguese (brasil), Portuguese (portugal), Italian, * Chinese (simplified), Chinese (traditional), Japanese, Russian, Turkish, Dutch, Arabic, Thai, Swedish, * Danish, Vietnamese, Norwegian, Polish, Finnish, Indonesian, Hebrew, Greek, Romanian, Hungarian, Czech, * Catalan, Slovak, Ukraininan, Croatian, Malay, Hindi. */ const backCameraKeywords: string[] = [ "rear", "back", "rück", "arrière", "trasera", "trás", "traseira", "posteriore", "后面", "後面", "背面", "后置", // alternative "後置", // alternative "背置", // alternative "задней", "الخلفية", "후", "arka", "achterzijde", "หลัง", "baksidan", "bagside", "sau", "bak", "tylny", "takakamera", "belakang", "אחורית", "πίσω", "spate", "hátsó", "zadní", "darrere", "zadná", "задня", "stražnja", "belakang", "बैक" ]; /** * @hidden */ const cameraObjects: Map = new Map(); /** * @hidden */ let getCamerasPromise: Promise | undefined; /** * @hidden * * @param label The camera label. * @returns Whether the label mentions the camera being a back-facing one. */ function isBackCameraLabel(label: string): boolean { const lowercaseLabel: string = label.toLowerCase(); return backCameraKeywords.some(keyword => { return lowercaseLabel.includes(keyword); }); } /** * @hidden * * Adjusts the cameras' type classification based on the given currently active video stream: * If the stream comes from an environment-facing camera, the camera is marked to be a back-facing camera * and the other cameras to be of other types accordingly (if they are not correctly set already). * * The method returns the currently active camera if it's actually the main (back or only) camera in use. * * @param mediaStreamTrack The currently active `MediaStreamTrack`. * @param cameras The array of available [[Camera]] objects. * @returns Whether the stream was actually from the main camera. */ export function adjustCamerasFromMainCameraStream( mediaStreamTrack: MediaStreamTrack, cameras: Camera[] ): Camera | undefined { let mediaTrackSettings: MediaTrackSettings | undefined; if (typeof mediaStreamTrack.getSettings === "function") { mediaTrackSettings = mediaStreamTrack.getSettings(); } const activeCamera: Camera | undefined = cameras.find(camera => { return ( (mediaTrackSettings != null && camera.deviceId === mediaTrackSettings.deviceId) || camera.label === mediaStreamTrack.label ); }); if (activeCamera !== undefined) { const activeCameraIsBackFacing: boolean = (mediaTrackSettings != null && mediaTrackSettings.facingMode === "environment") || isBackCameraLabel(mediaStreamTrack.label); let activeCameraIsMainBackCamera: boolean = activeCameraIsBackFacing; // TODO: also correct camera types when active camera is not back-facing if (activeCameraIsBackFacing && cameras.length > 1) { // Correct camera types if needed cameras.forEach(camera => { if (camera.deviceId === activeCamera.deviceId) { // tslint:disable-next-line:no-any (camera).cameraType = Camera.Type.BACK; } else if (!isBackCameraLabel(camera.label)) { // tslint:disable-next-line:no-any (camera).cameraType = Camera.Type.FRONT; } }); const mainBackCamera: Camera = cameras .filter(camera => { return camera.cameraType === Camera.Type.BACK; }) .sort((camera1, camera2) => { return camera1.label.localeCompare(camera2.label); })[0]; activeCameraIsMainBackCamera = activeCamera.deviceId === mainBackCamera.deviceId; } if (cameras.length === 1 || activeCameraIsMainBackCamera) { return activeCamera; } } return undefined; } /** * @hidden * * @param devices The list of available devices. * @returns The extracted list of camera objects initialized from the given devices. */ function extractCamerasFromDevices(devices: MediaDeviceInfo[]): Camera[] { const cameras: Camera[] = devices .filter(device => { return device.kind === "videoinput"; }) .map(videoDevice => { if (cameraObjects.has(videoDevice.deviceId)) { return cameraObjects.get(videoDevice.deviceId); } const label: string = videoDevice.label != null ? videoDevice.label : ""; const camera: Camera = { deviceId: videoDevice.deviceId, label, cameraType: isBackCameraLabel(label) ? Camera.Type.BACK : Camera.Type.FRONT }; if (label !== "") { cameraObjects.set(videoDevice.deviceId, camera); } return camera; }); if ( cameras.length > 1 && !cameras.some(camera => { return camera.cameraType === Camera.Type.BACK; }) ) { // Check if cameras are labeled with resolution information, take the higher-resolution one in that case // Otherwise pick the last camera let backCameraIndex: number = cameras.length - 1; const cameraResolutions: number[] = cameras.map(camera => { const match: RegExpMatchArray | null = camera.label.match(/\b([0-9]+)MP?\b/i); if (match != null) { return parseInt(match[1], 10); } return NaN; }); if ( !cameraResolutions.some(cameraResolution => { return isNaN(cameraResolution); }) ) { backCameraIndex = cameraResolutions.lastIndexOf(Math.max(...cameraResolutions)); } // tslint:disable-next-line:no-any (cameras[backCameraIndex]).cameraType = Camera.Type.BACK; } return cameras; } /** * Get a list of cameras (if any) available on the device, a camera access permission is requested to the user * the first time this method is called if needed. * * Depending on device features and user permissions for camera access, any of the following errors * could be the rejected result of the returned promise: * - `UnsupportedBrowserError` * - `PermissionDeniedError` * - `NotAllowedError` * - `NotFoundError` * - `AbortError` * - `NotReadableError` * - `InternalError` * * @returns A promise resolving to the array of available [[Camera]] objects (could be empty). */ export function getCameras(): Promise { if (getCamerasPromise != null) { return getCamerasPromise; } const browserCompatibility: BrowserCompatibility = BrowserHelper.checkBrowserCompatibility(); if (!browserCompatibility.fullSupport) { return Promise.reject(new UnsupportedBrowserError(browserCompatibility)); } const accessPermissionPromise: Promise = new Promise((resolve, reject) => { return enumerateDevices() .then(devices => { if ( devices .filter(device => { return device.kind === "videoinput"; }) .every(device => { return device.label === ""; }) ) { resolve( navigator.mediaDevices.getUserMedia({ video: true, audio: false }) ); } else { resolve(); } }) .catch(reject); }); getCamerasPromise = new Promise(async (resolve, reject) => { let stream!: void | MediaStream; try { stream = await accessPermissionPromise; const devices: MediaDeviceInfo[] = await enumerateDevices(); const cameras: Camera[] = extractCamerasFromDevices(devices); console.debug("Camera list: ", ...cameras); return resolve(cameras); } catch (error) { // istanbul ignore if if (error.name === "SourceUnavailableError") { error.name = "NotReadableError"; } return reject(error); } finally { // istanbul ignore else if (stream != null) { stream.getVideoTracks().forEach(track => { track.stop(); }); } getCamerasPromise = undefined; } }); return getCamerasPromise; } /** * @hidden * * Call `navigator.mediaDevices.getUserMedia` asynchronously in a `setTimeout` call. * * @param getUserMediaParams The parameters for the `navigator.mediaDevices.getUserMedia` call. * @returns A promise resolving when the camera is accessed. */ function getUserMediaDelayed(getUserMediaParams: MediaStreamConstraints): Promise { console.debug("Camera access:", getUserMediaParams.video); return new Promise((resolve, reject) => { window.setTimeout(() => { navigator.mediaDevices .getUserMedia(getUserMediaParams) .then(resolve) .catch(reject); }, 0); }); } /** * @hidden * * Get the *getUserMedia* *video* parameters to be used given a resolution fallback level and the browser used. * * @param resolutionFallbackLevel The number representing the wanted resolution, from 0 to 6, * resulting in higher to lower video resolutions. * @param isSafariBrowser Whether the browser is *Safari*. * @returns The resulting *getUserMedia* *video* parameters. */ function getUserMediaVideoParams(resolutionFallbackLevel: number, isSafariBrowser: boolean): MediaTrackConstraints { switch (resolutionFallbackLevel) { case 0: if (isSafariBrowser) { return { width: { min: 1400, ideal: 1920, max: 1920 }, height: { min: 900, ideal: 1080, max: 1440 } }; } else { return { width: { min: 1400, ideal: 1920, max: 1920 }, height: { min: 900, ideal: 1440, max: 1440 } }; } case 1: if (isSafariBrowser) { return { width: { min: 1200, ideal: 1600, max: 1920 }, height: { min: 900, ideal: 1080, max: 1200 } }; } else { return { width: { min: 1200, ideal: 1920, max: 1920 }, height: { min: 900, ideal: 1200, max: 1200 } }; } case 2: if (isSafariBrowser) { return { width: { min: 1080, ideal: 1600, max: 1920 }, height: { min: 900, ideal: 900, max: 1080 } }; } else { return { width: { min: 1080, ideal: 1920, max: 1920 }, height: { min: 900, ideal: 1080, max: 1080 } }; } case 3: if (isSafariBrowser) { return { width: { min: 960, ideal: 1280, max: 1440 }, height: { min: 480, ideal: 720, max: 960 } }; } else { return { width: { min: 960, ideal: 1280, max: 1440 }, height: { min: 480, ideal: 960, max: 960 } }; } case 4: if (isSafariBrowser) { return { width: { min: 720, ideal: 1024, max: 1440 }, height: { min: 480, ideal: 768, max: 768 } }; } else { return { width: { min: 720, ideal: 1280, max: 1440 }, height: { min: 480, ideal: 720, max: 768 } }; } case 5: if (isSafariBrowser) { return { width: { min: 640, ideal: 800, max: 1440 }, height: { min: 480, ideal: 600, max: 720 } }; } else { return { width: { min: 640, ideal: 960, max: 1440 }, height: { min: 480, ideal: 720, max: 720 } }; } default: return {}; } } /** * @hidden * * Try to access a given camera for video input at the given resolution level. * * @param resolutionFallbackLevel The number representing the wanted resolution, from 0 to 6, * resulting in higher to lower video resolutions. * @param camera The camera to try to access for video input. * @returns A promise resolving to the `MediaStream` object coming from the accessed camera. */ export function accessCameraStream(resolutionFallbackLevel: number, camera: Camera): Promise { const browserName: string | undefined = BrowserHelper.userAgentInfo.getBrowser().name; const getUserMediaParams: MediaStreamConstraints = { audio: false, video: getUserMediaVideoParams(resolutionFallbackLevel, browserName != null && browserName.includes("Safari")) }; if (camera.deviceId === "") { (getUserMediaParams.video).facingMode = { ideal: camera.cameraType === Camera.Type.BACK ? "environment" : "user" }; } else { (getUserMediaParams.video).deviceId = { exact: camera.deviceId }; } return getUserMediaDelayed(getUserMediaParams); } /** * @hidden * * Get a list of available devices in a cross-browser compatible way. * * @returns A promise resolving to the `MediaDeviceInfo` array of all available devices. */ function enumerateDevices(): Promise { if (typeof navigator.enumerateDevices === "function") { return navigator.enumerateDevices(); } else if ( typeof navigator.mediaDevices === "object" && typeof navigator.mediaDevices.enumerateDevices === "function" ) { return navigator.mediaDevices.enumerateDevices(); } else { return new Promise((resolve, reject) => { try { if (window.MediaStreamTrack == null || window.MediaStreamTrack.getSources == null) { throw new Error(); } window.MediaStreamTrack.getSources((devices: MediaDeviceInfo[]) => { resolve( devices .filter(device => { return device.kind.toLowerCase() === "video" || device.kind.toLowerCase() === "videoinput"; }) .map(device => { return { deviceId: device.deviceId != null ? device.deviceId : "", groupId: device.groupId, kind: "videoinput", label: device.label, toJSON: /* istanbul ignore next */ function(): MediaDeviceInfo { return this; } }; }) ); }); } catch { const browserCompatibility: BrowserCompatibility = { fullSupport: false, scannerSupport: true, missingFeatures: [BrowserCompatibility.Feature.MEDIA_DEVICES] }; return reject(new UnsupportedBrowserError(browserCompatibility)); } }); } } }