diff --git a/build/types/ui b/build/types/ui index 16dce4930..0624651f4 100644 --- a/build/types/ui +++ b/build/types/ui @@ -17,8 +17,10 @@ +../../ui/pip_button.js +../../ui/play_pause_button.js +../../ui/presentation_time.js ++../../ui/range_element.js +../../ui/resolution_selection.js +../../ui/rewind_button.js ++../../ui/seek_bar.js +../../ui/spacer.js +../../ui/text_displayer.js +../../ui/text_selection.js diff --git a/ui/controls.js b/ui/controls.js index 397b054ca..bd626da6d 100644 --- a/ui/controls.js +++ b/ui/controls.js @@ -19,12 +19,12 @@ goog.provide('shaka.ui.Controls'); goog.provide('shaka.ui.ControlsPanel'); -goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.ui.Constants'); goog.require('shaka.ui.Enums'); goog.require('shaka.ui.Locales'); goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.SeekBar'); goog.require('shaka.ui.Utils'); goog.require('shaka.util.Dom'); goog.require('shaka.util.EventManager'); @@ -79,19 +79,14 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { /** @private {!HTMLElement} */ this.videoContainer_ = videoContainer; + /** @private {shaka.ui.SeekBar} */ + this.seekBar_ = null; + /** @private {boolean} */ this.isSeeking_ = false; - /** - * This timer is used to introduce a delay between the user scrubbing across - * the seek bar and the seek being sent to the player. - * - * @private {shaka.util.Timer} - */ - this.seekTimer_ = new shaka.util.Timer(() => { - goog.asserts.assert(this.seekBar_ != null, 'Seekbar should not be null!'); - this.video_.currentTime = parseFloat(this.seekBar_.value); - }); + /** @private {!Array.} */ + this.settingsMenus_ = []; /** * This timer is used to detect when the user has stopped moving the mouse @@ -133,7 +128,10 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { * @private {shaka.util.Timer} */ this.timeAndSeekRangeTimer_ = new shaka.util.Timer(() => { - this.updateTimeAndSeekRange_(); + // Suppress timer-based updates if the controls are hidden. + if (this.isOpaque_()) { + this.updateTimeAndSeekRange_(); + } }); /** @private {?number} */ @@ -170,7 +168,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.timeAndSeekRangeTimer_.tickEvery(/* seconds= */ 0.125); } - /** * @override * @export @@ -181,11 +178,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.eventManager_ = null; } - if (this.seekTimer_) { - this.seekTimer_.stop(); - this.seekTimer_ = null; - } - if (this.mouseStillTimer_) { this.mouseStillTimer_.stop(); this.mouseStillTimer_ = null; @@ -222,6 +214,10 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { await Promise.all(this.elements_.map((element) => element.destroy())); this.elements_ = []; + + if (this.seekBar_) { + await this.seekBar_.destroy(); + } } @@ -318,7 +314,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { shaka.ui.ControlsPanel.elementNamesToFactories_.set(name, factory); } - /** * This allows the application to inhibit casting. * @@ -330,7 +325,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.onCastStatusChange_(); } - /** * Used by the application to notify the controls that a load operation is * complete. This allows the controls to recalculate play/paused state, which @@ -343,7 +337,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.onPlayStateChange_(); } - /** * @param {!shaka.extern.UIConfiguration} config * @export @@ -367,8 +360,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.addEventListeners_(); } - - /** /** * Enable or disable the custom controls. Enabling disables native * browser controls. @@ -400,7 +391,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.onPlayStateChange_(); } - /** * Enable or disable native browser controls. Enabling disables shaka * controls. @@ -421,7 +411,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** * @export * @return {shaka.cast.CastProxy} @@ -430,7 +419,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.castProxy_; } - /** * @return {shaka.ui.Localization} * @export @@ -439,7 +427,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.localization_; } - /** * @return {!HTMLElement} * @export @@ -448,7 +435,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.videoContainer_; } - /** * @return {HTMLMediaElement} * @export @@ -457,7 +443,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.video_; } - /** * @return {HTMLMediaElement} * @export @@ -466,7 +451,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.localVideo_; } - /** * @return {shaka.Player} * @export @@ -475,7 +459,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.player_; } - /** * @return {shaka.Player} * @export @@ -484,7 +467,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.localPlayer_; } - /** * @return {!HTMLElement} * @export @@ -493,7 +475,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.controlsContainer_; } - /** * @return {!shaka.extern.UIConfiguration} * @export @@ -502,7 +483,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.config_; } - /** * @return {boolean} * @export @@ -511,7 +491,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.isSeeking_; } - /** * @return {boolean} * @export @@ -520,20 +499,14 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.castAllowed_; } - /** * @return {number} * @export */ getDisplayTime() { - const displayTime = this.isSeeking_ ? - Number(this.seekBar_.value) : - Number(this.video_.currentTime); - - return displayTime; + return this.seekBar_ ? this.seekBar_.getValue() : this.video_.currentTime; } - /** * @param {?number} time * @export @@ -542,7 +515,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.lastTouchEventTime_ = time; } - /** * Display controls even if css says overwise. * Normally, controls opacity is controled by CSS, but there are @@ -557,7 +529,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.overrideCssShowControls_ = true; } - /** * @return {boolean} * @export @@ -567,26 +538,15 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { (menu) => menu.classList.contains('shaka-displayed')); } - - /** - * @export - */ + /** @export */ hideSettingsMenus() { this.hideSettingsMenusTimer_.tickNow(); } - - /** - * @private - */ + /** @private */ updateLocalizedStrings_() { const LocIds = shaka.ui.Locales.Ids; - if (this.seekBar_) { - this.seekBar_.setAttribute(shaka.ui.Constants.ARIA_LABEL, - this.localization_.resolve(LocIds.SEEK)); - } - // Localize state-dependant labels const makePlayNotPause = this.video_.paused && !this.isSeeking_; const playButtonAriaLabelId = makePlayNotPause ? LocIds.PLAY : LocIds.PAUSE; @@ -594,18 +554,12 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.localization_.resolve(playButtonAriaLabelId)); } - - /** - * @private - */ + /** @private */ createDOM_() { - // TODO: encapsulate/abstract range inputs and their containers - - /** @private {HTMLElement} */ - this.seekBarContainer_ = null; - - /** @private {HTMLInputElement} */ - this.seekBar_ = null; + if (this.seekBar_) { + this.seekBar_.destroy(); + this.seekBar_ = null; + } this.videoContainer_.classList.add('shaka-video-container'); this.video_.classList.add('shaka-video'); @@ -616,27 +570,20 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.addControlsButtonPanel_(); - // Seek bar - if (this.config_.addSeekBar) { - this.addSeekBar_(); - } - - /** @private {!Array.} */ this.settingsMenus_ = Array.from( this.videoContainer_.getElementsByClassName('shaka-settings-menu')); - // Settings menus need to be positioned lower, if the seekbar is absent. - if (!this.seekBar_) { + if (this.config_.addSeekBar) { + this.seekBar_ = new shaka.ui.SeekBar(this.bottomControls_, this); + } else { + // Settings menus need to be positioned lower if the seekbar is absent. for (const menu of this.settingsMenus_) { menu.classList.add('shaka-low-position'); } } } - - /** - * @private - */ + /** @private */ addControlsContainer_() { /** @private {!HTMLElement} */ this.controlsContainer_ = shaka.util.Dom.createHTMLElement('div'); @@ -652,10 +599,7 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { }); } - - /** - * @private - */ + /** @private */ addPlayButton_() { /** @private {!HTMLElement} */ this.playButtonContainer_ = shaka.util.Dom.createHTMLElement('div'); @@ -669,10 +613,7 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.onPlayStateChange_(); } - - /** - * @private - */ + /** @private */ addBufferingSpinner_() { /** @private {!HTMLElement} */ this.spinnerContainer_ = shaka.util.Dom.createHTMLElement('div'); @@ -708,10 +649,7 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { svg.appendChild(spinnerCircle); } - - /** - * @private - */ + /** @private */ addControlsButtonPanel_() { /** @private {!HTMLElement} */ this.bottomControls_ = shaka.util.Dom.createHTMLElement('div'); @@ -739,10 +677,7 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - - /** - * @private - */ + /** @private */ addEventListeners_() { this.eventManager_.listen(this.player_, 'buffering', () => { this.onBufferingStateChange_(); @@ -768,28 +703,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.onPlayStateChange_(); }); - if (this.seekBar_) { - this.eventManager_.listen(this.seekBar_, 'mousedown', () => { - this.onSeekStart_(); - }); - - this.eventManager_.listen(this.seekBar_, 'touchstart', () => { - this.onSeekStart_(); - }, {passive: true}); - - this.eventManager_.listen(this.seekBar_, 'input', () => { - this.onSeekInput_(); - }); - - this.eventManager_.listen(this.seekBar_, 'touchend', () => { - this.onSeekEnd_(); - }); - - this.eventManager_.listen(this.seekBar_, 'mouseup', () => { - this.onSeekEnd_(); - }); - } - // Elements that should not propagate clicks (controls panel, menus) const noPropagationElements = this.videoContainer_.getElementsByClassName( 'shaka-no-propagation'); @@ -853,34 +766,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { (e) => this.updateLocalizedStrings_()); } - - /** - * @private - */ - addSeekBar_() { - // This container is to support IE 11. See detailed notes in - // less/range_elements.less for a complete explanation. - // TODO: Factor this into a range-element component. - this.seekBarContainer_ = shaka.util.Dom.createHTMLElement('div'); - this.seekBarContainer_.classList.add('shaka-seek-bar-container'); - - this.seekBar_ = - /** @type {!HTMLInputElement} */ (document.createElement('input')); - this.seekBar_.classList.add('shaka-seek-bar'); - this.seekBar_.type = 'range'; - // NOTE: step=any causes keyboard nav problems on IE 11. - this.seekBar_.setAttribute('step', 'any'); - this.seekBar_.setAttribute('min', '0'); - this.seekBar_.setAttribute('max', '1'); - this.seekBar_.value = '0'; - this.seekBar_.classList.add('shaka-no-propagation'); - this.seekBar_.classList.add('shaka-show-controls-on-mouse-over'); - - this.seekBarContainer_.appendChild(this.seekBar_); - this.bottomControls_.appendChild(this.seekBarContainer_); - } - - /** * Hiding the cursor when the mouse stops moving seems to be the only * decent UX in fullscreen mode. Since we can't use pure CSS for that, @@ -917,8 +802,15 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { // Make sure we are not about to hide the settings menus and then force them // open. this.hideSettingsMenusTimer_.stop(); - this.setControlsOpacity_(shaka.ui.Enums.Opacity.OPAQUE); - this.updateTimeAndSeekRange_(); + + if (!this.isOpaque_()) { + // Only update the time and seek range on mouse movement if it's the very + // first movement and we're about to show the controls. Otherwise, the + // seek bar will be updated much more rapidly during mouse movement. Do + // this right before making it visible. + this.updateTimeAndSeekRange_(); + this.setControlsOpacity_(shaka.ui.Enums.Opacity.OPAQUE); + } // Hide the cursor when the mouse stops moving. // Only applies while the cursor is over the video container. @@ -932,7 +824,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** @private */ onMouseLeave_() { // We sometimes get 'mouseout' events with touches. Since we can never @@ -947,7 +838,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.mouseStillTimer_.tickNow(); } - /** * This callback is for when we are pretty sure that the mouse has stopped * moving (aka the mouse is still). This method should only be called via @@ -970,7 +860,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** * @param {!Event} event * @private @@ -993,10 +882,7 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - - /** - * @private - */ + /** @private */ onContainerClick_() { if (!this.enabled_) { return; @@ -1009,7 +895,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** @private */ onPlayPauseClick_() { if (!this.enabled_) { @@ -1030,10 +915,7 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - - /** - * @private - */ + /** @private */ onCastStatusChange_() { const isCasting = this.castProxy_.isCasting(); this.dispatchEvent(new shaka.util.FakeEvent('caststatuschanged', { @@ -1047,7 +929,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** @private */ onPlayStateChange_() { // On IE 11, a video may end without going into a paused state. To correct @@ -1069,60 +950,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - - /** @private */ - onSeekStart_() { - if (!this.enabled_) { - return; - } - - this.isSeeking_ = true; - this.video_.pause(); - } - - - /** @private */ - onSeekInput_() { - if (!this.enabled_) { - return; - } - - if (!this.video_.duration) { - // Can't seek yet. Ignore. - return; - } - - // Update the UI right away. - this.updateTimeAndSeekRange_(); - - // We want to wait until the user has stopped moving the seek bar for a - // little bit to avoid the number of times we ask the player to seek. - // - // To do this, we will start a timer that will fire in a little bit, but if - // we see another seek bar change, we will cancel that timer and re-start - // it. - // - // Calling |start| on an already pending timer will cancel the old request - // and start the new one. - this.seekTimer_.tickAfter(/* seconds= */ 0.125); - } - - - /** @private */ - onSeekEnd_() { - if (!this.enabled_) { - return; - } - - // They just let go of the seek bar, so cancel the timer and manually - // call the event so that we can respond immediately. - this.seekTimer_.tickNow(); - - this.isSeeking_ = false; - this.video_.play(); - } - - /** * Support controls with keyboard inputs. * @param {!Event} event @@ -1177,7 +1004,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** * Called both as an event listener and directly by the controls to initialize * the buffering state. @@ -1192,7 +1018,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.spinnerContainer_, this.player_.isBuffering()); } - /** * @return {boolean} * @private @@ -1211,7 +1036,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { return this.controlsContainer_.getAttribute('shown') != null; } - /** * Update the video's current time based on the keyboard operations. * @param {number} currentTime @@ -1220,107 +1044,35 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { */ seek_(currentTime, event) { this.video_.currentTime = currentTime; - this.updateTimeAndSeekRange_(); + if (this.isOpaque_()) { + // Only update the time and seek range if it's visible. + this.updateTimeAndSeekRange_(); + } } - /** * Called when the seek range or current time need to be updated. * @private */ updateTimeAndSeekRange_() { - // Suppress updates if the controls are hidden. - if (!this.isOpaque_()) { - return; - } - - const Constants = shaka.ui.Constants; - let displayTime = this.isSeeking_ ? - Number(this.seekBar_.value) : - Number(this.video_.currentTime); - const bufferedLength = this.video_.buffered.length; - const bufferedStart = bufferedLength ? this.video_.buffered.start(0) : 0; - const bufferedEnd = - bufferedLength ? this.video_.buffered.end(bufferedLength - 1) : 0; - const seekRange = this.player_.seekRange(); - const seekRangeSize = seekRange.end - seekRange.start; - if (this.seekBar_) { - this.seekBar_.min = seekRange.start; - this.seekBar_.max = seekRange.end; - } + this.seekBar_.setValue(this.video_.currentTime); + this.seekBar_.update(); - if (this.player_.isLive()) { - // The amount of time we are behind the live edge. - const behindLive = Math.floor(seekRange.end - displayTime); - displayTime = Math.max(0, behindLive); - - if (!this.isSeeking_ && this.seekBar_) { - this.seekBar_.value = seekRange.end - displayTime; - } - } else { - if (!this.isSeeking_ && this.seekBar_) { - this.seekBar_.value = displayTime; - } - } - - if (this.seekBar_) { - // Hide seekbar seek window is very small. - const seekRange = this.player_.seekRange(); - const seekWindow = seekRange.end - seekRange.start; - if (this.player_.isLive() && - seekWindow < Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR) { - shaka.ui.Utils.setDisplay(this.seekBarContainer_, false); - for (const menu of this.settingsMenus_) { - menu.classList.add('shaka-low-position'); - } - } else { - shaka.ui.Utils.setDisplay(this.seekBarContainer_, true); + if (this.seekBar_.isShowing()) { for (const menu of this.settingsMenus_) { menu.classList.remove('shaka-low-position'); } - - const gradient = ['to right']; - if (bufferedLength == 0) { - gradient.push('#000 0%'); - } else { - const clampedBufferStart = Math.max(bufferedStart, seekRange.start); - const clampedBufferEnd = Math.min(bufferedEnd, seekRange.end); - - const bufferStartDistance = - clampedBufferStart - seekRange.start; - const bufferEndDistance = - clampedBufferEnd - seekRange.start; - const playheadDistance = displayTime - seekRange.start; - - // NOTE: the fallback to zero eliminates NaN. - const bufferStartFraction = - (bufferStartDistance / seekRangeSize) || 0; - const bufferEndFraction = (bufferEndDistance / seekRangeSize) || 0; - const playheadFraction = (playheadDistance / seekRangeSize) || 0; - - gradient.push(Constants.SEEK_BAR_BASE_COLOR + ' ' + - (bufferStartFraction * 100) + '%'); - gradient.push(Constants.SEEK_BAR_PLAYED_COLOR + ' ' + - (bufferStartFraction * 100) + '%'); - gradient.push(Constants.SEEK_BAR_PLAYED_COLOR + ' ' + - (playheadFraction * 100) + '%'); - gradient.push(Constants.SEEK_BAR_BUFFERED_COLOR + ' ' + - (playheadFraction * 100) + '%'); - gradient.push(Constants.SEEK_BAR_BUFFERED_COLOR + ' ' + - (bufferEndFraction * 100) + '%'); - gradient.push(Constants.SEEK_BAR_BASE_COLOR + ' ' + - (bufferEndFraction * 100) + '%'); + } else { + for (const menu of this.settingsMenus_) { + menu.classList.add('shaka-low-position'); } - this.seekBarContainer_.style.background = - 'linear-gradient(' + gradient.join(',') + ')'; } } this.dispatchEvent(new shaka.util.FakeEvent('timeandseekrangeupdated')); } - /** * Add behaviors for keyboard navigation. * 1. Add blue outline for focused elements. @@ -1358,7 +1110,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** * When the user is using keyboard to navigate inside the overflow settings * menu (pressing Tab key to go forward, or pressing Shift + Tab keys to go @@ -1411,7 +1162,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** * For keyboard navigation, we use blue borders to highlight the active * element. If we detect that a mouse is being used, remove the blue border @@ -1423,7 +1173,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { this.eventManager_.listen(window, 'keydown', (e) => this.onKeyDown_(e)); } - /** * @param {!shaka.ui.Enums.Opacity} opacity * @private @@ -1441,7 +1190,6 @@ shaka.ui.Controls = class extends shaka.util.FakeEventTarget { } } - /** * Create a localization instance already pre-loaded with all the locales that * we support. diff --git a/ui/less/range_elements.less b/ui/less/range_elements.less index 273f634aa..0f51906d7 100644 --- a/ui/less/range_elements.less +++ b/ui/less/range_elements.less @@ -157,9 +157,11 @@ } } -.shaka-volume-bar-container { +.shaka-range-container { .range-container(); +} +.shaka-volume-bar-container { width: 100px; /* Hide volume slider on mobile-sized screens. */ @@ -169,16 +171,10 @@ } .shaka-seek-bar-container { - .range-container(); - /* TODO: Document why! */ width: 96.5%; } -.shaka-seek-bar { - .range-element(); -} - -.shaka-volume-bar { +.shaka-range-element { .range-element(); } diff --git a/ui/range_element.js b/ui/range_element.js new file mode 100644 index 000000000..f740c34bb --- /dev/null +++ b/ui/range_element.js @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +goog.provide('shaka.ui.RangeElement'); + +goog.require('shaka.ui.Element'); +goog.require('shaka.util.Dom'); + + +/** + * A range element, built to work across browsers. + * + * In particular, getting styles to work right on IE requires a specific + * structure. + * + * This also handles the case where the range element is being manipulated and + * updated at the same time. This can happen when seeking during playback or + * when casting. + * + * @extends {shaka.ui.Element} + * @export + */ +shaka.ui.RangeElement = class extends shaka.ui.Element { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + * @param {!Array.} containerClassNames + * @param {!Array.} barClassNames + */ + constructor(parent, controls, containerClassNames, barClassNames) { + super(parent, controls); + + /** + * This container is to support IE 11. See detailed notes in + * less/range_elements.less for a complete explanation. + * @protected {!HTMLElement} + */ + this.container = shaka.util.Dom.createHTMLElement('div'); + this.container.classList.add('shaka-range-container'); + this.container.classList.add(...containerClassNames); + + /** @private {boolean} */ + this.isChanging_ = false; + + /** @protected {!HTMLInputElement} */ + this.bar = + /** @type {!HTMLInputElement} */ (document.createElement('input')); + + this.bar.classList.add('shaka-range-element'); + this.bar.classList.add(...barClassNames); + this.bar.type = 'range'; + // TODO(#2027): step=any causes keyboard nav problems on IE 11. + this.bar.step = 'any'; + this.bar.min = '0'; + this.bar.max = '1'; + this.bar.value = '0'; + + this.container.appendChild(this.bar); + this.parent.appendChild(this.container); + + this.eventManager.listen(this.bar, 'mousedown', () => { + this.isChanging_ = true; + this.onChangeStart(); + }); + + this.eventManager.listen(this.bar, 'touchstart', () => { + this.isChanging_ = true; + this.onChangeStart(); + }, {passive: true}); + + this.eventManager.listen(this.bar, 'input', () => { + this.onChange(); + }); + + this.eventManager.listen(this.bar, 'touchend', () => { + this.isChanging_ = false; + this.onChangeEnd(); + }); + + this.eventManager.listen(this.bar, 'mouseup', () => { + this.isChanging_ = false; + this.onChangeEnd(); + }); + } + + /** + * @param {number} min + * @param {number} max + */ + setRange(min, max) { + this.bar.min = min; + this.bar.max = max; + } + + /** + * Called when user interaction begins. + * To be overridden by subclasses. + */ + onChangeStart() {} + + /** + * Called when a new value is set by user interaction. + * To be overridden by subclasses. + */ + onChange() {} + + /** + * Called when user interaction ends. + * To be overridden by subclasses. + */ + onChangeEnd() {} + + /** @return {number} */ + getValue() { + return parseFloat(this.bar.value); + } + + /** @param {number} value */ + setValue(value) { + // The user interaction overrides any external values being pushed in. + if (this.isChanging_) { + return; + } + + this.bar.value = value; + } +}; diff --git a/ui/seek_bar.js b/ui/seek_bar.js new file mode 100644 index 000000000..09ae0a5bb --- /dev/null +++ b/ui/seek_bar.js @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +goog.provide('shaka.ui.SeekBar'); + +goog.require('shaka.ui.Constants'); +goog.require('shaka.ui.Locales'); +goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.RangeElement'); +goog.require('shaka.ui.Utils'); +goog.require('shaka.util.Timer'); + + +/** + * @extends {shaka.ui.RangeElement} + * @final + * @export + */ +shaka.ui.SeekBar = class extends shaka.ui.RangeElement { + /** + * @param {!HTMLElement} parent + * @param {!shaka.ui.Controls} controls + */ + constructor(parent, controls) { + super(parent, controls, + ['shaka-seek-bar-container'], + [ + 'shaka-seek-bar', + 'shaka-no-propagation', + 'shaka-show-controls-on-mouse-over', + ]); + + /** + * This timer is used to introduce a delay between the user scrubbing across + * the seek bar and the seek being sent to the player. + * + * @private {shaka.util.Timer} + */ + this.seekTimer_ = new shaka.util.Timer(() => { + this.video.currentTime = this.getValue(); + }); + + this.eventManager.listen(this.localization, + shaka.ui.Localization.LOCALE_UPDATED, + () => this.updateAriaLabel_()); + + this.eventManager.listen(this.localization, + shaka.ui.Localization.LOCALE_CHANGED, + () => this.updateAriaLabel_()); + + // Initialize seek state and label. + this.setValue(this.video.currentTime); + this.update(); + this.updateAriaLabel_(); + } + + /** @override */ + async destroy() { + if (this.seekTimer_) { + this.seekTimer_.stop(); + this.seekTimer_ = null; + } + + await super.destroy(); + } + + /** + * Called by the base class when user interaction with the input element + * begins. + * + * @override + */ + onChangeStart() { + this.video.pause(); + } + + /** + * Update the video element's state to match the input element's state. + * Called by the base class when the input element changes. + * + * @override + */ + onChange() { + if (!this.video.duration) { + // Can't seek yet. Ignore. + return; + } + + // Update the UI right away. + this.update(); + + // We want to wait until the user has stopped moving the seek bar for a + // little bit to reduce the number of times we ask the player to seek. + // + // To do this, we will start a timer that will fire in a little bit, but if + // we see another seek bar change, we will cancel that timer and re-start + // it. + // + // Calling |start| on an already pending timer will cancel the old request + // and start the new one. + this.seekTimer_.tickAfter(/* seconds= */ 0.125); + } + + /** + * Called by the base class when user interaction with the input element + * ends. + * + * @override + */ + onChangeEnd() { + // They just let go of the seek bar, so cancel the timer and manually + // call the event so that we can respond immediately. + this.seekTimer_.tickNow(); + + this.video.play(); + } + + /** @return {boolean} */ + isShowing() { + // It is showing by default, so it is hidden if shaka-hidden is in the list. + return !this.container.classList.contains('shaka-hidden'); + } + + /** + * Called by Controls on a timer to update the state of the seek bar. + * Also called internally when the user interacts with the input element. + */ + update() { + const Constants = shaka.ui.Constants; + + const currentTime = this.getValue(); + const bufferedLength = this.video.buffered.length; + const bufferedStart = bufferedLength ? this.video.buffered.start(0) : 0; + const bufferedEnd = + bufferedLength ? this.video.buffered.end(bufferedLength - 1) : 0; + + const seekRange = this.player.seekRange(); + const seekRangeSize = seekRange.end - seekRange.start; + + this.setRange(seekRange.start, seekRange.end); + + // Hide seekbar if the seek window is very small. + if (this.player.isLive() && + seekRangeSize < Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR) { + shaka.ui.Utils.setDisplay(this.container, false); + } else { + shaka.ui.Utils.setDisplay(this.container, true); + + const gradient = ['to right']; + if (bufferedLength == 0) { + gradient.push('#000 0%'); + } else { + const clampedBufferStart = Math.max(bufferedStart, seekRange.start); + const clampedBufferEnd = Math.min(bufferedEnd, seekRange.end); + const clampedCurrentTime = Math.min( + Math.max(currentTime, seekRange.start), + seekRange.end); + + const bufferStartDistance = clampedBufferStart - seekRange.start; + const bufferEndDistance = clampedBufferEnd - seekRange.start; + const playheadDistance = clampedCurrentTime - seekRange.start; + + // NOTE: the fallback to zero eliminates NaN. + const bufferStartFraction = (bufferStartDistance / seekRangeSize) || 0; + const bufferEndFraction = (bufferEndDistance / seekRangeSize) || 0; + const playheadFraction = (playheadDistance / seekRangeSize) || 0; + + gradient.push(Constants.SEEK_BAR_BASE_COLOR + ' ' + + (bufferStartFraction * 100) + '%'); + gradient.push(Constants.SEEK_BAR_PLAYED_COLOR + ' ' + + (bufferStartFraction * 100) + '%'); + gradient.push(Constants.SEEK_BAR_PLAYED_COLOR + ' ' + + (playheadFraction * 100) + '%'); + gradient.push(Constants.SEEK_BAR_BUFFERED_COLOR + ' ' + + (playheadFraction * 100) + '%'); + gradient.push(Constants.SEEK_BAR_BUFFERED_COLOR + ' ' + + (bufferEndFraction * 100) + '%'); + gradient.push(Constants.SEEK_BAR_BASE_COLOR + ' ' + + (bufferEndFraction * 100) + '%'); + } + this.container.style.background = + 'linear-gradient(' + gradient.join(',') + ')'; + } + } + + /** @private */ + updateAriaLabel_() { + this.bar.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.localization.resolve(shaka.ui.Locales.Ids.SEEK)); + } +}; diff --git a/ui/volume_bar.js b/ui/volume_bar.js index 14130d190..c21f5dfff 100644 --- a/ui/volume_bar.js +++ b/ui/volume_bar.js @@ -18,95 +18,51 @@ goog.provide('shaka.ui.VolumeBar'); -goog.require('shaka.ui.Element'); +goog.require('shaka.ui.Constants'); goog.require('shaka.ui.Locales'); goog.require('shaka.ui.Localization'); +goog.require('shaka.ui.RangeElement'); /** - * @extends {shaka.ui.Element} + * @extends {shaka.ui.RangeElement} * @final * @export */ -shaka.ui.VolumeBar = class extends shaka.ui.Element { +shaka.ui.VolumeBar = class extends shaka.ui.RangeElement { /** * @param {!HTMLElement} parent * @param {!shaka.ui.Controls} controls */ constructor(parent, controls) { - super(parent, controls); + super(parent, controls, + ['shaka-volume-bar-container'], ['shaka-volume-bar']); - // This container is to support IE 11. See detailed notes in - // less/range_elements.less for a complete explanation. - // TODO: Factor this into a range-element component. - /** @private {!HTMLElement} */ - this.container_ = shaka.util.Dom.createHTMLElement('div'); - this.container_.classList.add('shaka-volume-bar-container'); + this.eventManager.listen(this.video, + 'volumechange', + () => this.onVolumeStateChange_()); - this.bar_ = - /** @type {!HTMLInputElement} */ (document.createElement('input')); - this.bar_.classList.add('shaka-volume-bar'); - this.bar_.setAttribute('type', 'range'); - // NOTE: step=any causes keyboard nav problems on IE 11. - this.bar_.setAttribute('step', 'any'); - this.bar_.setAttribute('min', '0'); - this.bar_.setAttribute('max', '1'); - this.bar_.setAttribute('value', '0'); + this.eventManager.listen(this.localization, + shaka.ui.Localization.LOCALE_UPDATED, + () => this.updateAriaLabel_()); - this.container_.appendChild(this.bar_); - this.parent.appendChild(this.container_); - this.updateAriaLabel_(); + this.eventManager.listen(this.localization, + shaka.ui.Localization.LOCALE_CHANGED, + () => this.updateAriaLabel_()); - this.eventManager.listen(this.video, 'volumechange', () => { - this.onVolumeStateChange_(); - }); - - this.eventManager.listen(this.bar_, 'input', () => { - this.onVolumeInput_(); - }); - - this.eventManager.listen( - this.localization, shaka.ui.Localization.LOCALE_UPDATED, () => { - this.updateAriaLabel_(); - }); - - this.eventManager.listen( - this.localization, shaka.ui.Localization.LOCALE_CHANGED, () => { - this.updateAriaLabel_(); - }); - - // Initialize volume display with a fake event. + // Initialize volume display and label. this.onVolumeStateChange_(); + this.updateAriaLabel_(); } - /** - * @private + * Update the video element's state to match the input element's state. + * Called by the base class when the input element changes. + * + * @override */ - onVolumeStateChange_() { - if (this.video.muted) { - this.bar_.value = 0; - } else { - this.bar_.value = this.video.volume; - } - - // TODO: Can we do this with LESS? - const gradient = ['to right']; - gradient.push(shaka.ui.Constants.VOLUME_BAR_VOLUME_LEVEL_COLOR + - (this.bar_.value * 100) + '%'); - gradient.push(shaka.ui.Constants.VOLUME_BAR_BASE_COLOR + - (this.bar_.value * 100) + '%'); - gradient.push(shaka.ui.Constants.VOLUME_BAR_BASE_COLOR + '100%'); - this.container_.style.background = - 'linear-gradient(' + gradient.join(',') + ')'; - } - - - /** - * @private - */ - onVolumeInput_() { - this.video.volume = parseFloat(this.bar_.value); + onChange() { + this.video.volume = this.getValue(); if (this.video.volume == 0) { this.video.muted = true; } else { @@ -114,12 +70,28 @@ shaka.ui.VolumeBar = class extends shaka.ui.Element { } } + /** @private */ + onVolumeStateChange_() { + if (this.video.muted) { + this.setValue(0); + } else { + this.setValue(this.video.volume); + } - /** - * @private - */ + const gradient = ['to right']; + gradient.push(shaka.ui.Constants.VOLUME_BAR_VOLUME_LEVEL_COLOR + + (this.getValue() * 100) + '%'); + gradient.push(shaka.ui.Constants.VOLUME_BAR_BASE_COLOR + + (this.getValue() * 100) + '%'); + gradient.push(shaka.ui.Constants.VOLUME_BAR_BASE_COLOR + '100%'); + + this.container.style.background = + 'linear-gradient(' + gradient.join(',') + ')'; + } + + /** @private */ updateAriaLabel_() { - this.bar_.setAttribute(shaka.ui.Constants.ARIA_LABEL, + this.bar.setAttribute(shaka.ui.Constants.ARIA_LABEL, this.localization.resolve(shaka.ui.Locales.Ids.VOLUME)); } };