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