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