define(function(require, exports, module) { 'use strict'; /** * Dependencies */ var debug = require('debug')('controller:camera'); var bindAll = require('lib/bind-all'); /** * Exports */ module.exports = function(app) { return new CameraController(app); }; module.exports.CameraController = CameraController; /** * Initialize a new `CameraController` * * @param {App} app */ function CameraController(app) { bindAll(this); this.app = app; this.camera = app.camera; this.settings = app.settings; this.activity = app.activity; this.hdrDisabled = this.settings.hdr.get('disabled'); this.notification = app.views.notification; // Wait until we get the battery state before turning // on the camera this.lowBattery = this.app.get('batteryStatus') === 'shutdown'; this.configure(); this.bindEvents(); debug('initialized'); } CameraController.prototype.bindEvents = function() { var settings = this.settings; var camera = this.camera; var app = this.app; // Relaying camera events means other modules // don't have to depend directly on camera camera.on('change:previewActive', this.app.firer('camera:previewactive')); camera.on('change:videoElapsed', app.firer('camera:recorderTimeUpdate')); camera.on('autofocuschanged', app.firer('camera:autofocuschanged')); camera.on('focusconfigured', app.firer('camera:focusconfigured')); camera.on('change:focus', app.firer('camera:focusstatechanged')); camera.on('filesizelimitreached', this.onFileSizeLimitReached); camera.on('facesdetected', app.firer('camera:facesdetected')); camera.on('willrecord', app.firer('camera:willrecord')); camera.on('configured', app.firer('camera:configured')); camera.on('requesting', app.firer('camera:requesting')); camera.on('change:recording', this.onRecordingChange); camera.on('newcamera', app.firer('camera:newcamera')); camera.on('newimage', app.firer('camera:newimage')); camera.on('newvideo', app.firer('camera:newvideo')); camera.on('shutter', app.firer('camera:shutter')); camera.on('loaded', app.firer('camera:loaded')); camera.on('closed', this.onCameraClosed); camera.on('error', app.firer('camera:error')); camera.on('ready', app.firer('ready')); camera.on('busy', app.firer('busy')); // App app.on('viewfinder:focuspointchanged', this.onFocusPointChanged); app.on('change:batteryStatus', this.onBatteryStatusChange); app.on('settings:configured', this.onSettingsConfigured); app.on('previewgallery:opened', this.onGalleryOpened); app.on('previewgallery:closed', this.onGalleryClosed); app.on('stoprecording', this.camera.stopRecording); app.on('storage:volumechanged', this.onStorageVolumeChanged); app.on('storage:changed', this.onStorageChanged); app.on('activity:pick', this.onPickActivity); app.on('keydown:capture', this.onCaptureKey); app.on('keydown:focus', this.onFocusKey); app.on('hidden', this.shutdownCamera); app.on('click', this.clearCountdown); app.on('visible', this.loadCamera); app.on('capture', this.capture); // Settings settings.recorderProfiles.on('change:selected', this.updateRecorderProfile); settings.pictureSizes.on('change:selected', this.updatePictureSize); settings.flashModes.on('change:selected', this.onFlashModeChange); settings.flashModes.on('change:selected', this.setFlashMode); settings.cameras.on('change:selected', this.setCamera); settings.mode.on('change:selected', this.setMode); settings.hdr.on('change:selected', this.setHDR); settings.hdr.on('change:selected', this.onHDRChange); debug('events bound'); }; CameraController.prototype.onRecordingChange = function(recording) { var active; if (recording === 'started') { active = true; } else if (recording === 'stopped') { active = false; } else { return; } this.app.set('recording', active); }; /** * Take picture or start/end recording * when a capture hardware key is invoked. * * Calling `.preventDefault()` prevents * the default system operation * (eg. changing volume level). We * only call it when capture request * succeeds. * * We don't want to .preventDefault() when * the preview-gallery is open as the * user may want to change the volume * of a video being played back. * * @param {Event} e * @private */ CameraController.prototype.onCaptureKey = function(e) { debug('on capture key', e); if (this.capture() !== false) { e.preventDefault(); } }; /** * Focus the camera when a focus * hardware key is invoked. * * @param {Event} e * @private */ CameraController.prototype.onFocusKey = function(e) { debug('on focus key', e); if (!this.shouldCapture()) { return; } this.camera.focus.focus(); }; /** * Configure the 'cameras' setting using the * `cameraList` data given by the camera hardware * * @private */ CameraController.prototype.configure = function() { this.settings.cameras.filterOptions(this.camera.cameraList); debug('configured'); }; /** * Once the settings have finished configuring * we do the final camera configuration. * * @private */ CameraController.prototype.onSettingsConfigured = function() { var recorderProfile = this.settings.recorderProfiles.selected('key'); var pictureSize = this.settings.pictureSizes.selected('data'); this.setWhiteBalance(); this.setFlashMode(); this.setISO(); this.setHDR(); this.camera.setRecorderProfile(recorderProfile); this.camera.setPictureSize(pictureSize); this.camera.configureZoom(); // Defer this work as it involves // expensive mozSettings calls setTimeout(this.updateZoomForMako); debug('camera configured with final settings'); }; /** * Updates camera configuration in * response to pick activity parameters. * * @param {Object} data * @private */ CameraController.prototype.onPickActivity = function(data) { // This is set so that the video recorder can // automatically stop when video size limit is reached. this.camera.set('maxFileSizeBytes', data.maxFileSizeBytes); // Disable camera config caches when in 'pick' activity // to prevent activity specific configuration persisting. this.camera.cacheConfig = false; }; CameraController.prototype.capture = function(options = {}) { var force = options.force; if (!this.shouldCapture()) { return false; } if (!force && this.shouldCountdown()) { return this.startCountdown(); } if (this.countdown) { return this.clearCountdown(); } var position = this.app.geolocation.position; return this.camera.capture({ position: position }); }; CameraController.prototype.shouldCapture = function() { return !this.app.get('confirmViewVisible') && !this.app.hidden && !this.app.busy && !this.galleryOpen && !this.lowBattery; }; CameraController.prototype.shouldCountdown = function() { var countdownSet = this.settings.countdown.selected('value'); var recording = this.app.get('recording'); return countdownSet && !this.countdown && !recording; }; CameraController.prototype.startCountdown = function() { if (this.countdown) { return; } var seconds = this.settings.countdown.selected('value'); var self = this; if (!seconds) { return; } this.app.emit('countdown:started', seconds); (function scheduleTick() { self.countdown = setTimeout(() => { if (--seconds <= 0) { self.clearCountdown(); self.capture({ force: true }); return; } self.app.emit('countdown:tick', seconds); scheduleTick(); }, 1000); })(); }; CameraController.prototype.clearCountdown = function() { if (!this.countdown) { return; } clearTimeout(this.countdown); this.countdown = null; this.app.emit('countdown:ended'); }; CameraController.prototype.onFileSizeLimitReached = function() { this.camera.stopRecording(); this.showSizeLimitAlert(); }; CameraController.prototype.showSizeLimitAlert = function() { if (this.sizeLimitAlertActive) { return; } this.sizeLimitAlertActive = true; var alertL10nId = this.activity.pick ? 'activity-size-limit-reached' : 'storage-size-limit-reached'; document.l10n.formatValue(alertL10nId).then(alert); this.sizeLimitAlertActive = false; }; /** * Set the camera's mode (picture/video). * * We send a signal to say that the camera * 'will change', this allows other parts * of the app to respond if need be. * * We then wait for the viewfinder to be 'hidden' * before setting the camera to prevent the * user from seeing the stream flicker/jump. * * @param {String} mode ['picture'|'video'] * @private */ CameraController.prototype.setMode = function(mode) { debug('set mode: %s', mode); var self = this; // Abort if didn't change. // // TODO: Perhaps the `Setting` instance should // not emit a `change` event if the value did // not change? This may require some deep checking // if the value is an object. Quite a risky change // to make, but would remove the need for us to check // here and in other change callbacks. Food 4 thought :) if (this.camera.isMode(mode)) { debug('mode didn\'t change'); return; } var l10nId = mode == 'video' ? 'Video-Mode' : 'Photo-Mode'; this.notification.display({ text: l10nId }); this.setFlashMode(); this.app.emit('camera:willchange'); this.app.once('viewfinder:hidden', function() { self.camera.setMode(mode); }); }; /** * Updates the camera's `pictureSize` to match * the size set in the app's settings. * * When in 'picture' mode we send a signal * to say that the camera 'will change', * this allows other parts of the app to * repsond if need be. * * We then wait for the viewfinder to be hidden * before setting the pictureSize to prevent the * user from seeing the stream flicker/jump. * * @private */ CameraController.prototype.updatePictureSize = function() { debug('update picture-size'); var pictureMode = this.settings.mode.selected('key') === 'picture'; var value = this.settings.pictureSizes.selected('data'); var self = this; // Don't do anything if the picture size didn't change if (this.camera.isPictureSize(value)) { return; } // If not currently in 'picture' // mode, just configure. if (!pictureMode) { this.camera.setPictureSize(value, { configure: false }); return; } // Make change once the viewfinder is hidden this.app.emit('camera:willchange'); this.app.once('viewfinder:hidden', function() { self.camera.setPictureSize(value); }); }; /** * Updates the camera's `recorderProfile` to * match the size set in the app's settings. * * When in 'picture' mode we send a signal * to say that the camera 'will change', * this allows other parts of the app to * repsond if need be. * * We then wait for the viewfinder to be hidden * before setting the pictureSize to prevent the * user from seeing the stream flicker/jump. * * @private */ CameraController.prototype.updateRecorderProfile = function() { debug('update recorder-profile'); var videoMode = this.settings.mode.selected('key') === 'video'; var key = this.settings.recorderProfiles.selected('key'); var self = this; // Don't do anything if the recorder-profile didn't change if (this.camera.isRecorderProfile(key)) { return; } // If not currently in 'video' // mode, just configure. if (!videoMode) { this.camera.setRecorderProfile(key, { configure: false }); return; } // Wait for the viewfinder to be hidden this.app.emit('camera:willchange'); this.app.once('viewfinder:hidden', function() { self.camera.setRecorderProfile(key); }); }; /** * Set the selected camera (front/back). * * We send a signal to say that the camera * 'will change', this allows other parts * of the app to respond if need be. * * We then wait for the viewfinder to be 'hidden' * before setting the camera to prevent the * user from seeing the stream flicker/jump. * * @param {String} camera ['front'|'back'] * @private */ CameraController.prototype.setCamera = function(camera) { debug('set camera: %s', camera); var self = this; this.app.emit('camera:willchange'); this.app.once('viewfinder:hidden', function() { self.camera.setCamera(camera); }); }; /** * Sets the flash mode on the camera * to match the current flash mode * in the app's settings. * * @private */ CameraController.prototype.setFlashMode = function() { var flashSetting = this.settings.flashModes; this.camera.setFlashMode(flashSetting.selected('key')); }; CameraController.prototype.setISO = function() { if (!this.settings.isoModes.get('disabled')) { this.camera.setISOMode(this.settings.isoModes.selected('key')); } }; CameraController.prototype.setWhiteBalance = function() { if (!this.settings.whiteBalance.get('disabled')) { this.camera.setWhiteBalance(this.settings.whiteBalance.selected('key')); } }; CameraController.prototype.setHDR = function() { if (this.hdrDisabled) { return; } this.camera.setHDR(this.settings.hdr.selected('key')); }; CameraController.prototype.onFlashModeChange = function(flashModes) { if (this.hdrDisabled) { return; } var ishdrOn = this.settings.hdr.selected('key') === 'on'; if (ishdrOn && flashModes !== 'off') { this.settings.hdr.select('off'); } }; CameraController.prototype.onHDRChange = function(hdr) { var flashMode = this.settings.flashModesPicture.selected('key'); var ishdrOn = hdr === 'on'; if (ishdrOn && flashMode !== 'off') { this.settings.flashModesPicture.select('off'); } }; CameraController.prototype.onBatteryStatusChange = function(status) { this.lowBattery = status === 'shutdown'; if (this.lowBattery) { this.shutdownCamera(); } else { this.loadCamera(); } }; /** * Stop recording if storage changes state. * Examples: * 'shared' usually due to the device being connected to * a computer via USB. * 'unavailable' when the SDCARD is yanked * * @private */ CameraController.prototype.onStorageChanged = function(state) { if (state !== 'available') { this.camera.stopRecording(); } }; /** * For instance, when the storage volume changes from to internal memory * to the SD Card * * @private */ CameraController.prototype.onStorageVolumeChanged = function(storage) { this.camera.setStorage(storage); }; /** * Updates focus area when the user clicks on the viewfinder */ CameraController.prototype.onFocusPointChanged = function(focusPoint) { if (this.countdown) { return; } this.camera.updateFocusArea(focusPoint.area); }; CameraController.prototype.loadCamera = function(showSpinner) { if (this.lowBattery || this.galleryOpen || this.app.hidden) { return; } if (showSpinner) { this.app.showSpinner(); } this.camera.load(); }; CameraController.prototype.shutdownCamera = function() { this.clearCountdown(); this.camera.shutdown(); }; /** * Camera hardware can be closed after a failure or after app request * It reboots the application in the case of failure * * @private */ CameraController.prototype.onCameraClosed = function(reason) { reason = reason || 'SystemFailure'; if (reason === 'SystemFailure') { this.app.emit('reboot'); } }; CameraController.prototype.onGalleryOpened = function() { this.galleryOpen = true; this.shutdownCamera(); }; /** * As the camera is shutdown when the * preview gallery is opened, we must * reload it when it is closed. * * Although if the app is has been minimised * we do not want to reload the camera as * the hardware must be released when the * app is not visible. * * @private */ CameraController.prototype.onGalleryClosed = function(reason) { this.galleryOpen = false; if (this.app.hidden) { return; } this.loadCamera(true); }; /** * For some reason, the above calculation * for `maxHardwareZoom` does not work * properly on Mako (Nexus-4) devices. * * Bug 983930 - [B2G][Camera] CameraControl API's * "zoom" attribute doesn't scale preview properly * * @private */ CameraController.prototype.updateZoomForMako = function() { debug('update zoom for mako'); var self = this; navigator.mozSettings .createLock() .get('deviceinfo.hardware') .onsuccess = onSuccess; debug('settings request made'); function onSuccess(e) { var device = e.target.result['deviceinfo.hardware']; if (device !== 'mako') { return; } var frontCamera = self.camera.selectedCamera === 'front'; var maxHardwareZoom = frontCamera ? 1 : 1.25; // Nexus 4 needs zoom preview adjustment since the viewfinder preview // stream does not automatically reflect the current zoom value. self.settings.zoom.set('useZoomPreviewAdjustment', true); self.camera.set('maxHardwareZoom', maxHardwareZoom); self.camera.emit('zoomconfigured', self.camera.getZoom()); debug('zoom reconfigured for mako'); } }; });