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