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 /** | |
6 * @fileoverview MediaControls class implements media playback controls | |
7 * that exist outside of the audio/video HTML element. | |
8 */ | |
9 | |
10 /** | |
11 * @param {HTMLElement} containerElement The container for the controls. | |
12 * @param {function} onMediaError Function to display an error message. | |
13 * @constructor | |
14 */ | |
15 function MediaControls(containerElement, onMediaError) { | |
16 this.container_ = containerElement; | |
17 this.document_ = this.container_.ownerDocument; | |
18 this.media_ = null; | |
19 | |
20 this.onMediaPlayBound_ = this.onMediaPlay_.bind(this, true); | |
21 this.onMediaPauseBound_ = this.onMediaPlay_.bind(this, false); | |
22 this.onMediaDurationBound_ = this.onMediaDuration_.bind(this); | |
23 this.onMediaProgressBound_ = this.onMediaProgress_.bind(this); | |
24 this.onMediaError_ = onMediaError || function(){}; | |
25 } | |
26 | |
27 MediaControls.prototype.getMedia = function() { return this.media_ }; | |
28 | |
29 /** | |
30 * Format the time in hh:mm:ss format (omitting redundant leading zeros). | |
31 * | |
32 * @param {number} timeInSec Time in seconds. | |
33 * @return {string} Formatted time string. | |
34 */ | |
35 MediaControls.formatTime_ = function(timeInSec) { | |
36 var seconds = Math.floor(timeInSec % 60); | |
37 var minutes = Math.floor((timeInSec / 60) % 60); | |
38 var hours = Math.floor(timeInSec / 60 / 60); | |
39 var result = ''; | |
40 if (hours) result += hours + ':'; | |
41 if (hours && (minutes < 10)) result += '0'; | |
42 result += minutes + ':'; | |
43 if (seconds < 10) result += '0'; | |
44 result += seconds; | |
45 return result; | |
46 }; | |
47 | |
48 /** | |
49 * Create a custom control. | |
50 * | |
51 * @param {string} className | |
52 * @param {HTMLElement=} opt_parent Parent element or container if undefined. | |
53 * @return {HTMLElement} | |
54 */ | |
55 MediaControls.prototype.createControl = function(className, opt_parent) { | |
56 var parent = opt_parent || this.container_; | |
57 var control = this.document_.createElement('div'); | |
58 control.className = className; | |
59 parent.appendChild(control); | |
60 return control; | |
61 }; | |
62 | |
63 /** | |
64 * Create a custom button. | |
65 * | |
66 * @param {string} className | |
67 * @param {function(Event)} handler | |
68 * @param {HTMLElement=} opt_parent Parent element or container if undefined. | |
69 * @param {Boolean} opt_toggle True if the button has toggle state. | |
70 * @return {HTMLElement} | |
71 */ | |
72 MediaControls.prototype.createButton = function( | |
73 className, handler, opt_parent, opt_toggle) { | |
74 var button = this.createControl(className, opt_parent); | |
75 button.classList.add('media-button'); | |
76 button.addEventListener('click', handler); | |
77 | |
78 var numStates = opt_toggle ? 2 : 1; | |
79 for (var state = 0; state != numStates; state++) { | |
80 var stateClass = 'state' + state; | |
81 this.createControl('normal ' + stateClass, button); | |
82 this.createControl('hover ' + stateClass, button); | |
83 this.createControl('active ' + stateClass, button); | |
84 } | |
85 this.createControl('disabled', button); | |
86 | |
87 button.setAttribute('state', 0); | |
88 button.addEventListener('click', handler); | |
89 return button; | |
90 }; | |
91 | |
92 MediaControls.prototype.enableControls_ = function(selector, on) { | |
93 var controls = this.container_.querySelectorAll(selector); | |
94 for (var i = 0; i != controls.length; i++) { | |
95 var classList = controls[i].classList; | |
96 if (on) | |
97 classList.remove('disabled'); | |
98 else | |
99 classList.add('disabled'); | |
100 } | |
101 }; | |
102 | |
103 /* | |
104 * Playback control. | |
105 */ | |
106 | |
107 MediaControls.prototype.play = function() { | |
108 this.media_.play(); | |
109 }; | |
110 | |
111 MediaControls.prototype.pause = function() { | |
112 this.media_.pause(); | |
113 }; | |
114 | |
115 MediaControls.prototype.isPlaying = function() { | |
116 return !this.media_.paused && !this.media_.ended; | |
117 }; | |
118 | |
119 MediaControls.prototype.togglePlayState = function() { | |
120 if (this.isPlaying()) | |
121 this.pause(); | |
122 else | |
123 this.play(); | |
124 }; | |
125 | |
126 MediaControls.prototype.initPlayButton = function(opt_parent) { | |
127 this.playButton_ = this.createButton('play media-control', | |
128 this.togglePlayState.bind(this), opt_parent, true /* toggle */); | |
129 }; | |
130 | |
131 /* | |
132 * Time controls | |
133 */ | |
134 | |
135 // The default range of 100 is too coarse for the media progress slider. | |
136 // 1000 should be enough as the entire media controls area is never longer | |
137 // than 800px. | |
138 MediaControls.PROGRESS_RANGE = 1000; | |
139 | |
140 MediaControls.prototype.initTimeControls = function(opt_seekMark, opt_parent) { | |
141 var timeControls = this.createControl('time-controls', opt_parent); | |
142 | |
143 var sliderConstructor = | |
144 opt_seekMark ? MediaControls.PreciseSlider : MediaControls.Slider; | |
145 | |
146 this.progressSlider_ = new sliderConstructor( | |
147 this.createControl('progress media-control', timeControls), | |
148 0, /* value */ | |
149 MediaControls.PROGRESS_RANGE, | |
150 this.onProgressChange_.bind(this), | |
151 this.onProgressDrag_.bind(this)); | |
152 | |
153 var timeBox = this.createControl('time media-control', timeControls); | |
154 | |
155 this.duration_ = this.createControl('duration', timeBox); | |
156 // Set the initial width to the minimum to reduce the flicker. | |
157 this.duration_.textContent = MediaControls.formatTime_(0); | |
158 | |
159 this.currentTime_ = this.createControl('current', timeBox); | |
160 }; | |
161 | |
162 MediaControls.prototype.displayProgress_ = function(current, duration) { | |
163 var ratio = current / duration; | |
164 this.progressSlider_.setValue(ratio); | |
165 this.currentTime_.textContent = MediaControls.formatTime_(current); | |
166 }; | |
167 | |
168 MediaControls.prototype.onProgressChange_ = function(value) { | |
169 if (!this.media_.seekable || !this.media_.duration) { | |
170 console.error("Inconsistent media state"); | |
171 return; | |
172 } | |
173 | |
174 var current = this.media_.duration * value; | |
175 this.media_.currentTime = current; | |
176 this.currentTime_.textContent = MediaControls.formatTime_(current); | |
177 }; | |
178 | |
179 MediaControls.prototype.onProgressDrag_ = function(on) { | |
180 if (on) { | |
181 this.resumeAfterDrag_ = this.isPlaying(); | |
182 this.media_.pause(); | |
183 } else { | |
184 if (this.resumeAfterDrag_) { | |
185 if (this.media_.ended) | |
186 this.onMediaPlay_(false); | |
187 else | |
188 this.media_.play(); | |
189 } | |
190 } | |
191 }; | |
192 | |
193 /* | |
194 * Volume controls | |
195 */ | |
196 | |
197 MediaControls.prototype.initVolumeControls = function(opt_parent) { | |
198 var volumeControls = this.createControl('volume-controls', opt_parent); | |
199 | |
200 this.soundButton_ = this.createButton('sound media-control', | |
201 this.onSoundButtonClick_.bind(this), volumeControls); | |
202 this.soundButton_.setAttribute('level', 3); // max level. | |
203 | |
204 this.volume_ = new MediaControls.AnimatedSlider( | |
205 this.createControl('volume media-control', volumeControls), | |
206 1, /* value */ | |
207 100 /* range */, | |
208 this.onVolumeChange_.bind(this), | |
209 this.onVolumeDrag_.bind(this)); | |
210 }; | |
211 | |
212 MediaControls.prototype.onSoundButtonClick_ = function() { | |
213 if (this.media_.volume == 0) { | |
214 this.volume_.setValue(this.savedVolume_ || 1); | |
215 } else { | |
216 this.savedVolume_ = this.media_.volume; | |
217 this.volume_.setValue(0); | |
218 } | |
219 this.onVolumeChange_(this.volume_.getValue()); | |
220 }; | |
221 | |
222 MediaControls.getVolumeLevel_ = function(value) { | |
223 if (value == 0) return 0; | |
224 if (value <= 1/3) return 1; | |
225 if (value <= 2/3) return 2; | |
226 return 3; | |
227 }; | |
228 | |
229 MediaControls.prototype.onVolumeChange_ = function(value) { | |
230 this.media_.volume = value; | |
231 this.soundButton_.setAttribute('level', MediaControls.getVolumeLevel_(value)); | |
232 }; | |
233 | |
234 MediaControls.prototype.onVolumeDrag_ = function(on) { | |
235 if (on && (this.media_.volume != 0)) { | |
236 this.savedVolume_ = this.media_.volume; | |
237 } | |
238 }; | |
239 | |
240 /* | |
241 * Media event handlers. | |
242 */ | |
243 | |
244 /** | |
245 * Attach a media element. | |
246 * | |
247 * @param {HTMLMediaElement} mediaElement The media element to control. | |
248 */ | |
249 MediaControls.prototype.attachMedia = function(mediaElement) { | |
250 this.media_ = mediaElement; | |
251 | |
252 this.media_.addEventListener('play', this.onMediaPlayBound_); | |
253 this.media_.addEventListener('pause', this.onMediaPauseBound_); | |
254 this.media_.addEventListener('durationchange', this.onMediaDurationBound_); | |
255 this.media_.addEventListener('timeupdate', this.onMediaProgressBound_); | |
256 this.media_.addEventListener('error', this.onMediaError_); | |
257 | |
258 // Reset the UI. | |
259 this.enableControls_('.media-control', false); | |
260 this.playButton_.setAttribute('state', 0); | |
261 this.displayProgress_(0, 1); | |
262 if (this.volume_) { | |
263 /* Copy the user selected volume to the new media element. */ | |
264 this.media_.volume = this.volume_.getValue(); | |
265 } | |
266 }; | |
267 | |
268 /** | |
269 * Detach media event handlers. | |
270 */ | |
271 MediaControls.prototype.detachMedia = function() { | |
272 if (!this.media_) | |
273 return; | |
274 | |
275 this.media_.removeEventListener('play', this.onMediaPlayBound_); | |
276 this.media_.removeEventListener('pause', this.onMediaPauseBound_); | |
277 this.media_.removeEventListener('durationchange', this.onMediaDurationBound_); | |
278 this.media_.removeEventListener('timeupdate', this.onMediaProgressBound_); | |
279 this.media_.removeEventListener('error', this.onMediaError_); | |
280 | |
281 this.media_ = null; | |
282 }; | |
283 | |
284 MediaControls.prototype.onMediaPlay_ = function(playing) { | |
285 if (this.progressSlider_.isDragging()) | |
286 return; | |
287 | |
288 this.playButton_.setAttribute('state', playing ? '1' : '0'); | |
289 }; | |
290 | |
291 MediaControls.prototype.onMediaDuration_ = function() { | |
292 if (!this.media_.duration) | |
293 return; | |
294 | |
295 this.enableControls_('.media-control', true); | |
296 | |
297 var sliderContainer = this.progressSlider_.getContainer(); | |
298 if (this.media_.seekable) | |
299 sliderContainer.classList.remove('readonly'); | |
300 else | |
301 sliderContainer.classList.add('readonly'); | |
302 | |
303 var valueToString = function(value) { | |
304 return MediaControls.formatTime_(this.media_.duration * value); | |
305 }.bind(this); | |
306 | |
307 this.duration_.textContent = valueToString(1); | |
308 | |
309 if (this.progressSlider_.setValueToStringFunction) | |
310 this.progressSlider_.setValueToStringFunction(valueToString); | |
311 }; | |
312 | |
313 MediaControls.prototype.onMediaProgress_ = function(e) { | |
314 if (!this.media_.duration) | |
315 return; | |
316 | |
317 var current = this.media_.currentTime; | |
318 var duration = this.media_.duration; | |
319 | |
320 if (this.progressSlider_.isDragging()) | |
321 return; | |
322 | |
323 this.displayProgress_(current, duration); | |
324 | |
325 if (current == duration) { | |
326 this.onMediaComplete(); | |
327 } | |
328 }; | |
329 | |
330 MediaControls.prototype.onMediaComplete = function() {}; | |
331 | |
332 /** | |
333 * Create a customized slider control. | |
334 * | |
335 * @param {HTMLElement} container The containing div element. | |
336 * @param {number} value Initial value [0..1]. | |
337 * @param {number} range Number of distinct slider positions to be supported. | |
338 * @param {function(number)} onChange | |
339 * @param {function(boolean)} onDrag | |
340 * @constructor | |
341 */ | |
342 | |
343 MediaControls.Slider = function(container, value, range, onChange, onDrag) { | |
344 this.container_ = container; | |
345 this.onChange_ = onChange; | |
346 this.onDrag_ = onDrag; | |
347 | |
348 var document = this.container_.ownerDocument; | |
349 | |
350 this.container_.classList.add('custom-slider'); | |
351 | |
352 this.input_ = document.createElement('input'); | |
353 this.input_.type = 'range'; | |
354 this.input_.min = 0; | |
355 this.input_.max = range; | |
356 this.input_.value = value * range; | |
357 this.container_.appendChild(this.input_); | |
358 | |
359 this.input_.addEventListener( | |
360 'change', this.onInputChange_.bind(this)); | |
361 this.input_.addEventListener( | |
362 'mousedown', this.onInputDrag_.bind(this, true)); | |
363 this.input_.addEventListener( | |
364 'mouseup', this.onInputDrag_.bind(this, false)); | |
365 | |
366 this.bar_ = document.createElement('div'); | |
367 this.bar_.className = 'bar'; | |
368 this.container_.appendChild(this.bar_); | |
369 | |
370 this.filled_ = document.createElement('div'); | |
371 this.filled_.className = 'filled'; | |
372 this.bar_.appendChild(this.filled_); | |
373 | |
374 var leftCap = document.createElement('div'); | |
375 leftCap.className = 'cap left'; | |
376 this.bar_.appendChild(leftCap); | |
377 | |
378 var rightCap = document.createElement('div'); | |
379 rightCap.className = 'cap right'; | |
380 this.bar_.appendChild(rightCap); | |
381 | |
382 this.value_ = value; | |
383 this.setFilled_(value); | |
384 }; | |
385 | |
386 /** | |
387 * @return {HTMLElement} The container element. | |
388 */ | |
389 MediaControls.Slider.prototype.getContainer = function() { | |
390 return this.container_; | |
391 }; | |
392 | |
393 /** | |
394 * @return {HTMLElement} The standard input element. | |
395 */ | |
396 MediaControls.Slider.prototype.getInput_ = function() { | |
397 return this.input_; | |
398 }; | |
399 | |
400 /** | |
401 * @return {HTMLElement} The slider bar element. | |
402 */ | |
403 MediaControls.Slider.prototype.getBar = function() { | |
404 return this.bar_; | |
405 }; | |
406 | |
407 /** | |
408 * @return {number} [0..1] The current value. | |
409 */ | |
410 MediaControls.Slider.prototype.getValue = function() { | |
411 return this.value_; | |
412 }; | |
413 | |
414 /** | |
415 * @param {number} value [0..1] | |
416 */ | |
417 MediaControls.Slider.prototype.setValue = function(value) { | |
418 this.value_ = value; | |
419 this.setValueToUI_(value); | |
420 }; | |
421 | |
422 /** | |
423 * Fill the given proportion the slider bar (from the left). | |
424 * | |
425 * @param {number} proportion [0..1] | |
426 */ | |
427 MediaControls.Slider.prototype.setFilled_ = function(proportion) { | |
428 this.filled_.style.width = proportion * 100 + '%'; | |
429 }; | |
430 | |
431 /** | |
432 * Get the value from the input element. | |
433 * | |
434 * @param {number} proportion [0..1] | |
435 */ | |
436 MediaControls.Slider.prototype.getValueFromUI_ = function() { | |
437 return this.input_.value / this.input_.max; | |
438 }; | |
439 | |
440 /** | |
441 * Update the UI with the current value. | |
442 * | |
443 * @param {number} value [0..1] | |
444 */ | |
445 MediaControls.Slider.prototype.setValueToUI_ = function(value) { | |
446 this.input_.value = value * this.input_.max; | |
447 this.setFilled_(value); | |
448 }; | |
449 | |
450 /** | |
451 * Compute the proportion in which the given position divides the slider bar. | |
452 * | |
453 * @param {number} position in pixels. | |
454 * @return {number} [0..1] proportion. | |
455 */ | |
456 MediaControls.Slider.prototype.getProportion = function(position) { | |
457 var rect = this.bar_.getBoundingClientRect(); | |
458 return Math.max(0, Math.min(1, (position - rect.left) / rect.width)); | |
459 }; | |
460 | |
461 MediaControls.Slider.prototype.onInputChange_ = function() { | |
462 this.value_ = this.getValueFromUI_(); | |
463 this.setFilled_(this.value_); | |
464 this.onChange_(this.value_); | |
465 }; | |
466 | |
467 MediaControls.Slider.prototype.isDragging = function() { | |
468 return this.isDragging_; | |
469 }; | |
470 | |
471 MediaControls.Slider.prototype.onInputDrag_ = function(on, event) { | |
472 this.isDragging_ = on; | |
473 this.onDrag_(on); | |
474 }; | |
475 | |
476 /** | |
477 * Create a customized slider with animated thumb movement. | |
478 * | |
479 * @param {HTMLElement} container The containing div element. | |
480 * @param {number} value Initial value [0..1]. | |
481 * @param {number} range Number of distinct slider positions to be supported. | |
482 * @param {function(number)} onChange | |
483 * @param {function(boolean)} onDrag | |
484 */ | |
485 MediaControls.AnimatedSlider = function( | |
486 container, value, range, onChange, onDrag, formatFunction) { | |
487 MediaControls.Slider.apply(this, arguments); | |
488 }; | |
489 | |
490 MediaControls.AnimatedSlider.prototype = { | |
491 __proto__: MediaControls.Slider.prototype | |
492 }; | |
493 | |
494 MediaControls.AnimatedSlider.STEPS = 10; | |
495 MediaControls.AnimatedSlider.DURATION = 100; | |
496 | |
497 MediaControls.AnimatedSlider.prototype.setValueToUI_ = function(value) { | |
498 if (this.animationInterval_) { | |
499 clearInterval(this.animationInterval_); | |
500 } | |
501 var oldValue = this.getValueFromUI_(); | |
502 var step = 0; | |
503 this.animationInterval_ = setInterval(function() { | |
504 step++; | |
505 var currentValue = oldValue + | |
506 (value - oldValue) * (step / MediaControls.AnimatedSlider.STEPS); | |
507 MediaControls.Slider.prototype.setValueToUI_.call(this, currentValue); | |
508 if (step == MediaControls.AnimatedSlider.STEPS) { | |
509 clearInterval(this.animationInterval_); | |
510 } | |
511 }.bind(this), | |
512 MediaControls.AnimatedSlider.DURATION / MediaControls.AnimatedSlider.STEPS); | |
513 }; | |
514 | |
515 /** | |
516 * Create a customized slider with a precise time feedback. | |
517 * | |
518 * The time value is shown above the slider bar at the mouse position. | |
519 * | |
520 * @param {HTMLElement} container The containing div element. | |
521 * @param {number} value Initial value [0..1]. | |
522 * @param {number} range Number of distinct slider positions to be supported. | |
523 * @param {function(number)} onChange | |
524 * @param {function(boolean)} onDrag | |
525 */ | |
526 MediaControls.PreciseSlider = function( | |
527 container, value, range, onChange, onDrag, formatFunction) { | |
528 MediaControls.Slider.apply(this, arguments); | |
529 | |
530 var doc = this.container_.ownerDocument; | |
531 | |
532 /** | |
533 * @type {function(number):string} | |
534 */ | |
535 this.valueToString_ = null; | |
536 | |
537 this.seekMark_ = doc.createElement('div'); | |
538 this.seekMark_.className = 'seek-mark'; | |
539 this.getBar().appendChild(this.seekMark_); | |
540 | |
541 this.seekLabel_ = doc.createElement('div'); | |
542 this.seekLabel_.className = 'seek-label'; | |
543 this.seekMark_.appendChild(this.seekLabel_); | |
544 | |
545 this.getContainer().addEventListener( | |
546 'mousemove', this.onMouseMove_.bind(this)); | |
547 this.getContainer().addEventListener( | |
548 'mouseout', this.onMouseOut_.bind(this)); | |
549 }; | |
550 | |
551 MediaControls.PreciseSlider.prototype = { | |
552 __proto__: MediaControls.Slider.prototype | |
553 }; | |
554 | |
555 MediaControls.PreciseSlider.SHOW_DELAY = 200; | |
556 MediaControls.PreciseSlider.HIDE_AFTER_MOVE_DELAY = 2500; | |
557 MediaControls.PreciseSlider.HIDE_AFTER_DRAG_DELAY = 750; | |
558 MediaControls.PreciseSlider.NO_AUTO_HIDE = 0; | |
559 | |
560 MediaControls.PreciseSlider.prototype.setValueToStringFunction = | |
561 function(func) { | |
562 this.valueToString_ = func; | |
563 | |
564 /* It is not completely accurate to assume that the max value corresponds | |
565 to the longest string, but generous CSS padding will compensate for that. */ | |
566 var labelWidth = this.valueToString_(1).length / 2 + 1; | |
567 this.seekLabel_.style.width = labelWidth + 'em'; | |
568 this.seekLabel_.style.marginLeft = -labelWidth/2 + 'em'; | |
569 }; | |
570 | |
571 /** | |
572 * Show the time above the slider. | |
573 * | |
574 * @param {number} ratio [0..1] The proportion of the duration. | |
575 * @param {number} timeout Timeout in ms after which the label should be hidden. | |
576 * MediaControls.PreciseSlider.NO_AUTO_HIDE means show until the next call. | |
577 */ | |
578 MediaControls.PreciseSlider.prototype.showSeekMark_ = | |
579 function(ratio, timeout) { | |
580 // Do not update the seek mark for the first 500ms after the drag is finished. | |
581 if (this.latestMouseUpTime_ && (this.latestMouseUpTime_ + 500 > Date.now())) | |
582 return; | |
583 | |
584 this.seekMark_.style.left = ratio * 100 + '%'; | |
585 | |
586 if (ratio < this.getValue()) { | |
587 this.seekMark_.classList.remove('inverted'); | |
588 } else { | |
589 this.seekMark_.classList.add('inverted'); | |
590 } | |
591 this.seekLabel_.textContent = this.valueToString_(ratio); | |
592 | |
593 this.seekMark_.classList.add('visible'); | |
594 | |
595 if (this.seekMarkTimer_) { | |
596 clearTimeout(this.seekMarkTimer_); | |
597 this.seekMarkTimer_ = null; | |
598 } | |
599 if (timeout != MediaControls.PreciseSlider.NO_AUTO_HIDE) { | |
600 this.seekMarkTimer_ = setTimeout(this.hideSeekMark_.bind(this), timeout); | |
601 } | |
602 }; | |
603 | |
604 MediaControls.PreciseSlider.prototype.hideSeekMark_ = function() { | |
605 this.seekMarkTimer_ = null; | |
606 this.seekMark_.classList.remove('visible'); | |
607 }; | |
608 | |
609 MediaControls.PreciseSlider.prototype.onMouseMove_ = function(event) { | |
610 this.latestSeekRatio_ = this.getProportion(event.clientX); | |
611 | |
612 var self = this; | |
613 function showMark() { | |
614 if (!self.isDragging()) { | |
615 self.showSeekMark_(self.latestSeekRatio_, | |
616 MediaControls.PreciseSlider.HIDE_AFTER_MOVE_DELAY); | |
617 } | |
618 } | |
619 | |
620 if (this.seekMark_.classList.contains('visible')) { | |
621 showMark(); | |
622 } else if (!this.seekMarkTimer_) { | |
623 this.seekMarkTimer_ = | |
624 setTimeout(showMark, MediaControls.PreciseSlider.SHOW_DELAY); | |
625 } | |
626 }; | |
627 | |
628 MediaControls.PreciseSlider.prototype.onMouseOut_ = function(e) { | |
629 for (var element = e.relatedTarget; element; element = element.parentNode) { | |
630 if (element == this.getContainer()) | |
631 return; | |
632 } | |
633 if (this.seekMarkTimer_) { | |
634 clearTimeout(this.seekMarkTimer_); | |
635 this.seekMarkTimer_ = null; | |
636 } | |
637 this.hideSeekMark_(); | |
638 }; | |
639 | |
640 MediaControls.PreciseSlider.prototype.onInputChange_ = function() { | |
641 MediaControls.Slider.prototype.onInputChange_.apply(this, arguments); | |
642 if (this.isDragging()) { | |
643 this.showSeekMark_( | |
644 this.getValue(), MediaControls.PreciseSlider.NO_AUTO_HIDE); | |
645 } | |
646 }; | |
647 | |
648 MediaControls.PreciseSlider.prototype.onInputDrag_ = function(on, event) { | |
649 MediaControls.Slider.prototype.onInputDrag_.apply(this, arguments); | |
650 | |
651 if (on) { | |
652 // Dragging started, align the seek mark with the thumb position. | |
653 this.showSeekMark_( | |
654 this.getValue(), MediaControls.PreciseSlider.NO_AUTO_HIDE); | |
655 } else { | |
656 // Just finished dragging. | |
657 // Show the label for the last time with a shorter timeout. | |
658 this.showSeekMark_( | |
659 this.getValue(), MediaControls.PreciseSlider.HIDE_AFTER_DRAG_DELAY); | |
660 this.latestMouseUpTime_ = Date.now(); | |
661 } | |
662 }; | |
663 | |
664 /** | |
665 * Create video controls. | |
666 * | |
667 * @param {HTMLElement} containerElement The container for the controls. | |
668 * @param {function} onMediaError Function to display an error message. | |
669 * @param {function} opt_fullScreenToggle Function to toggle fullscreen mode. | |
670 * @param {HTMLElement} opt_stateIconParent The parent for the icon that | |
671 * gives visual feedback when the playback state changes. | |
672 * @constructor | |
673 */ | |
674 function VideoControls(containerElement, onMediaError, | |
675 opt_fullScreenToggle, opt_stateIconParent) { | |
676 MediaControls.call(this, containerElement, onMediaError); | |
677 | |
678 this.container_.classList.add('video-controls'); | |
679 | |
680 this.initPlayButton(); | |
681 | |
682 this.initTimeControls(true /* show seek mark */); | |
683 | |
684 this.initVolumeControls(); | |
685 | |
686 if (opt_fullScreenToggle) { | |
687 this.fullscreenButton_ = | |
688 this.createButton('fullscreen', opt_fullScreenToggle); | |
689 } | |
690 | |
691 if (opt_stateIconParent) { | |
692 this.stateIcon_ = this.createControl( | |
693 'playback-state-icon', opt_stateIconParent); | |
694 } | |
695 | |
696 this.resumePositions_ = new TimeLimitedMap( | |
697 'VideoResumePosition', | |
698 VideoControls.RESUME_POSITIONS_CAPACITY, | |
699 VideoControls.RESUME_POSITION_LIFETIME); | |
700 } | |
701 | |
702 VideoControls.RESUME_POSITIONS_CAPACITY = 100; | |
703 VideoControls.RESUME_POSITION_LIFETIME = 30 * 24 * 60 * 60 * 1000; // 30 days. | |
704 VideoControls.RESUME_MARGIN = 0.03; | |
705 VideoControls.RESUME_THRESHOLD = 5 * 60; // No resume for videos < 5 min. | |
706 VideoControls.RESUME_REWIND = 5; // Rewind 5 seconds back when resuming. | |
707 | |
708 VideoControls.prototype = { __proto__: MediaControls.prototype }; | |
709 | |
710 VideoControls.prototype.onMediaComplete = function() { | |
711 this.onMediaPlay_(false); // Just update the UI. | |
712 this.savePosition(); // This will effectively forget the position. | |
713 }; | |
714 | |
715 VideoControls.prototype.togglePlayStateWithFeedback = function(e) { | |
716 if (!this.getMedia().duration) | |
717 return; | |
718 | |
719 this.togglePlayState(); | |
720 | |
721 var self = this; | |
722 | |
723 var delay = function(action, opt_timeout) { | |
724 if (self.statusIconTimer_) { | |
725 clearTimeout(self.statusIconTimer_); | |
726 } | |
727 self.statusIconTimer_ = setTimeout(function() { | |
728 self.statusIconTimer_ = null; | |
729 action(); | |
730 }, opt_timeout || 0); | |
731 }; | |
732 | |
733 function hideStatusIcon() { | |
734 self.stateIcon_.removeAttribute('visible'); | |
735 self.stateIcon_.removeAttribute('state'); | |
736 } | |
737 | |
738 hideStatusIcon(); | |
739 | |
740 // The delays are required to trigger the layout between attribute changes. | |
741 // Otherwise everything just goes to the final state without the animation. | |
742 delay(function() { | |
743 self.stateIcon_.setAttribute('visible', true); | |
744 delay(function(){ | |
745 self.stateIcon_.setAttribute( | |
746 'state', self.isPlaying() ? 'play' : 'pause'); | |
747 delay(hideStatusIcon, 1000); /* Twice the animation duration. */ | |
748 }); | |
749 }); | |
750 }; | |
751 | |
752 VideoControls.prototype.onMediaDuration_ = function() { | |
753 MediaControls.prototype.onMediaDuration_.apply(this, arguments); | |
754 if (this.media_.duration && | |
755 this.media_.duration >= VideoControls.RESUME_THRESHOLD && | |
756 this.media_.seekable) { | |
757 var position = this.resumePositions_.getValue(this.media_.src); | |
758 if (position) { | |
759 this.media_.currentTime = position; | |
760 } | |
761 } | |
762 }; | |
763 | |
764 VideoControls.prototype.togglePlayState = function(e) { | |
765 if (this.isPlaying()) { | |
766 // User gave the Pause command. | |
767 this.savePosition(); | |
768 } | |
769 MediaControls.prototype.togglePlayState.apply(this, arguments); | |
770 }; | |
771 | |
772 VideoControls.prototype.savePosition = function() { | |
773 if (!this.media_.duration || | |
774 this.media_.duration_ < VideoControls.RESUME_THRESHOLD) | |
775 return; | |
776 | |
777 var ratio = this.media_.currentTime / this.media_.duration; | |
778 if (ratio < VideoControls.RESUME_MARGIN || | |
779 ratio > (1 - VideoControls.RESUME_MARGIN)) { | |
780 // We are too close to the beginning or the end. | |
781 // Remove the resume position so that next time we start from the beginning. | |
782 this.resumePositions_.removeValue(this.media_.src); | |
783 } else { | |
784 this.resumePositions_.setValue(this.media_.src, Math.floor(Math.max(0, | |
785 this.media_.currentTime - VideoControls.RESUME_REWIND))); | |
786 } | |
787 }; | |
788 | |
789 /** | |
790 * TimeLimitedMap is persistent timestamped key-value storage backed by | |
791 * HTML5 local storage. | |
792 * | |
793 * It is not designed for frequent access. In order to avoid costly | |
794 * localStorage iteration all data is kept in a single localStorage item. | |
795 * There is no in-memory caching, so concurrent access is OK. | |
796 * | |
797 * @param {string} localStorageKey A key in the local storage. | |
798 * @param {number} capacity Maximim number of items. If exceeded, oldest items | |
799 * are removed. | |
800 * @param {number} lifetime Maximim time to keep an item (in milliseconds). | |
801 */ | |
802 function TimeLimitedMap(localStorageKey, capacity, lifetime) { | |
803 this.localStorageKey_ = localStorageKey; | |
804 this.capacity_ = capacity; | |
805 this.lifetime_ = lifetime; | |
806 } | |
807 | |
808 /** | |
809 * @param {string} key | |
810 * @return {string} value | |
811 */ | |
812 TimeLimitedMap.prototype.getValue = function(key) { | |
813 var map = this.read_(); | |
814 var entry = map[key]; | |
815 return entry && entry.value; | |
816 }; | |
817 | |
818 /** | |
819 * @param {string} key | |
820 * @param {string} value | |
821 */ | |
822 TimeLimitedMap.prototype.setValue = function(key, value) { | |
823 var map = this.read_(); | |
824 map[key] = { value: value, timestamp: Date.now() }; | |
825 this.cleanup_(map); | |
826 this.write_(map); | |
827 }; | |
828 | |
829 /** | |
830 * @param {string} key | |
831 */ | |
832 TimeLimitedMap.prototype.removeValue = function(key) { | |
833 var map = this.read_(); | |
834 if (!(key in map)) | |
835 return; // Nothing to do. | |
836 | |
837 delete map[key]; | |
838 this.cleanup_(map); | |
839 this.write_(map); | |
840 }; | |
841 | |
842 /** | |
843 * @return {Object} A map of timestamped key-value pairs. | |
844 */ | |
845 TimeLimitedMap.prototype.read_ = function() { | |
846 var json = localStorage[this.localStorageKey_]; | |
847 if (json) { | |
848 try { | |
849 return JSON.parse(json); | |
850 } catch(e) { | |
851 // The localStorage item somehow got messed up, start fresh. | |
852 } | |
853 } | |
854 return {}; | |
855 }; | |
856 | |
857 /** | |
858 * @param {Object} map A map of timestamped key-value pairs. | |
859 */ | |
860 TimeLimitedMap.prototype.write_ = function(map) { | |
861 localStorage[this.localStorageKey_] = JSON.stringify(map); | |
862 }; | |
863 | |
864 /** | |
865 * Remove over-capacity and obsolete items. | |
866 * | |
867 * @param {Object} map A map of timestamped key-value pairs. | |
868 */ | |
869 TimeLimitedMap.prototype.cleanup_ = function(map) { | |
870 // Sort keys by ascending timestamps. | |
871 var keys = []; | |
872 for (var key in map) { | |
873 keys.push(key); | |
874 } | |
875 keys.sort(function(a, b) { return map[a].timestamp > map[b].timestamp }); | |
876 | |
877 var cutoff = Date.now() - this.lifetime_; | |
878 | |
879 var obsolete = 0; | |
880 while (obsolete < keys.length && | |
881 map[keys[obsolete]].timestamp < cutoff) { | |
882 obsolete++; | |
883 } | |
884 | |
885 var overCapacity = Math.max(0, keys.length - this.capacity_); | |
886 | |
887 var itemsToDelete = Math.max(obsolete, overCapacity); | |
888 for (var i = 0; i != itemsToDelete; i++) { | |
889 delete map[keys[i]]; | |
890 } | |
891 }; | |
892 | |
893 | |
894 /** | |
895 * Create audio controls. | |
896 * | |
897 * @param {HTMLElement} container | |
898 * @param {function(boolean)} advanceTrack Parameter: true=forward. | |
899 * @constructor | |
900 */ | |
901 function AudioControls(container, advanceTrack) { | |
902 MediaControls.call(this, container, null /* onError */); | |
903 | |
904 this.container_.classList.add('audio-controls'); | |
905 | |
906 this.advanceTrack_ = advanceTrack; | |
907 | |
908 this.initPlayButton(); | |
909 this.initTimeControls(false /* no seek mark */); | |
910 /* No volume controls */ | |
911 this.createButton('previous', this.onAdvanceClick_.bind(this, false)); | |
912 this.createButton('next', this.onAdvanceClick_.bind(this, true)); | |
913 } | |
914 | |
915 AudioControls.prototype = { __proto__: MediaControls.prototype }; | |
916 | |
917 AudioControls.prototype.onMediaComplete = function() { | |
918 this.advanceTrack_(true); | |
919 }; | |
920 | |
921 AudioControls.TRACK_RESTART_THRESHOLD = 5; // seconds. | |
922 | |
923 AudioControls.prototype.onAdvanceClick_ = function(forward) { | |
924 if (!forward && | |
925 (this.getMedia().currentTime > AudioControls.TRACK_RESTART_THRESHOLD)) { | |
926 // We are far enough from the beginning of the current track. | |
927 // Restart it instead of than skipping to the previous one. | |
928 this.getMedia().currentTime = 0; | |
929 } else { | |
930 this.advanceTrack_(forward); | |
931 } | |
932 }; | |
OLD | NEW |