OLD | NEW |
| (Empty) |
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 progress. | |
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 within 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 }; | |
OLD | NEW |