From 556085e13ca9743ad0992665ac5368a6886c260c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 8 Jan 2018 09:21:51 +0100 Subject: [PATCH] MOBILE-2312 emulator: Emulate Camera and MediaCapture --- src/core/emulator/emulator.module.ts | 31 ++ .../pages/capture-media/capture-media.html | 55 +++ .../capture-media/capture-media.module.ts | 31 ++ .../pages/capture-media/capture-media.scss | 68 +++ .../pages/capture-media/capture-media.ts | 402 ++++++++++++++++++ src/core/emulator/providers/camera.ts | 48 +++ src/core/emulator/providers/capture-helper.ts | 222 ++++++++++ src/core/emulator/providers/helper.ts | 7 +- src/core/emulator/providers/media-capture.ts | 58 +++ 9 files changed, 920 insertions(+), 2 deletions(-) create mode 100644 src/core/emulator/pages/capture-media/capture-media.html create mode 100644 src/core/emulator/pages/capture-media/capture-media.module.ts create mode 100644 src/core/emulator/pages/capture-media/capture-media.scss create mode 100644 src/core/emulator/pages/capture-media/capture-media.ts create mode 100644 src/core/emulator/providers/camera.ts create mode 100644 src/core/emulator/providers/capture-helper.ts create mode 100644 src/core/emulator/providers/media-capture.ts diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index d4724b600..1701696d3 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -15,6 +15,8 @@ import { NgModule } from '@angular/core'; import { Platform } from 'ionic-angular'; +// Ionic Native services. +import { Camera } from '@ionic-native/camera'; import { Clipboard } from '@ionic-native/clipboard'; import { File } from '@ionic-native/file'; import { FileTransfer } from '@ionic-native/file-transfer'; @@ -22,22 +24,27 @@ import { Globalization } from '@ionic-native/globalization'; import { InAppBrowser } from '@ionic-native/in-app-browser'; import { Keyboard } from '@ionic-native/keyboard'; import { LocalNotifications } from '@ionic-native/local-notifications'; +import { MediaCapture } from '@ionic-native/media-capture'; import { Network } from '@ionic-native/network'; import { SplashScreen } from '@ionic-native/splash-screen'; import { StatusBar } from '@ionic-native/status-bar'; import { SQLite } from '@ionic-native/sqlite'; import { Zip } from '@ionic-native/zip'; +// Services that Mock Ionic Native in browser an desktop. +import { CameraMock } from './providers/camera'; import { ClipboardMock } from './providers/clipboard'; import { FileMock } from './providers/file'; import { FileTransferMock } from './providers/file-transfer'; import { GlobalizationMock } from './providers/globalization'; import { InAppBrowserMock } from './providers/inappbrowser'; import { LocalNotificationsMock } from './providers/local-notifications'; +import { MediaCaptureMock } from './providers/media-capture'; import { NetworkMock } from './providers/network'; import { ZipMock } from './providers/zip'; import { CoreEmulatorHelperProvider } from './providers/helper'; +import { CoreEmulatorCaptureHelperProvider } from './providers/capture-helper'; import { CoreAppProvider } from '../../providers/app'; import { CoreFileProvider } from '../../providers/file'; import { CoreTextUtilsProvider } from '../../providers/utils/text'; @@ -46,6 +53,15 @@ import { CoreUrlUtilsProvider } from '../../providers/utils/url'; import { CoreUtilsProvider } from '../../providers/utils/utils'; import { CoreInitDelegate } from '../../providers/init'; +/** + * This module handles the emulation of Cordova plugins in browser and desktop. + * + * It includes the "mock" of all the Ionic Native services that should be supported in browser and desktop, + * otherwise those features would only work in a Cordova environment. + * + * This module also determines if the app should use the original service or the mock. In each of the "useFactory" + * functions we check if the app is running in mobile or not, and then provide the right service to use. + */ @NgModule({ declarations: [ ], @@ -53,6 +69,14 @@ import { CoreInitDelegate } from '../../providers/init'; ], providers: [ CoreEmulatorHelperProvider, + CoreEmulatorCaptureHelperProvider, + { + provide: Camera, + deps: [CoreAppProvider, CoreEmulatorCaptureHelperProvider], + useFactory: (appProvider: CoreAppProvider, captureHelper: CoreEmulatorCaptureHelperProvider) => { + return appProvider.isMobile() ? new Camera() : new CameraMock(captureHelper); + } + }, { provide: Clipboard, deps: [CoreAppProvider], @@ -99,6 +123,13 @@ import { CoreInitDelegate } from '../../providers/init'; return appProvider.isMobile() ? new LocalNotifications() : new LocalNotificationsMock(appProvider, utils); } }, + { + provide: MediaCapture, + deps: [CoreAppProvider, CoreEmulatorCaptureHelperProvider], + useFactory: (appProvider: CoreAppProvider, captureHelper: CoreEmulatorCaptureHelperProvider) => { + return appProvider.isMobile() ? new MediaCapture() : new MediaCaptureMock(captureHelper); + } + }, { provide: Network, deps: [Platform], diff --git a/src/core/emulator/pages/capture-media/capture-media.html b/src/core/emulator/pages/capture-media/capture-media.html new file mode 100644 index 000000000..a6fc6dbe1 --- /dev/null +++ b/src/core/emulator/pages/capture-media/capture-media.html @@ -0,0 +1,55 @@ + + + + + + + {{ title }} + + + + + + + + +
+ + + + + + + + + {{ 'core.capturedimage' | translate }} + + +
+ + +
+
+
+
+ + + + + + + + + + + + + + diff --git a/src/core/emulator/pages/capture-media/capture-media.module.ts b/src/core/emulator/pages/capture-media/capture-media.module.ts new file mode 100644 index 000000000..133671a75 --- /dev/null +++ b/src/core/emulator/pages/capture-media/capture-media.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { CoreEmulatorCaptureMediaPage } from './capture-media'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../../components/components.module'; + +@NgModule({ + declarations: [ + CoreEmulatorCaptureMediaPage + ], + imports: [ + CoreComponentsModule, + IonicPageModule.forChild(CoreEmulatorCaptureMediaPage), + TranslateModule.forChild() + ] +}) +export class CoreEmulatorCaptureMediaPageModule {} diff --git a/src/core/emulator/pages/capture-media/capture-media.scss b/src/core/emulator/pages/capture-media/capture-media.scss new file mode 100644 index 000000000..58bb5d1a2 --- /dev/null +++ b/src/core/emulator/pages/capture-media/capture-media.scss @@ -0,0 +1,68 @@ +page-core-emulator-capture-media { + ion-content { + .core-av-wrapper { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: 0; + padding: 0; + clear: both; + + .core-webcam-image-canvas { + display: none; + } + + .core-audio-record-container { + width: 100%; + height: 100%; + + .core-audio-canvas { + width: 100%; + height: 100%; + } + + .core-audio-captured { + width: 100%; + } + } + + audio, 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); + } + } + } + + .scroll-content { + // We're modifying the height of the footer, the padding-bottom of the scroll needs to change too. + margin-bottom: 44px !important; + } + } + + ion-footer { + background-color: $gray; + border-top: 1px solid $gray-dark; + + .col { + padding: 0; + + .icon.ion-md-trash, .icon.ion-ios-trash { + color: $red; + } + } + + .chrono-container { + line-height: 24px; + } + } +} diff --git a/src/core/emulator/pages/capture-media/capture-media.ts b/src/core/emulator/pages/capture-media/capture-media.ts new file mode 100644 index 000000000..ba257c29f --- /dev/null +++ b/src/core/emulator/pages/capture-media/capture-media.ts @@ -0,0 +1,402 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, ViewController, NavParams } from 'ionic-angular'; +import { CoreFileProvider } from '../../../../providers/file'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreTimeUtilsProvider } from '../../../../providers/utils/time'; + +/** + * Page to capture media in browser or desktop. + */ +@IonicPage() +@Component({ + selector: 'page-core-emulator-capture-media', + templateUrl: 'capture-media.html', +}) +export class CoreEmulatorCaptureMediaPage implements OnInit, OnDestroy { + @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. + maxTime: number; // The max time to capture. + resetChrono: boolean; // Boolean to reset the chrono. + + protected type: string; // The type to capture: audio, video, image, captureimage. + protected isCaptureImage: boolean; // To identify if it's capturing an image using media capture plugin (instead of camera). + protected returnDataUrl: boolean; // Whether it should return a data img. Only if isImage. + protected facingMode: string; // Camera facing mode. + protected mimetype: string; + protected extension: string; + protected window: any; // Cast window to "any" because some of the properties used aren't in the window spec. + protected mediaRecorder; // To record video/audio. + protected audioDrawer; // To start/stop the display of audio sound. + protected quality; // Image only. + 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; + + constructor(private viewCtrl: ViewController, params: NavParams, private domUtils: CoreDomUtilsProvider, + private timeUtils: CoreTimeUtilsProvider, private fileProvider: CoreFileProvider, + private textUtils: CoreTextUtilsProvider, private cdr: ChangeDetectorRef) { + this.window = window; + this.type = params.get('type'); + this.maxTime = params.get('maxTime'); + this.facingMode = params.get('facingMode') || 'environment'; + this.mimetype = params.get('mimetype'); + this.extension = params.get('extension'); + this.quality = params.get('quality') || 0.92; + this.returnDataUrl = !!params.get('returnDataUrl'); + } + + /** + * Component being initialized. + */ + ngOnInit() { + this.initVariables(); + + let constraints = { + video: this.isAudio ? false : {facingMode: this.facingMode}, + audio: !this.isImage + }; + + navigator.mediaDevices.getUserMedia(constraints).then((stream) => { + let chunks = []; + 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 this.window.MediaRecorder(this.localMediaStream, {mimeType: this.mimetype}); + + // When video or audio is recorded, add it to the list of chunks. + this.mediaRecorder.ondataavailable = (e) => { + 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 = () => { + this.mediaBlob = new Blob(chunks); + chunks = []; + + this.previewMedia.src = window.URL.createObjectURL(this.mediaBlob); + }; + } + + if (this.isImage || this.isVideo) { + let hasLoaded = false, + waitTimeout; + + // Listen for stream ready to display the stream. + this.streamVideo.nativeElement.onloadedmetadata = () => { + 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.cdr.detectChanges(); + }; + + // Set the stream as the source of the video. + this.streamVideo.nativeElement.src = window.URL.createObjectURL(this.localMediaStream); + + // If stream isn't ready in a while, show error. + waitTimeout = setTimeout(() => { + if (!hasLoaded) { + // Show error. + hasLoaded = true; + this.dismissWithError(-1, 'Cannot connect to webcam.'); + } + }, 10000); + } else { + // It's ready to capture. + this.readyToCapture = true; + } + }).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 {MediaStream} stream Stream returned by getUserMedia. + */ + protected initAudioDrawer(stream: MediaStream) : void { + let audioCtx = new (this.window.AudioContext || this.window.webkitAudioContext)(), + canvasCtx = this.streamAudio.nativeElement.getContext('2d'), + source = audioCtx.createMediaStreamSource(stream), + analyser = audioCtx.createAnalyser(), + bufferLength = analyser.frequencyBinCount, + dataArray = new Uint8Array(bufferLength), + width = this.streamAudio.nativeElement.width, + height = this.streamAudio.nativeElement.height, + running = false, + skip = true, + drawAudio = () => { + 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; + } + + let sliceWidth = width / bufferLength, + 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++) { + let v = dataArray[i] / 128.0, + 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: () => { + if (running) { + return; + } + + running = true; + drawAudio(); + }, + stop: () => { + running = false; + } + }; + } + + /** + * Initialize some variables based on the params. + */ + protected initVariables() { + 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'; + } + } + + /** + * Main action clicked: record or stop recording. + */ + actionClicked() : void { + if (this.isCapturing) { + // It's capturing, stop. + this.stopCapturing(); + this.cdr.detectChanges(); + } else { + if (!this.isImage) { + // Start the capture. + this.isCapturing = true; + this.resetChrono = false; + this.mediaRecorder.start(); + this.cdr.detectChanges(); + } else { + // Get the image from the video and set it to the canvas, using video width/height. + let width = this.streamVideo.nativeElement.videoWidth, + height = this.streamVideo.nativeElement.videoHeight; + + 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. + let loadingModal = this.domUtils.showModalLoading(); + 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. + */ + cancel() : void { + // Send a "cancelled" error like the Cordova plugin does. + this.dismissWithError(3, 'Canceled.', 'Camera cancelled'); + } + + /** + * Discard the captured media. + */ + discard() : void { + this.previewMedia && this.previewMedia.pause(); + this.streamVideo && this.streamVideo.nativeElement.play(); + this.audioDrawer && this.audioDrawer.start(); + + this.hasCaptured = false; + this.isCapturing = false; + this.resetChrono = true; + delete this.mediaBlob; + this.cdr.detectChanges(); + }; + + /** + * Close the modal, returning some data (success). + * + * @param {any} data Data to return. + */ + dismissWithData(data: any) : void { + this.viewCtrl.dismiss(data, 'success'); + } + + /** + * Close the modal, returning an error. + * + * @param {number} code Error code. Will not be used if it's a Camera capture. + * @param {string} message Error message. + * @param {string} [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 { + let isCamera = this.isImage && !this.isCaptureImage, + error = isCamera ? (cameraMessage || message) : {code: code, message: message}; + this.viewCtrl.dismiss(error, 'error'); + } + + /** + * Done capturing, write the file. + */ + done() : void { + if (this.returnDataUrl) { + // Return the image as a base64 string. + this.dismissWithData(this.imgCanvas.nativeElement.toDataURL(this.mimetype, this.quality)); + return; + } + + if (!this.mediaBlob) { + // Shouldn't happen. + this.domUtils.showErrorModal('Please capture the media first.'); + return; + } + + // Create the file and return it. + let fileName = this.type + '_' + this.timeUtils.readableTimestamp() + '.' + this.extension, + path = this.textUtils.concatenatePaths(this.fileProvider.TMPFOLDER, 'media/' + fileName); + + let loadingModal = this.domUtils.showModalLoading(); + + this.fileProvider.writeFile(path, this.mediaBlob).then((fileEntry) => { + if (this.isImage && !this.isCaptureImage) { + this.dismissWithData(fileEntry.toURL()); + } else { + // The capture plugin returns a MediaFile, not a FileEntry. The only difference is that + // it supports a new function that won't be supported in desktop. + fileEntry.getFormatData = (successFn, errorFn) => {}; + + this.dismissWithData([fileEntry]); + } + }).catch((err) => { + this.domUtils.showErrorModal(err); + }).finally(() => { + loadingModal.dismiss(); + }); + }; + + /** + * Stop capturing. Only for video and audio. + */ + stopCapturing() : void { + this.streamVideo && this.streamVideo.nativeElement.pause(); + this.audioDrawer && this.audioDrawer.stop(); + this.mediaRecorder && this.mediaRecorder.stop(); + this.isCapturing = false; + this.hasCaptured = true; + }; + + /** + * Page destroyed. + */ + ngOnDestroy() : void { + const tracks = this.localMediaStream.getTracks(); + tracks.forEach((track) => { + track.stop(); + }); + this.streamVideo && this.streamVideo.nativeElement.pause(); + this.previewMedia && this.previewMedia.pause(); + this.audioDrawer && this.audioDrawer.stop(); + delete this.mediaBlob; + } +} \ No newline at end of file diff --git a/src/core/emulator/providers/camera.ts b/src/core/emulator/providers/camera.ts new file mode 100644 index 000000000..681fe1ca6 --- /dev/null +++ b/src/core/emulator/providers/camera.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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'; +import { CoreEmulatorCaptureHelperProvider } from './capture-helper'; + +/** + * Emulates the Cordova Camera plugin in desktop apps and in browser. + */ +@Injectable() +export class CameraMock extends Camera { + + constructor(private captureHelper: CoreEmulatorCaptureHelperProvider) { + super(); + } + + /** + * Remove intermediate image files that are kept in temporary storage after calling camera.getPicture. + * + * @return {Promise} Promise resolved when done. + */ + cleanup() : Promise { + // iOS only, nothing to do. + return Promise.resolve(); + } + + /** + * Take a picture. + * + * @param {CameraOptions} options Options that you want to pass to the camera. + * @return {Promise} Promise resolved when captured. + */ + getPicture(options: CameraOptions) : Promise { + return this.captureHelper.captureMedia('image', options); + } +} diff --git a/src/core/emulator/providers/capture-helper.ts b/src/core/emulator/providers/capture-helper.ts new file mode 100644 index 000000000..3d03605ba --- /dev/null +++ b/src/core/emulator/providers/capture-helper.ts @@ -0,0 +1,222 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { ModalController, Modal } from 'ionic-angular'; +import { CoreMimetypeUtilsProvider } from '../../../providers/utils/mimetype'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; + +/** + * 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' + }; + protected win: any; + videoMimeType: string; + audioMimeType: string; + + constructor(private utils: CoreUtilsProvider, private mimeUtils: CoreMimetypeUtilsProvider, + private modalCtrl: ModalController) { + // Convert the window to "any" type because some of the variables used (like MediaRecorder) aren't in the window spec. + this.win = window; + } + + /** + * Capture media (image, audio, video). + * + * @param {String} type Type of media: image, audio, video. + * @param {Function} successCallback Function called when media taken. + * @param {Function} errorCallback Function called when error or cancel. + * @param {Object} [options] Optional options. + * @return {Void} + */ + captureMedia(type: string, options: any) : Promise { + options = options || {}; + + try { + // Build the params to send to the modal. + let deferred = this.utils.promiseDefer(), + params: any = { + type: type + }, + mimeAndExt, + modal: Modal; + + // Initialize some data based on the type of media to capture. + if (type == 'video') { + mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); + params.mimetype = mimeAndExt.mimetype; + params.extension = mimeAndExt.extension; + } else if (type == 'audio') { + mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); + params.mimetype = mimeAndExt.mimetype; + params.extension = mimeAndExt.extension; + } else if (type == 'image') { + if (typeof options.sourceType != 'undefined' && options.sourceType != 1) { + return Promise.reject('This source type is not supported in desktop.'); + } + + if (options.cameraDirection == 1) { + params.facingMode = 'user'; + } + + if (options.encodingType == 1) { + params.mimetype = 'image/png'; + params.extension = 'png'; + } else { + params.mimetype = 'image/jpeg'; + params.extension = 'jpeg'; + } + + if (options.quality >= 0 && options.quality <= 100) { + params.quality = options.quality / 100; + } + + if (options.destinationType == 0) { + params.returnDataUrl = true; + } + } + + if (options.duration) { + params.maxTime = options.duration * 1000; + } + + modal = this.modalCtrl.create('CoreEmulatorCaptureMediaPage', params); + modal.present(); + modal.onDidDismiss((data: any, role: string) => { + if (role == 'success') { + deferred.resolve(data); + } else { + deferred.reject(data); + } + }); + + return deferred.promise; + } catch(ex) { + return Promise.reject(ex.toString()); + } + } + + /** + * Get the mimetype and extension to capture media. + * + * @param {string} type Type of media: image, audio, video. + * @param {string[]} [mimetypes] List of supported mimetypes. If undefined, all mimetypes supported. + * @return {{extension: string, mimetype: string}} An object with mimetype and extension to use. + */ + protected getMimeTypeAndExtension(type: string, mimetypes) : {extension: string, mimetype: string} { + var result: any = {}; + + if (mimetypes && mimetypes.length) { + // Search for a supported mimetype. + for (let i = 0; i < mimetypes.length; i++) { + let mimetype = mimetypes[i], + matches = mimetype.match(new RegExp('^' + type + '/')); + + if (matches && matches.length && this.win.MediaRecorder.isTypeSupported(mimetype)) { + result.mimetype = mimetype; + break; + } + } + } + + if (result.mimetype) { + // Found a supported mimetype in the mimetypes array, get the extension. + result.extension = this.mimeUtils.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 {boolean} Whether the function is supported. + */ + protected initGetUserMedia() : boolean { + let nav = navigator; + // Check if there is a function to get user media. + if (typeof nav.mediaDevices == 'undefined') { + nav.mediaDevices = {}; + } + + if (!nav.mediaDevices.getUserMedia) { + // New function doesn't exist, check if the deprecated function is supported. + nav.getUserMedia = nav.getUserMedia || nav.webkitGetUserMedia || nav.mozGetUserMedia || nav.msGetUserMedia; + + if (nav.getUserMedia) { + // Deprecated function exists, support the new function using the deprecated one. + navigator.mediaDevices.getUserMedia = (constraints) => { + let deferred = this.utils.promiseDefer(); + nav.getUserMedia(constraints, deferred.resolve, deferred.reject); + return deferred.promise; + }; + } else { + return false; + } + } + + return true; + } + + /** + * Initialize the mimetypes to use when capturing. + */ + protected initMimeTypes() : void { + // Determine video and audio mimetype to use. + for (let mimeType in this.possibleVideoMimeTypes) { + if (this.win.MediaRecorder.isTypeSupported(mimeType)) { + this.videoMimeType = mimeType; + break; + } + } + + for (let mimeType in this.possibleAudioMimeTypes) { + if (this.win.MediaRecorder.isTypeSupported(mimeType)) { + this.audioMimeType = mimeType; + break; + } + } + } + + /** + * Load the Mocks that need it. + * + * @return {Promise} Promise resolved when loaded. + */ + load() : Promise { + if (typeof this.win.MediaRecorder != 'undefined' && this.initGetUserMedia()) { + this.initMimeTypes(); + } + + return Promise.resolve(); + } +} diff --git a/src/core/emulator/providers/helper.ts b/src/core/emulator/providers/helper.ts index abfd542fa..1a6e2fa86 100644 --- a/src/core/emulator/providers/helper.ts +++ b/src/core/emulator/providers/helper.ts @@ -19,9 +19,10 @@ import { File } from '@ionic-native/file'; import { LocalNotifications } from '@ionic-native/local-notifications'; import { CoreInitDelegate, CoreInitHandler } from '../../../providers/init'; import { FileTransferErrorMock } from './file-transfer'; +import { CoreEmulatorCaptureHelperProvider } from './capture-helper'; /** - * Emulates the Cordova Zip plugin in desktop apps and in browser. + * Helper service for the emulator feature. It also acts as an init handler. */ @Injectable() export class CoreEmulatorHelperProvider implements CoreInitHandler { @@ -30,7 +31,8 @@ export class CoreEmulatorHelperProvider implements CoreInitHandler { blocking = true; constructor(private file: File, private fileProvider: CoreFileProvider, private utils: CoreUtilsProvider, - initDelegate: CoreInitDelegate, private localNotif: LocalNotifications) {} + initDelegate: CoreInitDelegate, private localNotif: LocalNotifications, + private captureHelper: CoreEmulatorCaptureHelperProvider) {} /** * Load the Mocks that need it. @@ -44,6 +46,7 @@ export class CoreEmulatorHelperProvider implements CoreInitHandler { this.fileProvider.setHTMLBasePath(basePath); })); promises.push((this.localNotif).load()); + promises.push(this.captureHelper.load()); (window).FileTransferError = FileTransferErrorMock; diff --git a/src/core/emulator/providers/media-capture.ts b/src/core/emulator/providers/media-capture.ts new file mode 100644 index 000000000..55a8b65ce --- /dev/null +++ b/src/core/emulator/providers/media-capture.ts @@ -0,0 +1,58 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@ionic-native/media-capture'; +import { CoreEmulatorCaptureHelperProvider } from './capture-helper'; + +/** + * Emulates the Cordova MediaCapture plugin in desktop apps and in browser. + */ +@Injectable() +export class MediaCaptureMock extends MediaCapture { + + constructor(private captureHelper: CoreEmulatorCaptureHelperProvider) { + super(); + } + + /** + * Start the audio recorder application and return information about captured audio clip files. + * + * @param {CaptureAudioOptions} options Options. + * @return {Promise} Promise resolved when captured. + */ + captureAudio(options: CaptureAudioOptions) : Promise { + return this.captureHelper.captureMedia('audio', options); + } + + /** + * Start the camera application and return information about captured image files. + * + * @param {CaptureImageOptions} options Options. + * @return {Promise} Promise resolved when captured. + */ + captureImage(options: CaptureImageOptions) : Promise { + return this.captureHelper.captureMedia('captureimage', options); + } + + /** + * Start the video recorder application and return information about captured video clip files. + * + * @param {CaptureVideoOptions} options Options. + * @return {Promise} Promise resolved when captured. + */ + captureVideo(options: CaptureVideoOptions) : Promise { + return this.captureHelper.captureMedia('video', options); + } +}