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.
4
5'use strict';
6
7/**
8 * @fileoverview MediaControls class implements media playback controls
9 * that exist outside of the audio/video HTML element.
10 */
11
12/**
13 * @param {HTMLElement} containerElement The container for the controls.
14 * @param {function} onMediaError Function to display an error message.
15 * @constructor
16 */
17function MediaControls(containerElement, onMediaError) {
18  this.container_ = containerElement;
19  this.document_ = this.container_.ownerDocument;
20  this.media_ = null;
21
22  this.onMediaPlayBound_ = this.onMediaPlay_.bind(this, true);
23  this.onMediaPauseBound_ = this.onMediaPlay_.bind(this, false);
24  this.onMediaDurationBound_ = this.onMediaDuration_.bind(this);
25  this.onMediaProgressBound_ = this.onMediaProgress_.bind(this);
26  this.onMediaError_ = onMediaError || function() {};
27
28  this.savedVolume_ = 1;  // 100% volume.
29}
30
31/**
32 * Button's state types. Values are used as CSS class names.
33 * @enum {string}
34 */
35MediaControls.ButtonStateType = {
36  DEFAULT: 'default',
37  PLAYING: 'playing',
38  ENDED: 'ended'
39};
40
41/**
42 * @return {HTMLAudioElement|HTMLVideoElement} The media element.
43 */
44MediaControls.prototype.getMedia = function() { return this.media_ };
45
46/**
47 * Format the time in hh:mm:ss format (omitting redundant leading zeros).
48 *
49 * @param {number} timeInSec Time in seconds.
50 * @return {string} Formatted time string.
51 * @private
52 */
53MediaControls.formatTime_ = function(timeInSec) {
54  var seconds = Math.floor(timeInSec % 60);
55  var minutes = Math.floor((timeInSec / 60) % 60);
56  var hours = Math.floor(timeInSec / 60 / 60);
57  var result = '';
58  if (hours) result += hours + ':';
59  if (hours && (minutes < 10)) result += '0';
60  result += minutes + ':';
61  if (seconds < 10) result += '0';
62  result += seconds;
63  return result;
64};
65
66/**
67 * Create a custom control.
68 *
69 * @param {string} className Class name.
70 * @param {HTMLElement=} opt_parent Parent element or container if undefined.
71 * @return {HTMLElement} The new control element.
72 */
73MediaControls.prototype.createControl = function(className, opt_parent) {
74  var parent = opt_parent || this.container_;
75  var control = this.document_.createElement('div');
76  control.className = className;
77  parent.appendChild(control);
78  return control;
79};
80
81/**
82 * Create a custom button.
83 *
84 * @param {string} className Class name.
85 * @param {function(Event)} handler Click handler.
86 * @param {HTMLElement=} opt_parent Parent element or container if undefined.
87 * @param {number=} opt_numStates Number of states, default: 1.
88 * @return {HTMLElement} The new button element.
89 */
90MediaControls.prototype.createButton = function(
91    className, handler, opt_parent, opt_numStates) {
92  opt_numStates = opt_numStates || 1;
93
94  var button = this.createControl(className, opt_parent);
95  button.classList.add('media-button');
96  button.addEventListener('click', handler);
97
98  var stateTypes = Object.keys(MediaControls.ButtonStateType);
99  for (var state = 0; state != opt_numStates; state++) {
100    var stateClass = MediaControls.ButtonStateType[stateTypes[state]];
101    this.createControl('normal ' + stateClass, button);
102    this.createControl('hover ' + stateClass, button);
103    this.createControl('active ' + stateClass, button);
104  }
105  this.createControl('disabled', button);
106
107  button.setAttribute('state', MediaControls.ButtonStateType.DEFAULT);
108  button.addEventListener('click', handler);
109  return button;
110};
111
112/**
113 * Enable/disable controls matching a given selector.
114 *
115 * @param {string} selector CSS selector.
116 * @param {boolean} on True if enable, false if disable.
117 * @private
118 */
119MediaControls.prototype.enableControls_ = function(selector, on) {
120  var controls = this.container_.querySelectorAll(selector);
121  for (var i = 0; i != controls.length; i++) {
122    var classList = controls[i].classList;
123    if (on)
124      classList.remove('disabled');
125    else
126      classList.add('disabled');
127  }
128};
129
130/*
131 * Playback control.
132 */
133
134/**
135 * Play the media.
136 */
137MediaControls.prototype.play = function() {
138  if (!this.media_)
139    return;  // Media is detached.
140
141  this.media_.play();
142};
143
144/**
145 * Pause the media.
146 */
147MediaControls.prototype.pause = function() {
148  if (!this.media_)
149    return;  // Media is detached.
150
151  this.media_.pause();
152};
153
154/**
155 * @return {boolean} True if the media is currently playing.
156 */
157MediaControls.prototype.isPlaying = function() {
158  return this.media_ && !this.media_.paused && !this.media_.ended;
159};
160
161/**
162 * Toggle play/pause.
163 */
164MediaControls.prototype.togglePlayState = function() {
165  if (this.isPlaying())
166    this.pause();
167  else
168    this.play();
169};
170
171/**
172 * Toggle play/pause state on a mouse click on the play/pause button. Can be
173 * called externally. TODO(mtomasz): Remove it. http://www.crbug.com/254318.
174 *
175 * @param {Event=} opt_event Mouse click event.
176 */
177MediaControls.prototype.onPlayButtonClicked = function(opt_event) {
178  this.togglePlayState();
179};
180
181/**
182 * @param {HTMLElement=} opt_parent Parent container.
183 */
184MediaControls.prototype.initPlayButton = function(opt_parent) {
185  this.playButton_ = this.createButton('play media-control',
186      this.onPlayButtonClicked.bind(this), opt_parent, 3 /* States. */);
187};
188
189/*
190 * Time controls
191 */
192
193/**
194 * The default range of 100 is too coarse for the media progress slider.
195 */
196MediaControls.PROGRESS_RANGE = 5000;
197
198/**
199 * @param {boolean=} opt_seekMark True if the progress slider should have
200 *     a seek mark.
201 * @param {HTMLElement=} opt_parent Parent container.
202 */
203MediaControls.prototype.initTimeControls = function(opt_seekMark, opt_parent) {
204  var timeControls = this.createControl('time-controls', opt_parent);
205
206  var sliderConstructor =
207      opt_seekMark ? MediaControls.PreciseSlider : MediaControls.Slider;
208
209  this.progressSlider_ = new sliderConstructor(
210      this.createControl('progress media-control', timeControls),
211      0, /* value */
212      MediaControls.PROGRESS_RANGE,
213      this.onProgressChange_.bind(this),
214      this.onProgressDrag_.bind(this));
215
216  var timeBox = this.createControl('time media-control', timeControls);
217
218  this.duration_ = this.createControl('duration', timeBox);
219  // Set the initial width to the minimum to reduce the flicker.
220  this.duration_.textContent = MediaControls.formatTime_(0);
221
222  this.currentTime_ = this.createControl('current', timeBox);
223};
224
225/**
226 * @param {number} current Current time is seconds.
227 * @param {number} duration Duration in seconds.
228 * @private
229 */
230MediaControls.prototype.displayProgress_ = function(current, duration) {
231  var ratio = current / duration;
232  this.progressSlider_.setValue(ratio);
233  this.currentTime_.textContent = MediaControls.formatTime_(current);
234};
235
236/**
237 * @param {number} value Progress [0..1].
238 * @private
239 */
240MediaControls.prototype.onProgressChange_ = function(value) {
241  if (!this.media_)
242    return;  // Media is detached.
243
244  if (!this.media_.seekable || !this.media_.duration) {
245    console.error('Inconsistent media state');
246    return;
247  }
248
249  var current = this.media_.duration * value;
250  this.media_.currentTime = current;
251  this.currentTime_.textContent = MediaControls.formatTime_(current);
252};
253
254/**
255 * @param {boolean} on True if dragging.
256 * @private
257 */
258MediaControls.prototype.onProgressDrag_ = function(on) {
259  if (!this.media_)
260    return;  // Media is detached.
261
262  if (on) {
263    this.resumeAfterDrag_ = this.isPlaying();
264    this.media_.pause(true /* seeking */);
265  } else {
266    if (this.resumeAfterDrag_) {
267      if (this.media_.ended)
268        this.onMediaPlay_(false);
269      else
270        this.media_.play(true /* seeking */);
271    }
272    this.updatePlayButtonState_(this.isPlaying());
273  }
274};
275
276/*
277 * Volume controls
278 */
279
280/**
281 * @param {HTMLElement=} opt_parent Parent element for the controls.
282 */
283MediaControls.prototype.initVolumeControls = function(opt_parent) {
284  var volumeControls = this.createControl('volume-controls', opt_parent);
285
286  this.soundButton_ = this.createButton('sound media-control',
287      this.onSoundButtonClick_.bind(this), volumeControls);
288  this.soundButton_.setAttribute('level', 3);  // max level.
289
290  this.volume_ = new MediaControls.AnimatedSlider(
291      this.createControl('volume media-control', volumeControls),
292      1, /* value */
293      100 /* range */,
294      this.onVolumeChange_.bind(this),
295      this.onVolumeDrag_.bind(this));
296};
297
298/**
299 * Click handler for the sound level button.
300 * @private
301 */
302MediaControls.prototype.onSoundButtonClick_ = function() {
303  if (this.media_.volume == 0) {
304    this.volume_.setValue(this.savedVolume_ || 1);
305  } else {
306    this.savedVolume_ = this.media_.volume;
307    this.volume_.setValue(0);
308  }
309  this.onVolumeChange_(this.volume_.getValue());
310};
311
312/**
313 * @param {number} value Volume [0..1].
314 * @return {number} The rough level [0..3] used to pick an icon.
315 * @private
316 */
317MediaControls.getVolumeLevel_ = function(value) {
318  if (value == 0) return 0;
319  if (value <= 1 / 3) return 1;
320  if (value <= 2 / 3) return 2;
321  return 3;
322};
323
324/**
325 * @param {number} value Volume [0..1].
326 * @private
327 */
328MediaControls.prototype.onVolumeChange_ = function(value) {
329  if (!this.media_)
330    return;  // Media is detached.
331
332  this.media_.volume = value;
333  this.soundButton_.setAttribute('level', MediaControls.getVolumeLevel_(value));
334};
335
336/**
337 * @param {boolean} on True if dragging is in progress.
338 * @private
339 */
340MediaControls.prototype.onVolumeDrag_ = function(on) {
341  if (on && (this.media_.volume != 0)) {
342    this.savedVolume_ = this.media_.volume;
343  }
344};
345
346/*
347 * Media event handlers.
348 */
349
350/**
351 * Attach a media element.
352 *
353 * @param {HTMLMediaElement} mediaElement The media element to control.
354 */
355MediaControls.prototype.attachMedia = function(mediaElement) {
356  this.media_ = mediaElement;
357
358  this.media_.addEventListener('play', this.onMediaPlayBound_);
359  this.media_.addEventListener('pause', this.onMediaPauseBound_);
360  this.media_.addEventListener('durationchange', this.onMediaDurationBound_);
361  this.media_.addEventListener('timeupdate', this.onMediaProgressBound_);
362  this.media_.addEventListener('error', this.onMediaError_);
363
364  // Reflect the media state in the UI.
365  this.onMediaDuration_();
366  this.onMediaPlay_(this.isPlaying());
367  this.onMediaProgress_();
368  if (this.volume_) {
369    /* Copy the user selected volume to the new media element. */
370    this.savedVolume_ = this.media_.volume = this.volume_.getValue();
371  }
372};
373
374/**
375 * Detach media event handlers.
376 */
377MediaControls.prototype.detachMedia = function() {
378  if (!this.media_)
379    return;
380
381  this.media_.removeEventListener('play', this.onMediaPlayBound_);
382  this.media_.removeEventListener('pause', this.onMediaPauseBound_);
383  this.media_.removeEventListener('durationchange', this.onMediaDurationBound_);
384  this.media_.removeEventListener('timeupdate', this.onMediaProgressBound_);
385  this.media_.removeEventListener('error', this.onMediaError_);
386
387  this.media_ = null;
388};
389
390/**
391 * Force-empty the media pipeline. This is a workaround for crbug.com/149957.
392 * The document is not going to be GC-ed until the last Files app window closes,
393 * but we want the media pipeline to deinitialize ASAP to minimize leakage.
394 */
395MediaControls.prototype.cleanup = function() {
396  if (!this.media_)
397    return;
398
399  this.media_.src = '';
400  this.media_.load();
401  this.detachMedia();
402};
403
404/**
405 * 'play' and 'pause' event handler.
406 * @param {boolean} playing True if playing.
407 * @private
408 */
409MediaControls.prototype.onMediaPlay_ = function(playing) {
410  if (this.progressSlider_.isDragging())
411    return;
412
413  this.updatePlayButtonState_(playing);
414  this.onPlayStateChanged();
415};
416
417/**
418 * 'durationchange' event handler.
419 * @private
420 */
421MediaControls.prototype.onMediaDuration_ = function() {
422  if (!this.media_ || !this.media_.duration) {
423    this.enableControls_('.media-control', false);
424    return;
425  }
426
427  this.enableControls_('.media-control', true);
428
429  var sliderContainer = this.progressSlider_.getContainer();
430  if (this.media_.seekable)
431    sliderContainer.classList.remove('readonly');
432  else
433    sliderContainer.classList.add('readonly');
434
435  var valueToString = function(value) {
436    var duration = this.media_ ? this.media_.duration : 0;
437    return MediaControls.formatTime_(this.media_.duration * value);
438  }.bind(this);
439
440  this.duration_.textContent = valueToString(1);
441
442  if (this.progressSlider_.setValueToStringFunction)
443    this.progressSlider_.setValueToStringFunction(valueToString);
444
445  if (this.media_.seekable)
446    this.restorePlayState();
447};
448
449/**
450 * 'timeupdate' event handler.
451 * @private
452 */
453MediaControls.prototype.onMediaProgress_ = function() {
454  if (!this.media_ || !this.media_.duration) {
455    this.displayProgress_(0, 1);
456    return;
457  }
458
459  var current = this.media_.currentTime;
460  var duration = this.media_.duration;
461
462  if (this.progressSlider_.isDragging())
463    return;
464
465  this.displayProgress_(current, duration);
466
467  if (current == duration) {
468    this.onMediaComplete();
469  }
470  this.onPlayStateChanged();
471};
472
473/**
474 * Called when the media playback is complete.
475 */
476MediaControls.prototype.onMediaComplete = function() {};
477
478/**
479 * Called when play/pause state is changed or on playback progress.
480 * This is the right moment to save the play state.
481 */
482MediaControls.prototype.onPlayStateChanged = function() {};
483
484/**
485 * Updates the play button state.
486 * @param {boolean} playing If the video is playing.
487 * @private
488 */
489MediaControls.prototype.updatePlayButtonState_ = function(playing) {
490  if (playing) {
491    this.playButton_.setAttribute('state',
492                                  MediaControls.ButtonStateType.PLAYING);
493  } else if (!this.media_.ended) {
494    this.playButton_.setAttribute('state',
495                                  MediaControls.ButtonStateType.DEFAULT);
496  } else {
497    this.playButton_.setAttribute('state',
498                                  MediaControls.ButtonStateType.ENDED);
499  }
500};
501
502/**
503 * Restore play state. Base implementation is empty.
504 */
505MediaControls.prototype.restorePlayState = function() {};
506
507/**
508 * Encode current state into the page URL or the app state.
509 */
510MediaControls.prototype.encodeState = function() {
511  if (!this.media_ || !this.media_.duration)
512    return;
513
514  if (window.appState) {
515    window.appState.time = this.media_.currentTime;
516    util.saveAppState();
517  }
518  return;
519};
520
521/**
522 * Decode current state from the page URL or the app state.
523 * @return {boolean} True if decode succeeded.
524 */
525MediaControls.prototype.decodeState = function() {
526  if (!this.media_ || !window.appState || !('time' in window.appState))
527    return false;
528  // There is no page reload for apps v2, only app restart.
529  // Always restart in paused state.
530  this.media_.currentTime = window.appState.time;
531  this.pause();
532  return true;
533};
534
535/**
536 * Remove current state from the page URL or the app state.
537 */
538MediaControls.prototype.clearState = function() {
539  if (!window.appState)
540    return;
541
542  if ('time' in window.appState)
543    delete window.appState.time;
544  util.saveAppState();
545  return;
546};
547
548/**
549 * Create a customized slider control.
550 *
551 * @param {HTMLElement} container The containing div element.
552 * @param {number} value Initial value [0..1].
553 * @param {number} range Number of distinct slider positions to be supported.
554 * @param {function(number)} onChange Value change handler.
555 * @param {function(boolean)} onDrag Drag begin/end handler.
556 * @constructor
557 */
558
559MediaControls.Slider = function(container, value, range, onChange, onDrag) {
560  this.container_ = container;
561  this.onChange_ = onChange;
562  this.onDrag_ = onDrag;
563
564  var document = this.container_.ownerDocument;
565
566  this.container_.classList.add('custom-slider');
567
568  this.input_ = document.createElement('input');
569  this.input_.type = 'range';
570  this.input_.min = 0;
571  this.input_.max = range;
572  this.input_.value = value * range;
573  this.container_.appendChild(this.input_);
574
575  this.input_.addEventListener(
576      'change', this.onInputChange_.bind(this));
577  this.input_.addEventListener(
578      'mousedown', this.onInputDrag_.bind(this, true));
579  this.input_.addEventListener(
580      'mouseup', this.onInputDrag_.bind(this, false));
581
582  this.bar_ = document.createElement('div');
583  this.bar_.className = 'bar';
584  this.container_.appendChild(this.bar_);
585
586  this.filled_ = document.createElement('div');
587  this.filled_.className = 'filled';
588  this.bar_.appendChild(this.filled_);
589
590  var leftCap = document.createElement('div');
591  leftCap.className = 'cap left';
592  this.bar_.appendChild(leftCap);
593
594  var rightCap = document.createElement('div');
595  rightCap.className = 'cap right';
596  this.bar_.appendChild(rightCap);
597
598  this.value_ = value;
599  this.setFilled_(value);
600};
601
602/**
603 * @return {HTMLElement} The container element.
604 */
605MediaControls.Slider.prototype.getContainer = function() {
606  return this.container_;
607};
608
609/**
610 * @return {HTMLElement} The standard input element.
611 * @private
612 */
613MediaControls.Slider.prototype.getInput_ = function() {
614  return this.input_;
615};
616
617/**
618 * @return {HTMLElement} The slider bar element.
619 */
620MediaControls.Slider.prototype.getBar = function() {
621  return this.bar_;
622};
623
624/**
625 * @return {number} [0..1] The current value.
626 */
627MediaControls.Slider.prototype.getValue = function() {
628  return this.value_;
629};
630
631/**
632 * @param {number} value [0..1].
633 */
634MediaControls.Slider.prototype.setValue = function(value) {
635  this.value_ = value;
636  this.setValueToUI_(value);
637};
638
639/**
640 * Fill the given proportion the slider bar (from the left).
641 *
642 * @param {number} proportion [0..1].
643 * @private
644 */
645MediaControls.Slider.prototype.setFilled_ = function(proportion) {
646  this.filled_.style.width = proportion * 100 + '%';
647};
648
649/**
650 * Get the value from the input element.
651 *
652 * @return {number} Value [0..1].
653 * @private
654 */
655MediaControls.Slider.prototype.getValueFromUI_ = function() {
656  return this.input_.value / this.input_.max;
657};
658
659/**
660 * Update the UI with the current value.
661 *
662 * @param {number} value [0..1].
663 * @private
664 */
665MediaControls.Slider.prototype.setValueToUI_ = function(value) {
666  this.input_.value = value * this.input_.max;
667  this.setFilled_(value);
668};
669
670/**
671 * Compute the proportion in which the given position divides the slider bar.
672 *
673 * @param {number} position in pixels.
674 * @return {number} [0..1] proportion.
675 */
676MediaControls.Slider.prototype.getProportion = function(position) {
677  var rect = this.bar_.getBoundingClientRect();
678  return Math.max(0, Math.min(1, (position - rect.left) / rect.width));
679};
680
681/**
682 * 'change' event handler.
683 * @private
684 */
685MediaControls.Slider.prototype.onInputChange_ = function() {
686  this.value_ = this.getValueFromUI_();
687  this.setFilled_(this.value_);
688  this.onChange_(this.value_);
689};
690
691/**
692 * @return {boolean} True if dragging is in progress.
693 */
694MediaControls.Slider.prototype.isDragging = function() {
695  return this.isDragging_;
696};
697
698/**
699 * Mousedown/mouseup handler.
700 * @param {boolean} on True if the mouse is down.
701 * @private
702 */
703MediaControls.Slider.prototype.onInputDrag_ = function(on) {
704  this.isDragging_ = on;
705  this.onDrag_(on);
706};
707
708/**
709 * Create a customized slider with animated thumb movement.
710 *
711 * @param {HTMLElement} container The containing div element.
712 * @param {number} value Initial value [0..1].
713 * @param {number} range Number of distinct slider positions to be supported.
714 * @param {function(number)} onChange Value change handler.
715 * @param {function(boolean)} onDrag Drag begin/end handler.
716 * @param {function(number):string} formatFunction Value formatting function.
717 * @constructor
718 */
719MediaControls.AnimatedSlider = function(
720    container, value, range, onChange, onDrag, formatFunction) {
721  MediaControls.Slider.apply(this, arguments);
722};
723
724MediaControls.AnimatedSlider.prototype = {
725  __proto__: MediaControls.Slider.prototype
726};
727
728/**
729 * Number of animation steps.
730 */
731MediaControls.AnimatedSlider.STEPS = 10;
732
733/**
734 * Animation duration.
735 */
736MediaControls.AnimatedSlider.DURATION = 100;
737
738/**
739 * @param {number} value [0..1].
740 * @private
741 */
742MediaControls.AnimatedSlider.prototype.setValueToUI_ = function(value) {
743  if (this.animationInterval_) {
744    clearInterval(this.animationInterval_);
745  }
746  var oldValue = this.getValueFromUI_();
747  var step = 0;
748  this.animationInterval_ = setInterval(function() {
749    step++;
750    var currentValue = oldValue +
751        (value - oldValue) * (step / MediaControls.AnimatedSlider.STEPS);
752    MediaControls.Slider.prototype.setValueToUI_.call(this, currentValue);
753    if (step == MediaControls.AnimatedSlider.STEPS) {
754      clearInterval(this.animationInterval_);
755    }
756  }.bind(this),
757  MediaControls.AnimatedSlider.DURATION / MediaControls.AnimatedSlider.STEPS);
758};
759
760/**
761 * Create a customized slider with a precise time feedback.
762 *
763 * The time value is shown above the slider bar at the mouse position.
764 *
765 * @param {HTMLElement} container The containing div element.
766 * @param {number} value Initial value [0..1].
767 * @param {number} range Number of distinct slider positions to be supported.
768 * @param {function(number)} onChange Value change handler.
769 * @param {function(boolean)} onDrag Drag begin/end handler.
770 * @param {function(number):string} formatFunction Value formatting function.
771 * @constructor
772 */
773MediaControls.PreciseSlider = function(
774    container, value, range, onChange, onDrag, formatFunction) {
775  MediaControls.Slider.apply(this, arguments);
776
777  var doc = this.container_.ownerDocument;
778
779  /**
780   * @type {function(number):string}
781   * @private
782   */
783  this.valueToString_ = null;
784
785  this.seekMark_ = doc.createElement('div');
786  this.seekMark_.className = 'seek-mark';
787  this.getBar().appendChild(this.seekMark_);
788
789  this.seekLabel_ = doc.createElement('div');
790  this.seekLabel_.className = 'seek-label';
791  this.seekMark_.appendChild(this.seekLabel_);
792
793  this.getContainer().addEventListener(
794      'mousemove', this.onMouseMove_.bind(this));
795  this.getContainer().addEventListener(
796      'mouseout', this.onMouseOut_.bind(this));
797};
798
799MediaControls.PreciseSlider.prototype = {
800  __proto__: MediaControls.Slider.prototype
801};
802
803/**
804 * Show the seek mark after a delay.
805 */
806MediaControls.PreciseSlider.SHOW_DELAY = 200;
807
808/**
809 * Hide the seek mark for this long after changing the position with a click.
810 */
811MediaControls.PreciseSlider.HIDE_AFTER_MOVE_DELAY = 2500;
812
813/**
814 * Hide the seek mark for this long after changing the position with a drag.
815 */
816MediaControls.PreciseSlider.HIDE_AFTER_DRAG_DELAY = 750;
817
818/**
819 * Default hide timeout (no hiding).
820 */
821MediaControls.PreciseSlider.NO_AUTO_HIDE = 0;
822
823/**
824 * @param {function(number):string} func Value formatting function.
825 */
826MediaControls.PreciseSlider.prototype.setValueToStringFunction =
827    function(func) {
828  this.valueToString_ = func;
829
830  /* It is not completely accurate to assume that the max value corresponds
831   to the longest string, but generous CSS padding will compensate for that. */
832  var labelWidth = this.valueToString_(1).length / 2 + 1;
833  this.seekLabel_.style.width = labelWidth + 'em';
834  this.seekLabel_.style.marginLeft = -labelWidth / 2 + 'em';
835};
836
837/**
838 * Show the time above the slider.
839 *
840 * @param {number} ratio [0..1] The proportion of the duration.
841 * @param {number} timeout Timeout in ms after which the label should be hidden.
842 *     MediaControls.PreciseSlider.NO_AUTO_HIDE means show until the next call.
843 * @private
844 */
845MediaControls.PreciseSlider.prototype.showSeekMark_ =
846    function(ratio, timeout) {
847  // Do not update the seek mark for the first 500ms after the drag is finished.
848  if (this.latestMouseUpTime_ && (this.latestMouseUpTime_ + 500 > Date.now()))
849    return;
850
851  this.seekMark_.style.left = ratio * 100 + '%';
852
853  if (ratio < this.getValue()) {
854    this.seekMark_.classList.remove('inverted');
855  } else {
856    this.seekMark_.classList.add('inverted');
857  }
858  this.seekLabel_.textContent = this.valueToString_(ratio);
859
860  this.seekMark_.classList.add('visible');
861
862  if (this.seekMarkTimer_) {
863    clearTimeout(this.seekMarkTimer_);
864    this.seekMarkTimer_ = null;
865  }
866  if (timeout != MediaControls.PreciseSlider.NO_AUTO_HIDE) {
867    this.seekMarkTimer_ = setTimeout(this.hideSeekMark_.bind(this), timeout);
868  }
869};
870
871/**
872 * @private
873 */
874MediaControls.PreciseSlider.prototype.hideSeekMark_ = function() {
875  this.seekMarkTimer_ = null;
876  this.seekMark_.classList.remove('visible');
877};
878
879/**
880 * 'mouseout' event handler.
881 * @param {Event} e Event.
882 * @private
883 */
884MediaControls.PreciseSlider.prototype.onMouseMove_ = function(e) {
885  this.latestSeekRatio_ = this.getProportion(e.clientX);
886
887  var self = this;
888  function showMark() {
889    if (!self.isDragging()) {
890      self.showSeekMark_(self.latestSeekRatio_,
891          MediaControls.PreciseSlider.HIDE_AFTER_MOVE_DELAY);
892    }
893  }
894
895  if (this.seekMark_.classList.contains('visible')) {
896    showMark();
897  } else if (!this.seekMarkTimer_) {
898    this.seekMarkTimer_ =
899        setTimeout(showMark, MediaControls.PreciseSlider.SHOW_DELAY);
900  }
901};
902
903/**
904 * 'mouseout' event handler.
905 * @param {Event} e Event.
906 * @private
907 */
908MediaControls.PreciseSlider.prototype.onMouseOut_ = function(e) {
909  for (var element = e.relatedTarget; element; element = element.parentNode) {
910    if (element == this.getContainer())
911      return;
912  }
913  if (this.seekMarkTimer_) {
914    clearTimeout(this.seekMarkTimer_);
915    this.seekMarkTimer_ = null;
916  }
917  this.hideSeekMark_();
918};
919
920/**
921 * 'change' event handler.
922 * @private
923 */
924MediaControls.PreciseSlider.prototype.onInputChange_ = function() {
925  MediaControls.Slider.prototype.onInputChange_.apply(this, arguments);
926  if (this.isDragging()) {
927    this.showSeekMark_(
928        this.getValue(), MediaControls.PreciseSlider.NO_AUTO_HIDE);
929  }
930};
931
932/**
933 * Mousedown/mouseup handler.
934 * @param {boolean} on True if the mouse is down.
935 * @private
936 */
937MediaControls.PreciseSlider.prototype.onInputDrag_ = function(on) {
938  MediaControls.Slider.prototype.onInputDrag_.apply(this, arguments);
939
940  if (on) {
941    // Dragging started, align the seek mark with the thumb position.
942    this.showSeekMark_(
943        this.getValue(), MediaControls.PreciseSlider.NO_AUTO_HIDE);
944  } else {
945    // Just finished dragging.
946    // Show the label for the last time with a shorter timeout.
947    this.showSeekMark_(
948        this.getValue(), MediaControls.PreciseSlider.HIDE_AFTER_DRAG_DELAY);
949    this.latestMouseUpTime_ = Date.now();
950  }
951};
952
953/**
954 * Create video controls.
955 *
956 * @param {HTMLElement} containerElement The container for the controls.
957 * @param {function} onMediaError Function to display an error message.
958 * @param {function(string):string} stringFunction Function providing localized
959 *     strings.
960 * @param {function=} opt_fullScreenToggle Function to toggle fullscreen mode.
961 * @param {HTMLElement=} opt_stateIconParent The parent for the icon that
962 *     gives visual feedback when the playback state changes.
963 * @constructor
964 */
965function VideoControls(containerElement, onMediaError, stringFunction,
966    opt_fullScreenToggle, opt_stateIconParent) {
967  MediaControls.call(this, containerElement, onMediaError);
968  this.stringFunction_ = stringFunction;
969
970  this.container_.classList.add('video-controls');
971  this.initPlayButton();
972  this.initTimeControls(true /* show seek mark */);
973  this.initVolumeControls();
974
975  // Create the cast button.
976  this.castButton_ = this.createButton('cast menubutton');
977  this.castButton_.setAttribute('menu', '#cast-menu');
978  this.castButton_.setAttribute(
979      'label', this.stringFunction_('VIDEO_PLAYER_PLAY_ON'));
980  cr.ui.decorate(this.castButton_, cr.ui.MenuButton);
981
982  if (opt_fullScreenToggle) {
983    this.fullscreenButton_ =
984        this.createButton('fullscreen', opt_fullScreenToggle);
985  }
986
987  if (opt_stateIconParent) {
988    this.stateIcon_ = this.createControl(
989        'playback-state-icon', opt_stateIconParent);
990    this.textBanner_ = this.createControl('text-banner', opt_stateIconParent);
991  }
992
993  // Disables all controls at first.
994  this.enableControls_('.media-control', false);
995
996  var videoControls = this;
997  chrome.mediaPlayerPrivate.onTogglePlayState.addListener(
998      function() { videoControls.togglePlayStateWithFeedback(); });
999}
1000
1001/**
1002 * No resume if we are within this margin from the start or the end.
1003 */
1004VideoControls.RESUME_MARGIN = 0.03;
1005
1006/**
1007 * No resume for videos shorter than this.
1008 */
1009VideoControls.RESUME_THRESHOLD = 5 * 60; // 5 min.
1010
1011/**
1012 * When resuming rewind back this much.
1013 */
1014VideoControls.RESUME_REWIND = 5;  // seconds.
1015
1016VideoControls.prototype = { __proto__: MediaControls.prototype };
1017
1018/**
1019 * Shows icon feedback for the current state of the video player.
1020 * @private
1021 */
1022VideoControls.prototype.showIconFeedback_ = function() {
1023  var stateIcon = this.stateIcon_;
1024  stateIcon.removeAttribute('state');
1025
1026  setTimeout(function() {
1027    var newState = this.isPlaying() ? 'play' : 'pause';
1028
1029    var onAnimationEnd = function(state, event) {
1030      if (stateIcon.getAttribute('state') === state)
1031        stateIcon.removeAttribute('state');
1032
1033      stateIcon.removeEventListener('webkitAnimationEnd', onAnimationEnd);
1034    }.bind(null, newState);
1035    stateIcon.addEventListener('webkitAnimationEnd', onAnimationEnd);
1036
1037    // Shows the icon with animation.
1038    stateIcon.setAttribute('state', newState);
1039  }.bind(this), 0);
1040};
1041
1042/**
1043 * Shows a text banner.
1044 *
1045 * @param {string} identifier String identifier.
1046 * @private
1047 */
1048VideoControls.prototype.showTextBanner_ = function(identifier) {
1049  this.textBanner_.removeAttribute('visible');
1050  this.textBanner_.textContent = this.stringFunction_(identifier);
1051  setTimeout(function() {
1052    this.textBanner_.setAttribute('visible', 'true');
1053  }.bind(this), 0);
1054};
1055
1056/**
1057 * Toggle play/pause state on a mouse click on the play/pause button. Can be
1058 * called externally.
1059 *
1060 * @param {Event} event Mouse click event.
1061 */
1062VideoControls.prototype.onPlayButtonClicked = function(event) {
1063  if (event.ctrlKey) {
1064    this.toggleLoopedModeWithFeedback(true);
1065    if (!this.isPlaying())
1066      this.togglePlayState();
1067  } else {
1068    this.togglePlayState();
1069  }
1070};
1071
1072/**
1073 * Media completion handler.
1074 */
1075VideoControls.prototype.onMediaComplete = function() {
1076  this.onMediaPlay_(false);  // Just update the UI.
1077  this.savePosition();  // This will effectively forget the position.
1078};
1079
1080/**
1081 * Toggles the looped mode with feedback.
1082 * @param {boolean} on Whether enabled or not.
1083 */
1084VideoControls.prototype.toggleLoopedModeWithFeedback = function(on) {
1085  if (!this.getMedia().duration)
1086    return;
1087  this.toggleLoopedMode(on);
1088  if (on) {
1089    // TODO(mtomasz): Simplify, crbug.com/254318.
1090    this.showTextBanner_('GALLERY_VIDEO_LOOPED_MODE');
1091  }
1092};
1093
1094/**
1095 * Toggles the looped mode.
1096 * @param {boolean} on Whether enabled or not.
1097 */
1098VideoControls.prototype.toggleLoopedMode = function(on) {
1099  this.getMedia().loop = on;
1100};
1101
1102/**
1103 * Toggles play/pause state and flash an icon over the video.
1104 */
1105VideoControls.prototype.togglePlayStateWithFeedback = function() {
1106  if (!this.getMedia().duration)
1107    return;
1108
1109  this.togglePlayState();
1110  this.showIconFeedback_();
1111};
1112
1113/**
1114 * Toggles play/pause state.
1115 */
1116VideoControls.prototype.togglePlayState = function() {
1117  if (this.isPlaying()) {
1118    // User gave the Pause command. Save the state and reset the loop mode.
1119    this.toggleLoopedMode(false);
1120    this.savePosition();
1121  }
1122  MediaControls.prototype.togglePlayState.apply(this, arguments);
1123};
1124
1125/**
1126 * Saves the playback position to the persistent storage.
1127 * @param {boolean=} opt_sync True if the position must be saved synchronously
1128 *     (required when closing app windows).
1129 */
1130VideoControls.prototype.savePosition = function(opt_sync) {
1131  if (!this.media_ ||
1132      !this.media_.duration ||
1133      this.media_.duration < VideoControls.RESUME_THRESHOLD) {
1134    return;
1135  }
1136
1137  var ratio = this.media_.currentTime / this.media_.duration;
1138  var position;
1139  if (ratio < VideoControls.RESUME_MARGIN ||
1140      ratio > (1 - VideoControls.RESUME_MARGIN)) {
1141    // We are too close to the beginning or the end.
1142    // Remove the resume position so that next time we start from the beginning.
1143    position = null;
1144  } else {
1145    position = Math.floor(
1146        Math.max(0, this.media_.currentTime - VideoControls.RESUME_REWIND));
1147  }
1148
1149  if (opt_sync) {
1150    // Packaged apps cannot save synchronously.
1151    // Pass the data to the background page.
1152    if (!window.saveOnExit)
1153      window.saveOnExit = [];
1154    window.saveOnExit.push({ key: this.media_.src, value: position });
1155  } else {
1156    util.AppCache.update(this.media_.src, position);
1157  }
1158};
1159
1160/**
1161 * Resumes the playback position saved in the persistent storage.
1162 */
1163VideoControls.prototype.restorePlayState = function() {
1164  if (this.media_ && this.media_.duration >= VideoControls.RESUME_THRESHOLD) {
1165    util.AppCache.getValue(this.media_.src, function(position) {
1166      if (position)
1167        this.media_.currentTime = position;
1168    }.bind(this));
1169  }
1170};
1171
1172/**
1173 * Updates style to best fit the size of the container.
1174 */
1175VideoControls.prototype.updateStyle = function() {
1176  // We assume that the video controls element fills the parent container.
1177  // This is easier than adding margins to this.container_.clientWidth.
1178  var width = this.container_.parentNode.clientWidth;
1179
1180  // Set the margin to 5px for width >= 400, 0px for width < 160,
1181  // interpolate linearly in between.
1182  this.container_.style.margin =
1183      Math.ceil((Math.max(160, Math.min(width, 400)) - 160) / 48) + 'px';
1184
1185  var hideBelow = function(selector, limit) {
1186    this.container_.querySelector(selector).style.display =
1187        width < limit ? 'none' : '-webkit-box';
1188  }.bind(this);
1189
1190  hideBelow('.time', 350);
1191  hideBelow('.volume', 275);
1192  hideBelow('.volume-controls', 210);
1193  hideBelow('.fullscreen', 150);
1194};
1195
1196/**
1197 * Creates audio controls.
1198 *
1199 * @param {HTMLElement} container Parent container.
1200 * @param {function(boolean)} advanceTrack Parameter: true=forward.
1201 * @param {function} onError Error handler.
1202 * @constructor
1203 */
1204function AudioControls(container, advanceTrack, onError) {
1205  MediaControls.call(this, container, onError);
1206
1207  this.container_.classList.add('audio-controls');
1208
1209  this.advanceTrack_ = advanceTrack;
1210
1211  this.initPlayButton();
1212  this.initTimeControls(false /* no seek mark */);
1213  /* No volume controls */
1214  this.createButton('previous', this.onAdvanceClick_.bind(this, false));
1215  this.createButton('next', this.onAdvanceClick_.bind(this, true));
1216
1217  // Disables all controls at first.
1218  this.enableControls_('.media-control', false);
1219
1220  var audioControls = this;
1221  chrome.mediaPlayerPrivate.onNextTrack.addListener(
1222      function() { audioControls.onAdvanceClick_(true); });
1223  chrome.mediaPlayerPrivate.onPrevTrack.addListener(
1224      function() { audioControls.onAdvanceClick_(false); });
1225  chrome.mediaPlayerPrivate.onTogglePlayState.addListener(
1226      function() { audioControls.togglePlayState(); });
1227}
1228
1229AudioControls.prototype = { __proto__: MediaControls.prototype };
1230
1231/**
1232 * Media completion handler. Advances to the next track.
1233 */
1234AudioControls.prototype.onMediaComplete = function() {
1235  this.advanceTrack_(true);
1236};
1237
1238/**
1239 * The track position after which "previous" button acts as "restart".
1240 */
1241AudioControls.TRACK_RESTART_THRESHOLD = 5;  // seconds.
1242
1243/**
1244 * @param {boolean} forward True if advancing forward.
1245 * @private
1246 */
1247AudioControls.prototype.onAdvanceClick_ = function(forward) {
1248  if (!forward &&
1249      (this.getMedia().currentTime > AudioControls.TRACK_RESTART_THRESHOLD)) {
1250    // We are far enough from the beginning of the current track.
1251    // Restart it instead of than skipping to the previous one.
1252    this.getMedia().currentTime = 0;
1253  } else {
1254    this.advanceTrack_(forward);
1255  }
1256};
1257