blob: 7a1541d8e8a91277c2dd030a7e9c1729b0a766f0 [file] [log] [blame]
// Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
'use strict';
/**
* Namespace for the Camera app.
*/
var camera = camera || {};
/**
* Namespace for views.
*/
camera.views = camera.views || {};
/**
* Creates the Camera view controller.
* @param {camera.View.Context} context Context object.
* @param {camera.Router} router View router to switch views.
* @constructor
*/
camera.views.Camera = function(context, router) {
camera.View.call(
this, context, router, document.querySelector('#camera'), 'camera');
/**
* Gallery model used to save taken pictures.
* @type {camera.models.Gallery}
* @private
*/
this.model_ = null;
/**
* Video element to catch the stream and plot it later onto a canvas.
* @type {Video}
* @private
*/
this.video_ = document.createElement('video');
/**
* Current camera stream.
* @type {MediaStream}
* @private
*/
this.stream_ = null;
/**
* MediaRecorder object to record motion pictures.
* @type {MediaRecorder}
* @private
*/
this.mediaRecorder_ = null;
/**
* TODO(yuli): Replace with a toggle button.
* @type {boolean}
* @private
*/
this.toggleRecChecked_ = false;
/**
* Last frame time, used to detect new frames of <video>.
* @type {number}
* @private
*/
this.lastFrameTime_ = -1;
/**
* @type {?number}
* @private
*/
this.retryStartTimer_ = null;
/**
* @type {?number}
* @private
*/
this.watchdog_ = null;
/**
* Shutter sound player.
* @type {Audio}
* @private
*/
this.shutterSound_ = document.createElement('audio');
/**
* Tick sound player.
* @type {Audio}
* @private
*/
this.tickSound_ = document.createElement('audio');
/**
* Recording sound player.
* @type {Audio}
* @private
*/
this.recordingSound_ = document.createElement('audio');
/**
* Canvas element with the current frame downsampled to small resolution, to
* be used in effect preview windows.
*
* @type {Canvas}
* @private
*/
this.effectInputCanvas_ = document.createElement('canvas');
/**
* Canvas element with the current frame downsampled to small resolution, to
* be used by the head tracker.
*
* @type {Canvas}
* @private
*/
this.trackerInputCanvas_ = document.createElement('canvas');
/**
* @type {boolean}
* @private
*/
this.running_ = false;
/**
* @type {boolean}
* @private
*/
this.capturing_ = false;
/**
* @type {boolean}
* @private
*/
this.locked_ = false;
/**
* The main (full screen) canvas for full quality capture.
* @type {fx.Canvas}
* @private
*/
this.mainCanvas_ = null;
/**
* Texture for the full quality canvas.
* @type {fx.Texture}
* @private
*/
this.mainCanvasTexture_ = null;
/**
* The main (full screen) canvas for previewing capture.
* @type {fx.Canvas}
* @private
*/
this.mainPreviewCanvas_ = null;
/**
* Texture for the previewing canvas.
* @type {fx.Texture}
* @private
*/
this.mainPreviewCanvasTexture_ = null;
/**
* The main (full screen canvas) for fast capture.
* @type {fx.Canvas}
* @private
*/
this.mainFastCanvas_ = null;
/**
* Texture for the fast canvas.
* @type {fx.Texture}
* @private
*/
this.mainFastCanvasTexture_ = null;
/**
* Shared fx canvas for effects' previews.
* @type {fx.Canvas}
* @private
*/
this.effectCanvas_ = null;
/**
* Texture for the effects' canvas.
* @type {fx.Texture}
* @private
*/
this.effectCanvasTexture_ = null;
/**
* The main (full screen) processor in the full quality mode.
* @type {camera.Processor}
* @private
*/
this.mainProcessor_ = null;
/**
* The main (full screen) processor in the previewing mode.
* @type {camera.Processor}
* @private
*/
this.mainPreviewProcessor_ = null;
/**
* The main (full screen) processor in the fast mode.
* @type {camera.Processor}
* @private
*/
this.mainFastProcessor_ = null;
/**
* Processors for effect previews.
* @type {Array.<camera.Processor>}
* @private
*/
this.effectProcessors_ = [];
/**
* Selected effect or null if no effect.
* @type {number}
* @private
*/
this.currentEffectIndex_ = 0;
/**
* Face detector and tracker.
* @type {camera.Tracker}
* @private
*/
this.tracker_ = new camera.Tracker(this.trackerInputCanvas_);
/**
* Current previewing frame.
* @type {number}
* @private
*/
this.frame_ = 0;
/**
* If the toolbar is expanded.
* @type {boolean}
* @private
*/
this.expanded_ = false;
/**
* If the window controls are visible.
* @type {boolean}
* @private
*/
this.controlsVisible_ = true;
/**
* Toolbar animation effect wrapper.
* @type {camera.util.StyleEffect}
* @private
*/
this.toolbarEffect_ = new camera.util.StyleEffect(
function(args, callback) {
var toolbar = document.querySelector('#toolbar');
var activeEffect = document.querySelector('#effects #effect-' +
this.currentEffectIndex_);
if (args) {
toolbar.classList.add('expanded');
} else {
toolbar.classList.remove('expanded');
// Make all of the effects non-focusable.
var elements = document.querySelectorAll('#effects li');
for (var index = 0; index < elements.length; index++) {
elements[index].tabIndex = -1;
}
// If something was focused before, then focus the toggle button.
if (document.activeElement != document.body)
document.querySelector('#filters-toggle').focus();
}
camera.util.waitForTransitionCompletion(
document.querySelector('#toolbar'), 500, function() {
// If the ribbon is opened, then make all of the items focusable.
if (args) {
activeEffect.tabIndex = 0; // In the focusing order.
// If the filters button was previously selected, then advance to
// the ribbon.
if (document.activeElement ==
document.querySelector('#filters-toggle')) {
activeEffect.focus();
}
}
callback();
});
}.bind(this));
/**
* Timer for hiding the toast message after some delay.
* @type {number?}
* @private
*/
this.toastHideTimer_ = null;
/**
* Toast transition wrapper. Shows or hides the toast with the passed message.
* @type {camera.util.StyleEffect}
* @private
*/
this.toastEffect_ = new camera.util.StyleEffect(
function(args, callback) {
var toastElement = document.querySelector('#toast');
var toastMessageElement = document.querySelector('#toast-message');
// Hide the message if visible.
if (!args.visible && toastElement.classList.contains('visible')) {
toastElement.classList.remove('visible');
camera.util.waitForTransitionCompletion(
toastElement, 500, callback);
} else if (args.visible) {
// If showing requested, then show.
toastMessageElement.textContent = args.message;
toastElement.classList.add('visible');
camera.util.waitForTransitionCompletion(
toastElement, 500, callback);
} else {
callback();
}
}.bind(this));
/**
* Window controls animation effect wrapper.
* @type {camera.util.StyleEffect}
* @private
*/
this.controlsEffect_ = new camera.util.StyleEffect(
function(args, callback) {
if (args)
document.body.classList.add('controls-visible');
else
document.body.classList.remove('controls-visible');
camera.util.waitForTransitionCompletion(
document.body, 500, callback);
});
/**
* Whether a picture is being taken. Used to decrease video quality of
* previews for smoother response.
* @type {boolean}
* @private
*/
this.taking_ = false;
/**
* Contains uncompleted fly-away animations for taken pictures.
* @type {Array.<function()>}
* @private
*/
this.flyAnimations_ = [];
/**
* Timer used to automatically collapse the tools.
* @type {?number}
* @private
*/
this.collapseTimer_ = null;
/**
* Set to true before the ribbon is displayed. Used to render the ribbon's
* frames while it is not yet displayed, so the previews have some image
* as soon as possible.
* @type {boolean}
* @private
*/
this.ribbonInitialization_ = true;
/**
* Scroller for the ribbon with effects.
* @type {camera.util.SmoothScroller}
* @private
*/
this.scroller_ = new camera.util.SmoothScroller(
document.querySelector('#effects'),
document.querySelector('#effects .padder'));
/**
* Scroll bar for the ribbon with effects.
* @type {camera.HorizontalScrollBar}
* @private
*/
this.scrollBar_ = new camera.HorizontalScrollBar(this.scroller_);
/**
* Detects if the mouse has been moved or clicked, and if any touch events
* have been performed on the view. If such events are detected, then the
* ribbon and the window buttons are shown.
*
* @type {camera.util.PointerTracker}
* @private
*/
this.pointerTracker_ = new camera.util.PointerTracker(
document.body, this.onPointerActivity_.bind(this));
/**
* Enables scrolling the ribbon by dragging the mouse.
* @type {camera.util.MouseScroller}
* @private
*/
this.mouseScroller_ = new camera.util.MouseScroller(this.scroller_);
/**
* Detects whether scrolling is being performed or not.
* @type {camera.util.ScrollTracker}
* @private
*/
this.scrollTracker_ = new camera.util.ScrollTracker(
this.scroller_, function() {}, this.onScrollEnded_.bind(this));
/**
* @type {string}
* @private
*/
this.keyBuffer_ = '';
/**
* Measures performance.
* @type {camera.util.NamedPerformanceMonitor}
* @private
*/
this.performanceMonitors_ = new camera.util.NamedPerformanceMonitors();
/**
* Makes the toolbar pullable.
* @type {camera.util.Puller}
* @private
*/
this.puller_ = new camera.util.Puller(
document.querySelector('#toolbar-puller-wrapper'),
document.querySelector('#toolbar-stripe'),
this.onRibbonPullReleased_.bind(this));
/**
* Counter used to refresh periodically invisible images on the ribbons, to
* avoid displaying stale ones.
* @type {number}
* @private
*/
this.staleEffectsRefreshIndex_ = 0;
/**
* Timer used for a multi-shot.
* @type {number?}
* @private
*/
this.multiShotInterval_ = null;
/**
* Timer used to countdown before taking the picture.
* @type {number?}
* @private
*/
this.takePictureTimer_ = null;
/**
* Used by the performance test to progress to a next step. If not null, then
* the performance test is in progress.
* @type {number?}
* @private
*/
this.performanceTestTimer_ = null;
/**
* Stores results of the performance test.
* @type {Array.<Object>}
* @private
*/
this.performanceTestResults_ = [];
/**
* Used by the performance test to periodically update the UI.
* @type {number?}
* @private
*/
this.performanceTestUIInterval_ = null;
/**
* DeviceId of the camera device used for the last time during this session.
* @type {string?}
* @private
*/
this.videoDeviceId_ = null;
/**
* Whether list of video devices is being refreshed now.
* @type {boolean}
* @private
*/
this.refreshingVideoDeviceIds_ = false;
/**
* List of available video device ids.
* @type {Promise<!Array<string>>}
* @private
*/
this.videoDeviceIds_ = null;
/**
* Legacy mirroring set per all devices.
* @type {boolean}
* @private
*/
this.legacyMirroringToggle_ = true;
/**
* Mirroring set per device.
* @type {!Object}
* @private
*/
this.mirroringToggles_ = {};
/**
* Overrides the default camera with a front facing camera due to
* limitations of WebRTC. This is a hack, to be removed when 58 hits
* post-stable.
* @type {boolean}
* @private
*/
this.forceDefaultFrontFacingForReef58_ = false;
// End of properties, seal the object.
Object.seal(this);
// If dimensions of the video are first known or changed, then synchronize
// the window bounds.
this.video_.addEventListener('loadedmetadata',
this.synchronizeBounds_.bind(this));
this.video_.addEventListener('resize',
this.synchronizeBounds_.bind(this));
// Sets dimensions of the input canvas for the effects' preview on the ribbon.
// Keep in sync with CSS.
this.effectInputCanvas_.width = camera.views.Camera.EFFECT_CANVAS_SIZE;
this.effectInputCanvas_.height = camera.views.Camera.EFFECT_CANVAS_SIZE;
// Handle the 'Take' button.
document.querySelector('#take-picture').addEventListener(
'click', this.onTakePictureClicked_.bind(this));
document.querySelector('#toolbar #album-enter').addEventListener('click',
this.onAlbumEnterClicked_.bind(this));
document.querySelector('#toolbar #filters-toggle').addEventListener('click',
this.onFiltersToggleClicked_.bind(this));
// Hide window controls when moving outside of the window.
window.addEventListener('mouseout', this.onWindowMouseOut_.bind(this));
// Hide window controls when any key pressed.
// TODO(mtomasz): Move managing window controls to main.js.
window.addEventListener('keydown', this.onWindowKeyDown_.bind(this));
document.querySelector('#toggle-timer').addEventListener(
'keypress', this.onToggleTimerKeyPress_.bind(this));
document.querySelector('#toggle-timer').addEventListener(
'click', this.onToggleTimerClicked_.bind(this));
document.querySelector('#toggle-multi').addEventListener(
'keypress', this.onToggleMultiKeyPress_.bind(this));
document.querySelector('#toggle-multi').addEventListener(
'click', this.onToggleMultiClicked_.bind(this));
document.querySelector('#toggle-camera').addEventListener(
'click', this.onToggleCameraClicked_.bind(this));
document.querySelector('#toggle-mirror').addEventListener(
'keypress', this.onToggleMirrorKeyPress_.bind(this));
document.querySelector('#toggle-mirror').addEventListener(
'click', this.onToggleMirrorClicked_.bind(this));
// Load the shutter, tick, and recording sound.
// TODO(yuli): Replace the recording sound.
this.shutterSound_.src = '../sounds/shutter.ogg';
this.tickSound_.src = '../sounds/tick.ogg';
this.recordingSound_.src = '../sounds/tick.ogg';
};
/**
* Frame draw mode.
* @enum {number}
*/
camera.views.Camera.DrawMode = Object.freeze({
FAST: 0, // Quality optimized for best performance.
NORMAL: 1, // Quality adapted to the window's current size.
BEST: 2 // The best quality possible.
});
/**
* Head tracker quality.
* @enum {number}
*/
camera.views.Camera.HeadTrackerQuality = Object.freeze({
LOW: 0, // Very low resolution, used for the effects' previews.
NORMAL: 1 // Default resolution, still low though.
});
/**
* Number of frames to be skipped between optimized effects' ribbon refreshes
* and the head detection invocations (which both use the preview back buffer).
*
* @type {number}
* @const
*/
camera.views.Camera.PREVIEW_BUFFER_SKIP_FRAMES = 3;
/**
* Number of frames to be skipped between the head tracker invocations when
* the head tracker is used for the ribbon only.
*
* @type {number}
* @const
*/
camera.views.Camera.RIBBON_HEAD_TRACKER_SKIP_FRAMES = 30;
/**
* Width and height of the effect canvases in pixels.
* Keep in sync with CSS.
*
* @type {number}
* @const
*/
camera.views.Camera.EFFECT_CANVAS_SIZE = 80;
camera.views.Camera.prototype = {
__proto__: camera.View.prototype,
get running() {
return this.running_;
},
get capturing() {
return this.capturing_;
}
};
/**
* Initializes the view.
* @override
*/
camera.views.Camera.prototype.initialize = function(callback) {
var effects = [camera.effects.Normal, camera.effects.Vintage,
camera.effects.Cinema, camera.effects.TiltShift,
camera.effects.Retro30, camera.effects.Retro50,
camera.effects.Retro60, camera.effects.PhotoLab,
camera.effects.BigHead, camera.effects.BigJaw,
camera.effects.BigEyes, camera.effects.BunnyHead,
camera.effects.Grayscale, camera.effects.Sepia,
camera.effects.Colorize, camera.effects.Modern,
camera.effects.Beauty, camera.effects.Newspaper,
camera.effects.Funky, camera.effects.Ghost,
camera.effects.Swirl];
// Workaround for: crbug.com/523216.
// Hide unsupported effects on alex.
camera.util.isBoard('x86-alex').then(function(result) {
if (result) {
var unsupported = [camera.effects.Cinema, camera.effects.TiltShift,
camera.effects.Beauty, camera.effects.Funky];
effects = effects.filter(function(item) {
return (unsupported.indexOf(item) == -1);
});
}
return camera.util.isBoard('reef');
}).then(function(isReef) {
this.forceDefaultFrontFacingForReef58_ = isReef;
}.bind(this)).then(function() {
// Initialize the webgl canvases.
try {
this.mainCanvas_ = fx.canvas();
this.mainPreviewCanvas_ = fx.canvas();
this.mainFastCanvas_ = fx.canvas();
this.effectCanvas_ = fx.canvas();
}
catch (e) {
// TODO(mtomasz): Replace with a better icon.
this.context_.onError('no-camera',
chrome.i18n.getMessage('errorMsgNoWebGL'),
chrome.i18n.getMessage('errorMsgNoWebGLHint'));
// Initialization failed due to lack of webgl.
document.body.classList.remove('initializing');
}
if (this.mainCanvas_ && this.mainPreviewCanvas_ && this.mainFastCanvas_) {
// Initialize the processors.
this.mainCanvasTexture_ = this.mainCanvas_.texture(this.video_);
this.mainPreviewCanvasTexture_ = this.mainPreviewCanvas_.texture(
this.video_);
this.mainFastCanvasTexture_ = this.mainFastCanvas_.texture(this.video_);
this.mainProcessor_ = new camera.Processor(
this.tracker_,
this.mainCanvasTexture_,
this.mainCanvas_,
this.mainCanvas_);
this.mainPreviewProcessor_ = new camera.Processor(
this.tracker_,
this.mainPreviewCanvasTexture_,
this.mainPreviewCanvas_,
this.mainPreviewCanvas_);
this.mainFastProcessor_ = new camera.Processor(
this.tracker_,
this.mainFastCanvasTexture_,
this.mainFastCanvas_,
this.mainFastCanvas_);
// Insert the main canvas to its container.
document.querySelector('#main-canvas-wrapper').appendChild(
this.mainCanvas_);
document.querySelector('#main-preview-canvas-wrapper').appendChild(
this.mainPreviewCanvas_);
document.querySelector('#main-fast-canvas-wrapper').appendChild(
this.mainFastCanvas_);
// Set the default effect.
this.mainProcessor_.effect = new camera.effects.Normal();
// Prepare effect previews.
this.effectCanvasTexture_ = this.effectCanvas_.texture(
this.effectInputCanvas_);
for (var index = 0; index < effects.length; index++) {
this.addEffect_(new effects[index]());
}
// Select the default effect and state of the timer toggle button.
// TODO(mtomasz): Move to chrome.storage.local.sync, after implementing
// syncing of the gallery.
chrome.storage.local.get(
{
effectIndex: 0,
toggleTimer: false,
toggleMulti: false,
toggleMirror: true, // Deprecated.
mirroringToggles: {}, // Per device.
},
function(values) {
if (values.effectIndex < this.effectProcessors_.length)
this.setCurrentEffect_(values.effectIndex);
else
this.setCurrentEffect_(0);
document.querySelector('#toggle-timer').checked = values.toggleTimer;
document.querySelector('#toggle-multi').checked =
values.toggleMulti;
this.legacyMirroringToggle_ = values.toggleMirror;
this.mirroringToggles_ = values.mirroringToggles;
// Initialize the web camera.
this.start_();
}.bind(this));
}
// TODO: Replace with "devicechanged" event once it's implemented in Chrome.
this.maybeRefreshVideoDeviceIds_();
setInterval(this.maybeRefreshVideoDeviceIds_.bind(this), 1000);
// Monitor the locked state to avoid retrying camera connection when locked.
chrome.idle.onStateChanged.addListener(function(newState) {
if (newState == 'locked')
this.locked_ = true;
else if (newState == 'active')
this.locked_ = false;
}.bind(this));
// Acquire the gallery model.
camera.models.Gallery.getInstance(function(model) {
this.model_ = model;
callback();
}.bind(this), function() {
// TODO(mtomasz): Add error handling.
console.error('Unable to initialize the file system.');
callback();
});
}.bind(this));
};
/**
* Enters the view.
* @override
*/
camera.views.Camera.prototype.onEnter = function() {
this.performanceMonitors_.reset();
this.mainProcessor_.performanceMonitors.reset();
this.mainPreviewProcessor_.performanceMonitors.reset();
this.mainFastProcessor_.performanceMonitors.reset();
this.tracker_.start();
this.onResize();
};
/**
* Leaves the view.
* @override
*/
camera.views.Camera.prototype.onLeave = function() {
this.tracker_.stop();
};
/**
* @override
*/
camera.views.Camera.prototype.onActivate = function() {
this.scrollTracker_.start();
if (document.activeElement != document.body)
document.querySelector('#take-picture').focus();
};
/**
* @override
*/
camera.views.Camera.prototype.onInactivate = function() {
this.scrollTracker_.stop();
};
/**
* @override
*/
camera.views.Camera.prototype.onInactivate = function() {
this.endTakePicture_();
};
/**
* Handles clicking on the take-picture button.
* @param {Event} event Mouse event
* @private
*/
camera.views.Camera.prototype.onTakePictureClicked_ = function(event) {
if (this.performanceTestTimer_)
return;
this.takePicture_();
};
/**
* Handles clicking on the album button.
* @param {Event} event Mouse event
* @private
*/
camera.views.Camera.prototype.onAlbumEnterClicked_ = function(event) {
if (this.performanceTestTimer_ || !this.model_)
return;
this.router.navigate(camera.Router.ViewIdentifier.ALBUM);
};
/**
* Handles clicking on the toggle filters button.
* @param {Event} event Mouse event
* @private
*/
camera.views.Camera.prototype.onFiltersToggleClicked_ = function(event) {
if (this.performanceTestTimer_)
return;
this.setExpanded_(!this.expanded_);
};
/**
* Handles releasing the puller on the ribbon, and toggles it.
* @param {number} distance Pulled distance in pixels.
* @private
*/
camera.views.Camera.prototype.onRibbonPullReleased_ = function(distance) {
if (this.performanceTestTimer_)
return;
if (distance < -50)
this.setExpanded_(!this.expanded_);
else if (distance > 25)
this.setExpanded_(false);
};
/**
* Handles moving the mouse outside of the window.
* @param {Event} event Mouse event
* @private
*/
camera.views.Camera.prototype.onWindowMouseOut_ = function(event) {
if (this.performanceTestTimer_)
return;
if (event.toElement !== null)
return;
this.setControlsVisible_(false, 1000);
};
/**
* Handles pressing a key within a window.
* TODO(mtomasz): Simplify this logic.
*
* @param {Event} event Key down event
* @private
*/
camera.views.Camera.prototype.onWindowKeyDown_ = function(event) {
if (this.performanceTestTimer_)
return;
// When the ribbon is focused, then do not collapse it when pressing keys.
if (document.activeElement == document.querySelector('#effects-wrapper')) {
this.setExpanded_(true);
this.setControlsVisible_(true);
return;
}
// If anything else is focused, then hide controls when navigation keys
// are pressed (or space).
switch (camera.util.getShortcutIdentifier(event)) {
case 'Right':
case 'Left':
case 'Up':
case 'Down':
case 'Space':
case 'Home':
case 'End':
this.setControlsVisible_(false);
default:
this.setControlsVisible_(true);
}
};
/**
* Handles pressing a key on the timer switch.
* @param {Event} event Key press event.
* @private
*/
camera.views.Camera.prototype.onToggleTimerKeyPress_ = function(event) {
if (this.performanceTestTimer_)
return;
if (camera.util.getShortcutIdentifier(event) == 'Enter')
document.querySelector('#toggle-timer').click();
};
/**
* Handles pressing a key on the multi-shot switch.
* @param {Event} event Key press event.
* @private
*/
camera.views.Camera.prototype.onToggleMultiKeyPress_ = function(event) {
if (this.performanceTestTimer_)
return;
if (camera.util.getShortcutIdentifier(event) == 'Enter')
document.querySelector('#toggle-multi').click();
};
/**
* Handles pressing a key on the mirror switch.
* @param {Event} event Key press event.
* @private
*/
camera.views.Camera.prototype.onToggleMirrorKeyPress_ = function(event) {
if (this.performanceTestTimer_)
return;
if (camera.util.getShortcutIdentifier(event) == 'Enter')
document.querySelector('#toggle-mirror').click();
};
/**
* Handles clicking on the timer switch.
* @param {Event} event Click event.
* @private
*/
camera.views.Camera.prototype.onToggleTimerClicked_ = function(event) {
if (this.performanceTestTimer_)
return;
var enabled = document.querySelector('#toggle-timer').checked;
this.showToastMessage_(
chrome.i18n.getMessage(enabled ? 'toggleTimerActiveMessage' :
'toggleTimerInactiveMessage'));
chrome.storage.local.set({toggleTimer: enabled});
};
/**
* Handles clicking on the multi-shot switch.
* @param {Event} event Click event.
* @private
*/
camera.views.Camera.prototype.onToggleMultiClicked_ = function(event) {
if (this.performanceTestTimer_)
return;
var enabled = document.querySelector('#toggle-multi').checked;
this.showToastMessage_(
chrome.i18n.getMessage(enabled ? 'toggleMultiActiveMessage' :
'toggleMultiInactiveMessage'));
chrome.storage.local.set({toggleMulti: enabled});
};
/**
* Handles clicking on the toggle camera switch.
* @param {Event} event Click event.
* @private
*/
camera.views.Camera.prototype.onToggleCameraClicked_ = function(event) {
if (this.performanceTestTimer_)
return;
this.videoDeviceIds_.then(function(deviceIds) {
var index = deviceIds.indexOf(this.videoDeviceId_);
if (index == -1)
index = 0;
if (deviceIds.length > 0) {
index = (index + 1) % deviceIds.length;
this.videoDeviceId_ = deviceIds[index];
}
// Add the initialization layer (if it's not there yet).
document.body.classList.add('initializing');
// Stop the camera stream to kick retrying opening the camera stream on the
// new device.
// TODO(mtomasz): Prevent blink. Clear somehow the video tag.
if (this.stream_)
this.stream_.getVideoTracks()[0].stop();
}.bind(this));
};
/**
* Handles clicking on the mirror switch.
* @param {Event} event Click event.
* @private
*/
camera.views.Camera.prototype.onToggleMirrorClicked_ = function(event) {
if (this.performanceTestTimer_)
return;
var enabled = document.querySelector('#toggle-mirror').checked;
this.showToastMessage_(
chrome.i18n.getMessage(enabled ? 'toggleMirrorActiveMessage' :
'toggleMirrorInactiveMessage'));
this.mirroringToggles_[this.videoDeviceId_] = enabled;
chrome.storage.local.set({mirroringToggles: this.mirroringToggles_});
this.updateMirroring_();
};
/**
* Handles pointer actions, such as mouse or touch activity.
* @param {Event} event Activity event.
* @private
*/
camera.views.Camera.prototype.onPointerActivity_ = function(event) {
if (this.performanceTestTimer_)
return;
// Show the window controls.
this.setControlsVisible_(true);
// Update the ribbon's visibility.
switch (event.type) {
case 'mousedown':
// Toggle the ribbon if clicking on static area.
if (event.target == document.body ||
document.querySelector('#main-canvas-wrapper').contains(
event.target) ||
document.querySelector('#main-preview-canvas-wrapper').contains(
event.target) ||
document.querySelector('#main-fast-canvas-wrapper').contains(
event.target)) {
this.setExpanded_(!this.expanded_);
break;
} // Otherwise continue.
default:
// Prevent auto-hiding the ribbon for any other activity.
if (this.expanded_)
this.setExpanded_(true);
break;
}
};
/**
* Handles end of scroll on the ribbon with effects.
* @private
*/
camera.views.Camera.prototype.onScrollEnded_ = function() {
if (document.activeElement != document.body && this.expanded_) {
var effect = document.querySelector('#effects #effect-' +
this.currentEffectIndex_);
effect.focus();
}
};
/**
* Updates the UI to reflect the mirroring either set automatically or by user.
* @private
*/
camera.views.Camera.prototype.updateMirroring_ = function() {
var toggleMirror = document.querySelector('#toggle-mirror')
var enabled;
var track = this.stream_ && this.stream_.getVideoTracks()[0];
var trackSettings = track.getSettings && track.getSettings();
var facingMode = trackSettings && trackSettings.facingMode;
toggleMirror.hidden = !!facingMode;
if (facingMode) {
// Automatic mirroring detection.
enabled = facingMode == 'user';
} else {
// Manual mirroring.
if (this.videoDeviceId_ in this.mirroringToggles_)
enabled = this.mirroringToggles_[this.videoDeviceId_];
else
enabled = this.legacyMirroringToggle_;
}
toggleMirror.checked = enabled;
document.body.classList.toggle('mirror', enabled);
};
/**
* Adds an effect to the user interface.
* @param {camera.Effect} effect Effect to be added.
* @private
*/
camera.views.Camera.prototype.addEffect_ = function(effect) {
// Create the preview on the ribbon.
var listPadder = document.querySelector('#effects .padder');
var wrapper = document.createElement('div');
wrapper.className = 'preview-canvas-wrapper';
var canvas = document.createElement('canvas');
canvas.width = 257; // Forces acceleration on the canvas.
canvas.height = 257;
var padder = document.createElement('div');
padder.className = 'preview-canvas-padder';
padder.appendChild(canvas);
wrapper.appendChild(padder);
var item = document.createElement('li');
item.appendChild(wrapper);
listPadder.appendChild(item);
var label = document.createElement('span');
label.textContent = effect.getTitle();
item.appendChild(label);
// Calculate the effect index.
var effectIndex = this.effectProcessors_.length;
item.id = 'effect-' + effectIndex;
// Set aria attributes.
item.setAttribute('i18n-aria-label', effect.getTitle());
item.setAttribute('aria-role', 'option');
item.setAttribute('aria-selected', 'false');
item.tabIndex = -1;
// Assign events.
item.addEventListener('click', function() {
if (this.currentEffectIndex_ == effectIndex)
this.effectProcessors_[effectIndex].effect.randomize();
this.setCurrentEffect_(effectIndex);
}.bind(this));
item.addEventListener('focus',
this.setCurrentEffect_.bind(this, effectIndex));
// Create the effect preview processor.
var processor = new camera.Processor(
this.tracker_,
this.effectCanvasTexture_,
canvas,
this.effectCanvas_);
processor.effect = effect;
this.effectProcessors_.push(processor);
};
/**
* Sets the current effect.
* @param {number} effectIndex Effect index.
* @private
*/
camera.views.Camera.prototype.setCurrentEffect_ = function(effectIndex) {
var previousEffect =
document.querySelector('#effects #effect-' + this.currentEffectIndex_);
previousEffect.removeAttribute('selected');
previousEffect.setAttribute('aria-selected', 'false');
if (this.expanded_)
previousEffect.tabIndex = -1;
var effect = document.querySelector('#effects #effect-' + effectIndex);
effect.setAttribute('selected', '');
effect.setAttribute('aria-selected', 'true');
if (this.expanded_)
effect.tabIndex = 0;
camera.util.ensureVisible(effect, this.scroller_);
// If there was something focused before, then synchronize the focus.
if (this.expanded_ && document.activeElement != document.body) {
// If not scrolling, then focus immediately. Otherwise, the element will
// be focused, when the scrolling is finished in onScrollEnded.
if (!this.scrollTracker_.scrolling && !this.scroller_.animating)
effect.focus();
}
this.mainProcessor_.effect = this.effectProcessors_[effectIndex].effect;
this.mainPreviewProcessor_.effect =
this.effectProcessors_[effectIndex].effect;
this.mainFastProcessor_.effect = this.effectProcessors_[effectIndex].effect;
var listWrapper = document.querySelector('#effects-wrapper');
listWrapper.setAttribute('aria-activedescendant', effect.id);
listWrapper.setAttribute('aria-labelledby', effect.id);
this.currentEffectIndex_ = effectIndex;
// Show the ribbon when switching effects.
if (!this.performanceTestTimer_)
this.setExpanded_(true);
// TODO(mtomasz): This is a little racy, since setting may be run in parallel,
// without guarantee which one will be written as the last one.
chrome.storage.local.set({effectIndex: effectIndex});
};
/**
* @override
*/
camera.views.Camera.prototype.onResize = function() {
this.synchronizeBounds_();
camera.util.ensureVisible(
document.querySelector('#effect-' + this.currentEffectIndex_),
this.scroller_);
};
/**
* @override
*/
camera.views.Camera.prototype.onKeyPressed = function(event) {
if (this.performanceTestTimer_)
return;
this.keyBuffer_ += String.fromCharCode(event.which);
this.keyBuffer_ = this.keyBuffer_.substr(-10);
// Allow to load a file stream (for debugging).
if (this.keyBuffer_.indexOf('CRAZYPONY') !== -1) {
this.chooseFileStream_();
this.keyBuffer_ = '';
}
if (this.keyBuffer_.indexOf('VER') !== -1) {
this.showVersion_();
this.printPerformanceStats_();
this.keyBuffer_ = '';
}
if (this.keyBuffer_.indexOf('CHOCOBUNNY') !== -1) {
this.startPerformanceTest_();
this.keyBuffer_ = '';
}
// TODO(yuli): Replace the recording key with a toggle and change the
// take-picture button icon when the recording toggle is checked.
if (this.keyBuffer_.indexOf('REC') !== -1 &&
!this.mediaRecorderRecording_()) {
this.toggleRecChecked_ = this.mediaRecorder_ && !this.toggleRecChecked_;
// Disable effects as recording with effects is not supported yet.
var toggleFilters = document.querySelector('#toolbar #filters-toggle');
toggleFilters.disabled = this.toggleRecChecked_;
this.mainProcessor_.effectDisabled = this.toggleRecChecked_;
this.mainPreviewProcessor_.effectDisabled = this.toggleRecChecked_;
this.mainFastProcessor_.effectDisabled = this.toggleRecChecked_;
if (toggleFilters.disabled) {
this.setExpanded_(false);
}
// TODO(yuli): Replace the toggle-multi with toggle-mutemic for recording.
document.querySelector('#toggle-multi').hidden = this.toggleRecChecked_;
this.showToastMessage_(chrome.i18n.getMessage(this.toggleRecChecked_ ?
'toggleRecActiveMessage' : 'toggleRecInactiveMessage'));
this.keyBuffer_ = '';
}
switch (camera.util.getShortcutIdentifier(event)) {
case 'Left':
this.setCurrentEffect_(
(this.currentEffectIndex_ + this.effectProcessors_.length - 1) %
this.effectProcessors_.length);
event.preventDefault();
break;
case 'Right':
this.setCurrentEffect_(
(this.currentEffectIndex_ + 1) % this.effectProcessors_.length);
event.preventDefault();
break;
case 'Home':
this.setCurrentEffect_(0);
event.preventDefault();
break;
case 'End':
this.setCurrentEffect_(this.effectProcessors_.length - 1);
event.preventDefault();
break;
case 'Escape':
// Complete all fly-away animations immediately.
while (this.flyAnimations_.length) {
this.flyAnimations_[0]();
}
event.preventDefault();
break;
case 'Space': // Space key for taking the picture.
document.querySelector('#take-picture').click();
event.stopPropagation();
event.preventDefault();
break;
case 'G': // G key for the gallery.
if (this.model_)
this.router.navigate(camera.Router.ViewIdentifier.ALBUM);
event.preventDefault();
break;
}
};
/**
* Shows a non-intrusive toast message in the middle of the screen.
* @param {string} message Message to be shown.
* @private
*/
camera.views.Camera.prototype.showToastMessage_ = function(message) {
var cancelHideTimer = function() {
if (this.toastHideTimer_) {
clearTimeout(this.toastHideTimer_);
this.toastHideTimer_ = null;
}
}.bind(this);
// If running, then reinvoke recursively after closing the toast message.
if (this.toastEffect_.animating || this.toastHideTimer_) {
cancelHideTimer();
this.toastEffect_.invoke({
visible: false
}, this.showToastMessage_.bind(this, message));
return;
}
// Cancel any pending hide timers.
cancelHideTimer();
// Start the hide timer.
this.toastHideTimer_ = setTimeout(function() {
this.toastEffect_.invoke({
visible: false
}, function() {});
this.toastHideTimer_ = null;
}.bind(this), 2000);
// Show the toast message.
this.toastEffect_.invoke({
visible: true,
message: message
}, function() {});
};
/**
* Toggles the toolbar visibility. However, it may delay the operation, if
* eg. some UI element is hovered.
*
* @param {boolean} expanded True to show the toolbar, false to hide.
* @private
*/
camera.views.Camera.prototype.setExpanded_ = function(expanded) {
if (this.collapseTimer_) {
clearTimeout(this.collapseTimer_);
this.collapseTimer_ = null;
}
if (expanded) {
var isRibbonHovered =
document.querySelector('#toolbar').webkitMatchesSelector(':hover');
if (!isRibbonHovered && !this.performanceTestTimer_) {
this.collapseTimer_ = setTimeout(
this.setExpanded_.bind(this, false), 3000);
}
if (!this.expanded_) {
this.toolbarEffect_.invoke(true, function() {
this.expanded_ = true;
}.bind(this));
}
} else {
if (this.expanded_) {
this.expanded_ = false;
this.toolbarEffect_.invoke(false, function() {});
}
}
};
/**
* Toggles the window controls visibility.
*
* @param {boolean} visible True to show the controls, false to hide.
* @param {number=} opt_delay Optional delay before toggling.
* @private
*/
camera.views.Camera.prototype.setControlsVisible_ = function(
visible, opt_delay) {
if (this.controlsVisible_ == visible)
return;
this.controlsEffect_.invoke(visible, function() {}, opt_delay);
// Set the visibility property as soon as possible, to avoid races, when
// showing, and hiding one after each other.
this.controlsVisible_ = visible;
};
/**
* Chooses a file stream to override the camera stream. Used for debugging.
* @private
*/
camera.views.Camera.prototype.chooseFileStream_ = function() {
chrome.fileSystem.chooseEntry(function(fileEntry) {
if (!fileEntry)
return;
fileEntry.file(function(file) {
var url = URL.createObjectURL(file);
this.video_.src = url;
this.video_.play();
}.bind(this));
}.bind(this));
};
/**
* Shows a version dialog.
* @private
*/
camera.views.Camera.prototype.showVersion_ = function() {
// No need to localize, since for debugging purpose only.
var message = 'Version: ' + chrome.runtime.getManifest().version + '\n' +
'Resolution: ' +
this.video_.videoWidth + 'x' + this.video_.videoHeight + '\n' +
'Frames per second: ' +
this.performanceMonitors_.fps('main').toPrecision(2) + '\n' +
'Head tracking frames per second: ' + this.tracker_.fps.toPrecision(2);
this.router.navigate(camera.Router.ViewIdentifier.DIALOG, {
type: camera.views.Dialog.Type.ALERT,
message: message
});
};
/**
* Starts a performance test.
* @private
*/
camera.views.Camera.prototype.startPerformanceTest_ = function() {
if (this.performanceTestTimer_)
return;
this.performanceTestResults_ = [];
// Start the test after resizing to desired dimensions.
var onBoundsChanged = function() {
document.body.classList.add('performance-test');
this.progressPerformanceTest_(0);
var perfTestBubble = document.querySelector('#perf-test-bubble');
this.performanceTestUIInterval_ = setInterval(function() {
var fps = this.performanceMonitors_.fps('main');
var scale = 1 + Math.min(fps / 60, 1);
// (10..30) -> (0..30)
var hue = 120 * Math.max(0, Math.min(fps, 30) * 40 / 30 - 10) / 30;
perfTestBubble.textContent = Math.round(fps);
perfTestBubble.style.backgroundColor =
'hsla(' + hue + ', 100%, 75%, 0.75)';
perfTestBubble.style.webkitTransform = 'scale(' + scale + ')';
}.bind(this), 100);
// Removing listener will be ignored if not registered earlier.
chrome.app.window.current().onBoundsChanged.removeListener(onBoundsChanged);
}.bind(this);
// Set the default window size and wait until it is applied.
var onRestored = function() {
if (this.setDefaultGeometry_())
chrome.app.window.current().onBoundsChanged.addListener(onBoundsChanged);
else
onBoundsChanged();
chrome.app.window.current().onRestored.removeListener(onRestored);
}.bind(this);
// If maximized, then restore before proceeding. The timer has to be used, to
// know that the performance test has started.
// TODO(mtomasz): Consider using a bool member instead of reusing timer.
this.performanceTestTimer_ = setTimeout(function() {
if (chrome.app.window.current().isMaximized()) {
chrome.app.window.current().restore();
chrome.app.window.current().onRestored.addListener(onRestored);
} else {
onRestored();
}
}, 0);
};
/**
* Progresses to the next step of the performance test.
* @param {number} index Step index to progress to.
* @private
*/
camera.views.Camera.prototype.progressPerformanceTest_ = function(index) {
// Finalize the previous test.
if (index) {
var result = {
effect: Math.floor(index - 1 / 2),
ribbon: (index - 1) % 2,
// TODO(mtomasz): Avoid localization. Use English instead.
name: this.mainProcessor_.effect.getTitle(),
fps: this.performanceMonitors_.fps('main')
};
this.performanceTestResults_.push(result);
}
// Check if the end.
if (index == this.effectProcessors_.length * 2) {
this.stopPerformanceTest_();
var message = '';
var score = 0;
this.performanceTestResults_.forEach(function(result) {
message += [
result.effect,
result.ribbon,
result.name,
Math.round(result.fps)
].join(', ') + '\n';
score += result.fps / this.performanceTestResults_.length;
}.bind(this));
var header = 'Score: ' + Math.round(score * 100) + '\n';
this.router.navigate(camera.Router.ViewIdentifier.DIALOG, {
type: camera.views.Dialog.Type.ALERT,
message: header + message
});
return;
}
// Run new test.
this.performanceMonitors_.reset();
this.mainProcessor_.performanceMonitors.reset();
this.mainPreviewProcessor_.performanceMonitors.reset();
this.mainFastProcessor_.performanceMonitors.reset();
this.setCurrentEffect_(Math.floor(index / 2));
this.setExpanded_(index % 2 == 1);
// Update the progress bar.
var progress = (index / (this.effectProcessors_.length * 2)) * 100;
var perfTestBar = document.querySelector('#perf-test-bar');
perfTestBar.textContent = Math.round(progress) + '%';
perfTestBar.style.width = progress + '%';
// Schedule the next test.
this.performanceTestTimer_ = setTimeout(function() {
this.progressPerformanceTest_(index + 1);
}.bind(this), 5000);
};
/**
* Stops the performance test.
* @private
*/
camera.views.Camera.prototype.stopPerformanceTest_ = function() {
if (!this.performanceTestTimer_)
return;
clearTimeout(this.performanceTestTimer_);
this.performanceTestTimer_ = null;
clearInterval(this.performanceTestUIInterval_);
this.performanceTestUIInterval_ = null;
this.showToastMessage_('Performance test terminated');
document.body.classList.remove('performance-test');
};
/**
* Takes the picture (maybe with a timer if enabled); or ends an ongoing
* recording started from the prior takePicture_() call.
* @private
*/
camera.views.Camera.prototype.takePicture_ = function() {
if (!this.running_ || !this.model_)
return;
if (this.mediaRecorderRecording_()) {
// End the prior ongoing recording. A new reocording won't be started until
// the prior recording is stopped.
this.endTakePicture_();
return;
}
var toggleTimer = document.querySelector('#toggle-timer');
var toggleMulti = document.querySelector('#toggle-multi');
// TODO(yuli): Disable toggle-recording button.
toggleTimer.disabled = true;
toggleMulti.disabled = true;
document.querySelector('#take-picture').disabled = true;
var tickCounter = toggleTimer.checked ? 6 : 1;
var onTimerTick = function() {
tickCounter--;
if (tickCounter == 0) {
var multiEnabled = !this.toggleRecChecked_ && toggleMulti.checked;
var multiShotCounter = 3;
var takePicture = function() {
this.takePictureImmediately_();
if (multiEnabled) {
multiShotCounter--;
if (multiShotCounter)
return;
}
// Don't end recording until another take-picture click.
if (!this.toggleRecChecked_)
this.endTakePicture_();
}.bind(this);
takePicture();
if (multiEnabled)
this.multiShotInterval_ = setInterval(takePicture, 250);
} else {
this.takePictureTimer_ = setTimeout(onTimerTick, 1000);
this.tickSound_.play();
// Blink the toggle timer button.
toggleTimer.classList.add('animate');
setTimeout(function() {
if (this.takePictureTimer_)
toggleTimer.classList.remove('animate');
}.bind(this), 500);
}
}.bind(this);
// First tick immediately in the next message loop cycle.
this.takePictureTimer_ = setTimeout(onTimerTick, 0);
};
/**
* Ends ongoing recording or clears scheduled further picture takes (if any).
* @private
*/
camera.views.Camera.prototype.endTakePicture_ = function() {
if (this.takePictureTimer_) {
clearTimeout(this.takePictureTimer_);
this.takePictureTimer_ = null;
}
if (this.multiShotInterval_) {
clearTimeout(this.multiShotInterval_);
this.multiShotInterval_ = null;
}
if (this.mediaRecorderRecording_()) {
this.mediaRecorder_.stop();
}
var toggleTimer = document.querySelector('#toggle-timer');
toggleTimer.classList.remove('animate');
toggleTimer.disabled = false;
document.querySelector('#take-picture').disabled = false;
document.querySelector('#toggle-multi').disabled = false;
};
/**
* Takes the still picture or starts to take the motion picture immediately,
* and saves and puts to the album with a nice animation when it's done.
* @private
*/
camera.views.Camera.prototype.takePictureImmediately_ = function() {
if (!this.running_) {
return;
}
// Lock refreshing for smoother experience.
this.taking_ = true;
var takePicture = function(motionPicture) {
this.drawCameraFrame_(camera.views.Camera.DrawMode.BEST);
this.mainCanvas_.toBlob(function(blob) {
// Create a picture preview animation.
var picturePreview = document.querySelector('#picture-preview');
var img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.style.webkitTransform = 'rotate(' + (Math.random() * 40 - 20) + 'deg)';
img.addEventListener('click', function() {
// For simplicity, always navigate to the newest picture.
if (this.model_.length) {
this.router.navigate(camera.Router.ViewIdentifier.BROWSER);
}
}.bind(this));
// Create the fly-away animation with the image.
var albumButton = document.querySelector('#toolbar #album-enter');
var flyAnimation = function() {
var removal = this.flyAnimations_.indexOf(flyAnimation);
if (removal == -1)
return;
this.flyAnimations_.splice(removal, 1);
img.classList.remove('activated');
var sourceRect = img.getBoundingClientRect();
var targetRect = albumButton.getBoundingClientRect();
// If the album button is hidden, then we can't get its geometry.
if (targetRect.width && targetRect.height) {
var translateXValue = (targetRect.left + targetRect.right) / 2 -
(sourceRect.left + sourceRect.right) / 2;
var translateYValue = (targetRect.top + targetRect.bottom) / 2 -
(sourceRect.top + sourceRect.bottom) / 2;
var scaleValue = targetRect.width / sourceRect.width;
img.style.webkitTransform =
'rotate(0) translateX(' + translateXValue +'px) ' +
'translateY(' + translateYValue + 'px) ' +
'scale(' + scaleValue + ')';
}
img.style.opacity = 0;
camera.util.waitForTransitionCompletion(img, 1200, function() {
img.parentNode.removeChild(img);
this.taking_ = false;
}.bind(this));
}.bind(this);
// Add picture to the gallery with a nice animation.
var addPicture = function(blob, type) {
// Preview the picture with the fly-away animation after two seconds.
this.flyAnimations_.push(flyAnimation);
setTimeout(flyAnimation, 2000);
var onPointerDown = function() {
img.classList.add('activated');
};
// When clicking or touching, zoom the preview a little to give feedback.
// Do not release the 'activated' flag since in most cases, releasing the
// mouse button or touch would redirect to the browser view.
img.addEventListener('touchstart', onPointerDown);
img.addEventListener('mousedown', onPointerDown);
// Animate the album button.
camera.util.setAnimationClass(albumButton, albumButton, 'flash');
// Play a shutter sound.
this.shutterSound_.currentTime = 0;
this.shutterSound_.play();
// Add to DOM.
picturePreview.appendChild(img);
// Add the picture to the model.
this.model_.addPicture(blob, type, function() {
this.showToastMessage_(
chrome.i18n.getMessage('errorMsgGallerySaveFailed'));
}.bind(this));
}.bind(this);
if (motionPicture) {
var recordedChunks = [];
this.mediaRecorder_.ondataavailable = function(event) {
if (event.data && event.data.size > 0) {
recordedChunks.push(event.data);
}
}.bind(this);
var takeButton = document.querySelector('#take-picture');
this.mediaRecorder_.onstop = function(event) {
takeButton.classList.remove('flash');
// Add the motion picture after the recording is ended.
// TODO(yuli): Handle insufficient storage.
var recordedBlob = new Blob(recordedChunks, {type: 'video/webm'});
recordedChunks = [];
if (recordedBlob.size) {
// TODO(yuli): Use the preview image's blob to create video's
// thumbnail to save time.
addPicture(recordedBlob, camera.models.Gallery.PictureType.MOTION);
} else {
// The recording may have no data available because it's too short
// or the media recorder is not stable and Chrome needs to restart.
this.showToastMessage_(
chrome.i18n.getMessage('errorMsgEmptyRecording'));
}
}.bind(this);
// Start recording.
this.mediaRecorder_.start();
// Re-enable the take-picture button to stop recording later and flash
// the take-picture button until the recording is stopped.
takeButton.disabled = false;
takeButton.classList.add('flash');
} else {
addPicture(blob, camera.models.Gallery.PictureType.STILL);
}
}.bind(this), 'image/jpeg');
}.bind(this);
if (this.toggleRecChecked_) {
// Play a sound before recording started.
this.recordingSound_.currentTime = 0;
this.recordingSound_.onended = function() {
setTimeout(function() { takePicture(true) }, 0);
}.bind(this);
this.recordingSound_.play();
} else {
setTimeout(function() { takePicture(false) }, 0);
}
};
/**
* Resolutions to be probed on the camera. Format: [[width, height], ...].
* TODO(mtomasz): Remove this list and always use the highest available
* resolution.
*
* @type {Array.<Array.<number>>}
* @const
*/
camera.views.Camera.RESOLUTIONS =
[[2560, 1920], [2048, 1536], [1920, 1080], [1280, 720], [800, 600],
[640, 480]];
/**
* Synchronizes video size with the window's current size.
* @private
*/
camera.views.Camera.prototype.synchronizeBounds_ = function() {
if (!this.video_.videoHeight)
return;
var videoRatio = this.video_.videoWidth / this.video_.videoHeight;
var bodyRatio = document.body.offsetWidth / document.body.offsetHeight;
var scale;
if (videoRatio > bodyRatio) {
scale = Math.min(1, document.body.offsetHeight / this.video_.videoHeight)
document.body.classList.add('letterbox');
} else {
scale = Math.min(1, document.body.offsetWidth / this.video_.videoWidth);
document.body.classList.remove('letterbox');
}
this.mainPreviewProcessor_.scale = scale;
this.mainFastProcessor_.scale = scale / 2;
this.video_.width = this.video_.videoWidth;
this.video_.height = this.video_.videoHeight;
}
/**
* Sets resolution of the low-resolution tracker input canvas. Depending on the
* argument, the resolution is low, or very low.
*
* @param {camera.views.Camera.HeadTrackerQuality} quality Quality of the head
* tracker.
* @private
*/
camera.views.Camera.prototype.setHeadTrackerQuality_ = function(quality) {
var videoRatio = this.video_.videoWidth / this.video_.videoHeight;
var scale;
switch (quality) {
case camera.views.Camera.HeadTrackerQuality.NORMAL:
scale = 1;
break;
case camera.views.Camera.HeadTrackerQuality.LOW:
scale = 0.75;
break;
}
if (videoRatio < 1.5) {
// For resolutions: 800x600.
this.trackerInputCanvas_.width = Math.round(120 * scale);
this.trackerInputCanvas_.height = Math.round(90 * scale);
} else {
// For wide resolutions (any other).
this.trackerInputCanvas_.width = Math.round(160 * scale);
this.trackerInputCanvas_.height = Math.round(90 * scale);
}
};
/**
* Creates the media recorder for the video stream.
*
* @param {MediaStream} stream Media stream to be recorded.
* @return {MediaRecorder} Media recorder created.
* @private
*/
camera.views.Camera.prototype.createMediaRecorder_ = function(stream) {
if (!window.MediaRecorder) {
return null;
}
var options = {mimeType: ''};
var mimeTypes = ['video/webm; codecs=vp9', 'video/webm; codecs=vp8',
'video/webm'];
for (var i = 0; i < mimeTypes.length; i++) {
if (MediaRecorder.isTypeSupported(mimeTypes[i])) {
options = {mimeType: mimeTypes[i]};
break;
}
}
try {
// TODO(yuli): Add a mute-microphone toggle.
var muteMic = function(mute) {
stream.getAudioTracks()[0].enabled = !mute;
};
return new MediaRecorder(stream, options);
} catch (e) {
// TODO(yuli): Disable the recording toggle.
console.error('Unable to create MediaRecorder: ' + e + '. mimeType: ' +
options.mimeType);
return null;
}
};
/**
* Checks if the media recorder is currently recording.
*
* @return {boolean} True if the media recorder is recording, false otherwise.
* @private
*/
camera.views.Camera.prototype.mediaRecorderRecording_ = function() {
return this.mediaRecorder_ && this.mediaRecorder_.state == 'recording';
};
/**
* Starts capturing with the specified constraints.
*
* @param {!Object} constraints Constraints passed to WebRTC as mandatory ones.
* @param {function()} onSuccess Success callback.
* @param {function()} onFailure Failure callback, eg. the constraints are
* not supported.
* @param {function()} onDisconnected Called when the camera connection is lost.
* @private
*/
camera.views.Camera.prototype.startWithConstraints_ =
function(constraints, onSuccess, onFailure, onDisconnected) {
// Convert the constraints to legacy ones, as the new format is not well
// supported yet and it's buggy.
var legacyConstraints = {
video: {
mandatory: {
minWidth: constraints.video.width,
minHeight: constraints.video.height
}
}
};
// If the deviceId is passed, then request it. Otherwise, let's open the
// default system camera.
if (constraints.video.deviceId) {
legacyConstraints.video.mandatory.sourceId = constraints.video.deviceId;
}
// For reef devices probing resolutions may cause selecting a device
// which is not the default one on Chrome 58 and older.
//
// To workaround it, on reef, for the time being, force selecting the user
// facing camera, no matter what's the default camera set to in
// chrome://settings. On 58 passing larger resolution than the front facing
// camera can handle, would select the back facing, so hard-code the
// resolution.
if (this.forceDefaultFrontFacingForReef58_ && !constraints.video.deviceId) {
legacyConstraints = {
video: {
width: { exact: 1280 },
height: { exact: 720 },
facingMode: { exact: 'user' }
}
};
}
navigator.mediaDevices.getUserMedia(legacyConstraints).then(function(stream) {
// Mute to avoid echo from the captured audio.
this.video_.muted = true;
this.video_.src = window.URL.createObjectURL(stream);
this.stream_ = stream;
var onLoadedMetadata = function() {
this.video_.removeEventListener('loadedmetadata', onLoadedMetadata);
this.running_ = true;
// Use a watchdog since the stream.onended event is unreliable in the
// recent version of Chrome. As of 55, the event is still broken.
this.watchdog_ = setInterval(function() {
// Check if video stream is ended (audio stream may still be live).
if (!stream.getVideoTracks().length ||
stream.getVideoTracks()[0].readyState == 'ended') {
this.capturing_ = false;
this.endTakePicture_();
onDisconnected();
clearInterval(this.watchdog_);
this.watchdog_ = null;
}
}.bind(this), 100);
this.mediaRecorder_ = this.createMediaRecorder_(stream);
this.capturing_ = true;
var onAnimationFrame = function() {
if (!this.running_)
return;
this.onAnimationFrame_();
requestAnimationFrame(onAnimationFrame);
}.bind(this);
onAnimationFrame();
onSuccess();
}.bind(this);
// Load the stream and wait for the metadata.
this.video_.addEventListener('loadedmetadata', onLoadedMetadata);
this.video_.play();
}.bind(this), function(error) {
if (error && error.name != 'ConstraintNotSatisfiedError') {
// Constraint errors are expected, so don't report them.
console.error(error);
}
onFailure();
});
};
/**
* Sets the window size to the default dimensions.
* @return {boolean} Whether the window has been resized.
* @private
*/
camera.views.Camera.prototype.setDefaultGeometry_ = function() {
var bounds = chrome.app.window.current().innerBounds;
var targetAspectRatio = this.video_.videoWidth / this.video_.videoHeight;
var targetWidth = Math.round(screen.width * 0.8);
var targetHeight = Math.round(targetWidth / targetAspectRatio);
if (targetWidth == bounds.width && targetHeight == bounds.height)
return false;
bounds.setSize(targetWidth, targetHeight)
bounds.setPosition(
Math.round(bounds.left - (targetWidth - bounds.width) / 2),
Math.round(bounds.top - (targetHeight - bounds.height) / 2));
return true;
};
/**
* Snaps the window aspect ratio to the frame size.
* @private
*/
camera.views.Camera.prototype.snapWindowAspectRatio_ = function() {
var bounds = chrome.app.window.current().innerBounds;
var windowAspectRatio = bounds.width / bounds.height;
var targetAspectRatio = this.video_.videoWidth / this.video_.videoHeight;
// If a user significantly resized the window, then keep the dimensions.
if (targetAspectRatio / windowAspectRatio > 1.5 ||
windowAspectRatio / targetAspectRatio > 1.5) {
return;
}
var targetWidth = Math.round(bounds.height * targetAspectRatio);
var targetHeight = bounds.height;
if (targetWidth > screen.width) {
targetWidth = bounds.width;
targetHeight = Math.round(bounds.width / targetAspectRatio);
}
bounds.setSize(targetWidth, targetHeight);
var targetLeft = Math.round(bounds.left - (targetWidth - bounds.width) / 2);
var targetTop = Math.round(bounds.top - (targetHeight - bounds.height) / 2);
// Clamp the window position so it stays always visible.
targetLeft = Math.max(0, Math.min(targetLeft, screen.width - targetWidth));
targetTop = Math.max(0, Math.min(targetTop, screen.height - targetHeight));
bounds.setPosition(targetLeft, targetTop);
};
/**
* Updates list of available video devices when changed, including the UI.
* Does nothing if refreshing is already in progress.
* @private
*/
camera.views.Camera.prototype.maybeRefreshVideoDeviceIds_ = function() {
if (this.refreshingVideoDeviceIds_)
return;
this.refreshingVideoDeviceIds_ = true;
this.videoDeviceIds_ = this.collectVideoDevices_();
// Update the UI.
this.videoDeviceIds_.then(function(devices) {
document.querySelector('#toggle-camera').hidden = devices.length < 2;
}, function() {
document.querySelector('#toggle-camera').hidden = true;
}).then(function() {
this.refreshingVideoDeviceIds_ = false;
}.bind(this));
};
/**
* Collects all of the available video input devices.
* @return {!Promise<!Array<string>}
* @private
*/
camera.views.Camera.prototype.collectVideoDevices_ = function() {
return navigator.mediaDevices.enumerateDevices().then(function(devices) {
var availableVideoDevices = [];
devices.forEach(function(device) {
if (device.kind != 'videoinput')
return;
availableVideoDevices.push(device.deviceId);
});
return availableVideoDevices;
});
};
/**
* Starts capturing the camera with the highest possible resolution.
* Can be called only once.
* @private
*/
camera.views.Camera.prototype.start_ = function() {
var scheduleRetry = function() {
if (this.retryStartTimer_) {
clearTimeout(this.retryStartTimer_);
this.retryStartTimer_ = null;
}
this.retryStartTimer_ = setTimeout(this.start_.bind(this), 1000);
}.bind(this);
if (this.locked_) {
scheduleRetry();
return;
}
var onSuccess = function() {
// Set the default dimensions to at most half of the available width
// and to the compatible aspect ratio. 640/360 dimensions are used to
// detect that the window has never been opened.
var bounds = chrome.app.window.current().getBounds();
if (bounds.width == 640 && bounds.height == 360)
this.setDefaultGeometry_();
else
this.snapWindowAspectRatio_();
// Remove the initialization layer.
document.body.classList.remove('initializing');
// Set the ribbon in the initialization mode for 500 ms. This forces repaint
// of the ribbon, even if it is hidden, or animations are in progress.
setTimeout(function() {
this.ribbonInitialization_ = false;
}.bind(this), 500);
if (this.retryStartTimer_) {
clearTimeout(this.retryStartTimer_);
this.retryStartTimer_ = null;
}
this.context_.onErrorRecovered('no-camera');
}.bind(this);
var onFailure = function() {
document.body.classList.remove('initializing');
this.context_.onError(
'no-camera',
chrome.i18n.getMessage('errorMsgNoCamera'),
chrome.i18n.getMessage('errorMsgNoCameraHint'));
scheduleRetry();
}.bind(this);
var constraintsCandidates = [];
var tryStartWithConstraints = function(index) {
if (this.locked_) {
scheduleRetry();
return;
}
if (index >= constraintsCandidates.length) {
onFailure();
return;
}
this.startWithConstraints_(
constraintsCandidates[index],
function() {
if (constraintsCandidates[index].video.deviceId) {
// For non-default cameras fetch the deviceId from constraints.
// Works on all supported Chrome versions.
this.videoDeviceId_ = constraintsCandidates[index].video.deviceId;
} else {
// For default camera, obtain the deviceId from settings, which is
// a feature available only from 59. For older Chrome versions,
// it's impossible to detect the device id. As a result, if the
// default camera was changed to rear in chrome://settings, then
// toggling the camera may not work when pressed for the first time
// (the same camera would be opened).
var track = this.stream_.getVideoTracks()[0];
var trackSettings = track.getSettings && track.getSettings();
this.videoDeviceId_ = trackSettings && trackSettings.deviceId ||
null;
}
this.updateMirroring_();
onSuccess();
}.bind(this),
function() {
// TODO(mtomasz): Workaround for crbug.com/383241.
setTimeout(tryStartWithConstraints.bind(this, index + 1), 0);
},
scheduleRetry); // onDisconnected
}.bind(this);
this.videoDeviceIds_.then(function(deviceIds) {
if (deviceIds.length == 0) {
return Promise.reject("Device list empty.");
}
// Put the preferred camera first.
var sortedDeviceIds = deviceIds.slice(0).sort(function(a, b) {
if (a == b)
return 0;
if (a == this.videoDeviceId_)
return -1;
else
return 1;
}.bind(this));
// Prepended 'null' deviceId means the system default camera. Add it only
// when the app is launched (no deviceId_ set).
if (this.videoDeviceId_ == null) {
sortedDeviceIds.unshift(null);
}
// TODO(mtomasz): Remove this when support for advanced (multiple)
// constraints is added to Chrome. For now try to obtain stream with
// each candidate separately.
sortedDeviceIds.forEach(function(deviceId) {
camera.views.Camera.RESOLUTIONS.forEach(function(resolution) {
constraintsCandidates.push({
video: {
deviceId: deviceId,
width: resolution[0],
height: resolution[1]
}
});
});
});
tryStartWithConstraints(0);
}.bind(this)).catch(function(error) {
console.error('Failed to initialize camera.', error);
onFailure();
});
};
/**
* Draws the effects' ribbon.
* @param {camera.views.Camera.DrawMode} mode Drawing mode.
* @private
*/
camera.views.Camera.prototype.drawEffectsRibbon_ = function(mode) {
var notDrawn = [];
// Draw visible frames only when in DrawMode.NORMAL mode. Otherwise, only one
// per method call.
for (var index = 0; index < this.effectProcessors_.length; index++) {
var processor = this.effectProcessors_[index];
var effectRect = processor.output.getBoundingClientRect();
if (mode == camera.views.Camera.DrawMode.NORMAL && effectRect.right >= 0 &&
effectRect.left < document.body.offsetWidth) {
processor.processFrame();
} else {
notDrawn.push(processor);
}
}
// Additionally, draw one frame which is not visible. This is to avoid stale
// images when scrolling.
this.staleEffectsRefreshIndex_++;
if (notDrawn.length)
notDrawn[this.staleEffectsRefreshIndex_ % notDrawn.length].processFrame();
};
/**
* Draws a single frame for the main canvas and effects.
* @param {camera.views.Camera.DrawMode} mode Drawing mode.
* @private
*/
camera.views.Camera.prototype.drawCameraFrame_ = function(mode) {
{
var finishMeasuring = this.performanceMonitors_.startMeasuring(
'main-fast-processor-load-contents-and-process');
if (this.frame_ % 10 == 0 || mode == camera.views.Camera.DrawMode.FAST) {
this.mainFastCanvasTexture_.loadContentsOf(this.video_);
this.mainFastProcessor_.processFrame();
}
finishMeasuring();
}
switch (mode) {
case camera.views.Camera.DrawMode.FAST:
this.mainCanvas_.parentNode.hidden = true;
this.mainPreviewCanvas_.parentNode.hidden = true;
this.mainFastCanvas_.parentNode.hidden = false;
break;
case camera.views.Camera.DrawMode.NORMAL:
{
var finishMeasuring = this.performanceMonitors_.startMeasuring(
'main-preview-processor-load-contents-and-process');
this.mainPreviewCanvasTexture_.loadContentsOf(this.video_);
this.mainPreviewProcessor_.processFrame();
finishMeasuring();
}
this.mainCanvas_.parentNode.hidden = true;
this.mainPreviewCanvas_.parentNode.hidden = false;
this.mainFastCanvas_.parentNode.hidden = true;
break;
case camera.views.Camera.DrawMode.BEST:
{
var finishMeasuring = this.performanceMonitors_.startMeasuring(
'main-processor-canvas-to-texture');
this.mainCanvasTexture_.loadContentsOf(this.video_);
finishMeasuring();
}
this.mainProcessor_.processFrame();
{
var finishMeasuring = this.performanceMonitors_.startMeasuring(
'main-processor-dom');
this.mainCanvas_.parentNode.hidden = false;
this.mainPreviewCanvas_.parentNode.hidden = true;
this.mainFastCanvas_.parentNode.hidden = true;
finishMeasuring();
}
break;
}
};
/**
* Prints performance stats for named monitors to the console.
* @private
*/
camera.views.Camera.prototype.printPerformanceStats_ = function() {
console.info('Camera view');
console.info(this.performanceMonitors_.toDebugString());
console.info('Main processor');
console.info(this.mainProcessor_.performanceMonitors.toDebugString());
console.info('Main preview processor');
console.info(this.mainPreviewProcessor_.performanceMonitors.toDebugString());
console.info('Main fast processor');
console.info(this.mainFastProcessor_.performanceMonitors.toDebugString());
};
/**
* Handles the animation frame event and refreshes the viewport if necessary.
* @private
*/
camera.views.Camera.prototype.onAnimationFrame_ = function() {
// No capturing when the view is inactive.
if (!this.active)
return;
// No capturing while resizing.
if (this.context.resizing)
return;
// If the animation is called more often than the video provides input, then
// there is no reason to process it. This will cup FPS to the Web Cam frame
// rate (eg. head tracker interpolation, nor ghost effect will not be updated
// more often than frames provided). Since we can assume that the webcam
// serves frames with 30 FPS speed it should be OK. As a result, we will
// significantly reduce CPU usage.
if (this.lastFrameTime_ == this.video_.currentTime)
return;
var finishFrameMeasuring = this.performanceMonitors_.startMeasuring('main');
this.frame_++;
// Copy the video frame to the back buffer. The back buffer is low
// resolution, since it is only used by the effects' previews.
{
var finishMeasuring = this.performanceMonitors_.startMeasuring(
'resample-and-upload-preview-texture');
if (this.frame_ % camera.views.Camera.PREVIEW_BUFFER_SKIP_FRAMES == 0) {
var context = this.effectInputCanvas_.getContext('2d');
// Since the effect input canvas is square, cut the center out of the
// original frame.
var sw = Math.min(this.video_.width, this.video_.height);
var sh = Math.min(this.video_.width, this.video_.height);
var sx = Math.round(this.video_.width / 2 - sw / 2);
var sy = Math.round(this.video_.height / 2 - sh / 2);
context.drawImage(this.video_,
sx,
sy,
sw,
sh,
0,
0,
camera.views.Camera.EFFECT_CANVAS_SIZE,
camera.views.Camera.EFFECT_CANVAS_SIZE);
this.effectCanvasTexture_.loadContentsOf(this.effectInputCanvas_);
}
finishMeasuring();
}
// Request update of the head tracker always if it is used by the active
// effect, or periodically if used on the visible ribbon only.
// TODO(mtomasz): Do not call the head tracker when performing any CSS
// transitions or animations.
var requestHeadTrackerUpdate = this.mainProcessor_.effect.usesHeadTracker() ||
(this.expanded_ && this.frame_ %
camera.views.Camera.RIBBON_HEAD_TRACKER_SKIP_FRAMES == 0);
// Copy the video frame to the back buffer. The back buffer is low resolution
// since it is only used by the head tracker. Also, if the currently selected
// effect does not use head tracking, then use even lower resolution, so we
// can get higher FPS, when the head tracker is used for tiny effect previews
// only.
{
var finishMeasuring = this.performanceMonitors_.startMeasuring(
'resample-and-schedule-head-tracking');
if (!this.tracker_.busy && requestHeadTrackerUpdate) {
this.setHeadTrackerQuality_(
this.mainProcessor_.effect.usesHeadTracker() ?
camera.views.Camera.HeadTrackerQuality.NORMAL :
camera.views.Camera.HeadTrackerQuality.LOW);
// Aspect ratios are required to be same.
var context = this.trackerInputCanvas_.getContext('2d');
context.drawImage(this.video_,
0,
0,
this.trackerInputCanvas_.width,
this.trackerInputCanvas_.height);
this.tracker_.detect();
}
finishMeasuring();
}
// Update internal state of the tracker.
{
var finishMeasuring =
this.performanceMonitors_.startMeasuring('interpolate-head-tracker');
this.tracker_.update();
finishMeasuring();
}
// Draw the camera frame. Decrease the rendering resolution when scrolling, or
// while performing animations.
{
var finishMeasuring =
this.performanceMonitors_.startMeasuring('draw-frame');
if (this.mainProcessor_.effect.isMultiframe()) {
// Always draw in best quality as taken pictures need multiple frames.
this.drawCameraFrame_(camera.views.Camera.DrawMode.BEST);
} else if (this.toolbarEffect_.animating ||
this.controlsEffect_.animating || this.mainProcessor_.effect.isSlow() ||
this.context.isUIAnimating() || this.toastEffect_.animating ||
(this.scrollTracker_.scrolling && this.expanded_)) {
this.drawCameraFrame_(camera.views.Camera.DrawMode.FAST);
} else {
this.drawCameraFrame_(camera.views.Camera.DrawMode.NORMAL);
}
finishMeasuring();
}
// Draw the effects' ribbon.
// Process effect preview canvases. Ribbon initialization is true before the
// ribbon is expanded for the first time. This trick is used to fill the
// ribbon with images as soon as possible.
{
var finishMeasuring =
this.performanceMonitors_.startMeasuring('draw-ribbon');
if (!this.taking_ && !this.controlsEffect_.animating &&
!this.context.isUIAnimating() && !this.scrollTracker_.scrolling &&
!this.toolbarEffect_.animating && !this.toastEffect_.animating ||
this.ribbonInitialization_) {
if (this.expanded_ &&
this.frame_ % camera.views.Camera.PREVIEW_BUFFER_SKIP_FRAMES == 0) {
// Render all visible + one not visible.
this.drawEffectsRibbon_(camera.views.Camera.DrawMode.NORMAL);
} else {
// Render only one effect per frame. This is to avoid stale images.
this.drawEffectsRibbon_(camera.views.Camera.DrawMode.FAST);
}
}
finishMeasuring();
}
this.frame_++;
finishFrameMeasuring();
this.lastFrameTime_ = this.video_.currentTime;
};