import { BarcodePickerGui } from "./barcodePickerGui"; import { Camera } from "./camera"; import { CameraAccess } from "./cameraAccess"; import { CameraManager } from "./cameraManager"; import { CameraSettings } from "./cameraSettings"; import { CustomError } from "./customError"; /** * @hidden */ export enum MeteringMode { CONTINUOUS = "continuous", MANUAL = "manual", NONE = "none", SINGLE_SHOT = "single-shot" } /** * @hidden */ export interface ExtendedMediaTrackCapabilities extends MediaTrackCapabilities { focusMode?: MeteringMode[]; torch?: boolean; zoom?: { max: number; min: number; step: number; }; } /** * @hidden */ export interface ExtendedMediaTrackConstraintSet extends MediaTrackConstraintSet { torch?: boolean; zoom?: number; } /** * @hidden * * A barcode picker utility class used to handle camera interaction. */ export class BarcodePickerCameraManager extends CameraManager { private static readonly cameraAccessTimeoutMs: number = 4000; private static readonly cameraMetadataCheckTimeoutMs: number = 4000; private static readonly cameraMetadataCheckIntervalMs: number = 50; private static readonly getCapabilitiesTimeoutMs: number = 500; private static readonly autofocusIntervalMs: number = 1500; private static readonly manualToAutofocusResumeTimeoutMs: number = 5000; private static readonly manualFocusWaitTimeoutMs: number = 400; private static readonly noCameraErrorParameters: { name: string; message: string } = { name: "NoCameraAvailableError", message: "No camera available" }; private readonly triggerFatalError: (error: Error) => void; private readonly barcodePickerGui: BarcodePickerGui; private readonly postStreamInitializationListener: () => void = this.postStreamInitialization.bind(this); private readonly videoTrackUnmuteListener: () => void = this.videoTrackUnmuteRecovery.bind(this); private readonly triggerManualFocusListener: () => void = this.triggerManualFocus.bind(this); private readonly triggerZoomStartListener: () => void = this.triggerZoomStart.bind(this); private readonly triggerZoomMoveListener: () => void = this.triggerZoomMove.bind(this); private selectedCameraSettings?: CameraSettings; private mediaStream?: MediaStream; private mediaTrackCapabilities?: ExtendedMediaTrackCapabilities; private cameraAccessTimeout: number; private cameraMetadataCheckInterval: number; private getCapabilitiesTimeout: number; private autofocusInterval: number; private manualToAutofocusResumeTimeout: number; private manualFocusWaitTimeout: number; private cameraSwitcherEnabled: boolean; private torchToggleEnabled: boolean; private tapToFocusEnabled: boolean; private pinchToZoomEnabled: boolean; private pinchToZoomDistance?: number; private pinchToZoomInitialZoom: number; private torchEnabled: boolean; private cameraInitializationPromise?: Promise; constructor(triggerFatalError: (error: Error) => void, barcodePickerGui: BarcodePickerGui) { super(); this.triggerFatalError = triggerFatalError; this.barcodePickerGui = barcodePickerGui; } public setInteractionOptions( cameraSwitcherEnabled: boolean, torchToggleEnabled: boolean, tapToFocusEnabled: boolean, pinchToZoomEnabled: boolean ): void { this.cameraSwitcherEnabled = cameraSwitcherEnabled; this.torchToggleEnabled = torchToggleEnabled; this.tapToFocusEnabled = tapToFocusEnabled; this.pinchToZoomEnabled = pinchToZoomEnabled; } public isCameraSwitcherEnabled(): boolean { return this.cameraSwitcherEnabled; } public async setCameraSwitcherEnabled(enabled: boolean): Promise { this.cameraSwitcherEnabled = enabled; if (this.cameraSwitcherEnabled) { const cameras: Camera[] = await CameraAccess.getCameras(); if (cameras.length > 1) { this.barcodePickerGui.setCameraSwitcherVisible(true); } } else { this.barcodePickerGui.setCameraSwitcherVisible(false); } } public isTorchToggleEnabled(): boolean { return this.torchToggleEnabled; } public setTorchToggleEnabled(enabled: boolean): void { this.torchToggleEnabled = enabled; if (this.torchToggleEnabled) { if ( this.mediaStream != null && this.mediaTrackCapabilities != null && this.mediaTrackCapabilities.torch != null && this.mediaTrackCapabilities.torch ) { this.barcodePickerGui.setTorchTogglerVisible(true); } } else { this.barcodePickerGui.setTorchTogglerVisible(false); } } public isTapToFocusEnabled(): boolean { return this.tapToFocusEnabled; } public setTapToFocusEnabled(enabled: boolean): void { this.tapToFocusEnabled = enabled; if (this.mediaStream != null) { if (this.tapToFocusEnabled) { this.enableTapToFocusListeners(); } else { this.disableTapToFocusListeners(); } } } public isPinchToZoomEnabled(): boolean { return this.pinchToZoomEnabled; } public setPinchToZoomEnabled(enabled: boolean): void { this.pinchToZoomEnabled = enabled; if (this.mediaStream != null) { if (this.pinchToZoomEnabled) { this.enablePinchToZoomListeners(); } else { this.disablePinchToZoomListeners(); } } } public setSelectedCamera(camera?: Camera): void { this.selectedCamera = camera; } public setSelectedCameraSettings(cameraSettings?: CameraSettings): void { this.selectedCameraSettings = cameraSettings; } public async setupCameras(): Promise { if (this.cameraInitializationPromise != null) { return this.cameraInitializationPromise; } const mediaStreamTrack: void | MediaStreamTrack = await this.accessInitialCamera(); const cameras: Camera[] = await CameraAccess.getCameras(); if (this.cameraSwitcherEnabled && cameras.length > 1) { this.barcodePickerGui.setCameraSwitcherVisible(true); } if (mediaStreamTrack != null) { // We successfully accessed a camera, check if it's really the main (back or only) camera const mainCamera: Camera | undefined = CameraAccess.adjustCamerasFromMainCameraStream(mediaStreamTrack, cameras); if (mainCamera != null) { this.selectedCamera = mainCamera; this.updateActiveCameraCurrentResolution(mainCamera); return Promise.resolve(); } this.setSelectedCamera(); } if (this.selectedCamera == null) { let autoselectedCamera: Camera | undefined = cameras .filter(camera => { return camera.cameraType === Camera.Type.BACK; }) .sort((camera1, camera2) => { return camera1.label.localeCompare(camera2.label); })[0]; if (autoselectedCamera == null) { autoselectedCamera = cameras[0]; if (autoselectedCamera == null) { throw new CustomError(BarcodePickerCameraManager.noCameraErrorParameters); } } return this.initializeCameraWithSettings(autoselectedCamera, this.selectedCameraSettings); } else { return this.initializeCameraWithSettings(this.selectedCamera, this.selectedCameraSettings); } } public stopStream(): void { if (this.activeCamera != null) { this.activeCamera.currentResolution = undefined; } this.activeCamera = undefined; if (this.mediaStream != null) { window.clearTimeout(this.cameraAccessTimeout); window.clearInterval(this.cameraMetadataCheckInterval); window.clearTimeout(this.getCapabilitiesTimeout); window.clearTimeout(this.manualFocusWaitTimeout); window.clearTimeout(this.manualToAutofocusResumeTimeout); window.clearInterval(this.autofocusInterval); this.mediaStream.getVideoTracks().forEach(track => { track.stop(); }); this.mediaStream = undefined; this.mediaTrackCapabilities = undefined; } } public applyCameraSettings(cameraSettings?: CameraSettings): Promise { this.selectedCameraSettings = cameraSettings; if (this.activeCamera == null) { return Promise.reject(new CustomError(BarcodePickerCameraManager.noCameraErrorParameters)); } return this.initializeCameraWithSettings(this.activeCamera, cameraSettings); } public reinitializeCamera(): void { if (this.activeCamera != null) { this.initializeCameraWithSettings(this.activeCamera, this.activeCameraSettings).catch(this.triggerFatalError); } } public async initializeCameraWithSettings(camera: Camera, cameraSettings?: CameraSettings): Promise { let existingCameraInitializationPromise: Promise = Promise.resolve(); if (this.cameraInitializationPromise != null) { existingCameraInitializationPromise = this.cameraInitializationPromise; } await existingCameraInitializationPromise; this.setSelectedCamera(camera); this.selectedCameraSettings = this.activeCameraSettings = cameraSettings; if (cameraSettings != null && cameraSettings.resolutionPreference === CameraSettings.ResolutionPreference.FULL_HD) { this.cameraInitializationPromise = this.initializeCameraAndCheckUpdatedSettings(camera); } else { this.cameraInitializationPromise = this.initializeCameraAndCheckUpdatedSettings(camera, 3); } return this.cameraInitializationPromise; } public async setTorchEnabled(enabled: boolean): Promise { if ( this.mediaStream != null && this.mediaTrackCapabilities != null && this.mediaTrackCapabilities.torch != null && this.mediaTrackCapabilities.torch ) { this.torchEnabled = enabled; const videoTracks: MediaStreamTrack[] = this.mediaStream.getVideoTracks(); // istanbul ignore else if (videoTracks.length !== 0 && typeof videoTracks[0].applyConstraints === "function") { await videoTracks[0].applyConstraints({ advanced: [{ torch: enabled }] }); } } } public async toggleTorch(): Promise { this.torchEnabled = !this.torchEnabled; await this.setTorchEnabled(this.torchEnabled); } public async setZoom(zoomPercentage: number, currentZoom?: number): Promise { if (this.mediaStream != null && this.mediaTrackCapabilities != null && this.mediaTrackCapabilities.zoom != null) { const videoTracks: MediaStreamTrack[] = this.mediaStream.getVideoTracks(); // istanbul ignore else if (videoTracks.length !== 0 && typeof videoTracks[0].applyConstraints === "function") { const zoomRange: number = this.mediaTrackCapabilities.zoom.max - this.mediaTrackCapabilities.zoom.min; if (currentZoom == null) { currentZoom = this.mediaTrackCapabilities.zoom.min; } const targetZoom: number = Math.max( this.mediaTrackCapabilities.zoom.min, Math.min(currentZoom + zoomRange * zoomPercentage, this.mediaTrackCapabilities.zoom.max) ); await videoTracks[0].applyConstraints({ advanced: [{ zoom: targetZoom }] }); } } } private accessInitialCamera(): Promise { let initialCameraAccessPromise: Promise = Promise.resolve(); if (this.selectedCamera == null) { // Try to directly access primary (back or only) camera const primaryCamera: Camera = { deviceId: "", label: "", cameraType: Camera.Type.BACK }; initialCameraAccessPromise = new Promise(async resolve => { try { await this.initializeCameraWithSettings(primaryCamera, this.selectedCameraSettings); if (this.mediaStream != null) { const videoTracks: MediaStreamTrack[] = this.mediaStream.getVideoTracks(); if (videoTracks.length !== 0) { return resolve(videoTracks[0]); } } } catch { // Ignored } finally { resolve(); } }); } return initialCameraAccessPromise; } private updateActiveCameraCurrentResolution(camera: Camera): void { this.activeCamera = camera; this.activeCamera.currentResolution = { width: this.barcodePickerGui.videoElement.videoWidth, height: this.barcodePickerGui.videoElement.videoHeight }; this.barcodePickerGui.setMirrorImageEnabled(this.barcodePickerGui.isMirrorImageEnabled(), false); } private postStreamInitialization(): void { window.clearTimeout(this.getCapabilitiesTimeout); this.getCapabilitiesTimeout = window.setTimeout(() => { this.storeStreamCapabilities(); this.setupAutofocus(); if ( this.torchToggleEnabled && this.mediaStream != null && this.mediaTrackCapabilities != null && this.mediaTrackCapabilities.torch != null && this.mediaTrackCapabilities.torch ) { this.barcodePickerGui.setTorchTogglerVisible(true); } }, BarcodePickerCameraManager.getCapabilitiesTimeoutMs); } private videoTrackUnmuteRecovery(): void { this.reinitializeCamera(); } private async triggerManualFocusForContinuous(): Promise { this.manualToAutofocusResumeTimeout = window.setTimeout(async () => { await this.triggerFocusMode(MeteringMode.CONTINUOUS); }, BarcodePickerCameraManager.manualToAutofocusResumeTimeoutMs); try { await this.triggerFocusMode(MeteringMode.CONTINUOUS); this.manualFocusWaitTimeout = window.setTimeout(async () => { await this.triggerFocusMode(MeteringMode.MANUAL); }, BarcodePickerCameraManager.manualFocusWaitTimeoutMs); } catch { // istanbul ignore next } } private async triggerManualFocusForSingleShot(): Promise { window.clearInterval(this.autofocusInterval); this.manualToAutofocusResumeTimeout = window.setTimeout(() => { this.autofocusInterval = window.setInterval( this.triggerAutoFocus.bind(this), BarcodePickerCameraManager.autofocusIntervalMs ); }, BarcodePickerCameraManager.manualToAutofocusResumeTimeoutMs); try { await this.triggerFocusMode(MeteringMode.SINGLE_SHOT); } catch { // istanbul ignore next } } private async triggerManualFocus(event?: MouseEvent | TouchEvent): Promise { if (event != null) { event.preventDefault(); if (event.type === "touchend" && (event).touches.length !== 0) { return; } // Check if we were using pinch-to-zoom if (this.pinchToZoomDistance != null) { this.pinchToZoomDistance = undefined; return; } } window.clearTimeout(this.manualFocusWaitTimeout); window.clearTimeout(this.manualToAutofocusResumeTimeout); if (this.mediaStream != null && this.mediaTrackCapabilities != null) { const focusModeCapability: MeteringMode[] | undefined = this.mediaTrackCapabilities.focusMode; if (focusModeCapability instanceof Array && focusModeCapability.includes(MeteringMode.SINGLE_SHOT)) { if ( focusModeCapability.includes(MeteringMode.CONTINUOUS) && focusModeCapability.includes(MeteringMode.MANUAL) ) { await this.triggerManualFocusForContinuous(); } else if (!focusModeCapability.includes(MeteringMode.CONTINUOUS)) { await this.triggerManualFocusForSingleShot(); } } } } private triggerZoomStart(event?: TouchEvent): void { if (event == null || event.touches.length !== 2) { return; } event.preventDefault(); this.pinchToZoomDistance = Math.hypot( (event.touches[1].screenX - event.touches[0].screenX) / screen.width, (event.touches[1].screenY - event.touches[0].screenY) / screen.height ); if (this.mediaStream != null && this.mediaTrackCapabilities != null && this.mediaTrackCapabilities.zoom != null) { const videoTracks: MediaStreamTrack[] = this.mediaStream.getVideoTracks(); // istanbul ignore else if (videoTracks.length !== 0 && typeof videoTracks[0].getConstraints === "function") { this.pinchToZoomInitialZoom = this.mediaTrackCapabilities.zoom.min; const currentConstraints: MediaTrackConstraints = videoTracks[0].getConstraints(); if (currentConstraints.advanced != null) { const currentZoomConstraint: ExtendedMediaTrackConstraintSet | undefined = currentConstraints.advanced.find( constraint => { return "zoom" in constraint; } ); if (currentZoomConstraint != null && currentZoomConstraint.zoom != null) { this.pinchToZoomInitialZoom = currentZoomConstraint.zoom; } } } } } private async triggerZoomMove(event?: TouchEvent): Promise { if (this.pinchToZoomDistance == null || event == null || event.touches.length !== 2) { return; } event.preventDefault(); await this.setZoom( (Math.hypot( (event.touches[1].screenX - event.touches[0].screenX) / screen.width, (event.touches[1].screenY - event.touches[0].screenY) / screen.height ) - this.pinchToZoomDistance) * 2, this.pinchToZoomInitialZoom ); } private storeStreamCapabilities(): void { // istanbul ignore else if (this.mediaStream != null) { const videoTracks: MediaStreamTrack[] = this.mediaStream.getVideoTracks(); // istanbul ignore else if (videoTracks.length !== 0 && typeof videoTracks[0].getCapabilities === "function") { this.mediaTrackCapabilities = videoTracks[0].getCapabilities(); } } } private setupAutofocus(): void { window.clearTimeout(this.manualFocusWaitTimeout); window.clearTimeout(this.manualToAutofocusResumeTimeout); // istanbul ignore else if (this.mediaStream != null && this.mediaTrackCapabilities != null) { const focusModeCapability: MeteringMode[] | undefined = this.mediaTrackCapabilities.focusMode; if ( focusModeCapability instanceof Array && !focusModeCapability.includes(MeteringMode.CONTINUOUS) && focusModeCapability.includes(MeteringMode.SINGLE_SHOT) ) { window.clearInterval(this.autofocusInterval); this.autofocusInterval = window.setInterval( this.triggerAutoFocus.bind(this), BarcodePickerCameraManager.autofocusIntervalMs ); } } } private triggerAutoFocus(): void { this.triggerFocusMode(MeteringMode.SINGLE_SHOT).catch( /* istanbul ignore next */ () => { // Ignored } ); } private triggerFocusMode(focusMode: MeteringMode): Promise { // istanbul ignore else if (this.mediaStream != null) { const videoTracks: MediaStreamTrack[] = this.mediaStream.getVideoTracks(); if (videoTracks.length !== 0 && typeof videoTracks[0].applyConstraints === "function") { return videoTracks[0].applyConstraints({ advanced: ([{ focusMode }]) }); } } return Promise.reject(undefined); } private enableTapToFocusListeners(): void { ["touchend", "mousedown"].forEach(eventName => { this.barcodePickerGui.videoElement.addEventListener(eventName, this.triggerManualFocusListener); }); } private enablePinchToZoomListeners(): void { this.barcodePickerGui.videoElement.addEventListener("touchstart", this.triggerZoomStartListener); this.barcodePickerGui.videoElement.addEventListener("touchmove", this.triggerZoomMoveListener); } private disableTapToFocusListeners(): void { ["touchend", "mousedown"].forEach(eventName => { this.barcodePickerGui.videoElement.removeEventListener(eventName, this.triggerManualFocusListener); }); } private disablePinchToZoomListeners(): void { this.barcodePickerGui.videoElement.removeEventListener("touchstart", this.triggerZoomStartListener); this.barcodePickerGui.videoElement.removeEventListener("touchmove", this.triggerZoomMoveListener); } private async initializeCameraAndCheckUpdatedSettings( camera: Camera, resolutionFallbackLevel?: number ): Promise { try { await this.initializeCamera(camera, resolutionFallbackLevel); // Check if due to asynchronous behaviour camera settings were changed while camera was initialized if ( this.selectedCameraSettings !== this.activeCameraSettings && (this.selectedCameraSettings == null || this.activeCameraSettings == null || (<(keyof CameraSettings)[]>Object.keys(this.selectedCameraSettings)).some(cameraSettingsProperty => { return ( (this.selectedCameraSettings)[cameraSettingsProperty] !== (this.activeCameraSettings)[cameraSettingsProperty] ); })) ) { this.activeCameraSettings = this.selectedCameraSettings; return this.initializeCameraAndCheckUpdatedSettings(camera, resolutionFallbackLevel); } } finally { this.cameraInitializationPromise = undefined; } } private retryInitializeCameraIfNeeded( camera: Camera, resolutionFallbackLevel: number, resolve: (value?: void | PromiseLike | undefined) => void, reject: (reason?: Error) => void, error: Error ): Promise | void { if (resolutionFallbackLevel < 6) { return this.initializeCamera(camera, resolutionFallbackLevel + 1) .then(resolve) .catch(reject); } else { return reject(error); } } private async handleCameraInitializationError( error: Error, resolutionFallbackLevel: number, camera: Camera, resolve: (value?: void | PromiseLike | undefined) => void, reject: (reason?: Error) => void ): Promise { // istanbul ignore if if (error.name === "SourceUnavailableError") { error.name = "NotReadableError"; } if ( error.message === "Invalid constraint" || // tslint:disable-next-line:no-any (error.name === "OverconstrainedError" && (error).constraint === "deviceId") ) { // Camera might have changed deviceId: check for new cameras with same label and type but different deviceId const cameras: Camera[] = await CameraAccess.getCameras(); const newCamera: Camera | undefined = cameras.find(currentCamera => { return ( currentCamera.label === camera.label && currentCamera.cameraType === camera.cameraType && currentCamera.deviceId !== camera.deviceId ); }); if (newCamera == null) { return this.retryInitializeCameraIfNeeded(camera, resolutionFallbackLevel, resolve, reject, error); } else { return this.initializeCamera(newCamera, resolutionFallbackLevel) .then(resolve) .catch(reject); } } if ( ["PermissionDeniedError", "PermissionDismissedError", "NotAllowedError", "NotFoundError", "AbortError"].includes( error.name ) ) { // Camera is not accessible at all return reject(error); } return this.retryInitializeCameraIfNeeded(camera, resolutionFallbackLevel, resolve, reject, error); } private initializeCamera(camera: Camera, resolutionFallbackLevel: number = 0): Promise { if (camera == null) { return Promise.reject(new CustomError(BarcodePickerCameraManager.noCameraErrorParameters)); } this.stopStream(); this.torchEnabled = false; this.barcodePickerGui.setTorchTogglerVisible(false); return new Promise(async (resolve, reject) => { try { const stream: MediaStream = await CameraAccess.accessCameraStream(resolutionFallbackLevel, camera); // Detect weird browser behaviour that on unsupported resolution returns a 2x2 video instead if (typeof stream.getTracks()[0].getSettings === "function") { const mediaTrackSettings: MediaTrackSettings = stream.getTracks()[0].getSettings(); if ( mediaTrackSettings.width != null && mediaTrackSettings.height != null && (mediaTrackSettings.width === 2 || mediaTrackSettings.height === 2) ) { if (resolutionFallbackLevel === 6) { return reject( new CustomError({ name: "NotReadableError", message: "Could not initialize camera correctly" }) ); } else { return this.initializeCamera(camera, resolutionFallbackLevel + 1) .then(resolve) .catch(reject); } } } this.mediaStream = stream; this.mediaStream.getVideoTracks().forEach(track => { // Reinitialize camera on weird pause/resumption coming from the OS // This will add the listener only once in the case of multiple calls, identical listeners are ignored track.addEventListener("unmute", this.videoTrackUnmuteListener); }); // This will add the listener only once in the case of multiple calls, identical listeners are ignored this.barcodePickerGui.videoElement.addEventListener("loadedmetadata", this.postStreamInitializationListener); if (this.tapToFocusEnabled) { this.enableTapToFocusListeners(); } if (this.pinchToZoomEnabled) { this.enablePinchToZoomListeners(); } this.resolveInitializeCamera(camera, resolve, reject); this.barcodePickerGui.videoElement.srcObject = stream; this.barcodePickerGui.videoElement.load(); this.barcodePickerGui.playVideo(); } catch (error) { await this.handleCameraInitializationError(error, resolutionFallbackLevel, camera, resolve, reject); } }); } private resolveInitializeCamera(camera: Camera, resolve: () => void, reject: (reason: Error) => void): void { const cameraNotReadableError: Error = new CustomError({ name: "NotReadableError", message: "Could not initialize camera correctly" }); window.clearTimeout(this.cameraAccessTimeout); this.cameraAccessTimeout = window.setTimeout(() => { this.stopStream(); reject(cameraNotReadableError); }, BarcodePickerCameraManager.cameraAccessTimeoutMs); this.barcodePickerGui.videoElement.onresize = () => { this.updateActiveCameraCurrentResolution(camera); }; this.barcodePickerGui.videoElement.onloadeddata = () => { this.barcodePickerGui.videoElement.onloadeddata = null; window.clearTimeout(this.cameraAccessTimeout); // Detect weird browser behaviour that on unsupported resolution returns a 2x2 video instead // Also detect failed camera access with no error but also no video stream provided if ( this.barcodePickerGui.videoElement.videoWidth > 2 && this.barcodePickerGui.videoElement.videoHeight > 2 && this.barcodePickerGui.videoElement.currentTime > 0 ) { if (camera.deviceId !== "") { this.updateActiveCameraCurrentResolution(camera); } return resolve(); } const cameraMetadataCheckStartTime: number = performance.now(); window.clearInterval(this.cameraMetadataCheckInterval); this.cameraMetadataCheckInterval = window.setInterval(() => { // Detect weird browser behaviour that on unsupported resolution returns a 2x2 video instead // Also detect failed camera access with no error but also no video stream provided if ( this.barcodePickerGui.videoElement.videoWidth === 2 || this.barcodePickerGui.videoElement.videoHeight === 2 || this.barcodePickerGui.videoElement.currentTime === 0 ) { if ( performance.now() - cameraMetadataCheckStartTime > BarcodePickerCameraManager.cameraMetadataCheckTimeoutMs ) { window.clearInterval(this.cameraMetadataCheckInterval); this.stopStream(); return reject(cameraNotReadableError); } return; } window.clearInterval(this.cameraMetadataCheckInterval); if (camera.deviceId !== "") { this.updateActiveCameraCurrentResolution(camera); this.barcodePickerGui.videoElement.dispatchEvent(new Event("canplay")); } return resolve(); }, BarcodePickerCameraManager.cameraMetadataCheckIntervalMs); }; } }