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