UNPKG

15.6 kBPlain TextView Raw
1import { BrowserCompatibility } from "./browserCompatibility";
2import { BrowserHelper } from "./browserHelper";
3import { Camera } from "./camera";
4import { UnsupportedBrowserError } from "./unsupportedBrowserError";
5
6/**
7 * A helper object to interact with cameras.
8 */
9export namespace CameraAccess {
10 /**
11 * @hidden
12 *
13 * Handle localized camera labels. Supported languages:
14 * English, German, French, Spanish (spain), Portuguese (brasil), Portuguese (portugal), Italian,
15 * Chinese (simplified), Chinese (traditional), Japanese, Russian, Turkish, Dutch, Arabic, Thai, Swedish,
16 * Danish, Vietnamese, Norwegian, Polish, Finnish, Indonesian, Hebrew, Greek, Romanian, Hungarian, Czech,
17 * Catalan, Slovak, Ukraininan, Croatian, Malay, Hindi.
18 */
19 const backCameraKeywords: string[] = [
20 "rear",
21 "back",
22 "rück",
23 "arrière",
24 "trasera",
25 "trás",
26 "traseira",
27 "posteriore",
28 "后面",
29 "後面",
30 "背面",
31 "后置", // alternative
32 "後置", // alternative
33 "背置", // alternative
34 "задней",
35 "الخلفية",
36 "후",
37 "arka",
38 "achterzijde",
39 "หลัง",
40 "baksidan",
41 "bagside",
42 "sau",
43 "bak",
44 "tylny",
45 "takakamera",
46 "belakang",
47 "אחורית",
48 "πίσω",
49 "spate",
50 "hátsó",
51 "zadní",
52 "darrere",
53 "zadná",
54 "задня",
55 "stražnja",
56 "belakang",
57 "बैक"
58 ];
59
60 /**
61 * @hidden
62 */
63 const cameraObjects: Map<string, Camera> = new Map<string, Camera>();
64
65 /**
66 * @hidden
67 */
68 let getCamerasPromise: Promise<Camera[]> | undefined;
69
70 /**
71 * @hidden
72 *
73 * @param label The camera label.
74 * @returns Whether the label mentions the camera being a back-facing one.
75 */
76 function isBackCameraLabel(label: string): boolean {
77 const lowercaseLabel: string = label.toLowerCase();
78
79 return backCameraKeywords.some(keyword => {
80 return lowercaseLabel.includes(keyword);
81 });
82 }
83
84 /**
85 * @hidden
86 *
87 * Adjusts the cameras' type classification based on the given currently active video stream:
88 * If the stream comes from an environment-facing camera, the camera is marked to be a back-facing camera
89 * and the other cameras to be of other types accordingly (if they are not correctly set already).
90 *
91 * The method returns the currently active camera if it's actually the main (back or only) camera in use.
92 *
93 * @param mediaStreamTrack The currently active `MediaStreamTrack`.
94 * @param cameras The array of available [[Camera]] objects.
95 * @returns Whether the stream was actually from the main camera.
96 */
97 export function adjustCamerasFromMainCameraStream(
98 mediaStreamTrack: MediaStreamTrack,
99 cameras: Camera[]
100 ): Camera | undefined {
101 let mediaTrackSettings: MediaTrackSettings | undefined;
102 if (typeof mediaStreamTrack.getSettings === "function") {
103 mediaTrackSettings = mediaStreamTrack.getSettings();
104 }
105 const activeCamera: Camera | undefined = cameras.find(camera => {
106 return (
107 (mediaTrackSettings != null && camera.deviceId === mediaTrackSettings.deviceId) ||
108 camera.label === mediaStreamTrack.label
109 );
110 });
111 if (activeCamera !== undefined) {
112 const activeCameraIsBackFacing: boolean =
113 (mediaTrackSettings != null && mediaTrackSettings.facingMode === "environment") ||
114 isBackCameraLabel(mediaStreamTrack.label);
115 let activeCameraIsMainBackCamera: boolean = activeCameraIsBackFacing;
116 // TODO: also correct camera types when active camera is not back-facing
117 if (activeCameraIsBackFacing && cameras.length > 1) {
118 // Correct camera types if needed
119 cameras.forEach(camera => {
120 if (camera.deviceId === activeCamera.deviceId) {
121 // tslint:disable-next-line:no-any
122 (<any>camera).cameraType = Camera.Type.BACK;
123 } else if (!isBackCameraLabel(camera.label)) {
124 // tslint:disable-next-line:no-any
125 (<any>camera).cameraType = Camera.Type.FRONT;
126 }
127 });
128
129 const mainBackCamera: Camera = cameras
130 .filter(camera => {
131 return camera.cameraType === Camera.Type.BACK;
132 })
133 .sort((camera1, camera2) => {
134 return camera1.label.localeCompare(camera2.label);
135 })[0];
136 activeCameraIsMainBackCamera = activeCamera.deviceId === mainBackCamera.deviceId;
137 }
138
139 if (cameras.length === 1 || activeCameraIsMainBackCamera) {
140 return activeCamera;
141 }
142 }
143
144 return undefined;
145 }
146
147 /**
148 * @hidden
149 *
150 * @param devices The list of available devices.
151 * @returns The extracted list of camera objects initialized from the given devices.
152 */
153 function extractCamerasFromDevices(devices: MediaDeviceInfo[]): Camera[] {
154 const cameras: Camera[] = devices
155 .filter(device => {
156 return device.kind === "videoinput";
157 })
158 .map(videoDevice => {
159 if (cameraObjects.has(videoDevice.deviceId)) {
160 return <Camera>cameraObjects.get(videoDevice.deviceId);
161 }
162
163 const label: string = videoDevice.label != null ? videoDevice.label : "";
164 const camera: Camera = {
165 deviceId: videoDevice.deviceId,
166 label,
167 cameraType: isBackCameraLabel(label) ? Camera.Type.BACK : Camera.Type.FRONT
168 };
169
170 if (label !== "") {
171 cameraObjects.set(videoDevice.deviceId, camera);
172 }
173
174 return camera;
175 });
176 if (
177 cameras.length > 1 &&
178 !cameras.some(camera => {
179 return camera.cameraType === Camera.Type.BACK;
180 })
181 ) {
182 // Check if cameras are labeled with resolution information, take the higher-resolution one in that case
183 // Otherwise pick the last camera
184 let backCameraIndex: number = cameras.length - 1;
185
186 const cameraResolutions: number[] = cameras.map(camera => {
187 const match: RegExpMatchArray | null = camera.label.match(/\b([0-9]+)MP?\b/i);
188 if (match != null) {
189 return parseInt(match[1], 10);
190 }
191
192 return NaN;
193 });
194 if (
195 !cameraResolutions.some(cameraResolution => {
196 return isNaN(cameraResolution);
197 })
198 ) {
199 backCameraIndex = cameraResolutions.lastIndexOf(Math.max(...cameraResolutions));
200 }
201
202 // tslint:disable-next-line:no-any
203 (<any>cameras[backCameraIndex]).cameraType = Camera.Type.BACK;
204 }
205
206 return cameras;
207 }
208
209 /**
210 * Get a list of cameras (if any) available on the device, a camera access permission is requested to the user
211 * the first time this method is called if needed.
212 *
213 * Depending on device features and user permissions for camera access, any of the following errors
214 * could be the rejected result of the returned promise:
215 * - `UnsupportedBrowserError`
216 * - `PermissionDeniedError`
217 * - `NotAllowedError`
218 * - `NotFoundError`
219 * - `AbortError`
220 * - `NotReadableError`
221 * - `InternalError`
222 *
223 * @returns A promise resolving to the array of available [[Camera]] objects (could be empty).
224 */
225 export function getCameras(): Promise<Camera[]> {
226 if (getCamerasPromise != null) {
227 return getCamerasPromise;
228 }
229
230 const browserCompatibility: BrowserCompatibility = BrowserHelper.checkBrowserCompatibility();
231 if (!browserCompatibility.fullSupport) {
232 return Promise.reject(new UnsupportedBrowserError(browserCompatibility));
233 }
234
235 const accessPermissionPromise: Promise<void | MediaStream> = new Promise((resolve, reject) => {
236 return enumerateDevices()
237 .then(devices => {
238 if (
239 devices
240 .filter(device => {
241 return device.kind === "videoinput";
242 })
243 .every(device => {
244 return device.label === "";
245 })
246 ) {
247 resolve(
248 navigator.mediaDevices.getUserMedia({
249 video: true,
250 audio: false
251 })
252 );
253 } else {
254 resolve();
255 }
256 })
257 .catch(reject);
258 });
259
260 getCamerasPromise = new Promise(async (resolve, reject) => {
261 let stream!: void | MediaStream;
262 try {
263 stream = await accessPermissionPromise;
264 const devices: MediaDeviceInfo[] = await enumerateDevices();
265 const cameras: Camera[] = extractCamerasFromDevices(devices);
266
267 console.debug("Camera list: ", ...cameras);
268
269 return resolve(cameras);
270 } catch (error) {
271 // istanbul ignore if
272 if (error.name === "SourceUnavailableError") {
273 error.name = "NotReadableError";
274 }
275
276 return reject(error);
277 } finally {
278 // istanbul ignore else
279 if (stream != null) {
280 stream.getVideoTracks().forEach(track => {
281 track.stop();
282 });
283 }
284 getCamerasPromise = undefined;
285 }
286 });
287
288 return getCamerasPromise;
289 }
290
291 /**
292 * @hidden
293 *
294 * Call `navigator.mediaDevices.getUserMedia` asynchronously in a `setTimeout` call.
295 *
296 * @param getUserMediaParams The parameters for the `navigator.mediaDevices.getUserMedia` call.
297 * @returns A promise resolving when the camera is accessed.
298 */
299 function getUserMediaDelayed(getUserMediaParams: MediaStreamConstraints): Promise<MediaStream> {
300 console.debug("Camera access:", getUserMediaParams.video);
301
302 return new Promise((resolve, reject) => {
303 window.setTimeout(() => {
304 navigator.mediaDevices
305 .getUserMedia(getUserMediaParams)
306 .then(resolve)
307 .catch(reject);
308 }, 0);
309 });
310 }
311
312 /**
313 * @hidden
314 *
315 * Get the *getUserMedia* *video* parameters to be used given a resolution fallback level and the browser used.
316 *
317 * @param resolutionFallbackLevel The number representing the wanted resolution, from 0 to 6,
318 * resulting in higher to lower video resolutions.
319 * @param isSafariBrowser Whether the browser is *Safari*.
320 * @returns The resulting *getUserMedia* *video* parameters.
321 */
322 function getUserMediaVideoParams(resolutionFallbackLevel: number, isSafariBrowser: boolean): MediaTrackConstraints {
323 switch (resolutionFallbackLevel) {
324 case 0:
325 if (isSafariBrowser) {
326 return {
327 width: { min: 1400, ideal: 1920, max: 1920 },
328 height: { min: 900, ideal: 1080, max: 1440 }
329 };
330 } else {
331 return {
332 width: { min: 1400, ideal: 1920, max: 1920 },
333 height: { min: 900, ideal: 1440, max: 1440 }
334 };
335 }
336 case 1:
337 if (isSafariBrowser) {
338 return {
339 width: { min: 1200, ideal: 1600, max: 1920 },
340 height: { min: 900, ideal: 1080, max: 1200 }
341 };
342 } else {
343 return {
344 width: { min: 1200, ideal: 1920, max: 1920 },
345 height: { min: 900, ideal: 1200, max: 1200 }
346 };
347 }
348 case 2:
349 if (isSafariBrowser) {
350 return {
351 width: { min: 1080, ideal: 1600, max: 1920 },
352 height: { min: 900, ideal: 900, max: 1080 }
353 };
354 } else {
355 return {
356 width: { min: 1080, ideal: 1920, max: 1920 },
357 height: { min: 900, ideal: 1080, max: 1080 }
358 };
359 }
360 case 3:
361 if (isSafariBrowser) {
362 return {
363 width: { min: 960, ideal: 1280, max: 1440 },
364 height: { min: 480, ideal: 720, max: 960 }
365 };
366 } else {
367 return {
368 width: { min: 960, ideal: 1280, max: 1440 },
369 height: { min: 480, ideal: 960, max: 960 }
370 };
371 }
372 case 4:
373 if (isSafariBrowser) {
374 return {
375 width: { min: 720, ideal: 1024, max: 1440 },
376 height: { min: 480, ideal: 768, max: 768 }
377 };
378 } else {
379 return {
380 width: { min: 720, ideal: 1280, max: 1440 },
381 height: { min: 480, ideal: 720, max: 768 }
382 };
383 }
384 case 5:
385 if (isSafariBrowser) {
386 return {
387 width: { min: 640, ideal: 800, max: 1440 },
388 height: { min: 480, ideal: 600, max: 720 }
389 };
390 } else {
391 return {
392 width: { min: 640, ideal: 960, max: 1440 },
393 height: { min: 480, ideal: 720, max: 720 }
394 };
395 }
396 default:
397 return {};
398 }
399 }
400
401 /**
402 * @hidden
403 *
404 * Try to access a given camera for video input at the given resolution level.
405 *
406 * @param resolutionFallbackLevel The number representing the wanted resolution, from 0 to 6,
407 * resulting in higher to lower video resolutions.
408 * @param camera The camera to try to access for video input.
409 * @returns A promise resolving to the `MediaStream` object coming from the accessed camera.
410 */
411 export function accessCameraStream(resolutionFallbackLevel: number, camera: Camera): Promise<MediaStream> {
412 const browserName: string | undefined = BrowserHelper.userAgentInfo.getBrowser().name;
413 const getUserMediaParams: MediaStreamConstraints = {
414 audio: false,
415 video: getUserMediaVideoParams(resolutionFallbackLevel, browserName != null && browserName.includes("Safari"))
416 };
417
418 if (camera.deviceId === "") {
419 (<MediaTrackConstraints>getUserMediaParams.video).facingMode = {
420 ideal: camera.cameraType === Camera.Type.BACK ? "environment" : "user"
421 };
422 } else {
423 (<MediaTrackConstraints>getUserMediaParams.video).deviceId = {
424 exact: camera.deviceId
425 };
426 }
427
428 return getUserMediaDelayed(getUserMediaParams);
429 }
430
431 /**
432 * @hidden
433 *
434 * Get a list of available devices in a cross-browser compatible way.
435 *
436 * @returns A promise resolving to the `MediaDeviceInfo` array of all available devices.
437 */
438 function enumerateDevices(): Promise<MediaDeviceInfo[]> {
439 if (typeof navigator.enumerateDevices === "function") {
440 return navigator.enumerateDevices();
441 } else if (
442 typeof navigator.mediaDevices === "object" &&
443 typeof navigator.mediaDevices.enumerateDevices === "function"
444 ) {
445 return navigator.mediaDevices.enumerateDevices();
446 } else {
447 return new Promise((resolve, reject) => {
448 try {
449 if (window.MediaStreamTrack == null || window.MediaStreamTrack.getSources == null) {
450 throw new Error();
451 }
452 window.MediaStreamTrack.getSources((devices: MediaDeviceInfo[]) => {
453 resolve(
454 devices
455 .filter(device => {
456 return device.kind.toLowerCase() === "video" || device.kind.toLowerCase() === "videoinput";
457 })
458 .map(device => {
459 return {
460 deviceId: device.deviceId != null ? device.deviceId : "",
461 groupId: device.groupId,
462 kind: <MediaDeviceKind>"videoinput",
463 label: device.label,
464 toJSON: /* istanbul ignore next */ function(): MediaDeviceInfo {
465 return this;
466 }
467 };
468 })
469 );
470 });
471 } catch {
472 const browserCompatibility: BrowserCompatibility = {
473 fullSupport: false,
474 scannerSupport: true,
475 missingFeatures: [BrowserCompatibility.Feature.MEDIA_DEVICES]
476 };
477
478 return reject(new UnsupportedBrowserError(browserCompatibility));
479 }
480 });
481 }
482 }
483}