Home | History | Annotate | Download | only in media
      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  */
     17 function 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  */
     33 MediaControls.ButtonStateType = {
     34   DEFAULT: 'default',
     35   PLAYING: 'playing',
     36   ENDED: 'ended'
     37 };
     38 
     39 /**
     40  * @return {HTMLAudioElement|HTMLVideoElement} The media element.
     41  */
     42 MediaControls.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  */
     51 MediaControls.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  */
     71 MediaControls.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  */
     88 MediaControls.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  */
    117 MediaControls.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  */
    135 MediaControls.prototype.play = function() {
    136   this.media_.play();
    137 };
    138 
    139 /**
    140  * Pause the media.
    141  */
    142 MediaControls.prototype.pause = function() {
    143   this.media_.pause();
    144 };
    145 
    146 /**
    147  * @return {boolean} True if the media is currently playing.
    148  */
    149 MediaControls.prototype.isPlaying = function() {
    150   return !this.media_.paused && !this.media_.ended;
    151 };
    152 
    153 /**
    154  * Toggle play/pause.
    155  */
    156 MediaControls.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  */
    169 MediaControls.prototype.onPlayButtonClicked = function(opt_event) {
    170   this.togglePlayState();
    171 };
    172 
    173 /**
    174  * @param {HTMLElement=} opt_parent Parent container.
    175  */
    176 MediaControls.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  */
    188 MediaControls.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  */
    195 MediaControls.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  */
    222 MediaControls.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  */
    232 MediaControls.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  */
    247 MediaControls.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  */
    269 MediaControls.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  */
    288 MediaControls.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  */
    303 MediaControls.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  */
    314 MediaControls.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  */
    323 MediaControls.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  */
    338 MediaControls.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  */
    360 MediaControls.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  */
    378 MediaControls.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  */
    389 MediaControls.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  */
    401 MediaControls.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  */
    432 MediaControls.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  */
    455 MediaControls.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  */
    461 MediaControls.prototype.onPlayStateChanged = function() {};
    462 
    463 /**
    464  * Updates the play button state.
    465  * @param {boolean} playing If the video is playing.
    466  * @private
    467  */
    468 MediaControls.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  */
    484 MediaControls.prototype.restorePlayState = function() {};
    485 
    486 /**
    487  * Encode current state into the page URL or the app state.
    488  */
    489 MediaControls.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  */
    514 MediaControls.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  */
    550 MediaControls.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 
    575 MediaControls.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  */
    621 MediaControls.Slider.prototype.getContainer = function() {
    622   return this.container_;
    623 };
    624 
    625 /**
    626  * @return {HTMLElement} The standard input element.
    627  * @private
    628  */
    629 MediaControls.Slider.prototype.getInput_ = function() {
    630   return this.input_;
    631 };
    632 
    633 /**
    634  * @return {HTMLElement} The slider bar element.
    635  */
    636 MediaControls.Slider.prototype.getBar = function() {
    637   return this.bar_;
    638 };
    639 
    640 /**
    641  * @return {number} [0..1] The current value.
    642  */
    643 MediaControls.Slider.prototype.getValue = function() {
    644   return this.value_;
    645 };
    646 
    647 /**
    648  * @param {number} value [0..1].
    649  */
    650 MediaControls.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  */
    661 MediaControls.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  */
    671 MediaControls.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  */
    681 MediaControls.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  */
    692 MediaControls.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  */
    701 MediaControls.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 progres.
    709  */
    710 MediaControls.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  */
    719 MediaControls.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  */
    735 MediaControls.AnimatedSlider = function(
    736     container, value, range, onChange, onDrag, formatFunction) {
    737   MediaControls.Slider.apply(this, arguments);
    738 };
    739 
    740 MediaControls.AnimatedSlider.prototype = {
    741   __proto__: MediaControls.Slider.prototype
    742 };
    743 
    744 /**
    745  * Number of animation steps.
    746  */
    747 MediaControls.AnimatedSlider.STEPS = 10;
    748 
    749 /**
    750  * Animation duration.
    751  */
    752 MediaControls.AnimatedSlider.DURATION = 100;
    753 
    754 /**
    755  * @param {number} value [0..1].
    756  * @private
    757  */
    758 MediaControls.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  */
    789 MediaControls.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 
    815 MediaControls.PreciseSlider.prototype = {
    816   __proto__: MediaControls.Slider.prototype
    817 };
    818 
    819 /**
    820  * Show the seek mark after a delay.
    821  */
    822 MediaControls.PreciseSlider.SHOW_DELAY = 200;
    823 
    824 /**
    825  * Hide the seek mark for this long after changing the position with a click.
    826  */
    827 MediaControls.PreciseSlider.HIDE_AFTER_MOVE_DELAY = 2500;
    828 
    829 /**
    830  * Hide the seek mark for this long after changing the position with a drag.
    831  */
    832 MediaControls.PreciseSlider.HIDE_AFTER_DRAG_DELAY = 750;
    833 
    834 /**
    835  * Default hide timeout (no hiding).
    836  */
    837 MediaControls.PreciseSlider.NO_AUTO_HIDE = 0;
    838 
    839 /**
    840  * @param {function(number):string} func Value formatting function.
    841  */
    842 MediaControls.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  */
    861 MediaControls.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  */
    890 MediaControls.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  */
    900 MediaControls.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  */
    924 MediaControls.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  */
    940 MediaControls.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  */
    953 MediaControls.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  */
    981 function 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 withing this margin from the start or the end.
   1009  */
   1010 VideoControls.RESUME_MARGIN = 0.03;
   1011 
   1012 /**
   1013  * No resume for videos shorter than this.
   1014  */
   1015 VideoControls.RESUME_THRESHOLD = 5 * 60; // 5 min.
   1016 
   1017 /**
   1018  * When resuming rewind back this much.
   1019  */
   1020 VideoControls.RESUME_REWIND = 5;  // seconds.
   1021 
   1022 VideoControls.prototype = { __proto__: MediaControls.prototype };
   1023 
   1024 /**
   1025  * Shows icon feedback for the current state of the video player.
   1026  * @private
   1027  */
   1028 VideoControls.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  */
   1041 VideoControls.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  */
   1055 VideoControls.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  */
   1068 VideoControls.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  */
   1077 VideoControls.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  */
   1091 VideoControls.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  */
   1098 VideoControls.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  */
   1109 VideoControls.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  */
   1123 VideoControls.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  */
   1155 VideoControls.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  */
   1167 VideoControls.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  */
   1196 function 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 
   1218 AudioControls.prototype = { __proto__: MediaControls.prototype };
   1219 
   1220 /**
   1221  * Media completion handler. Advances to the next track.
   1222  */
   1223 AudioControls.prototype.onMediaComplete = function() {
   1224   this.advanceTrack_(true);
   1225 };
   1226 
   1227 /**
   1228  * The track position after which "previous" button acts as "restart".
   1229  */
   1230 AudioControls.TRACK_RESTART_THRESHOLD = 5;  // seconds.
   1231 
   1232 /**
   1233  * @param {boolean} forward True if advancing forward.
   1234  * @private
   1235  */
   1236 AudioControls.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