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 29/** 30 * Button's state types. Values are used as CSS class names. 31 * @enum {string} 32 */ 33MediaControls.ButtonStateType = { 34 DEFAULT: 'default', 35 PLAYING: 'playing', 36 ENDED: 'ended' 37}; 38 39/** 40 * @return {HTMLAudioElement|HTMLVideoElement} The media element. 41 */ 42MediaControls.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 */ 51MediaControls.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 */ 71MediaControls.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 */ 88MediaControls.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 */ 117MediaControls.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 */ 135MediaControls.prototype.play = function() { 136 this.media_.play(); 137}; 138 139/** 140 * Pause the media. 141 */ 142MediaControls.prototype.pause = function() { 143 this.media_.pause(); 144}; 145 146/** 147 * @return {boolean} True if the media is currently playing. 148 */ 149MediaControls.prototype.isPlaying = function() { 150 return !this.media_.paused && !this.media_.ended; 151}; 152 153/** 154 * Toggle play/pause. 155 */ 156MediaControls.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 */ 169MediaControls.prototype.onPlayButtonClicked = function(opt_event) { 170 this.togglePlayState(); 171}; 172 173/** 174 * @param {HTMLElement=} opt_parent Parent container. 175 */ 176MediaControls.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 */ 188MediaControls.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 */ 195MediaControls.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 */ 222MediaControls.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 */ 232MediaControls.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 */ 247MediaControls.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 */ 269MediaControls.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 */ 288MediaControls.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 */ 303MediaControls.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 */ 314MediaControls.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 */ 323MediaControls.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 */ 338MediaControls.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 */ 360MediaControls.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 */ 378MediaControls.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 */ 389MediaControls.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 */ 401MediaControls.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 */ 432MediaControls.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 */ 455MediaControls.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 */ 461MediaControls.prototype.onPlayStateChanged = function() {}; 462 463/** 464 * Updates the play button state. 465 * @param {boolean} playing If the video is playing. 466 * @private 467 */ 468MediaControls.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 */ 484MediaControls.prototype.restorePlayState = function() {}; 485 486/** 487 * Encode current state into the page URL or the app state. 488 */ 489MediaControls.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 } 497 return; 498}; 499 500/** 501 * Decode current state from the page URL or the app state. 502 * @return {boolean} True if decode succeeded. 503 */ 504MediaControls.prototype.decodeState = function() { 505 if (!window.appState || !('time' in window.appState)) 506 return false; 507 // There is no page reload for apps v2, only app restart. 508 // Always restart in paused state. 509 this.media_.currentTime = window.appState.time; 510 this.pause(); 511 return true; 512}; 513 514/** 515 * Remove current state from the page URL or the app state. 516 */ 517MediaControls.prototype.clearState = function() { 518 if (!window.appState) 519 return; 520 521 if ('time' in window.appState) 522 delete window.appState.time; 523 util.saveAppState(); 524 return; 525}; 526 527/** 528 * Create a customized slider control. 529 * 530 * @param {HTMLElement} container The containing div element. 531 * @param {number} value Initial value [0..1]. 532 * @param {number} range Number of distinct slider positions to be supported. 533 * @param {function(number)} onChange Value change handler. 534 * @param {function(boolean)} onDrag Drag begin/end handler. 535 * @constructor 536 */ 537 538MediaControls.Slider = function(container, value, range, onChange, onDrag) { 539 this.container_ = container; 540 this.onChange_ = onChange; 541 this.onDrag_ = onDrag; 542 543 var document = this.container_.ownerDocument; 544 545 this.container_.classList.add('custom-slider'); 546 547 this.input_ = document.createElement('input'); 548 this.input_.type = 'range'; 549 this.input_.min = 0; 550 this.input_.max = range; 551 this.input_.value = value * range; 552 this.container_.appendChild(this.input_); 553 554 this.input_.addEventListener( 555 'change', this.onInputChange_.bind(this)); 556 this.input_.addEventListener( 557 'mousedown', this.onInputDrag_.bind(this, true)); 558 this.input_.addEventListener( 559 'mouseup', this.onInputDrag_.bind(this, false)); 560 561 this.bar_ = document.createElement('div'); 562 this.bar_.className = 'bar'; 563 this.container_.appendChild(this.bar_); 564 565 this.filled_ = document.createElement('div'); 566 this.filled_.className = 'filled'; 567 this.bar_.appendChild(this.filled_); 568 569 var leftCap = document.createElement('div'); 570 leftCap.className = 'cap left'; 571 this.bar_.appendChild(leftCap); 572 573 var rightCap = document.createElement('div'); 574 rightCap.className = 'cap right'; 575 this.bar_.appendChild(rightCap); 576 577 this.value_ = value; 578 this.setFilled_(value); 579}; 580 581/** 582 * @return {HTMLElement} The container element. 583 */ 584MediaControls.Slider.prototype.getContainer = function() { 585 return this.container_; 586}; 587 588/** 589 * @return {HTMLElement} The standard input element. 590 * @private 591 */ 592MediaControls.Slider.prototype.getInput_ = function() { 593 return this.input_; 594}; 595 596/** 597 * @return {HTMLElement} The slider bar element. 598 */ 599MediaControls.Slider.prototype.getBar = function() { 600 return this.bar_; 601}; 602 603/** 604 * @return {number} [0..1] The current value. 605 */ 606MediaControls.Slider.prototype.getValue = function() { 607 return this.value_; 608}; 609 610/** 611 * @param {number} value [0..1]. 612 */ 613MediaControls.Slider.prototype.setValue = function(value) { 614 this.value_ = value; 615 this.setValueToUI_(value); 616}; 617 618/** 619 * Fill the given proportion the slider bar (from the left). 620 * 621 * @param {number} proportion [0..1]. 622 * @private 623 */ 624MediaControls.Slider.prototype.setFilled_ = function(proportion) { 625 this.filled_.style.width = proportion * 100 + '%'; 626}; 627 628/** 629 * Get the value from the input element. 630 * 631 * @return {number} Value [0..1]. 632 * @private 633 */ 634MediaControls.Slider.prototype.getValueFromUI_ = function() { 635 return this.input_.value / this.input_.max; 636}; 637 638/** 639 * Update the UI with the current value. 640 * 641 * @param {number} value [0..1]. 642 * @private 643 */ 644MediaControls.Slider.prototype.setValueToUI_ = function(value) { 645 this.input_.value = value * this.input_.max; 646 this.setFilled_(value); 647}; 648 649/** 650 * Compute the proportion in which the given position divides the slider bar. 651 * 652 * @param {number} position in pixels. 653 * @return {number} [0..1] proportion. 654 */ 655MediaControls.Slider.prototype.getProportion = function(position) { 656 var rect = this.bar_.getBoundingClientRect(); 657 return Math.max(0, Math.min(1, (position - rect.left) / rect.width)); 658}; 659 660/** 661 * 'change' event handler. 662 * @private 663 */ 664MediaControls.Slider.prototype.onInputChange_ = function() { 665 this.value_ = this.getValueFromUI_(); 666 this.setFilled_(this.value_); 667 this.onChange_(this.value_); 668}; 669 670/** 671 * @return {boolean} True if dragging is in progress. 672 */ 673MediaControls.Slider.prototype.isDragging = function() { 674 return this.isDragging_; 675}; 676 677/** 678 * Mousedown/mouseup handler. 679 * @param {boolean} on True if the mouse is down. 680 * @private 681 */ 682MediaControls.Slider.prototype.onInputDrag_ = function(on) { 683 this.isDragging_ = on; 684 this.onDrag_(on); 685}; 686 687/** 688 * Create a customized slider with animated thumb movement. 689 * 690 * @param {HTMLElement} container The containing div element. 691 * @param {number} value Initial value [0..1]. 692 * @param {number} range Number of distinct slider positions to be supported. 693 * @param {function(number)} onChange Value change handler. 694 * @param {function(boolean)} onDrag Drag begin/end handler. 695 * @param {function(number):string} formatFunction Value formatting function. 696 * @constructor 697 */ 698MediaControls.AnimatedSlider = function( 699 container, value, range, onChange, onDrag, formatFunction) { 700 MediaControls.Slider.apply(this, arguments); 701}; 702 703MediaControls.AnimatedSlider.prototype = { 704 __proto__: MediaControls.Slider.prototype 705}; 706 707/** 708 * Number of animation steps. 709 */ 710MediaControls.AnimatedSlider.STEPS = 10; 711 712/** 713 * Animation duration. 714 */ 715MediaControls.AnimatedSlider.DURATION = 100; 716 717/** 718 * @param {number} value [0..1]. 719 * @private 720 */ 721MediaControls.AnimatedSlider.prototype.setValueToUI_ = function(value) { 722 if (this.animationInterval_) { 723 clearInterval(this.animationInterval_); 724 } 725 var oldValue = this.getValueFromUI_(); 726 var step = 0; 727 this.animationInterval_ = setInterval(function() { 728 step++; 729 var currentValue = oldValue + 730 (value - oldValue) * (step / MediaControls.AnimatedSlider.STEPS); 731 MediaControls.Slider.prototype.setValueToUI_.call(this, currentValue); 732 if (step == MediaControls.AnimatedSlider.STEPS) { 733 clearInterval(this.animationInterval_); 734 } 735 }.bind(this), 736 MediaControls.AnimatedSlider.DURATION / MediaControls.AnimatedSlider.STEPS); 737}; 738 739/** 740 * Create a customized slider with a precise time feedback. 741 * 742 * The time value is shown above the slider bar at the mouse position. 743 * 744 * @param {HTMLElement} container The containing div element. 745 * @param {number} value Initial value [0..1]. 746 * @param {number} range Number of distinct slider positions to be supported. 747 * @param {function(number)} onChange Value change handler. 748 * @param {function(boolean)} onDrag Drag begin/end handler. 749 * @param {function(number):string} formatFunction Value formatting function. 750 * @constructor 751 */ 752MediaControls.PreciseSlider = function( 753 container, value, range, onChange, onDrag, formatFunction) { 754 MediaControls.Slider.apply(this, arguments); 755 756 var doc = this.container_.ownerDocument; 757 758 /** 759 * @type {function(number):string} 760 * @private 761 */ 762 this.valueToString_ = null; 763 764 this.seekMark_ = doc.createElement('div'); 765 this.seekMark_.className = 'seek-mark'; 766 this.getBar().appendChild(this.seekMark_); 767 768 this.seekLabel_ = doc.createElement('div'); 769 this.seekLabel_.className = 'seek-label'; 770 this.seekMark_.appendChild(this.seekLabel_); 771 772 this.getContainer().addEventListener( 773 'mousemove', this.onMouseMove_.bind(this)); 774 this.getContainer().addEventListener( 775 'mouseout', this.onMouseOut_.bind(this)); 776}; 777 778MediaControls.PreciseSlider.prototype = { 779 __proto__: MediaControls.Slider.prototype 780}; 781 782/** 783 * Show the seek mark after a delay. 784 */ 785MediaControls.PreciseSlider.SHOW_DELAY = 200; 786 787/** 788 * Hide the seek mark for this long after changing the position with a click. 789 */ 790MediaControls.PreciseSlider.HIDE_AFTER_MOVE_DELAY = 2500; 791 792/** 793 * Hide the seek mark for this long after changing the position with a drag. 794 */ 795MediaControls.PreciseSlider.HIDE_AFTER_DRAG_DELAY = 750; 796 797/** 798 * Default hide timeout (no hiding). 799 */ 800MediaControls.PreciseSlider.NO_AUTO_HIDE = 0; 801 802/** 803 * @param {function(number):string} func Value formatting function. 804 */ 805MediaControls.PreciseSlider.prototype.setValueToStringFunction = 806 function(func) { 807 this.valueToString_ = func; 808 809 /* It is not completely accurate to assume that the max value corresponds 810 to the longest string, but generous CSS padding will compensate for that. */ 811 var labelWidth = this.valueToString_(1).length / 2 + 1; 812 this.seekLabel_.style.width = labelWidth + 'em'; 813 this.seekLabel_.style.marginLeft = -labelWidth / 2 + 'em'; 814}; 815 816/** 817 * Show the time above the slider. 818 * 819 * @param {number} ratio [0..1] The proportion of the duration. 820 * @param {number} timeout Timeout in ms after which the label should be hidden. 821 * MediaControls.PreciseSlider.NO_AUTO_HIDE means show until the next call. 822 * @private 823 */ 824MediaControls.PreciseSlider.prototype.showSeekMark_ = 825 function(ratio, timeout) { 826 // Do not update the seek mark for the first 500ms after the drag is finished. 827 if (this.latestMouseUpTime_ && (this.latestMouseUpTime_ + 500 > Date.now())) 828 return; 829 830 this.seekMark_.style.left = ratio * 100 + '%'; 831 832 if (ratio < this.getValue()) { 833 this.seekMark_.classList.remove('inverted'); 834 } else { 835 this.seekMark_.classList.add('inverted'); 836 } 837 this.seekLabel_.textContent = this.valueToString_(ratio); 838 839 this.seekMark_.classList.add('visible'); 840 841 if (this.seekMarkTimer_) { 842 clearTimeout(this.seekMarkTimer_); 843 this.seekMarkTimer_ = null; 844 } 845 if (timeout != MediaControls.PreciseSlider.NO_AUTO_HIDE) { 846 this.seekMarkTimer_ = setTimeout(this.hideSeekMark_.bind(this), timeout); 847 } 848}; 849 850/** 851 * @private 852 */ 853MediaControls.PreciseSlider.prototype.hideSeekMark_ = function() { 854 this.seekMarkTimer_ = null; 855 this.seekMark_.classList.remove('visible'); 856}; 857 858/** 859 * 'mouseout' event handler. 860 * @param {Event} e Event. 861 * @private 862 */ 863MediaControls.PreciseSlider.prototype.onMouseMove_ = function(e) { 864 this.latestSeekRatio_ = this.getProportion(e.clientX); 865 866 var self = this; 867 function showMark() { 868 if (!self.isDragging()) { 869 self.showSeekMark_(self.latestSeekRatio_, 870 MediaControls.PreciseSlider.HIDE_AFTER_MOVE_DELAY); 871 } 872 } 873 874 if (this.seekMark_.classList.contains('visible')) { 875 showMark(); 876 } else if (!this.seekMarkTimer_) { 877 this.seekMarkTimer_ = 878 setTimeout(showMark, MediaControls.PreciseSlider.SHOW_DELAY); 879 } 880}; 881 882/** 883 * 'mouseout' event handler. 884 * @param {Event} e Event. 885 * @private 886 */ 887MediaControls.PreciseSlider.prototype.onMouseOut_ = function(e) { 888 for (var element = e.relatedTarget; element; element = element.parentNode) { 889 if (element == this.getContainer()) 890 return; 891 } 892 if (this.seekMarkTimer_) { 893 clearTimeout(this.seekMarkTimer_); 894 this.seekMarkTimer_ = null; 895 } 896 this.hideSeekMark_(); 897}; 898 899/** 900 * 'change' event handler. 901 * @private 902 */ 903MediaControls.PreciseSlider.prototype.onInputChange_ = function() { 904 MediaControls.Slider.prototype.onInputChange_.apply(this, arguments); 905 if (this.isDragging()) { 906 this.showSeekMark_( 907 this.getValue(), MediaControls.PreciseSlider.NO_AUTO_HIDE); 908 } 909}; 910 911/** 912 * Mousedown/mouseup handler. 913 * @param {boolean} on True if the mouse is down. 914 * @private 915 */ 916MediaControls.PreciseSlider.prototype.onInputDrag_ = function(on) { 917 MediaControls.Slider.prototype.onInputDrag_.apply(this, arguments); 918 919 if (on) { 920 // Dragging started, align the seek mark with the thumb position. 921 this.showSeekMark_( 922 this.getValue(), MediaControls.PreciseSlider.NO_AUTO_HIDE); 923 } else { 924 // Just finished dragging. 925 // Show the label for the last time with a shorter timeout. 926 this.showSeekMark_( 927 this.getValue(), MediaControls.PreciseSlider.HIDE_AFTER_DRAG_DELAY); 928 this.latestMouseUpTime_ = Date.now(); 929 } 930}; 931 932/** 933 * Create video controls. 934 * 935 * @param {HTMLElement} containerElement The container for the controls. 936 * @param {function} onMediaError Function to display an error message. 937 * @param {function(string):string} stringFunction Function providing localized 938 * strings. 939 * @param {function=} opt_fullScreenToggle Function to toggle fullscreen mode. 940 * @param {HTMLElement=} opt_stateIconParent The parent for the icon that 941 * gives visual feedback when the playback state changes. 942 * @constructor 943 */ 944function VideoControls(containerElement, onMediaError, stringFunction, 945 opt_fullScreenToggle, opt_stateIconParent) { 946 MediaControls.call(this, containerElement, onMediaError); 947 this.stringFunction_ = stringFunction; 948 949 this.container_.classList.add('video-controls'); 950 this.initPlayButton(); 951 this.initTimeControls(true /* show seek mark */); 952 this.initVolumeControls(); 953 954 if (opt_fullScreenToggle) { 955 this.fullscreenButton_ = 956 this.createButton('fullscreen', opt_fullScreenToggle); 957 } 958 959 if (opt_stateIconParent) { 960 this.stateIcon_ = this.createControl( 961 'playback-state-icon', opt_stateIconParent); 962 this.textBanner_ = this.createControl('text-banner', opt_stateIconParent); 963 } 964 965 var videoControls = this; 966 chrome.mediaPlayerPrivate.onTogglePlayState.addListener( 967 function() { videoControls.togglePlayStateWithFeedback(); }); 968} 969 970/** 971 * No resume if we are within this margin from the start or the end. 972 */ 973VideoControls.RESUME_MARGIN = 0.03; 974 975/** 976 * No resume for videos shorter than this. 977 */ 978VideoControls.RESUME_THRESHOLD = 5 * 60; // 5 min. 979 980/** 981 * When resuming rewind back this much. 982 */ 983VideoControls.RESUME_REWIND = 5; // seconds. 984 985VideoControls.prototype = { __proto__: MediaControls.prototype }; 986 987/** 988 * Shows icon feedback for the current state of the video player. 989 * @private 990 */ 991VideoControls.prototype.showIconFeedback_ = function() { 992 this.stateIcon_.removeAttribute('state'); 993 setTimeout(function() { 994 this.stateIcon_.setAttribute('state', this.isPlaying() ? 'play' : 'pause'); 995 }.bind(this), 0); 996}; 997 998/** 999 * Shows a text banner. 1000 * 1001 * @param {string} identifier String identifier. 1002 * @private 1003 */ 1004VideoControls.prototype.showTextBanner_ = function(identifier) { 1005 this.textBanner_.removeAttribute('visible'); 1006 this.textBanner_.textContent = this.stringFunction_(identifier); 1007 setTimeout(function() { 1008 this.textBanner_.setAttribute('visible', 'true'); 1009 }.bind(this), 0); 1010}; 1011 1012/** 1013 * Toggle play/pause state on a mouse click on the play/pause button. Can be 1014 * called externally. 1015 * 1016 * @param {Event} event Mouse click event. 1017 */ 1018VideoControls.prototype.onPlayButtonClicked = function(event) { 1019 if (event.ctrlKey) { 1020 this.toggleLoopedModeWithFeedback(true); 1021 if (!this.isPlaying()) 1022 this.togglePlayState(); 1023 } else { 1024 this.togglePlayState(); 1025 } 1026}; 1027 1028/** 1029 * Media completion handler. 1030 */ 1031VideoControls.prototype.onMediaComplete = function() { 1032 this.onMediaPlay_(false); // Just update the UI. 1033 this.savePosition(); // This will effectively forget the position. 1034}; 1035 1036/** 1037 * Toggles the looped mode with feedback. 1038 * @param {boolean} on Whether enabled or not. 1039 */ 1040VideoControls.prototype.toggleLoopedModeWithFeedback = function(on) { 1041 if (!this.getMedia().duration) 1042 return; 1043 this.toggleLoopedMode(on); 1044 if (on) { 1045 // TODO(mtomasz): Simplify, crbug.com/254318. 1046 this.showTextBanner_('GALLERY_VIDEO_LOOPED_MODE'); 1047 } 1048}; 1049 1050/** 1051 * Toggles the looped mode. 1052 * @param {boolean} on Whether enabled or not. 1053 */ 1054VideoControls.prototype.toggleLoopedMode = function(on) { 1055 this.getMedia().loop = on; 1056}; 1057 1058/** 1059 * Toggles play/pause state and flash an icon over the video. 1060 */ 1061VideoControls.prototype.togglePlayStateWithFeedback = function() { 1062 if (!this.getMedia().duration) 1063 return; 1064 1065 this.togglePlayState(); 1066 this.showIconFeedback_(); 1067}; 1068 1069/** 1070 * Toggles play/pause state. 1071 */ 1072VideoControls.prototype.togglePlayState = function() { 1073 if (this.isPlaying()) { 1074 // User gave the Pause command. Save the state and reset the loop mode. 1075 this.toggleLoopedMode(false); 1076 this.savePosition(); 1077 } 1078 MediaControls.prototype.togglePlayState.apply(this, arguments); 1079}; 1080 1081/** 1082 * Saves the playback position to the persistent storage. 1083 * @param {boolean=} opt_sync True if the position must be saved synchronously 1084 * (required when closing app windows). 1085 */ 1086VideoControls.prototype.savePosition = function(opt_sync) { 1087 if (!this.media_.duration || 1088 this.media_.duration < VideoControls.RESUME_THRESHOLD) { 1089 return; 1090 } 1091 1092 var ratio = this.media_.currentTime / this.media_.duration; 1093 var position; 1094 if (ratio < VideoControls.RESUME_MARGIN || 1095 ratio > (1 - VideoControls.RESUME_MARGIN)) { 1096 // We are too close to the beginning or the end. 1097 // Remove the resume position so that next time we start from the beginning. 1098 position = null; 1099 } else { 1100 position = Math.floor( 1101 Math.max(0, this.media_.currentTime - VideoControls.RESUME_REWIND)); 1102 } 1103 1104 if (opt_sync) { 1105 // Packaged apps cannot save synchronously. 1106 // Pass the data to the background page. 1107 if (!window.saveOnExit) 1108 window.saveOnExit = []; 1109 window.saveOnExit.push({ key: this.media_.src, value: position }); 1110 } else { 1111 util.AppCache.update(this.media_.src, position); 1112 } 1113}; 1114 1115/** 1116 * Resumes the playback position saved in the persistent storage. 1117 */ 1118VideoControls.prototype.restorePlayState = function() { 1119 if (this.media_.duration >= VideoControls.RESUME_THRESHOLD) { 1120 util.AppCache.getValue(this.media_.src, function(position) { 1121 if (position) 1122 this.media_.currentTime = position; 1123 }.bind(this)); 1124 } 1125}; 1126 1127/** 1128 * Updates style to best fit the size of the container. 1129 */ 1130VideoControls.prototype.updateStyle = function() { 1131 // We assume that the video controls element fills the parent container. 1132 // This is easier than adding margins to this.container_.clientWidth. 1133 var width = this.container_.parentNode.clientWidth; 1134 1135 // Set the margin to 5px for width >= 400, 0px for width < 160, 1136 // interpolate linearly in between. 1137 this.container_.style.margin = 1138 Math.ceil((Math.max(160, Math.min(width, 400)) - 160) / 48) + 'px'; 1139 1140 var hideBelow = function(selector, limit) { 1141 this.container_.querySelector(selector).style.display = 1142 width < limit ? 'none' : '-webkit-box'; 1143 }.bind(this); 1144 1145 hideBelow('.time', 350); 1146 hideBelow('.volume', 275); 1147 hideBelow('.volume-controls', 210); 1148 hideBelow('.fullscreen', 150); 1149}; 1150 1151/** 1152 * Creates audio controls. 1153 * 1154 * @param {HTMLElement} container Parent container. 1155 * @param {function(boolean)} advanceTrack Parameter: true=forward. 1156 * @param {function} onError Error handler. 1157 * @constructor 1158 */ 1159function AudioControls(container, advanceTrack, onError) { 1160 MediaControls.call(this, container, onError); 1161 1162 this.container_.classList.add('audio-controls'); 1163 1164 this.advanceTrack_ = advanceTrack; 1165 1166 this.initPlayButton(); 1167 this.initTimeControls(false /* no seek mark */); 1168 /* No volume controls */ 1169 this.createButton('previous', this.onAdvanceClick_.bind(this, false)); 1170 this.createButton('next', this.onAdvanceClick_.bind(this, true)); 1171 1172 var audioControls = this; 1173 chrome.mediaPlayerPrivate.onNextTrack.addListener( 1174 function() { audioControls.onAdvanceClick_(true); }); 1175 chrome.mediaPlayerPrivate.onPrevTrack.addListener( 1176 function() { audioControls.onAdvanceClick_(false); }); 1177 chrome.mediaPlayerPrivate.onTogglePlayState.addListener( 1178 function() { audioControls.togglePlayState(); }); 1179} 1180 1181AudioControls.prototype = { __proto__: MediaControls.prototype }; 1182 1183/** 1184 * Media completion handler. Advances to the next track. 1185 */ 1186AudioControls.prototype.onMediaComplete = function() { 1187 this.advanceTrack_(true); 1188}; 1189 1190/** 1191 * The track position after which "previous" button acts as "restart". 1192 */ 1193AudioControls.TRACK_RESTART_THRESHOLD = 5; // seconds. 1194 1195/** 1196 * @param {boolean} forward True if advancing forward. 1197 * @private 1198 */ 1199AudioControls.prototype.onAdvanceClick_ = function(forward) { 1200 if (!forward && 1201 (this.getMedia().currentTime > AudioControls.TRACK_RESTART_THRESHOLD)) { 1202 // We are far enough from the beginning of the current track. 1203 // Restart it instead of than skipping to the previous one. 1204 this.getMedia().currentTime = 0; 1205 } else { 1206 this.advanceTrack_(forward); 1207 } 1208}; 1209