From 4bb7f0e97f4e0f3e68271b958c51e043bd62dce3 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 6 Nov 2020 15:20:28 +0100 Subject: [PATCH] MOBILE-3585 emulator: Add mocks of media services --- package-lock.json | 60 ++ package.json | 4 + src/app/classes/errors/captureerror.ts | 30 + .../capture-media/capture-media.html | 66 ++ .../capture-media/capture-media.scss | 71 +++ .../components/capture-media/capture-media.ts | 588 ++++++++++++++++++ .../emulator/components/components.module.ts | 41 ++ src/app/core/emulator/emulator.module.ts | 26 + src/app/core/emulator/services/camera.ts | 47 ++ .../core/emulator/services/capture.helper.ts | 218 +++++++ .../core/emulator/services/media-capture.ts | 62 ++ 11 files changed, 1213 insertions(+) create mode 100644 src/app/classes/errors/captureerror.ts create mode 100644 src/app/core/emulator/components/capture-media/capture-media.html create mode 100644 src/app/core/emulator/components/capture-media/capture-media.scss create mode 100644 src/app/core/emulator/components/capture-media/capture-media.ts create mode 100644 src/app/core/emulator/components/components.module.ts create mode 100644 src/app/core/emulator/services/camera.ts create mode 100644 src/app/core/emulator/services/capture.helper.ts create mode 100644 src/app/core/emulator/services/media-capture.ts diff --git a/package-lock.json b/package-lock.json index 6f81cf9a7..8183fe042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2115,6 +2115,36 @@ } } }, + "@ionic-native/camera": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@ionic-native/camera/-/camera-5.29.0.tgz", + "integrity": "sha512-JOmFb2eWeh8zZWu2JlNVRbhcSvOcwiTSdoabEfGtw0ITXs0FzuRmzAQgF2PQGyPA8844wkr3T5IUhcMpYxW6UQ==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, + "@ionic-native/chooser": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@ionic-native/chooser/-/chooser-5.29.0.tgz", + "integrity": "sha512-1/+zr+SbijWqd0FomOh83aQb8vqH2qO2CAlgX2FyjJuK4fgt3BF9GMXpzTjkd/qrHO9rbxUMFAcrQAv/HAVNiA==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, "@ionic-native/clipboard": { "version": "5.28.0", "resolved": "https://registry.npmjs.org/@ionic-native/clipboard/-/clipboard-5.28.0.tgz", @@ -2247,6 +2277,36 @@ "@types/cordova": "^0.0.34" } }, + "@ionic-native/media": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@ionic-native/media/-/media-5.29.0.tgz", + "integrity": "sha512-XC8MtrbeR0X0I6B0FABStc2mSAmgIQidaRjFqP4jBAElAwjZC7PHwaDyyVJUOR1Rx5Nest46hZAU6jpAPZ8+pw==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, + "@ionic-native/media-capture": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.29.0.tgz", + "integrity": "sha512-5NdTXQGbrpXLeeLbI+cGQaeNmpmOrPC9vgX4jvUT6whUdDXGZ93wLT1/eeRj208czNiqbdetjG8Dji3OJZ5MKA==", + "requires": { + "@types/cordova": "^0.0.34" + }, + "dependencies": { + "@types/cordova": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", + "integrity": "sha1-6nrd907Ow9dimCegw54smt3HPQQ=" + } + } + }, "@ionic-native/network": { "version": "5.28.0", "resolved": "https://registry.npmjs.org/@ionic-native/network/-/network-5.28.0.tgz", diff --git a/package.json b/package.json index c16199081..22bee0290 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "@angular/platform-browser": "~10.0.0", "@angular/platform-browser-dynamic": "~10.0.0", "@angular/router": "~10.0.0", + "@ionic-native/camera": "^5.29.0", + "@ionic-native/chooser": "^5.29.0", "@ionic-native/clipboard": "^5.28.0", "@ionic-native/core": "^5.0.0", "@ionic-native/device": "^5.28.0", @@ -51,6 +53,8 @@ "@ionic-native/ionic-webview": "^5.28.0", "@ionic-native/keyboard": "^5.28.0", "@ionic-native/local-notifications": "^5.28.0", + "@ionic-native/media": "^5.29.0", + "@ionic-native/media-capture": "^5.29.0", "@ionic-native/network": "^5.28.0", "@ionic-native/push": "^5.28.0", "@ionic-native/qr-scanner": "^5.28.0", diff --git a/src/app/classes/errors/captureerror.ts b/src/app/classes/errors/captureerror.ts new file mode 100644 index 000000000..302ffb5a8 --- /dev/null +++ b/src/app/classes/errors/captureerror.ts @@ -0,0 +1,30 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreError } from './error'; + +/** + * Capture error. + */ +export class CoreCaptureError extends CoreError { + + code: number; + + constructor(code: number, message?: string) { + super(message); + + this.code = code; + } + +} diff --git a/src/app/core/emulator/components/capture-media/capture-media.html b/src/app/core/emulator/components/capture-media/capture-media.html new file mode 100644 index 000000000..b2afae1dd --- /dev/null +++ b/src/app/core/emulator/components/capture-media/capture-media.html @@ -0,0 +1,66 @@ + + + {{ title | translate }} + + + {{ 'core.cancel' | translate }} + {{ 'core.done' | translate }} + + + + + +
+ + + + + + + + + {{ 'core.capturedimage' | translate }} + + +
+ + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/core/emulator/components/capture-media/capture-media.scss b/src/app/core/emulator/components/capture-media/capture-media.scss new file mode 100644 index 000000000..8e911e0b4 --- /dev/null +++ b/src/app/core/emulator/components/capture-media/capture-media.scss @@ -0,0 +1,71 @@ +:host { + .core-av-wrapper { + // @todo: For some reason it takes a while to apply these styles, first it's displayed too big and then it's resized. + width: 100%; + height: 100%; + + .core-webcam-image-canvas { + display: none; + } + + .core-audio-record-container { + width: 100%; + height: 100%; + position: relative; + + .core-audio-canvas { + width: 100%; + height: 100%; + } + + .core-audio-captured { + width: 100%; + } + + .button { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: auto; + height: 120px; + width: 120px; + + .icon { + font-size: 120px; + width: auto; + } + } + + audio { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: auto; + } + } + + video, img { + width: 100%; + height: 100%; + display: table-cell; + text-align: center; + vertical-align: middle; + object-fit: contain; + + &.core-webcam-stream { + -webkit-transform: scaleX(-1); + transform: scaleX(-1); + } + } + } + + + ion-footer { + background-color: var(--gray); + border-top: 1px solid var(--gray-dark); + } +} diff --git a/src/app/core/emulator/components/capture-media/capture-media.ts b/src/app/core/emulator/components/capture-media/capture-media.ts new file mode 100644 index 000000000..a4ea47e75 --- /dev/null +++ b/src/app/core/emulator/components/capture-media/capture-media.ts @@ -0,0 +1,588 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectorRef, Input } from '@angular/core'; +import { MediaObject } from '@ionic-native/media/ngx'; +import { FileEntry } from '@ionic-native/file/ngx'; +import { MediaFile } from '@ionic-native/media-capture/ngx'; + +import { CoreApp } from '@services/app'; +import { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { Platform, ModalController, Media, Translate } from '@singletons/core.singletons'; +import { CoreError } from '@classes/errors/error'; +import { CoreCaptureError } from '@classes/errors/captureerror'; +import { CoreCanceledError } from '@classes/errors/cancelederror'; + +/** + * Page to capture media in browser, or to capture audio in mobile devices. + */ +@Component({ + selector: 'core-emulator-capture-media', + templateUrl: 'capture-media.html', + styleUrls: ['capture-media.scss'], +}) +export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { + + @Input() type?: 'audio' | 'video' | 'image' | 'captureimage'; + @Input() maxTime?: number; // Max time to capture. + @Input() facingMode?: string; // Camera facing mode. + @Input() mimetype?: string; + @Input() extension?: string; + @Input() quality?: number; // Only for images. + @Input() returnDataUrl?: boolean; // Whether it should return a data img. Only for images. + + @ViewChild('streamVideo') streamVideo?: ElementRef; + @ViewChild('previewVideo') previewVideo?: ElementRef; + @ViewChild('imgCanvas') imgCanvas?: ElementRef; + @ViewChild('previewImage') previewImage?: ElementRef; + @ViewChild('streamAudio') streamAudio?: ElementRef; + @ViewChild('previewAudio') previewAudio?: ElementRef; + + title?: string; // The title of the page. + isAudio?: boolean; // Whether it should capture audio. + isVideo?: boolean; // Whether it should capture video. + isImage?: boolean; // Whether it should capture image. + readyToCapture?: boolean; // Whether it's ready to capture. + hasCaptured?: boolean; // Whether it has captured something. + isCapturing?: boolean; // Whether it's capturing. + resetChrono?: boolean; // Boolean to reset the chrono. + isCordovaAudioCapture?: boolean; // Whether it's capturing audio using Cordova plugin. + + protected isCaptureImage?: boolean; // To identify if it's capturing an image using media capture plugin (instead of camera). + protected mediaRecorder?: MediaRecorder; // To record video/audio. + protected previewMedia?: HTMLAudioElement | HTMLVideoElement; // The element to preview the audio/video captured. + protected mediaBlob?: Blob; // A Blob where the captured data is stored. + protected localMediaStream?: MediaStream; + protected audioDrawer?: {start: () => void; stop: () => void }; // To start/stop the display of audio sound. + + // Variables for Cordova Media capture. + protected mediaFile?: MediaObject; + protected filePath?: string; + protected fileEntry?: FileEntry; + + constructor( + protected changeDetectorRef: ChangeDetectorRef, + ) {} + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initVariables(); + + if (this.isCordovaAudioCapture) { + this.initCordovaMediaPlugin(); + } else { + this.initHtmlCapture(); + } + } + + /** + * Initialize some variables based on the params. + */ + protected initVariables(): void { + this.facingMode = this.facingMode || 'environment'; + this.quality = this.quality || 0.92; + + if (this.type == 'captureimage') { + this.isCaptureImage = true; + this.type = 'image'; + } + + // Initialize some data based on the type of media to capture. + if (this.type == 'video') { + this.isVideo = true; + this.title = 'core.capturevideo'; + } else if (this.type == 'audio') { + this.isAudio = true; + this.title = 'core.captureaudio'; + } else if (this.type == 'image') { + this.isImage = true; + this.title = 'core.captureimage'; + } + + this.isCordovaAudioCapture = CoreApp.instance.isMobile() && this.isAudio; + + if (this.isCordovaAudioCapture) { + this.extension = Platform.instance.is('ios') ? 'wav' : 'aac'; + this.returnDataUrl = false; + } + } + + /** + * Init recording with Cordova media plugin. + * + * @return Promise resolved when ready. + */ + protected async initCordovaMediaPlugin(): Promise { + this.filePath = this.getFilePath(); + let absolutePath = CoreTextUtils.instance.concatenatePaths(CoreFile.instance.getBasePathInstant(), this.filePath); + + if (Platform.instance.is('ios')) { + // In iOS we need to remove the file:// part. + absolutePath = absolutePath.replace(/^file:\/\//, ''); + } + + try { + // First create the file. + this.fileEntry = await CoreFile.instance.createFile(this.filePath); + + // Now create the media instance. + this.mediaFile = Media.instance.create(absolutePath); + this.readyToCapture = true; + this.previewMedia = this.previewAudio?.nativeElement; + } catch (error) { + this.dismissWithError(-1, error.message || error); + } + } + + /** + * Init HTML recorder for browser + * . + * + * @return Promise resolved when done. + */ + protected async initHtmlCapture(): Promise { + const constraints = { + video: this.isAudio ? false : { facingMode: this.facingMode }, + audio: !this.isImage, + }; + + try { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + + let chunks: Blob[] = []; + this.localMediaStream = stream; + + if (!this.isImage) { + if (this.isVideo) { + this.previewMedia = this.previewVideo?.nativeElement; + } else { + this.previewMedia = this.previewAudio?.nativeElement; + this.initAudioDrawer(this.localMediaStream); + this.audioDrawer?.start(); + } + + this.mediaRecorder = new MediaRecorder(this.localMediaStream, { mimeType: this.mimetype }); + + // When video or audio is recorded, add it to the list of chunks. + this.mediaRecorder.ondataavailable = (e): void => { + if (e.data.size > 0) { + chunks.push(e.data); + } + }; + + // When recording stops, create a Blob element with the recording and set it to the video or audio. + this.mediaRecorder.onstop = (): void => { + this.mediaBlob = new Blob(chunks); + chunks = []; + + if (this.previewMedia) { + this.previewMedia.src = window.URL.createObjectURL(this.mediaBlob); + } + }; + } + + if (!this.isImage && !this.isVideo) { + // It's ready to capture. + this.readyToCapture = true; + + return; + } + + if (!this.streamVideo) { + throw new CoreError('Video element not found.'); + } + + let hasLoaded = false; + + // If stream isn't ready in a while, show error. + const waitTimeout = window.setTimeout(() => { + if (!hasLoaded) { + // Show error. + hasLoaded = true; + this.dismissWithError(-1, 'Cannot connect to webcam.'); + } + }, 10000); + + // Listen for stream ready to display the stream. + this.streamVideo.nativeElement.onloadedmetadata = (): void => { + if (hasLoaded) { + // Already loaded or timeout triggered, stop. + return; + } + + hasLoaded = true; + clearTimeout(waitTimeout); + this.readyToCapture = true; + this.streamVideo!.nativeElement.onloadedmetadata = null; + // Force change detection. Angular doesn't detect these async operations. + this.changeDetectorRef.detectChanges(); + }; + + // Set the stream as the source of the video. + if ('srcObject' in this.streamVideo.nativeElement) { + this.streamVideo.nativeElement.srcObject = this.localMediaStream; + } else { + // Fallback for old browsers. + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/srcObject#Examples + this.streamVideo.nativeElement.src = window.URL.createObjectURL(this.localMediaStream); + } + } catch (error) { + this.dismissWithError(-1, error.message || error); + } + } + + /** + * Initialize the audio drawer. This code has been extracted from MDN's example on MediaStream Recording: + * https://github.com/mdn/web-dictaphone + * + * @param stream Stream returned by getUserMedia. + */ + protected initAudioDrawer(stream: MediaStream): void { + if (!this.streamAudio) { + return; + } + + let skip = true; + let running = false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const audioCtx = new (window.AudioContext || ( window).webkitAudioContext)(); + const canvasCtx = this.streamAudio.nativeElement.getContext('2d'); + const source = audioCtx.createMediaStreamSource(stream); + const analyser = audioCtx.createAnalyser(); + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + const width = this.streamAudio.nativeElement.width; + const height = this.streamAudio.nativeElement.height; + const drawAudio = (): void => { + if (!running) { + return; + } + + // Update the draw every animation frame. + requestAnimationFrame(drawAudio); + + // Skip half of the frames to improve performance, shouldn't affect the smoothness. + skip = !skip; + if (skip) { + return; + } + + const sliceWidth = width / bufferLength; + let x = 0; + + analyser.getByteTimeDomainData(dataArray); + + canvasCtx.fillStyle = 'rgb(200, 200, 200)'; + canvasCtx.fillRect(0, 0, width, height); + + canvasCtx.lineWidth = 1; + canvasCtx.strokeStyle = 'rgb(0, 0, 0)'; + + canvasCtx.beginPath(); + + for (let i = 0; i < bufferLength; i++) { + const v = dataArray[i] / 128.0; + const y = v * height / 2; + + if (i === 0) { + canvasCtx.moveTo(x, y); + } else { + canvasCtx.lineTo(x, y); + } + + x += sliceWidth; + } + + canvasCtx.lineTo(width, height / 2); + canvasCtx.stroke(); + }; + + analyser.fftSize = 2048; + source.connect(analyser); + + this.audioDrawer = { + start: (): void => { + if (running) { + return; + } + + running = true; + drawAudio(); + }, + stop: (): void => { + running = false; + }, + }; + } + + /** + * Main action clicked: record or stop recording. + */ + async actionClicked(): Promise { + if (this.isCapturing) { + // It's capturing, stop. + this.stopCapturing(); + this.changeDetectorRef.detectChanges(); + + return; + } + + if (!this.isImage) { + // Start the capture. + this.isCapturing = true; + this.resetChrono = false; + + if (this.isCordovaAudioCapture) { + this.mediaFile?.startRecord(); + if (this.previewMedia) { + this.previewMedia.src = ''; + } + } else { + this.mediaRecorder?.start(); + } + + this.changeDetectorRef.detectChanges(); + } else { + if (!this.imgCanvas) { + return; + } + + // Get the image from the video and set it to the canvas, using video width/height. + const width = this.streamVideo?.nativeElement.videoWidth; + const height = this.streamVideo?.nativeElement.videoHeight; + const loadingModal = await CoreDomUtils.instance.showModalLoading(); + + + this.imgCanvas.nativeElement.width = width; + this.imgCanvas.nativeElement.height = height; + this.imgCanvas.nativeElement.getContext('2d').drawImage(this.streamVideo?.nativeElement, 0, 0, width, height); + + // Convert the image to blob and show it in an image element. + this.imgCanvas.nativeElement.toBlob((blob) => { + loadingModal.dismiss(); + + this.mediaBlob = blob; + this.previewImage?.nativeElement.setAttribute('src', window.URL.createObjectURL(this.mediaBlob)); + this.hasCaptured = true; + }, this.mimetype, this.quality); + } + } + + /** + * User cancelled. + */ + async cancel(): Promise { + if (this.hasCaptured) { + try { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmcanceledit')); + } catch { + // Canceled. + return; + } + } + + // Send a "cancelled" error like the Cordova plugin does. + this.dismissWithCanceledError('Canceled.', 'Camera cancelled'); + + if (this.isCordovaAudioCapture && this.filePath) { + // Delete the tmp file. + CoreFile.instance.removeFile(this.filePath); + } + } + + /** + * Discard the captured media. + */ + discard(): void { + this.previewMedia?.pause(); + this.streamVideo?.nativeElement.play(); + this.audioDrawer?.start(); + + this.hasCaptured = false; + this.isCapturing = false; + this.resetChrono = true; + delete this.mediaBlob; + this.changeDetectorRef.detectChanges(); + } + + /** + * Close the modal, returning some data (success). + * + * @param data Data to return. + */ + dismissWithData(data?: [MediaFile] | string): void { + ModalController.instance.dismiss(data, 'success'); + } + + /** + * Close the modal, returning an error. + * + * @param code Error code. Will not be used if it's a Camera capture. + * @param message Error message. + * @param cameraMessage A specific message to use if it's a Camera capture. If not set, message will be used. + */ + dismissWithCanceledError(message: string, cameraMessage?: string): void { + const isCamera = this.isImage && !this.isCaptureImage; + const error = isCamera ? new CoreCanceledError(cameraMessage || message) : new CoreCaptureError(3, message); + + ModalController.instance.dismiss(error, 'error'); + } + + /** + * Close the modal, returning an error. + * + * @param code Error code. Will not be used if it's a Camera capture. + * @param message Error message. + * @param cameraMessage A specific message to use if it's a Camera capture. If not set, message will be used. + */ + dismissWithError(code: number, message: string, cameraMessage?: string): void { + const isCamera = this.isImage && !this.isCaptureImage; + const error = isCamera ? new CoreError(cameraMessage || message) : new CoreCaptureError(code, message); + + ModalController.instance.dismiss(error, 'error'); + } + + /** + * Done capturing, write the file. + */ + async done(): Promise { + if (this.returnDataUrl) { + // Return the image as a base64 string. + this.dismissWithData(( this.imgCanvas?.nativeElement).toDataURL(this.mimetype, this.quality)); + + return; + } + + if (!this.mediaBlob && !this.isCordovaAudioCapture) { + // Shouldn't happen. + CoreDomUtils.instance.showErrorModal('Please capture the media first.'); + + return; + } + + let fileEntry = this.fileEntry; + const loadingModal = await CoreDomUtils.instance.showModalLoading(); + + try { + if (!this.isCordovaAudioCapture) { + // Capturing in browser. Write the blob in a file. + if (!this.mediaBlob) { + // Shouldn't happen. + throw new Error('Please capture the media first.'); + } + + fileEntry = await CoreFile.instance.writeFile(this.getFilePath(), this.mediaBlob); + } + + if (!fileEntry) { + throw new CoreError('File not found.'); + } + + if (this.isImage && !this.isCaptureImage) { + this.dismissWithData(fileEntry.toURL()); + } else { + // The capture plugin should return a MediaFile, not a FileEntry. Convert it. + const metadata = await CoreFile.instance.getMetadata(fileEntry); + + let mimetype: string | undefined; + if (this.extension) { + mimetype = CoreMimetypeUtils.instance.getMimeType(this.extension); + } + + const mediaFile: MediaFile = { + name: fileEntry.name, + fullPath: fileEntry.nativeURL || fileEntry.fullPath, + type: mimetype || '', + lastModifiedDate: metadata.modificationTime, + size: metadata.size, + getFormatData: (): void => { + // Nothing to do. + }, + }; + + this.dismissWithData([mediaFile]); + } + } catch (err) { + CoreDomUtils.instance.showErrorModal(err); + } finally { + loadingModal.dismiss(); + } + } + + /** + * Get path to the file where the media will be stored. + * + * @return Path. + */ + protected getFilePath(): string { + const fileName = this.type + '_' + CoreTimeUtils.instance.readableTimestamp() + '.' + this.extension; + + return CoreTextUtils.instance.concatenatePaths(CoreFileProvider.TMPFOLDER, 'media/' + fileName); + } + + /** + * Stop capturing. Only for video and audio. + */ + stopCapturing(): void { + this.isCapturing = false; + this.hasCaptured = true; + + if (this.isCordovaAudioCapture) { + this.mediaFile?.stopRecord(); + if (this.previewMedia && this.fileEntry) { + this.previewMedia.src = CoreFile.instance.convertFileSrc(this.fileEntry.toURL()); + } + } else { + this.streamVideo && this.streamVideo.nativeElement.pause(); + this.audioDrawer && this.audioDrawer.stop(); + this.mediaRecorder && this.mediaRecorder.stop(); + } + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.mediaFile?.release(); + + if (this.localMediaStream) { + const tracks = this.localMediaStream.getTracks(); + tracks.forEach((track) => { + track.stop(); + }); + } + this.streamVideo?.nativeElement.pause(); + this.previewMedia?.pause(); + this.audioDrawer?.stop(); + delete this.mediaBlob; + } + +} + +export type CaptureMediaComponentInputs = { + type: 'audio' | 'video' | 'image' | 'captureimage'; + maxTime?: number; // Max time to capture. + facingMode?: string; // Camera facing mode. + mimetype?: string; + extension?: string; + quality?: number; // Only for images. + returnDataUrl?: boolean; // Whether it should return a data img. Only for images. +}; diff --git a/src/app/core/emulator/components/components.module.ts b/src/app/core/emulator/components/components.module.ts new file mode 100644 index 000000000..c5d5c3e34 --- /dev/null +++ b/src/app/core/emulator/components/components.module.ts @@ -0,0 +1,41 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@app/components/components.module'; +import { CoreDirectivesModule } from '@app/directives/directives.module'; +import { CorePipesModule } from '@app/pipes/pipes.module'; +import { CoreEmulatorCaptureMediaComponent } from './capture-media/capture-media'; + +@NgModule({ + declarations: [ + CoreEmulatorCaptureMediaComponent, + ], + imports: [ + CommonModule, + IonicModule.forRoot(), + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + ], + exports: [ + CoreEmulatorCaptureMediaComponent, + ], +}) +export class CoreEmulatorComponentsModule {} diff --git a/src/app/core/emulator/emulator.module.ts b/src/app/core/emulator/emulator.module.ts index 173d6beeb..db41f24f5 100644 --- a/src/app/core/emulator/emulator.module.ts +++ b/src/app/core/emulator/emulator.module.ts @@ -17,8 +17,12 @@ import { Platform } from '@ionic/angular'; import { CoreInitDelegate } from '@services/init'; import { CoreEmulatorHelperProvider } from './services/helper'; +import { CoreEmulatorCaptureHelperProvider } from './services/capture.helper'; +import { CoreEmulatorComponentsModule } from './components/components.module'; // Ionic Native services. +import { Camera } from '@ionic-native/camera/ngx'; +import { Chooser } from '@ionic-native/chooser/ngx'; import { Clipboard } from '@ionic-native/clipboard/ngx'; import { Device } from '@ionic-native/device/ngx'; import { Diagnostic } from '@ionic-native/diagnostic/ngx'; @@ -31,6 +35,8 @@ import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; import { WebView } from '@ionic-native/ionic-webview/ngx'; import { Keyboard } from '@ionic-native/keyboard/ngx'; import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; +import { Media } from '@ionic-native/media/ngx'; +import { MediaCapture } from '@ionic-native/media-capture/ngx'; import { Network } from '@ionic-native/network/ngx'; import { Push } from '@ionic-native/push/ngx'; import { QRScanner } from '@ionic-native/qr-scanner/ngx'; @@ -41,12 +47,14 @@ import { WebIntent } from '@ionic-native/web-intent/ngx'; import { Zip } from '@ionic-native/zip/ngx'; // Mock services. +import { CameraMock } from './services/camera'; import { ClipboardMock } from './services/clipboard'; import { FileMock } from './services/file'; import { FileOpenerMock } from './services/file-opener'; import { FileTransferMock } from './services/file-transfer'; import { GeolocationMock } from './services/geolocation'; import { InAppBrowserMock } from './services/inappbrowser'; +import { MediaCaptureMock } from './services/media-capture'; import { NetworkMock } from './services/network'; import { ZipMock } from './services/zip'; @@ -63,9 +71,17 @@ import { ZipMock } from './services/zip'; declarations: [ ], imports: [ + CoreEmulatorComponentsModule, ], providers: [ CoreEmulatorHelperProvider, + CoreEmulatorCaptureHelperProvider, + { + provide: Camera, + deps: [Platform], + useFactory: (platform: Platform): Camera => platform.is('cordova') ? new Camera() : new CameraMock(), + }, + Chooser, { provide: Clipboard, deps: [Platform], // Use platform instead of AppProvider to prevent errors with singleton injection. @@ -101,6 +117,16 @@ import { ZipMock } from './services/zip'; }, Keyboard, LocalNotifications, + { + provide: Media, + deps: [], + useFactory: (): Media => new Media(), + }, + { + provide: MediaCapture, + deps: [Platform], + useFactory: (platform: Platform): MediaCapture => platform.is('cordova') ? new MediaCapture() : new MediaCaptureMock(), + }, { provide: Network, deps: [Platform], diff --git a/src/app/core/emulator/services/camera.ts b/src/app/core/emulator/services/camera.ts new file mode 100644 index 000000000..49c0fee01 --- /dev/null +++ b/src/app/core/emulator/services/camera.ts @@ -0,0 +1,47 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Camera, CameraOptions } from '@ionic-native/camera/ngx'; + +import { CoreEmulatorCaptureHelper } from './capture.helper'; + +/** + * Emulates the Cordova Camera plugin in browser. + */ +@Injectable() +export class CameraMock extends Camera { + + /** + * Remove intermediate image files that are kept in temporary storage after calling camera.getPicture. + * + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cleanup(): Promise { + // This function is iOS only, nothing to do. + return Promise.resolve(); + } + + /** + * Take a picture. + * + * @param options Options that you want to pass to the camera. + * @return Promise resolved when captured. + */ + getPicture(options: CameraOptions): Promise { + return CoreEmulatorCaptureHelper.instance.captureMedia('image', options); + } + +} diff --git a/src/app/core/emulator/services/capture.helper.ts b/src/app/core/emulator/services/capture.helper.ts new file mode 100644 index 000000000..a8589a625 --- /dev/null +++ b/src/app/core/emulator/services/capture.helper.ts @@ -0,0 +1,218 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreCanceledError } from '@/app/classes/errors/cancelederror'; +import { Injectable } from '@angular/core'; +import { CameraOptions } from '@ionic-native/camera/ngx'; +import { CaptureAudioOptions, CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx'; + +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { makeSingleton, ModalController } from '@singletons/core.singletons'; +import { CaptureMediaComponentInputs, CoreEmulatorCaptureMediaComponent } from '../components/capture-media/capture-media'; + +/** + * Helper service with some features to capture media (image, audio, video). + */ +@Injectable() +export class CoreEmulatorCaptureHelperProvider { + + protected possibleAudioMimeTypes = { + 'audio/webm': 'weba', + 'audio/ogg': 'ogg', + }; + + protected possibleVideoMimeTypes = { + 'video/webm;codecs=vp9': 'webm', + 'video/webm;codecs=vp8': 'webm', + 'video/ogg': 'ogv', + }; + + videoMimeType?: string; + audioMimeType?: string; + + /** + * Capture media (image, audio, video). + * + * @param type Type of media: image, audio, video. + * @param options Optional options. + * @return Promise resolved when captured, rejected if error. + */ + captureMedia(type: 'image', options?: MockCameraOptions): Promise; + captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise; + captureMedia(type: 'audio', options?: MockCaptureAudioOptions): Promise; + captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise; + async captureMedia( + type: 'image' | 'captureimage' | 'audio' | 'video', + options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureAudioOptions | MockCaptureVideoOptions, + ): Promise { + options = options || {}; + + // Build the params to send to the modal. + const params: CaptureMediaComponentInputs = { + type: type, + }; + + // Initialize some data based on the type of media to capture. + if (type == 'video') { + const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); + params.mimetype = mimeAndExt.mimetype; + params.extension = mimeAndExt.extension; + } else if (type == 'audio') { + const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); + params.mimetype = mimeAndExt.mimetype; + params.extension = mimeAndExt.extension; + } else if (type == 'image') { + if ('sourceType' in options && options.sourceType !== undefined && options.sourceType != 1) { + return Promise.reject('This source type is not supported in browser.'); + } + + if ('cameraDirection' in options && options.cameraDirection == 1) { + params.facingMode = 'user'; + } + + if ('encodingType' in options && options.encodingType == 1) { + params.mimetype = 'image/png'; + params.extension = 'png'; + } else { + params.mimetype = 'image/jpeg'; + params.extension = 'jpeg'; + } + + if ('quality' in options && options.quality !== undefined && options.quality >= 0 && options.quality <= 100) { + params.quality = options.quality / 100; + } + + if ('destinationType' in options && options.destinationType == 0) { + params.returnDataUrl = true; + } + } + + if ('duration' in options && options.duration) { + params.maxTime = options.duration * 1000; + } + + const modal = await ModalController.instance.create({ + component: CoreEmulatorCaptureMediaComponent, + cssClass: 'core-modal-fullscreen', + componentProps: params, + }); + + modal.present(); + + const result = await modal.onDidDismiss(); + + if (result.role == 'success') { + return result.data; + } else { + throw result.data; + } + } + + /** + * Get the mimetype and extension to capture media. + * + * @param type Type of media: image, audio, video. + * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. + * @return An object with mimetype and extension to use. + */ + protected getMimeTypeAndExtension(type: string, mimetypes?: string[]): { extension?: string; mimetype?: string } { + const result: { extension?: string; mimetype?: string } = {}; + + if (mimetypes?.length) { + // Search for a supported mimetype. + for (let i = 0; i < mimetypes.length; i++) { + const mimetype = mimetypes[i]; + const matches = mimetype.match(new RegExp('^' + type + '/')); + + if (matches?.length && window.MediaRecorder.isTypeSupported(mimetype)) { + result.mimetype = mimetype; + break; + } + } + } + + if (result.mimetype) { + // Found a supported mimetype in the mimetypes array, get the extension. + result.extension = CoreMimetypeUtils.instance.getExtension(result.mimetype); + } else if (type == 'video') { + // No mimetype found, use default extension. + result.mimetype = this.videoMimeType; + result.extension = this.possibleVideoMimeTypes[result.mimetype!]; + } else if (type == 'audio') { + // No mimetype found, use default extension. + result.mimetype = this.audioMimeType; + result.extension = this.possibleAudioMimeTypes[result.mimetype!]; + } + + return result; + } + + /** + * Init the getUserMedia function, using a deprecated function as fallback if the new one doesn't exist. + * + * @return Whether the function is supported. + */ + protected initGetUserMedia(): boolean { + return !!navigator.mediaDevices.getUserMedia; + } + + /** + * Initialize the mimetypes to use when capturing. + */ + protected initMimeTypes(): void { + // Determine video and audio mimetype to use. + for (const mimeType in this.possibleVideoMimeTypes) { + if (window.MediaRecorder.isTypeSupported(mimeType)) { + this.videoMimeType = mimeType; + break; + } + } + + for (const mimeType in this.possibleAudioMimeTypes) { + if (window.MediaRecorder.isTypeSupported(mimeType)) { + this.audioMimeType = mimeType; + break; + } + } + } + + /** + * Load the Mocks that need it. + * + * @return Promise resolved when loaded. + */ + load(): Promise { + if (typeof window.MediaRecorder != 'undefined' && this.initGetUserMedia()) { + this.initMimeTypes(); + } + + return Promise.resolve(); + } + +} + +export class CoreEmulatorCaptureHelper extends makeSingleton(CoreEmulatorCaptureHelperProvider) {} + +export interface MockCameraOptions extends CameraOptions { + mimetypes?: string[]; // Allowed mimetypes. +} +export interface MockCaptureImageOptions extends CaptureImageOptions { + mimetypes?: string[]; // Allowed mimetypes. +} +export interface MockCaptureAudioOptions extends CaptureAudioOptions { + mimetypes?: string[]; // Allowed mimetypes. +} +export interface MockCaptureVideoOptions extends CaptureVideoOptions { + mimetypes?: string[]; // Allowed mimetypes. +} diff --git a/src/app/core/emulator/services/media-capture.ts b/src/app/core/emulator/services/media-capture.ts new file mode 100644 index 000000000..9818ea22b --- /dev/null +++ b/src/app/core/emulator/services/media-capture.ts @@ -0,0 +1,62 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { + MediaCapture, + CaptureAudioOptions, + CaptureImageOptions, + CaptureVideoOptions, + MediaFile, +} from '@ionic-native/media-capture/ngx'; + +import { CoreEmulatorCaptureHelper } from './capture.helper'; + +/** + * Emulates the Cordova MediaCapture plugin in browser. + */ +@Injectable() +export class MediaCaptureMock extends MediaCapture { + + /** + * Start the audio recorder application and return information about captured audio clip files. + * + * @param options Options. + * @return Promise resolved when captured. + */ + captureAudio(options: CaptureAudioOptions): Promise { + return CoreEmulatorCaptureHelper.instance.captureMedia('audio', options); + } + + /** + * Start the camera application and return information about captured image files. + * + * @param options Options. + * @return Promise resolved when captured. + */ + captureImage(options: CaptureImageOptions): Promise { + return CoreEmulatorCaptureHelper.instance.captureMedia('captureimage', options); + } + + /** + * Start the video recorder application and return information about captured video clip files. + * + * @param options Options. + * @return Promise resolved when captured. + */ + captureVideo(options: CaptureVideoOptions): Promise { + return CoreEmulatorCaptureHelper.instance.captureMedia('video', options); + } + +}