1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 cr.define('options', function() {
6 /** @const */ var ArrayDataModel = cr.ui.ArrayDataModel;
7 /** @const */ var Grid = cr.ui.Grid;
8 /** @const */ var GridItem = cr.ui.GridItem;
9 /** @const */ var GridSelectionController = cr.ui.GridSelectionController;
10 /** @const */ var ListSingleSelectionModel = cr.ui.ListSingleSelectionModel;
13 * Interval between consecutive camera presence checks in msec.
16 var CAMERA_CHECK_INTERVAL_MS = 3000;
19 * Interval between consecutive camera liveness checks in msec.
22 var CAMERA_LIVENESS_CHECK_MS = 3000;
25 * Number of frames recorded by takeVideo().
28 var RECORD_FRAMES = 48;
31 * FPS at which camera stream is recorded.
37 * Dimensions for camera capture.
46 * Path for internal URLs.
49 var CHROME_THEME_PATH = 'chrome://theme';
52 * Creates a new user images grid item.
53 * @param {{url: string, title: string=, decorateFn: function=,
54 * clickHandler: function=}} imageInfo User image URL, optional title,
55 * decorator callback and click handler.
57 * @extends {cr.ui.GridItem}
59 function UserImagesGridItem(imageInfo) {
60 var el = new GridItem(imageInfo);
61 el.__proto__ = UserImagesGridItem.prototype;
65 UserImagesGridItem.prototype = {
66 __proto__: GridItem.prototype,
69 decorate: function() {
70 GridItem.prototype.decorate.call(this);
71 var imageEl = cr.doc.createElement('img');
72 // Force 1x scale for chrome://theme URLs. Grid elements are much smaller
73 // than actual images so there is no need in full scale on HDPI.
74 var url = this.dataItem.url;
75 if (url.slice(0, CHROME_THEME_PATH.length) == CHROME_THEME_PATH)
76 imageEl.src = this.dataItem.url + '@1x';
78 imageEl.src = this.dataItem.url;
79 imageEl.title = this.dataItem.title || '';
80 if (typeof this.dataItem.clickHandler == 'function')
81 imageEl.addEventListener('mousedown', this.dataItem.clickHandler);
82 // Remove any garbage added by GridItem and ListItem decorators.
83 this.textContent = '';
84 this.appendChild(imageEl);
85 if (typeof this.dataItem.decorateFn == 'function')
86 this.dataItem.decorateFn(this);
87 this.setAttribute('role', 'option');
88 this.oncontextmenu = function(e) { e.preventDefault(); };
93 * Creates a selection controller that wraps selection on grid ends
94 * and translates Enter presses into 'activate' events.
95 * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
97 * @param {cr.ui.Grid} grid The grid to interact with.
99 * @extends {cr.ui.GridSelectionController}
101 function UserImagesGridSelectionController(selectionModel, grid) {
102 GridSelectionController.call(this, selectionModel, grid);
105 UserImagesGridSelectionController.prototype = {
106 __proto__: GridSelectionController.prototype,
109 getIndexBefore: function(index) {
111 GridSelectionController.prototype.getIndexBefore.call(this, index);
112 return result == -1 ? this.getLastIndex() : result;
116 getIndexAfter: function(index) {
118 GridSelectionController.prototype.getIndexAfter.call(this, index);
119 return result == -1 ? this.getFirstIndex() : result;
123 handleKeyDown: function(e) {
124 if (e.keyIdentifier == 'Enter')
125 cr.dispatchSimpleEvent(this.grid_, 'activate');
127 GridSelectionController.prototype.handleKeyDown.call(this, e);
132 * Creates a new user images grid element.
133 * @param {Object=} opt_propertyBag Optional properties.
135 * @extends {cr.ui.Grid}
137 var UserImagesGrid = cr.ui.define('grid');
139 UserImagesGrid.prototype = {
140 __proto__: Grid.prototype,
143 createSelectionController: function(sm) {
144 return new UserImagesGridSelectionController(sm, this);
148 decorate: function() {
149 Grid.prototype.decorate.call(this);
150 this.dataModel = new ArrayDataModel([]);
151 this.itemConstructor = UserImagesGridItem;
152 this.selectionModel = new ListSingleSelectionModel();
153 this.inProgramSelection_ = false;
154 this.addEventListener('dblclick', this.handleDblClick_.bind(this));
155 this.addEventListener('change', this.handleChange_.bind(this));
156 this.setAttribute('role', 'listbox');
157 this.autoExpands = true;
161 * Handles double click on the image grid.
162 * @param {Event} e Double click Event.
165 handleDblClick_: function(e) {
166 // If a child element is double-clicked and not the grid itself, handle
167 // this as 'Enter' keypress.
168 if (e.target != this)
169 cr.dispatchSimpleEvent(this, 'activate');
173 * Handles selection change.
174 * @param {Event} e Double click Event.
177 handleChange_: function(e) {
178 if (this.selectedItem === null)
181 var oldSelectionType = this.selectionType;
183 // Update current selection type.
184 this.selectionType = this.selectedItem.type;
186 // Show grey silhouette with the same border as stock images.
187 if (/^chrome:\/\/theme\//.test(this.selectedItemUrl))
188 this.previewElement.classList.add('default-image');
190 this.updatePreview_();
192 var e = new Event('select');
193 e.oldSelectionType = oldSelectionType;
194 this.dispatchEvent(e);
198 * Updates the preview image, if present.
201 updatePreview_: function() {
202 var url = this.selectedItemUrl;
203 if (url && this.previewImage_) {
204 if (url.slice(0, CHROME_THEME_PATH.length) == CHROME_THEME_PATH)
205 this.previewImage_.src = url + '@' + window.devicePixelRatio + 'x';
207 this.previewImage_.src = url;
212 * Start camera presence check.
215 checkCameraPresence_: function() {
216 if (this.cameraPresentCheckTimer_) {
217 window.clearTimeout(this.cameraPresentCheckTimer_);
218 this.cameraPresentCheckTimer_ = null;
220 if (!this.cameraVideo_)
222 chrome.send('checkCameraPresence');
226 * Whether a camera is present or not.
229 get cameraPresent() {
230 return this.cameraPresent_;
232 set cameraPresent(value) {
233 this.cameraPresent_ = value;
235 this.cameraImage = null;
236 // Repeat the check after some time.
237 this.cameraPresentCheckTimer_ = window.setTimeout(
238 this.checkCameraPresence_.bind(this),
239 CAMERA_CHECK_INTERVAL_MS);
243 * Whether camera is actually streaming video. May be |false| even when
244 * camera is present and shown but still initializing.
248 return this.previewElement.classList.contains('online');
250 set cameraOnline(value) {
251 this.previewElement.classList[value ? 'add' : 'remove']('online');
253 this.cameraLiveCheckTimer_ = window.setInterval(
254 this.checkCameraLive_.bind(this), CAMERA_LIVENESS_CHECK_MS);
255 } else if (this.cameraLiveCheckTimer_) {
256 window.clearInterval(this.cameraLiveCheckTimer_);
257 this.cameraLiveCheckTimer_ = null;
262 * Tries to starts camera stream capture.
263 * @param {function(): boolean} onAvailable Callback that is called if
264 * camera is available. If it returns |true|, capture is started
267 startCamera: function(onAvailable, onAbsent) {
269 this.cameraStartInProgress_ = true;
270 navigator.webkitGetUserMedia(
272 this.handleCameraAvailable_.bind(this, onAvailable),
273 this.handleCameraAbsent_.bind(this));
277 * Stops camera capture, if it's currently active.
279 stopCamera: function() {
280 this.cameraOnline = false;
281 if (this.cameraVideo_)
282 this.cameraVideo_.src = '';
283 if (this.cameraStream_)
284 this.cameraStream_.stop();
285 // Cancel any pending getUserMedia() checks.
286 this.cameraStartInProgress_ = false;
290 * Handles successful camera check.
291 * @param {function(): boolean} onAvailable Callback to call. If it returns
292 * |true|, capture is started immediately.
293 * @param {MediaStream} stream Stream object as returned by getUserMedia.
296 handleCameraAvailable_: function(onAvailable, stream) {
297 if (this.cameraStartInProgress_ && onAvailable()) {
298 this.cameraVideo_.src = window.webkitURL.createObjectURL(stream);
299 this.cameraStream_ = stream;
303 this.cameraStartInProgress_ = false;
307 * Handles camera check failure.
308 * @param {NavigatorUserMediaError=} err Error object.
311 handleCameraAbsent_: function(err) {
312 this.cameraPresent = false;
313 this.cameraOnline = false;
314 this.cameraStartInProgress_ = false;
318 * Handles successful camera capture start.
321 handleVideoStarted_: function() {
322 this.cameraOnline = true;
323 this.handleVideoUpdate_();
327 * Handles camera stream update. Called regularly (at rate no greater then
328 * 4/sec) while camera stream is live.
331 handleVideoUpdate_: function() {
332 this.lastFrameTime_ = new Date().getTime();
336 * Checks if camera is still live by comparing the timestamp of the last
337 * 'timeupdate' event with the current time.
340 checkCameraLive_: function() {
341 if (new Date().getTime() - this.lastFrameTime_ >
342 CAMERA_LIVENESS_CHECK_MS) {
343 this.cameraPresent = false;
348 * Type of the selected image (one of 'default', 'profile', 'camera').
349 * Setting it will update class list of |previewElement|.
352 get selectionType() {
353 return this.selectionType_;
355 set selectionType(value) {
356 this.selectionType_ = value;
357 var previewClassList = this.previewElement.classList;
358 previewClassList[value == 'default' ? 'add' : 'remove']('default-image');
359 previewClassList[value == 'profile' ? 'add' : 'remove']('profile-image');
360 previewClassList[value == 'camera' ? 'add' : 'remove']('camera');
362 var setFocusIfLost = function() {
363 // Set focus to the grid, if focus is not on UI.
364 if (!document.activeElement ||
365 document.activeElement.tagName == 'BODY') {
366 $('user-image-grid').focus();
369 // Timeout guarantees processing AFTER style changes display attribute.
370 setTimeout(setFocusIfLost, 0);
374 * Current image captured from camera as data URL. Setting to null will
375 * return to the live camera stream.
379 return this.cameraImage_;
381 set cameraImage(imageUrl) {
382 this.cameraLive = !imageUrl;
383 if (this.cameraPresent && !imageUrl)
384 imageUrl = UserImagesGrid.ButtonImages.TAKE_PHOTO;
386 this.cameraImage_ = this.cameraImage_ ?
387 this.updateItem(this.cameraImage_, imageUrl, this.cameraTitle_) :
388 this.addItem(imageUrl, this.cameraTitle_, undefined, 0);
389 this.cameraImage_.type = 'camera';
391 this.removeItem(this.cameraImage_);
392 this.cameraImage_ = null;
397 * Updates the titles for the camera element.
398 * @param {string} placeholderTitle Title when showing a placeholder.
399 * @param {string} capturedImageTitle Title when showing a captured photo.
401 setCameraTitles: function(placeholderTitle, capturedImageTitle) {
402 this.placeholderTitle_ = placeholderTitle;
403 this.capturedImageTitle_ = capturedImageTitle;
404 this.cameraTitle_ = this.placeholderTitle_;
408 * True when camera is in live mode (i.e. no still photo selected).
412 return this.cameraLive_;
414 set cameraLive(value) {
415 this.cameraLive_ = value;
416 this.previewElement.classList[value ? 'add' : 'remove']('live');
420 * Should only be queried from the 'change' event listener, true if the
421 * change event was triggered by a programmatical selection change.
424 get inProgramSelection() {
425 return this.inProgramSelection_;
429 * URL of the image selected.
432 get selectedItemUrl() {
433 var selectedItem = this.selectedItem;
434 return selectedItem ? selectedItem.url : null;
436 set selectedItemUrl(url) {
437 for (var i = 0, el; el = this.dataModel.item(i); i++) {
439 this.selectedItemIndex = i;
444 * Set index to the image selected.
445 * @type {number} index The index of selected image.
447 set selectedItemIndex(index) {
448 this.inProgramSelection_ = true;
449 this.selectionModel.selectedIndex = index;
450 this.inProgramSelection_ = false;
455 var index = this.selectionModel.selectedIndex;
456 return index != -1 ? this.dataModel.item(index) : null;
458 set selectedItem(selectedItem) {
459 var index = this.indexOf(selectedItem);
460 this.inProgramSelection_ = true;
461 this.selectionModel.selectedIndex = index;
462 this.selectionModel.leadIndex = index;
463 this.inProgramSelection_ = false;
467 * Element containing the preview image (the first IMG element) and the
468 * camera live stream (the first VIDEO element).
469 * @type {HTMLElement}
471 get previewElement() {
472 // TODO(ivankr): temporary hack for non-HTML5 version.
473 return this.previewElement_ || this;
475 set previewElement(value) {
476 this.previewElement_ = value;
477 this.previewImage_ = value.querySelector('img');
478 this.cameraVideo_ = value.querySelector('video');
479 this.cameraVideo_.addEventListener('canplay',
480 this.handleVideoStarted_.bind(this));
481 this.cameraVideo_.addEventListener('timeupdate',
482 this.handleVideoUpdate_.bind(this));
483 this.updatePreview_();
484 // Initialize camera state and check for its presence.
485 this.cameraLive = true;
486 this.cameraPresent = false;
490 * Whether the camera live stream and photo should be flipped horizontally.
491 * If setting this property results in photo update, 'photoupdated' event
492 * will be fired with 'dataURL' property containing the photo encoded as
497 return this.flipPhoto_ || false;
499 set flipPhoto(value) {
500 if (this.flipPhoto_ == value)
502 this.flipPhoto_ = value;
503 this.previewElement.classList.toggle('flip-x');
504 if (!this.cameraLive) {
505 // Flip current still photo.
506 var e = new Event('photoupdated');
507 e.dataURL = this.flipPhoto ?
508 this.flipFrame_(this.previewImage_) : this.previewImage_.src;
509 this.dispatchEvent(e);
514 * Performs photo capture from the live camera stream. 'phototaken' event
515 * will be fired as soon as captured photo is available, with 'dataURL'
516 * property containing the photo encoded as a data URL.
517 * @return {boolean} Whether photo capture was successful.
519 takePhoto: function() {
520 if (!this.cameraOnline)
522 var canvas = document.createElement('canvas');
523 canvas.width = CAPTURE_SIZE.width;
524 canvas.height = CAPTURE_SIZE.height;
526 this.cameraVideo_, canvas.getContext('2d'), CAPTURE_SIZE);
527 // Preload image before displaying it.
528 var previewImg = new Image();
529 previewImg.addEventListener('load', function(e) {
530 this.cameraTitle_ = this.capturedImageTitle_;
531 this.cameraImage = previewImg.src;
533 previewImg.src = canvas.toDataURL('image/png');
534 var e = new Event('phototaken');
535 e.dataURL = this.flipPhoto ? this.flipFrame_(canvas) : previewImg.src;
536 this.dispatchEvent(e);
541 * Performs video capture from the live camera stream.
542 * @param {function=} opt_callback Callback that receives taken video as
543 * data URL of a vertically stacked PNG sprite.
545 takeVideo: function(opt_callback) {
546 var canvas = document.createElement('canvas');
547 canvas.width = CAPTURE_SIZE.width;
548 canvas.height = CAPTURE_SIZE.height * RECORD_FRAMES;
549 var ctx = canvas.getContext('2d');
550 // Force canvas initialization to prevent FPS lag on the first frame.
551 ctx.fillRect(0, 0, 1, 1);
553 callback: opt_callback,
557 lastTimestamp: new Date().getTime()
559 captureData.timer = window.setInterval(
560 this.captureVideoFrame_.bind(this, captureData), 1000 / RECORD_FPS);
564 * Discard current photo and return to the live camera stream.
566 discardPhoto: function() {
567 this.cameraTitle_ = this.placeholderTitle_;
568 this.cameraImage = null;
572 * Capture a single still frame from a <video> element, placing it at the
573 * current drawing origin of a canvas context.
574 * @param {HTMLVideoElement} video Video element to capture from.
575 * @param {CanvasRenderingContext2D} ctx Canvas context to draw onto.
576 * @param {{width: number, height: number}} destSize Capture size.
579 captureFrame_: function(video, ctx, destSize) {
580 var width = video.videoWidth;
581 var height = video.videoHeight;
582 if (width < destSize.width || height < destSize.height) {
583 console.error('Video capture size too small: ' +
584 width + 'x' + height + '!');
587 if (width / destSize.width > height / destSize.height) {
588 // Full height, crop left/right.
590 src.width = height * destSize.width / destSize.height;
592 // Full width, crop top/bottom.
594 src.height = width * destSize.height / destSize.width;
596 src.x = (width - src.width) / 2;
597 src.y = (height - src.height) / 2;
598 ctx.drawImage(video, src.x, src.y, src.width, src.height,
599 0, 0, destSize.width, destSize.height);
603 * Flips frame horizontally.
604 * @param {HTMLImageElement|HTMLCanvasElement|HTMLVideoElement} source
606 * @return {string} Flipped frame as data URL.
608 flipFrame_: function(source) {
609 var canvas = document.createElement('canvas');
610 canvas.width = CAPTURE_SIZE.width;
611 canvas.height = CAPTURE_SIZE.height;
612 var ctx = canvas.getContext('2d');
613 ctx.translate(CAPTURE_SIZE.width, 0);
614 ctx.scale(-1.0, 1.0);
615 ctx.drawImage(source, 0, 0);
616 return canvas.toDataURL('image/png');
620 * Capture next frame of the video being recorded after a takeVideo() call.
621 * @param {Object} data Property bag with the recorder details.
624 captureVideoFrame_: function(data) {
625 var lastTimestamp = new Date().getTime();
626 var delayMs = lastTimestamp - data.lastTimestamp;
627 console.error('Delay: ' + delayMs + ' (' + (1000 / delayMs + ' FPS)'));
628 data.lastTimestamp = lastTimestamp;
630 this.captureFrame_(this.cameraVideo_, data.ctx, CAPTURE_SIZE);
631 data.ctx.translate(0, CAPTURE_SIZE.height);
633 if (++data.frameNo == RECORD_FRAMES) {
634 window.clearTimeout(data.timer);
635 if (data.callback && typeof data.callback == 'function')
636 data.callback(data.canvas.toDataURL('image/png'));
641 * Adds new image to the user image grid.
642 * @param {string} src Image URL.
643 * @param {string=} opt_title Image tooltip.
644 * @param {function=} opt_clickHandler Image click handler.
645 * @param {number=} opt_position If given, inserts new image into
646 * that position (0-based) in image list.
647 * @param {function=} opt_decorateFn Function called with the list element
648 * as argument to do any final decoration.
649 * @return {!Object} Image data inserted into the data model.
651 // TODO(ivankr): this function needs some argument list refactoring.
652 addItem: function(url, opt_title, opt_clickHandler, opt_position,
657 clickHandler: opt_clickHandler,
658 decorateFn: opt_decorateFn
660 this.inProgramSelection_ = true;
661 if (opt_position !== undefined)
662 this.dataModel.splice(opt_position, 0, imageInfo);
664 this.dataModel.push(imageInfo);
665 this.inProgramSelection_ = false;
670 * Returns index of an image in grid.
671 * @param {Object} imageInfo Image data returned from addItem() call.
672 * @return {number} Image index (0-based) or -1 if image was not found.
674 indexOf: function(imageInfo) {
675 return this.dataModel.indexOf(imageInfo);
679 * Replaces an image in the grid.
680 * @param {Object} imageInfo Image data returned from addItem() call.
681 * @param {string} imageUrl New image URL.
682 * @param {string=} opt_title New image tooltip (if undefined, tooltip
683 * is left unchanged).
684 * @return {!Object} Image data of the added or updated image.
686 updateItem: function(imageInfo, imageUrl, opt_title) {
687 var imageIndex = this.indexOf(imageInfo);
688 var wasSelected = this.selectionModel.selectedIndex == imageIndex;
689 this.removeItem(imageInfo);
690 var newInfo = this.addItem(
692 opt_title === undefined ? imageInfo.title : opt_title,
693 imageInfo.clickHandler,
695 imageInfo.decorateFn);
696 // Update image data with the reset of the keys from the old data.
697 for (k in imageInfo) {
699 newInfo[k] = imageInfo[k];
702 this.selectedItem = newInfo;
707 * Removes previously added image from the grid.
708 * @param {Object} imageInfo Image data returned from the addItem() call.
710 removeItem: function(imageInfo) {
711 var index = this.indexOf(imageInfo);
713 var wasSelected = this.selectionModel.selectedIndex == index;
714 this.inProgramSelection_ = true;
715 this.dataModel.splice(index, 1);
717 // If item removed was selected, select the item next to it.
718 this.selectedItem = this.dataModel.item(
719 Math.min(this.dataModel.length - 1, index));
721 this.inProgramSelection_ = false;
726 * Forces re-display, size re-calculation and focuses grid.
728 updateAndFocus: function() {
729 // Recalculate the measured item size.
730 this.measured_ = null;
738 * URLs of special button images.
741 UserImagesGrid.ButtonImages = {
742 TAKE_PHOTO: 'chrome://theme/IDR_BUTTON_USER_IMAGE_TAKE_PHOTO',
743 CHOOSE_FILE: 'chrome://theme/IDR_BUTTON_USER_IMAGE_CHOOSE_FILE',
744 PROFILE_PICTURE: 'chrome://theme/IDR_PROFILE_PICTURE_LOADING'
748 UserImagesGrid: UserImagesGrid