1// Copyright 2014 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.
4
5'use strict';
6
7/**
8 * Slide mode displays a single image and has a set of controls to navigate
9 * between the images and to edit an image.
10 *
11 * TODO(kaznacheev): Introduce a parameter object.
12 *
13 * @param {Element} container Main container element.
14 * @param {Element} content Content container element.
15 * @param {Element} toolbar Toolbar element.
16 * @param {ImageEditor.Prompt} prompt Prompt.
17 * @param {cr.ui.ArrayDataModel} dataModel Data model.
18 * @param {cr.ui.ListSelectionModel} selectionModel Selection model.
19 * @param {Object} context Context.
20 * @param {function(function())} toggleMode Function to toggle the Gallery mode.
21 * @param {function(string):string} displayStringFunction String formatting
22 *     function.
23 * @constructor
24 */
25function SlideMode(container, content, toolbar, prompt,
26                   dataModel, selectionModel, context,
27                   toggleMode, displayStringFunction) {
28  this.container_ = container;
29  this.document_ = container.ownerDocument;
30  this.content = content;
31  this.toolbar_ = toolbar;
32  this.prompt_ = prompt;
33  this.dataModel_ = dataModel;
34  this.selectionModel_ = selectionModel;
35  this.context_ = context;
36  this.metadataCache_ = context.metadataCache;
37  this.toggleMode_ = toggleMode;
38  this.displayStringFunction_ = displayStringFunction;
39
40  this.onSelectionBound_ = this.onSelection_.bind(this);
41  this.onSpliceBound_ = this.onSplice_.bind(this);
42  this.onContentBound_ = this.onContentChange_.bind(this);
43
44  // Unique numeric key, incremented per each load attempt used to discard
45  // old attempts. This can happen especially when changing selection fast or
46  // Internet connection is slow.
47  this.currentUniqueKey_ = 0;
48
49  this.initListeners_();
50  this.initDom_();
51}
52
53/**
54 * SlideMode extends cr.EventTarget.
55 */
56SlideMode.prototype.__proto__ = cr.EventTarget.prototype;
57
58/**
59 * List of available editor modes.
60 * @type {Array.<ImageEditor.Mode>}
61 */
62SlideMode.editorModes = [
63  new ImageEditor.Mode.InstantAutofix(),
64  new ImageEditor.Mode.Crop(),
65  new ImageEditor.Mode.Exposure(),
66  new ImageEditor.Mode.OneClick(
67      'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)),
68  new ImageEditor.Mode.OneClick(
69      'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1))
70];
71
72/**
73 * @return {string} Mode name.
74 */
75SlideMode.prototype.getName = function() { return 'slide' };
76
77/**
78 * @return {string} Mode title.
79 */
80SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE' };
81
82/**
83 * Initialize the listeners.
84 * @private
85 */
86SlideMode.prototype.initListeners_ = function() {
87  window.addEventListener('resize', this.onResize_.bind(this), false);
88};
89
90/**
91 * Initialize the UI.
92 * @private
93 */
94SlideMode.prototype.initDom_ = function() {
95  // Container for displayed image or video.
96  this.imageContainer_ = util.createChild(
97      this.document_.querySelector('.content'), 'image-container');
98  this.imageContainer_.addEventListener('click', this.onClick_.bind(this));
99
100  this.document_.addEventListener('click', this.onDocumentClick_.bind(this));
101
102  // Overwrite options and info bubble.
103  this.options_ = util.createChild(
104      this.toolbar_.querySelector('.filename-spacer'), 'options');
105
106  this.savedLabel_ = util.createChild(this.options_, 'saved');
107  this.savedLabel_.textContent = this.displayStringFunction_('GALLERY_SAVED');
108
109  var overwriteOriginalBox =
110      util.createChild(this.options_, 'overwrite-original');
111
112  this.overwriteOriginal_ = util.createChild(
113      overwriteOriginalBox, 'common white', 'input');
114  this.overwriteOriginal_.type = 'checkbox';
115  this.overwriteOriginal_.id = 'overwrite-checkbox';
116  util.platform.getPreference(SlideMode.OVERWRITE_KEY, function(value) {
117    // Out-of-the box default is 'true'
118    this.overwriteOriginal_.checked =
119        (typeof value !== 'string' || value === 'true');
120  }.bind(this));
121  this.overwriteOriginal_.addEventListener('click',
122      this.onOverwriteOriginalClick_.bind(this));
123
124  var overwriteLabel = util.createChild(overwriteOriginalBox, '', 'label');
125  overwriteLabel.textContent =
126      this.displayStringFunction_('GALLERY_OVERWRITE_ORIGINAL');
127  overwriteLabel.setAttribute('for', 'overwrite-checkbox');
128
129  this.bubble_ = util.createChild(this.toolbar_, 'bubble');
130  this.bubble_.hidden = true;
131
132  var bubbleContent = util.createChild(this.bubble_);
133  bubbleContent.innerHTML = this.displayStringFunction_(
134      'GALLERY_OVERWRITE_BUBBLE');
135
136  util.createChild(this.bubble_, 'pointer bottom', 'span');
137
138  var bubbleClose = util.createChild(this.bubble_, 'close-x');
139  bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this));
140
141  // Video player controls.
142  this.mediaSpacer_ =
143      util.createChild(this.container_, 'video-controls-spacer');
144  this.mediaToolbar_ = util.createChild(this.mediaSpacer_, 'tool');
145  this.mediaControls_ = new VideoControls(
146      this.mediaToolbar_,
147      this.showErrorBanner_.bind(this, 'GALLERY_VIDEO_ERROR'),
148      this.displayStringFunction_.bind(this),
149      this.toggleFullScreen_.bind(this),
150      this.container_);
151
152  // Ribbon and related controls.
153  this.arrowBox_ = util.createChild(this.container_, 'arrow-box');
154
155  this.arrowLeft_ =
156      util.createChild(this.arrowBox_, 'arrow left tool dimmable');
157  this.arrowLeft_.addEventListener('click',
158      this.advanceManually.bind(this, -1));
159  util.createChild(this.arrowLeft_);
160
161  util.createChild(this.arrowBox_, 'arrow-spacer');
162
163  this.arrowRight_ =
164      util.createChild(this.arrowBox_, 'arrow right tool dimmable');
165  this.arrowRight_.addEventListener('click',
166      this.advanceManually.bind(this, 1));
167  util.createChild(this.arrowRight_);
168
169  this.ribbonSpacer_ = util.createChild(this.toolbar_, 'ribbon-spacer');
170  this.ribbon_ = new Ribbon(this.document_,
171      this.metadataCache_, this.dataModel_, this.selectionModel_);
172  this.ribbonSpacer_.appendChild(this.ribbon_);
173
174  // Error indicator.
175  var errorWrapper = util.createChild(this.container_, 'prompt-wrapper');
176  errorWrapper.setAttribute('pos', 'center');
177
178  this.errorBanner_ = util.createChild(errorWrapper, 'error-banner');
179
180  util.createChild(this.container_, 'spinner');
181
182  var slideShowButton = util.createChild(this.toolbar_,
183      'button slideshow', 'button');
184  slideShowButton.title = this.displayStringFunction_('GALLERY_SLIDESHOW');
185  slideShowButton.addEventListener('click',
186      this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST));
187
188  var slideShowToolbar =
189      util.createChild(this.container_, 'tool slideshow-toolbar');
190  util.createChild(slideShowToolbar, 'slideshow-play').
191      addEventListener('click', this.toggleSlideshowPause_.bind(this));
192  util.createChild(slideShowToolbar, 'slideshow-end').
193      addEventListener('click', this.stopSlideshow_.bind(this));
194
195  // Editor.
196
197  this.editButton_ = util.createChild(this.toolbar_, 'button edit', 'button');
198  this.editButton_.title = this.displayStringFunction_('GALLERY_EDIT');
199  this.editButton_.setAttribute('disabled', '');  // Disabled by default.
200  this.editButton_.addEventListener('click', this.toggleEditor.bind(this));
201
202  this.printButton_ = util.createChild(this.toolbar_, 'button print', 'button');
203  this.printButton_.title = this.displayStringFunction_('GALLERY_PRINT');
204  this.printButton_.setAttribute('disabled', '');  // Disabled by default.
205  this.printButton_.addEventListener('click', this.print_.bind(this));
206
207  this.editBarSpacer_ = util.createChild(this.toolbar_, 'edit-bar-spacer');
208  this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main');
209
210  this.editBarMode_ = util.createChild(this.container_, 'edit-modal');
211  this.editBarModeWrapper_ = util.createChild(
212      this.editBarMode_, 'edit-modal-wrapper');
213  this.editBarModeWrapper_.hidden = true;
214
215  // Objects supporting image display and editing.
216  this.viewport_ = new Viewport();
217
218  this.imageView_ = new ImageView(
219      this.imageContainer_,
220      this.viewport_,
221      this.metadataCache_);
222
223  this.editor_ = new ImageEditor(
224      this.viewport_,
225      this.imageView_,
226      this.prompt_,
227      {
228        root: this.container_,
229        image: this.imageContainer_,
230        toolbar: this.editBarMain_,
231        mode: this.editBarModeWrapper_
232      },
233      SlideMode.editorModes,
234      this.displayStringFunction_,
235      this.onToolsVisibilityChanged_.bind(this));
236
237  this.editor_.getBuffer().addOverlay(
238      new SwipeOverlay(this.advanceManually.bind(this)));
239};
240
241/**
242 * Load items, display the selected item.
243 * @param {Rect} zoomFromRect Rectangle for zoom effect.
244 * @param {function} displayCallback Called when the image is displayed.
245 * @param {function} loadCallback Called when the image is displayed.
246 */
247SlideMode.prototype.enter = function(
248    zoomFromRect, displayCallback, loadCallback) {
249  this.sequenceDirection_ = 0;
250  this.sequenceLength_ = 0;
251
252  var loadDone = function(loadType, delay) {
253    this.active_ = true;
254
255    this.selectionModel_.addEventListener('change', this.onSelectionBound_);
256    this.dataModel_.addEventListener('splice', this.onSpliceBound_);
257    this.dataModel_.addEventListener('content', this.onContentBound_);
258
259    ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
260    this.ribbon_.enable();
261
262    // Wait 1000ms after the animation is done, then prefetch the next image.
263    this.requestPrefetch(1, delay + 1000);
264
265    if (loadCallback) loadCallback();
266  }.bind(this);
267
268  // The latest |leave| call might have left the image animating. Remove it.
269  this.unloadImage_();
270
271  if (this.getItemCount_() === 0) {
272    this.displayedIndex_ = -1;
273    //TODO(kaznacheev) Show this message in the grid mode too.
274    this.showErrorBanner_('GALLERY_NO_IMAGES');
275    loadDone();
276  } else {
277    // Remember the selection if it is empty or multiple. It will be restored
278    // in |leave| if the user did not changing the selection manually.
279    var currentSelection = this.selectionModel_.selectedIndexes;
280    if (currentSelection.length === 1)
281      this.savedSelection_ = null;
282    else
283      this.savedSelection_ = currentSelection;
284
285    // Ensure valid single selection.
286    // Note that the SlideMode object is not listening to selection change yet.
287    this.select(Math.max(0, this.getSelectedIndex()));
288    this.displayedIndex_ = this.getSelectedIndex();
289
290    var selectedItem = this.getSelectedItem();
291    // Show the selected item ASAP, then complete the initialization
292    // (loading the ribbon thumbnails can take some time).
293    this.metadataCache_.getOne(selectedItem.getEntry(), Gallery.METADATA_TYPE,
294        function(metadata) {
295          this.loadItem_(selectedItem.getEntry(), metadata,
296              zoomFromRect && this.imageView_.createZoomEffect(zoomFromRect),
297              displayCallback, loadDone);
298        }.bind(this));
299
300  }
301};
302
303/**
304 * Leave the mode.
305 * @param {Rect} zoomToRect Rectangle for zoom effect.
306 * @param {function} callback Called when the image is committed and
307 *   the zoom-out animation has started.
308 */
309SlideMode.prototype.leave = function(zoomToRect, callback) {
310  var commitDone = function() {
311      this.stopEditing_();
312      this.stopSlideshow_();
313      ImageUtil.setAttribute(this.arrowBox_, 'active', false);
314      this.selectionModel_.removeEventListener(
315          'change', this.onSelectionBound_);
316      this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
317      this.dataModel_.removeEventListener('content', this.onContentBound_);
318      this.ribbon_.disable();
319      this.active_ = false;
320      if (this.savedSelection_)
321        this.selectionModel_.selectedIndexes = this.savedSelection_;
322      this.unloadImage_(zoomToRect);
323      callback();
324    }.bind(this);
325
326  if (this.getItemCount_() === 0) {
327    this.showErrorBanner_(false);
328    commitDone();
329  } else {
330    this.commitItem_(commitDone);
331  }
332
333  // Disable the slide-mode only buttons when leaving.
334  this.editButton_.setAttribute('disabled', '');
335  this.printButton_.setAttribute('disabled', '');
336};
337
338
339/**
340 * Execute an action when the editor is not busy.
341 *
342 * @param {function} action Function to execute.
343 */
344SlideMode.prototype.executeWhenReady = function(action) {
345  this.editor_.executeWhenReady(action);
346};
347
348/**
349 * @return {boolean} True if the mode has active tools (that should not fade).
350 */
351SlideMode.prototype.hasActiveTool = function() {
352  return this.isEditing();
353};
354
355/**
356 * @return {number} Item count.
357 * @private
358 */
359SlideMode.prototype.getItemCount_ = function() {
360  return this.dataModel_.length;
361};
362
363/**
364 * @param {number} index Index.
365 * @return {Gallery.Item} Item.
366 */
367SlideMode.prototype.getItem = function(index) {
368  return this.dataModel_.item(index);
369};
370
371/**
372 * @return {Gallery.Item} Selected index.
373 */
374SlideMode.prototype.getSelectedIndex = function() {
375  return this.selectionModel_.selectedIndex;
376};
377
378/**
379 * @return {Rect} Screen rectangle of the selected image.
380 */
381SlideMode.prototype.getSelectedImageRect = function() {
382  if (this.getSelectedIndex() < 0)
383    return null;
384  else
385    return this.viewport_.getScreenClipped();
386};
387
388/**
389 * @return {Gallery.Item} Selected item.
390 */
391SlideMode.prototype.getSelectedItem = function() {
392  return this.getItem(this.getSelectedIndex());
393};
394
395/**
396 * Toggles the full screen mode.
397 * @private
398 */
399SlideMode.prototype.toggleFullScreen_ = function() {
400  util.toggleFullScreen(this.context_.appWindow,
401                        !util.isFullScreen(this.context_.appWindow));
402};
403
404/**
405 * Selection change handler.
406 *
407 * Commits the current image and displays the newly selected image.
408 * @private
409 */
410SlideMode.prototype.onSelection_ = function() {
411  if (this.selectionModel_.selectedIndexes.length === 0)
412    return;  // Temporary empty selection.
413
414  // Forget the saved selection if the user changed the selection manually.
415  if (!this.isSlideshowOn_())
416    this.savedSelection_ = null;
417
418  if (this.getSelectedIndex() === this.displayedIndex_)
419    return;  // Do not reselect.
420
421  this.commitItem_(this.loadSelectedItem_.bind(this));
422};
423
424/**
425 * Handles changes in tools visibility, and if the header is dimmed, then
426 * requests disabling the draggable app region.
427 *
428 * @private
429 */
430SlideMode.prototype.onToolsVisibilityChanged_ = function() {
431  var headerDimmed =
432      this.document_.querySelector('.header').hasAttribute('dimmed');
433  this.context_.onAppRegionChanged(!headerDimmed);
434};
435
436/**
437 * Change the selection.
438 *
439 * @param {number} index New selected index.
440 * @param {number=} opt_slideHint Slide animation direction (-1|1).
441 */
442SlideMode.prototype.select = function(index, opt_slideHint) {
443  this.slideHint_ = opt_slideHint;
444  this.selectionModel_.selectedIndex = index;
445  this.selectionModel_.leadIndex = index;
446};
447
448/**
449 * Load the selected item.
450 *
451 * @private
452 */
453SlideMode.prototype.loadSelectedItem_ = function() {
454  var slideHint = this.slideHint_;
455  this.slideHint_ = undefined;
456
457  var index = this.getSelectedIndex();
458  if (index === this.displayedIndex_)
459    return;  // Do not reselect.
460
461  var step = slideHint || (index - this.displayedIndex_);
462
463  if (Math.abs(step) != 1) {
464    // Long leap, the sequence is broken, we have no good prefetch candidate.
465    this.sequenceDirection_ = 0;
466    this.sequenceLength_ = 0;
467  } else if (this.sequenceDirection_ === step) {
468    // Keeping going in sequence.
469    this.sequenceLength_++;
470  } else {
471    // Reversed the direction. Reset the counter.
472    this.sequenceDirection_ = step;
473    this.sequenceLength_ = 1;
474  }
475
476  if (this.sequenceLength_ <= 1) {
477    // We have just broke the sequence. Touch the current image so that it stays
478    // in the cache longer.
479    this.imageView_.prefetch(this.imageView_.contentEntry_);
480  }
481
482  this.displayedIndex_ = index;
483
484  function shouldPrefetch(loadType, step, sequenceLength) {
485    // Never prefetch when selecting out of sequence.
486    if (Math.abs(step) != 1)
487      return false;
488
489    // Never prefetch after a video load (decoding the next image can freeze
490    // the UI for a second or two).
491    if (loadType === ImageView.LOAD_TYPE_VIDEO_FILE)
492      return false;
493
494    // Always prefetch if the previous load was from cache.
495    if (loadType === ImageView.LOAD_TYPE_CACHED_FULL)
496      return true;
497
498    // Prefetch if we have been going in the same direction for long enough.
499    return sequenceLength >= 3;
500  }
501
502  var selectedItem = this.getSelectedItem();
503  this.currentUniqueKey_++;
504  var selectedUniqueKey = this.currentUniqueKey_;
505  var onMetadata = function(metadata) {
506    // Discard, since another load has been invoked after this one.
507    if (selectedUniqueKey != this.currentUniqueKey_) return;
508    this.loadItem_(selectedItem.getEntry(), metadata,
509        new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()),
510        function() {} /* no displayCallback */,
511        function(loadType, delay) {
512          // Discard, since another load has been invoked after this one.
513          if (selectedUniqueKey != this.currentUniqueKey_) return;
514          if (shouldPrefetch(loadType, step, this.sequenceLength_)) {
515            this.requestPrefetch(step, delay);
516          }
517          if (this.isSlideshowPlaying_())
518            this.scheduleNextSlide_();
519        }.bind(this));
520  }.bind(this);
521  this.metadataCache_.getOne(
522      selectedItem.getEntry(), Gallery.METADATA_TYPE, onMetadata);
523};
524
525/**
526 * Unload the current image.
527 *
528 * @param {Rect} zoomToRect Rectangle for zoom effect.
529 * @private
530 */
531SlideMode.prototype.unloadImage_ = function(zoomToRect) {
532  this.imageView_.unload(zoomToRect);
533  this.container_.removeAttribute('video');
534};
535
536/**
537 * Data model 'splice' event handler.
538 * @param {Event} event Event.
539 * @private
540 */
541SlideMode.prototype.onSplice_ = function(event) {
542  ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
543
544  // Splice invalidates saved indices, drop the saved selection.
545  this.savedSelection_ = null;
546
547  if (event.removed.length != 1)
548    return;
549
550  // Delay the selection to let the ribbon splice handler work first.
551  setTimeout(function() {
552    if (event.index < this.dataModel_.length) {
553      // There is the next item, select it.
554      // The next item is now at the same index as the removed one, so we need
555      // to correct displayIndex_ so that loadSelectedItem_ does not think
556      // we are re-selecting the same item (and does right-to-left slide-in
557      // animation).
558      this.displayedIndex_ = event.index - 1;
559      this.select(event.index);
560    } else if (this.dataModel_.length) {
561      // Removed item is the rightmost, but there are more items.
562      this.select(event.index - 1);  // Select the new last index.
563    } else {
564      // No items left. Unload the image and show the banner.
565      this.commitItem_(function() {
566        this.unloadImage_();
567        this.showErrorBanner_('GALLERY_NO_IMAGES');
568      }.bind(this));
569    }
570  }.bind(this), 0);
571};
572
573/**
574 * @param {number} direction -1 for left, 1 for right.
575 * @return {number} Next index in the given direction, with wrapping.
576 * @private
577 */
578SlideMode.prototype.getNextSelectedIndex_ = function(direction) {
579  function advance(index, limit) {
580    index += (direction > 0 ? 1 : -1);
581    if (index < 0)
582      return limit - 1;
583    if (index === limit)
584      return 0;
585    return index;
586  }
587
588  // If the saved selection is multiple the Slideshow should cycle through
589  // the saved selection.
590  if (this.isSlideshowOn_() &&
591      this.savedSelection_ && this.savedSelection_.length > 1) {
592    var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()),
593        this.savedSelection_.length);
594    return this.savedSelection_[pos];
595  } else {
596    return advance(this.getSelectedIndex(), this.getItemCount_());
597  }
598};
599
600/**
601 * Advance the selection based on the pressed key ID.
602 * @param {string} keyID Key identifier.
603 */
604SlideMode.prototype.advanceWithKeyboard = function(keyID) {
605  this.advanceManually(keyID === 'Up' || keyID === 'Left' ? -1 : 1);
606};
607
608/**
609 * Advance the selection as a result of a user action (as opposed to an
610 * automatic change in the slideshow mode).
611 * @param {number} direction -1 for left, 1 for right.
612 */
613SlideMode.prototype.advanceManually = function(direction) {
614  if (this.isSlideshowPlaying_()) {
615    this.pauseSlideshow_();
616    cr.dispatchSimpleEvent(this, 'useraction');
617  }
618  this.selectNext(direction);
619};
620
621/**
622 * Select the next item.
623 * @param {number} direction -1 for left, 1 for right.
624 */
625SlideMode.prototype.selectNext = function(direction) {
626  this.select(this.getNextSelectedIndex_(direction), direction);
627};
628
629/**
630 * Select the first item.
631 */
632SlideMode.prototype.selectFirst = function() {
633  this.select(0);
634};
635
636/**
637 * Select the last item.
638 */
639SlideMode.prototype.selectLast = function() {
640  this.select(this.getItemCount_() - 1);
641};
642
643// Loading/unloading
644
645/**
646 * Load and display an item.
647 *
648 * @param {FileEntry} entry Item entry to be loaded.
649 * @param {Object} metadata Item metadata.
650 * @param {Object} effect Transition effect object.
651 * @param {function} displayCallback Called when the image is displayed
652 *     (which can happen before the image load due to caching).
653 * @param {function} loadCallback Called when the image is fully loaded.
654 * @private
655 */
656SlideMode.prototype.loadItem_ = function(
657    entry, metadata, effect, displayCallback, loadCallback) {
658  this.selectedImageMetadata_ = MetadataCache.cloneMetadata(metadata);
659
660  this.showSpinner_(true);
661
662  var loadDone = function(loadType, delay, error) {
663    var video = this.isShowingVideo_();
664    ImageUtil.setAttribute(this.container_, 'video', video);
665
666    this.showSpinner_(false);
667    if (loadType === ImageView.LOAD_TYPE_ERROR) {
668      // if we have a specific error, then display it
669      if (error) {
670        this.showErrorBanner_(error);
671      } else {
672        // otherwise try to infer general error
673        this.showErrorBanner_(
674            video ? 'GALLERY_VIDEO_ERROR' : 'GALLERY_IMAGE_ERROR');
675      }
676    } else if (loadType === ImageView.LOAD_TYPE_OFFLINE) {
677      this.showErrorBanner_(
678          video ? 'GALLERY_VIDEO_OFFLINE' : 'GALLERY_IMAGE_OFFLINE');
679    }
680
681    if (video) {
682      // The editor toolbar does not make sense for video, hide it.
683      this.stopEditing_();
684      this.mediaControls_.attachMedia(this.imageView_.getVideo());
685
686      // TODO(kaznacheev): Add metrics for video playback.
687    } else {
688      ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View'));
689
690      var toMillions = function(number) {
691        return Math.round(number / (1000 * 1000));
692      };
693
694      ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'),
695          toMillions(metadata.filesystem.size));
696
697      var canvas = this.imageView_.getCanvas();
698      ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'),
699          toMillions(canvas.width * canvas.height));
700
701      var extIndex = entry.name.lastIndexOf('.');
702      var ext = extIndex < 0 ? '' :
703          entry.name.substr(extIndex + 1).toLowerCase();
704      if (ext === 'jpeg') ext = 'jpg';
705      ImageUtil.metrics.recordEnum(
706          ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES);
707    }
708
709    // Enable or disable buttons for editing and printing.
710    if (video || error) {
711      this.editButton_.setAttribute('disabled', '');
712      this.printButton_.setAttribute('disabled', '');
713    } else {
714      this.editButton_.removeAttribute('disabled');
715      this.printButton_.removeAttribute('disabled');
716    }
717
718    // For once edited image, disallow the 'overwrite' setting change.
719    ImageUtil.setAttribute(this.options_, 'saved',
720        !this.getSelectedItem().isOriginal());
721
722    util.platform.getPreference(SlideMode.OVERWRITE_BUBBLE_KEY,
723        function(value) {
724          var times = typeof value === 'string' ? parseInt(value, 10) : 0;
725          if (times < SlideMode.OVERWRITE_BUBBLE_MAX_TIMES) {
726            this.bubble_.hidden = false;
727            if (this.isEditing()) {
728              util.platform.setPreference(
729                  SlideMode.OVERWRITE_BUBBLE_KEY, times + 1);
730            }
731          }
732        }.bind(this));
733
734    loadCallback(loadType, delay);
735  }.bind(this);
736
737  var displayDone = function() {
738    cr.dispatchSimpleEvent(this, 'image-displayed');
739    displayCallback();
740  }.bind(this);
741
742  this.editor_.openSession(entry, metadata, effect,
743      this.saveCurrentImage_.bind(this), displayDone, loadDone);
744};
745
746/**
747 * Commit changes to the current item and reset all messages/indicators.
748 *
749 * @param {function} callback Callback.
750 * @private
751 */
752SlideMode.prototype.commitItem_ = function(callback) {
753  this.showSpinner_(false);
754  this.showErrorBanner_(false);
755  this.editor_.getPrompt().hide();
756
757  // Detach any media attached to the controls.
758  if (this.mediaControls_.getMedia())
759    this.mediaControls_.detachMedia();
760
761  // If showing the video, then pause it. Note, that it may not be attached
762  // to the media controls yet.
763  if (this.isShowingVideo_()) {
764    this.imageView_.getVideo().pause();
765    // Force stop downloading, if uncached on Drive.
766    this.imageView_.getVideo().src = '';
767    this.imageView_.getVideo().load();
768  }
769
770  this.editor_.closeSession(callback);
771};
772
773/**
774 * Request a prefetch for the next image.
775 *
776 * @param {number} direction -1 or 1.
777 * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image
778 *   loading from disrupting the animation that might be still in progress.
779 */
780SlideMode.prototype.requestPrefetch = function(direction, delay) {
781  if (this.getItemCount_() <= 1) return;
782
783  var index = this.getNextSelectedIndex_(direction);
784  var nextItemEntry = this.getItem(index).getEntry();
785  this.imageView_.prefetch(nextItemEntry, delay);
786};
787
788// Event handlers.
789
790/**
791 * Unload handler, to be called from the top frame.
792 * @param {boolean} exiting True if the app is exiting.
793 */
794SlideMode.prototype.onUnload = function(exiting) {
795  if (this.isShowingVideo_() && this.mediaControls_.isPlaying()) {
796    this.mediaControls_.savePosition(exiting);
797  }
798};
799
800/**
801 * Click handler for the image container.
802 *
803 * @param {Event} event Mouse click event.
804 * @private
805 */
806SlideMode.prototype.onClick_ = function(event) {
807  if (!this.isShowingVideo_() || !this.mediaControls_.getMedia())
808    return;
809  if (event.ctrlKey) {
810    this.mediaControls_.toggleLoopedModeWithFeedback(true);
811    if (!this.mediaControls_.isPlaying())
812      this.mediaControls_.togglePlayStateWithFeedback();
813  } else {
814    this.mediaControls_.togglePlayStateWithFeedback();
815  }
816};
817
818/**
819 * Click handler for the entire document.
820 * @param {Event} e Mouse click event.
821 * @private
822 */
823SlideMode.prototype.onDocumentClick_ = function(e) {
824  // Close the bubble if clicked outside of it and if it is visible.
825  if (!this.bubble_.contains(e.target) &&
826      !this.editButton_.contains(e.target) &&
827      !this.arrowLeft_.contains(e.target) &&
828      !this.arrowRight_.contains(e.target) &&
829      !this.bubble_.hidden) {
830    this.bubble_.hidden = true;
831  }
832};
833
834/**
835 * Keydown handler.
836 *
837 * @param {Event} event Event.
838 * @return {boolean} True if handled.
839 */
840SlideMode.prototype.onKeyDown = function(event) {
841  var keyID = util.getKeyModifiers(event) + event.keyIdentifier;
842
843  if (this.isSlideshowOn_()) {
844    switch (keyID) {
845      case 'U+001B':  // Escape exits the slideshow.
846        this.stopSlideshow_(event);
847        break;
848
849      case 'U+0020':  // Space pauses/resumes the slideshow.
850        this.toggleSlideshowPause_();
851        break;
852
853      case 'Up':
854      case 'Down':
855      case 'Left':
856      case 'Right':
857        this.advanceWithKeyboard(keyID);
858        break;
859    }
860    return true;  // Consume all keystrokes in the slideshow mode.
861  }
862
863  if (this.isEditing() && this.editor_.onKeyDown(event))
864    return true;
865
866  switch (keyID) {
867    case 'U+0020':  // Space toggles the video playback.
868      if (this.isShowingVideo_() && this.mediaControls_.getMedia())
869        this.mediaControls_.togglePlayStateWithFeedback();
870      break;
871
872    case 'Ctrl-U+0050':  // Ctrl+'p' prints the current image.
873      if (!this.printButton_.hasAttribute('disabled'))
874        this.print_();
875      break;
876
877    case 'U+0045':  // 'e' toggles the editor.
878      if (!this.editButton_.hasAttribute('disabled'))
879        this.toggleEditor(event);
880      break;
881
882    case 'U+001B':  // Escape
883      if (!this.isEditing())
884        return false;  // Not handled.
885      this.toggleEditor(event);
886      break;
887
888    case 'Home':
889      this.selectFirst();
890      break;
891    case 'End':
892      this.selectLast();
893      break;
894    case 'Up':
895    case 'Down':
896    case 'Left':
897    case 'Right':
898      this.advanceWithKeyboard(keyID);
899      break;
900
901    default: return false;
902  }
903
904  return true;
905};
906
907/**
908 * Resize handler.
909 * @private
910 */
911SlideMode.prototype.onResize_ = function() {
912  this.viewport_.sizeByFrameAndFit(this.container_);
913  this.viewport_.repaint();
914};
915
916/**
917 * Update thumbnails.
918 */
919SlideMode.prototype.updateThumbnails = function() {
920  this.ribbon_.reset();
921  if (this.active_)
922    this.ribbon_.redraw();
923};
924
925// Saving
926
927/**
928 * Save the current image to a file.
929 *
930 * @param {function} callback Callback.
931 * @private
932 */
933SlideMode.prototype.saveCurrentImage_ = function(callback) {
934  var item = this.getSelectedItem();
935  var oldEntry = item.getEntry();
936  var canvas = this.imageView_.getCanvas();
937
938  this.showSpinner_(true);
939  var metadataEncoder = ImageEncoder.encodeMetadata(
940      this.selectedImageMetadata_.media, canvas, 1 /* quality */);
941  var selectedImageMetadata = ContentProvider.ConvertContentMetadata(
942      metadataEncoder.getMetadata(), this.selectedImageMetadata_);
943  if (selectedImageMetadata.filesystem)
944    selectedImageMetadata.filesystem.modificationTime = new Date();
945  this.selectedImageMetadata_ = selectedImageMetadata;
946  this.metadataCache_.set(oldEntry,
947                          Gallery.METADATA_TYPE,
948                          selectedImageMetadata);
949
950  item.saveToFile(
951      this.context_.saveDirEntry,
952      this.shouldOverwriteOriginal_(),
953      canvas,
954      metadataEncoder,
955      function(success) {
956        // TODO(kaznacheev): Implement write error handling.
957        // Until then pretend that the save succeeded.
958        this.showSpinner_(false);
959        this.flashSavedLabel_();
960
961        var event = new Event('content');
962        event.item = item;
963        event.oldEntry = oldEntry;
964        event.metadata = selectedImageMetadata;
965        this.dataModel_.dispatchEvent(event);
966
967        // Allow changing the 'Overwrite original' setting only if the user
968        // used Undo to restore the original image AND it is not a copy.
969        // Otherwise lock the setting in its current state.
970        var mayChangeOverwrite = !this.editor_.canUndo() && item.isOriginal();
971        ImageUtil.setAttribute(this.options_, 'saved', !mayChangeOverwrite);
972
973        if (this.imageView_.getContentRevision() === 1) {  // First edit.
974          ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit'));
975        }
976
977        if (!util.isSameEntry(oldEntry, item.getEntry())) {
978          this.dataModel_.splice(
979              this.getSelectedIndex(), 0, new Gallery.Item(oldEntry));
980          // The ribbon will ignore the splice above and redraw after the
981          // select call below (while being obscured by the Editor toolbar,
982          // so there is no need for nice animation here).
983          // SlideMode will ignore the selection change as the displayed item
984          // index has not changed.
985          this.select(++this.displayedIndex_);
986        }
987        callback();
988        cr.dispatchSimpleEvent(this, 'image-saved');
989      }.bind(this));
990};
991
992/**
993 * Update caches when the selected item has been renamed.
994 * @param {Event} event Event.
995 * @private
996 */
997SlideMode.prototype.onContentChange_ = function(event) {
998  var newEntry = event.item.getEntry();
999  if (util.isSameEntry(newEntry, event.oldEntry))
1000    this.imageView_.changeEntry(newEntry);
1001  this.metadataCache_.clear(event.oldEntry, Gallery.METADATA_TYPE);
1002};
1003
1004/**
1005 * Flash 'Saved' label briefly to indicate that the image has been saved.
1006 * @private
1007 */
1008SlideMode.prototype.flashSavedLabel_ = function() {
1009  var setLabelHighlighted =
1010      ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted');
1011  setTimeout(setLabelHighlighted.bind(null, true), 0);
1012  setTimeout(setLabelHighlighted.bind(null, false), 300);
1013};
1014
1015/**
1016 * Local storage key for the 'Overwrite original' setting.
1017 * @type {string}
1018 */
1019SlideMode.OVERWRITE_KEY = 'gallery-overwrite-original';
1020
1021/**
1022 * Local storage key for the number of times that
1023 * the overwrite info bubble has been displayed.
1024 * @type {string}
1025 */
1026SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble';
1027
1028/**
1029 * Max number that the overwrite info bubble is shown.
1030 * @type {number}
1031 */
1032SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5;
1033
1034/**
1035 * @return {boolean} True if 'Overwrite original' is set.
1036 * @private
1037 */
1038SlideMode.prototype.shouldOverwriteOriginal_ = function() {
1039   return this.overwriteOriginal_.checked;
1040};
1041
1042/**
1043 * 'Overwrite original' checkbox handler.
1044 * @param {Event} event Event.
1045 * @private
1046 */
1047SlideMode.prototype.onOverwriteOriginalClick_ = function(event) {
1048  util.platform.setPreference(SlideMode.OVERWRITE_KEY, event.target.checked);
1049};
1050
1051/**
1052 * Overwrite info bubble close handler.
1053 * @private
1054 */
1055SlideMode.prototype.onCloseBubble_ = function() {
1056  this.bubble_.hidden = true;
1057  util.platform.setPreference(SlideMode.OVERWRITE_BUBBLE_KEY,
1058      SlideMode.OVERWRITE_BUBBLE_MAX_TIMES);
1059};
1060
1061// Slideshow
1062
1063/**
1064 * Slideshow interval in ms.
1065 */
1066SlideMode.SLIDESHOW_INTERVAL = 5000;
1067
1068/**
1069 * First slideshow interval in ms. It should be shorter so that the user
1070 * is not guessing whether the button worked.
1071 */
1072SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000;
1073
1074/**
1075 * Empirically determined duration of the fullscreen toggle animation.
1076 */
1077SlideMode.FULLSCREEN_TOGGLE_DELAY = 500;
1078
1079/**
1080 * @return {boolean} True if the slideshow is on.
1081 * @private
1082 */
1083SlideMode.prototype.isSlideshowOn_ = function() {
1084  return this.container_.hasAttribute('slideshow');
1085};
1086
1087/**
1088 * Start the slideshow.
1089 * @param {number=} opt_interval First interval in ms.
1090 * @param {Event=} opt_event Event.
1091 */
1092SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) {
1093  // Set the attribute early to prevent the toolbar from flashing when
1094  // the slideshow is being started from the mosaic view.
1095  this.container_.setAttribute('slideshow', 'playing');
1096
1097  if (this.active_) {
1098    this.stopEditing_();
1099  } else {
1100    // We are in the Mosaic mode. Toggle the mode but remember to return.
1101    this.leaveAfterSlideshow_ = true;
1102    this.toggleMode_(this.startSlideshow.bind(
1103        this, SlideMode.SLIDESHOW_INTERVAL, opt_event));
1104    return;
1105  }
1106
1107  if (opt_event)  // Caused by user action, notify the Gallery.
1108    cr.dispatchSimpleEvent(this, 'useraction');
1109
1110  this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow);
1111  if (!this.fullscreenBeforeSlideshow_) {
1112    // Wait until the zoom animation from the mosaic mode is done.
1113    setTimeout(this.toggleFullScreen_.bind(this),
1114               ImageView.ZOOM_ANIMATION_DURATION);
1115    opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) +
1116        SlideMode.FULLSCREEN_TOGGLE_DELAY;
1117  }
1118
1119  this.resumeSlideshow_(opt_interval);
1120};
1121
1122/**
1123 * Stop the slideshow.
1124 * @param {Event=} opt_event Event.
1125 * @private
1126 */
1127SlideMode.prototype.stopSlideshow_ = function(opt_event) {
1128  if (!this.isSlideshowOn_())
1129    return;
1130
1131  if (opt_event)  // Caused by user action, notify the Gallery.
1132    cr.dispatchSimpleEvent(this, 'useraction');
1133
1134  this.pauseSlideshow_();
1135  this.container_.removeAttribute('slideshow');
1136
1137  // Do not restore fullscreen if we exited fullscreen while in slideshow.
1138  var fullscreen = util.isFullScreen(this.context_.appWindow);
1139  var toggleModeDelay = 0;
1140  if (!this.fullscreenBeforeSlideshow_ && fullscreen) {
1141    this.toggleFullScreen_();
1142    toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY;
1143  }
1144  if (this.leaveAfterSlideshow_) {
1145    this.leaveAfterSlideshow_ = false;
1146    setTimeout(this.toggleMode_.bind(this), toggleModeDelay);
1147  }
1148};
1149
1150/**
1151 * @return {boolean} True if the slideshow is playing (not paused).
1152 * @private
1153 */
1154SlideMode.prototype.isSlideshowPlaying_ = function() {
1155  return this.container_.getAttribute('slideshow') === 'playing';
1156};
1157
1158/**
1159 * Pause/resume the slideshow.
1160 * @private
1161 */
1162SlideMode.prototype.toggleSlideshowPause_ = function() {
1163  cr.dispatchSimpleEvent(this, 'useraction');  // Show the tools.
1164  if (this.isSlideshowPlaying_()) {
1165    this.pauseSlideshow_();
1166  } else {
1167    this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST);
1168  }
1169};
1170
1171/**
1172 * @param {number=} opt_interval Slideshow interval in ms.
1173 * @private
1174 */
1175SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) {
1176  console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state');
1177
1178  if (this.slideShowTimeout_)
1179    clearTimeout(this.slideShowTimeout_);
1180
1181  this.slideShowTimeout_ = setTimeout(function() {
1182        this.slideShowTimeout_ = null;
1183        this.selectNext(1);
1184      }.bind(this),
1185      opt_interval || SlideMode.SLIDESHOW_INTERVAL);
1186};
1187
1188/**
1189 * Resume the slideshow.
1190 * @param {number=} opt_interval Slideshow interval in ms.
1191 * @private
1192 */
1193SlideMode.prototype.resumeSlideshow_ = function(opt_interval) {
1194  this.container_.setAttribute('slideshow', 'playing');
1195  this.scheduleNextSlide_(opt_interval);
1196};
1197
1198/**
1199 * Pause the slideshow.
1200 * @private
1201 */
1202SlideMode.prototype.pauseSlideshow_ = function() {
1203  this.container_.setAttribute('slideshow', 'paused');
1204  if (this.slideShowTimeout_) {
1205    clearTimeout(this.slideShowTimeout_);
1206    this.slideShowTimeout_ = null;
1207  }
1208};
1209
1210/**
1211 * @return {boolean} True if the editor is active.
1212 */
1213SlideMode.prototype.isEditing = function() {
1214  return this.container_.hasAttribute('editing');
1215};
1216
1217/**
1218 * Stop editing.
1219 * @private
1220 */
1221SlideMode.prototype.stopEditing_ = function() {
1222  if (this.isEditing())
1223    this.toggleEditor();
1224};
1225
1226/**
1227 * Activate/deactivate editor.
1228 * @param {Event=} opt_event Event.
1229 */
1230SlideMode.prototype.toggleEditor = function(opt_event) {
1231  if (opt_event)  // Caused by user action, notify the Gallery.
1232    cr.dispatchSimpleEvent(this, 'useraction');
1233
1234  if (!this.active_) {
1235    this.toggleMode_(this.toggleEditor.bind(this));
1236    return;
1237  }
1238
1239  this.stopSlideshow_();
1240  if (!this.isEditing() && this.isShowingVideo_())
1241    return;  // No editing for videos.
1242
1243  ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing());
1244
1245  if (this.isEditing()) { // isEditing has just been flipped to a new value.
1246    if (this.context_.readonlyDirName) {
1247      this.editor_.getPrompt().showAt(
1248          'top', 'GALLERY_READONLY_WARNING', 0, this.context_.readonlyDirName);
1249    }
1250  } else {
1251    this.editor_.getPrompt().hide();
1252    this.editor_.leaveModeGently();
1253  }
1254};
1255
1256/**
1257 * Prints the current item.
1258 * @private
1259 */
1260SlideMode.prototype.print_ = function() {
1261  cr.dispatchSimpleEvent(this, 'useraction');
1262  window.print();
1263};
1264
1265/**
1266 * Display the error banner.
1267 * @param {string} message Message.
1268 * @private
1269 */
1270SlideMode.prototype.showErrorBanner_ = function(message) {
1271  if (message) {
1272    this.errorBanner_.textContent = this.displayStringFunction_(message);
1273  }
1274  ImageUtil.setAttribute(this.container_, 'error', !!message);
1275};
1276
1277/**
1278 * Show/hide the busy spinner.
1279 *
1280 * @param {boolean} on True if show, false if hide.
1281 * @private
1282 */
1283SlideMode.prototype.showSpinner_ = function(on) {
1284  if (this.spinnerTimer_) {
1285    clearTimeout(this.spinnerTimer_);
1286    this.spinnerTimer_ = null;
1287  }
1288
1289  if (on) {
1290    this.spinnerTimer_ = setTimeout(function() {
1291      this.spinnerTimer_ = null;
1292      ImageUtil.setAttribute(this.container_, 'spinner', true);
1293    }.bind(this), 1000);
1294  } else {
1295    ImageUtil.setAttribute(this.container_, 'spinner', false);
1296  }
1297};
1298
1299/**
1300 * @return {boolean} True if the current item is a video.
1301 * @private
1302 */
1303SlideMode.prototype.isShowingVideo_ = function() {
1304  return !!this.imageView_.getVideo();
1305};
1306
1307/**
1308 * Overlay that handles swipe gestures. Changes to the next or previous file.
1309 * @param {function(number)} callback A callback accepting the swipe direction
1310 *    (1 means left, -1 right).
1311 * @constructor
1312 * @implements {ImageBuffer.Overlay}
1313 */
1314function SwipeOverlay(callback) {
1315  this.callback_ = callback;
1316}
1317
1318/**
1319 * Inherit ImageBuffer.Overlay.
1320 */
1321SwipeOverlay.prototype.__proto__ = ImageBuffer.Overlay.prototype;
1322
1323/**
1324 * @param {number} x X pointer position.
1325 * @param {number} y Y pointer position.
1326 * @param {boolean} touch True if dragging caused by touch.
1327 * @return {function} The closure to call on drag.
1328 */
1329SwipeOverlay.prototype.getDragHandler = function(x, y, touch) {
1330  if (!touch)
1331    return null;
1332  var origin = x;
1333  var done = false;
1334  return function(x, y) {
1335    if (!done && origin - x > SwipeOverlay.SWIPE_THRESHOLD) {
1336      this.callback_(1);
1337      done = true;
1338    } else if (!done && x - origin > SwipeOverlay.SWIPE_THRESHOLD) {
1339      this.callback_(-1);
1340      done = true;
1341    }
1342  }.bind(this);
1343};
1344
1345/**
1346 * If the user touched the image and moved the finger more than SWIPE_THRESHOLD
1347 * horizontally it's considered as a swipe gesture (change the current image).
1348 */
1349SwipeOverlay.SWIPE_THRESHOLD = 100;
1350