Source: ui/controls.js

/**
 * @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.Controls');

goog.require('goog.asserts');
goog.require('shaka.ui.Constants');
goog.require('shaka.ui.Enums');
goog.require('shaka.ui.Locales');
goog.require('shaka.ui.Localization');
goog.require('shaka.ui.Utils');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.FakeEventTarget');
goog.require('shaka.util.Timer');


/**
 * A container for custom video controls.
 * @param {!shaka.Player} player
 * @param {!HTMLElement} videoContainer
 * @param {!HTMLMediaElement} video
 * @param {shaka.extern.UIConfiguration} config
 * @constructor
 * @struct
 * @implements {shaka.util.IDestroyable}
 * @extends {shaka.util.FakeEventTarget}
 * @export
 */
shaka.ui.Controls = function(player, videoContainer, video, config) {
  shaka.util.FakeEventTarget.call(this);

  /** @private {boolean} */
  this.enabled_ = true;

  /** @private {boolean} */
  this.overrideCssShowControls_ = false;

  /** shaka.extern.UIConfiguration */
  this.config_ = config;

  /** @private {!shaka.cast.CastProxy} */
  this.castProxy_ = new shaka.cast.CastProxy(
    video, player, this.config_.castReceiverAppId);

  /** @private {boolean} */
  this.castAllowed_ = true;

  /** @private {!HTMLMediaElement} */
  this.video_ = this.castProxy_.getVideo();

  /** @private {!HTMLMediaElement} */
  this.localVideo_ = video;

  /** @private {!shaka.Player} */
  this.player_ = this.castProxy_.getPlayer();

  /** @private {!HTMLElement} */
  this.videoContainer_ = videoContainer;

  /** @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);
  });

  /**
   * This timer is used to detect when the user has stopped moving the mouse
   * and we should fade out the ui.
   *
   * @private {shaka.util.Timer}
   */
  this.mouseStillTimer_ = new shaka.util.Timer(() => {
    this.onMouseStill_();
  });

  /**
   * This timer will be used to hide all settings menus. When the timer ticks
   * it will force all controls to invisible.
   *
   * Rather than calling the callback directly, |Controls| will always call it
   * through the timer to avoid conflicts.
   *
   * @private {shaka.util.Timer}
   */
  this.hideSettingsMenusTimer_ = new shaka.util.Timer(() => {
    /** type {function(!HTMLElement)} */
    const hide = (control) => {
      shaka.ui.Controls.setDisplay(control, /* visible= */ false);
    };

    for (const menu of this.settingsMenus_) {
      hide(/** @type {!HTMLElement} */ (menu));
    }
  });

  /**
   * This timer is used to regularly update the time and seek range elements
   * so that we are communicating the current state as accurately as possibly.
   *
   * Unlike the other timers, this timer does not "own" the callback because
   * this timer is acting like a heartbeat.
   *
   * @private {shaka.util.Timer}
   */
  this.timeAndSeekRangeTimer_ = new shaka.util.Timer(() => {
    this.updateTimeAndSeekRange_();
  });

  /** @private {?number} */
  this.lastTouchEventTime_ = null;

  /** @private {!Array.<!shaka.extern.IUIElement>} */
  this.elements_ = [];

  /** @private {shaka.ui.Localization} */
  this.localization_ = shaka.ui.Controls.createLocalization_();

  this.createDOM_();

  this.updateLocalizedStrings_();

  /** @private {shaka.util.EventManager} */
  this.eventManager_ = new shaka.util.EventManager();

  this.addEventListeners_();

  /**
   * The pressed keys set is used to record which keys are currently pressed
   * down, so we can know what keys are pressed at the same time.
   * Used by the focusInsideOverflowMenu_() function.
   * @private {!Set.<number>}
   */
  this.pressedKeys_ = new Set();

  // We might've missed a caststatuschanged event from the proxy between
  // the controls creation and initializing. Run onCastStatusChange_()
  // to ensure we have the casting state right.
  this.onCastStatusChange_(null);

  // Start this timer after we are finished initializing everything,
  this.timeAndSeekRangeTimer_.start(/* seconds= */ 0.125,
                                    /* repeating= */ true);
};

goog.inherits(shaka.ui.Controls, shaka.util.FakeEventTarget);


/** @private {!Map.<string, !shaka.extern.IUIElement.Factory>} */
shaka.ui.Controls.elementNamesToFactories_ = new Map();


/**
 * @override
 * @export
 */
shaka.ui.Controls.prototype.destroy = function() {
  if (this.eventManager_) {
    this.eventManager_.release();
    this.eventManager_ = null;
  }

  if (this.seekTimer_) {
    this.seekTimer_.stop();
    this.seekTimer_ = null;
  }

  if (this.mouseStillTimer_) {
    this.mouseStillTimer_.stop();
    this.mouseStillTimer_ = null;
  }

  if (this.hideSettingsMenusTimer_) {
    this.hideSettingsMenusTimer_.stop();
    this.hideSettingsMenusTimer_ = null;
  }

  if (this.timeAndSeekRangeTimer_) {
    this.timeAndSeekRangeTimer_.stop();
    this.timeAndSeekRangeTimer_ = null;
  }

  this.localization_ = null;
  this.pressedKeys_.clear();

  return Promise.resolve();
};


/**
 * @param {string} name
 * @param {!shaka.extern.IUIElement.Factory} factory
 * @export
 */
shaka.ui.Controls.registerElement = function(name, factory) {
  shaka.ui.Controls.elementNamesToFactories_.set(name, factory);
};


/**
 * This allows the application to inhibit casting.
 *
 * @param {boolean} allow
 * @export
 */
shaka.ui.Controls.prototype.allowCast = function(allow) {
  this.castAllowed_ = allow;
  this.onCastStatusChange_(null);
};


/**
 * Used by the application to notify the controls that a load operation is
 * complete.  This allows the controls to recalculate play/paused state, which
 * is important for platforms like Android where autoplay is disabled.
 * @export
 */
shaka.ui.Controls.prototype.loadComplete = function() {
  // If we are on Android or if autoplay is false, video.paused should be true.
  // Otherwise, video.paused is false and the content is autoplaying.
  this.onPlayStateChange_();
};


/**
 * Enable or disable the custom controls. Enabling disables native
 * browser controls.
 *
 * @param {boolean} enabled
 * @export
 */
shaka.ui.Controls.prototype.setEnabledShakaControls = function(enabled) {
  this.enabled_ = enabled;
  if (enabled) {
    shaka.ui.Controls.setDisplay(
      this.controlsButtonPanel_.parentElement, true);

    // If we're hiding native controls, make sure the video element itself is
    // not tab-navigable.  Our custom controls will still be tab-navigable.
    this.video_.tabIndex = -1;
    this.video_.controls = false;
  } else {
    shaka.ui.Controls.setDisplay(
      this.controlsButtonPanel_.parentElement, false);
  }

  // The effects of play state changes are inhibited while showing native
  // browser controls.  Recalculate that state now.
  this.onPlayStateChange_();
};


/**
 * Enable or disable native browser controls. Enabling disables shaka
 * controls.
 *
 * @param {boolean} enabled
 * @export
 */
shaka.ui.Controls.prototype.setEnabledNativeControls = function(enabled) {
  // If we enable the native controls, the element must be tab-navigable.
  // If we disable the native controls, we want to make sure that the video
  // element itself is not tab-navigable, so that the element is skipped over
  // when tabbing through the page.
  this.video_.controls = enabled;
  this.video_.tabIndex = enabled ? 0 : -1;

  if (enabled) {
    this.setEnabledShakaControls(false);
  }
};


/**
 * @export
 * @return {!shaka.cast.CastProxy}
 */
shaka.ui.Controls.prototype.getCastProxy = function() {
  return this.castProxy_;
};


/**
 * @return {shaka.ui.Localization}
 * @export
 */
shaka.ui.Controls.prototype.getLocalization = function() {
  return this.localization_;
};


/**
 * @return {!HTMLElement}
 * @export
 */
shaka.ui.Controls.prototype.getVideoContainer = function() {
  return this.videoContainer_;
};


/**
 * @return {!HTMLMediaElement}
 * @export
 */
shaka.ui.Controls.prototype.getVideo = function() {
  return this.video_;
};


/**
 * @return {!HTMLMediaElement}
 * @export
 */
shaka.ui.Controls.prototype.getLocalVideo = function() {
  return this.localVideo_;
};


/**
 * @return {!shaka.Player}
 * @export
 */
shaka.ui.Controls.prototype.getPlayer = function() {
  return this.player_;
};


/**
 * @return {!HTMLElement}
 * @export
 */
shaka.ui.Controls.prototype.getControlsContainer = function() {
  return this.controlsContainer_;
};


/**
 * @return {!shaka.extern.UIConfiguration}
 * @export
 */
shaka.ui.Controls.prototype.getConfig = function() {
  return this.config_;
};


/**
 * @return {boolean}
 * @export
 */
shaka.ui.Controls.prototype.isSeeking = function() {
  return this.isSeeking_;
};


/**
 * @return {boolean}
 * @export
 */
shaka.ui.Controls.prototype.isCastAllowed = function() {
  return this.castAllowed_;
};


/**
 * @return {number}
 * @export
 */
shaka.ui.Controls.prototype.getDisplayTime = function() {
  const displayTime = this.isSeeking_ ?
        Number(this.seekBar_.value) :
        Number(this.video_.currentTime);

  return displayTime;
};


/**
 * @param {?number} time
 * @export
 */
shaka.ui.Controls.prototype.setLastTouchEventTime = function(time) {
  this.lastTouchEventTime_ = time;
};


/**
 * Depending on the value of display, sets/removes css class of element to
 * either display it or hide.
 *
 * @param {Element} element
 * @param {boolean} display
 * @export
 */
shaka.ui.Controls.setDisplay = function(element, display) {
  if (!element) return;
  if (display) {
    element.classList.add('shaka-displayed');
    // Removing a non-existent class doesn't throw, so, even if
    // the element is not hidden, this should be fine. Same for displayed
    // below.
    element.classList.remove('shaka-hidden');
  } else {
    element.classList.add('shaka-hidden');
    element.classList.remove('shaka-displayed');
  }
};


/**
 * Display controls even if css says overwise.
 * Normally, controls opacity is controled by CSS, but there are
 * a few special cases where we want controls to be displayed no
 * matter what. For example, if the focus is on one of the settings
 * menus. This method is called when we want to signal an exception
 * to normal CSS opacity rules and keep the controls visible.
 *
 * @export
 */
shaka.ui.Controls.prototype.overrideCssShowControls = function() {
  this.overrideCssShowControls_ = true;
};


/**
 * @return {boolean}
 * @export
 */
shaka.ui.Controls.prototype.anySettingsMenusAreOpen = function() {
  return this.settingsMenus_.some(
      (menu) => menu.classList.contains('shaka-displayed'));
};


/**
 * @export
 */
shaka.ui.Controls.prototype.hideSettingsMenus = function() {
  this.hideSettingsMenusTimer_.tick();
};


/**
 * @private
 */
shaka.ui.Controls.prototype.updateLocalizedStrings_ = function() {
  const LocIds = shaka.ui.Locales.Ids;

  if (this.seekBar_) {
    this.seekBar_.setAttribute(shaka.ui.Constants.ARIA_LABEL,
          this.localization_.resolve(LocIds.ARIA_LABEL_SEEK));
  }

  // Localize state-dependant labels
  const makePlayNotPause = this.video_.paused && !this.isSeeking_;
  const playButtonAriaLabelId = makePlayNotPause ? LocIds.ARIA_LABEL_PLAY :
                                                   LocIds.ARIA_LABEL_PAUSE;
  this.playButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL,
      this.localization_.resolve(playButtonAriaLabelId));
};


/**
 * @private
 */
shaka.ui.Controls.prototype.initOptionalElementsToNull_ = function() {
  // TODO: encapsulate/abstract range inputs and their containers

  /** @private {HTMLElement} */
  this.seekBarContainer_ = null;

  /** @private {HTMLInputElement} */
  this.seekBar_ = null;
};


/**
 * @private
 */
shaka.ui.Controls.prototype.createDOM_ = function() {
  this.initOptionalElementsToNull_();

  this.videoContainer_.classList.add('shaka-video-container');
  this.video_.classList.add('shaka-video');

  this.addControlsContainer_();

  this.addPlayButton_();

  this.addBufferingSpinner_();

  this.addControlsButtonPanel_();

  // Seek bar
  if (this.config_.addSeekBar) {
    this.addSeekBar_();
  }

  /** @private {!Array.<!Element>} */
  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_) {
    for (let menu of this.settingsMenus_) {
      menu.classList.add('shaka-low-position');
    }
  }
};


/**
 * @private
 */
shaka.ui.Controls.prototype.addControlsContainer_ = function() {
  /** @private {!HTMLElement} */
  this.controlsContainer_ = shaka.ui.Utils.createHTMLElement('div');
  this.controlsContainer_.classList.add('shaka-controls-container');
  this.videoContainer_.appendChild(this.controlsContainer_);
};


/**
 * @private
 */
shaka.ui.Controls.prototype.addPlayButton_ = function() {
  /** @private {!HTMLElement} */
  this.playButtonContainer_ = shaka.ui.Utils.createHTMLElement('div');
  this.playButtonContainer_.classList.add('shaka-play-button-container');
  this.controlsContainer_.appendChild(this.playButtonContainer_);

  /** @private {!HTMLElement} */
  this.playButton_ = shaka.ui.Utils.createHTMLElement('button');
  this.playButton_.classList.add('shaka-play-button');
  this.playButton_.setAttribute('icon', 'play');
  this.playButtonContainer_.appendChild(this.playButton_);
};


/**
 * @private
 */
shaka.ui.Controls.prototype.addBufferingSpinner_ = function() {
  goog.asserts.assert(this.playButtonContainer_,
                      'Must have play button container before spinner!');

  // Svg elements have to be created with the svg xml namespace.
  const xmlns = 'http://www.w3.org/2000/svg';

  /** @private {!HTMLElement} */
  this.bufferingSpinner_ =
      /** @type {!HTMLElement} */(document.createElementNS(xmlns, 'svg'));
  // NOTE: SVG elements do not have a classList on IE, so use setAttribute.
  this.bufferingSpinner_.setAttribute('class', 'shaka-spinner-svg');
  this.bufferingSpinner_.setAttribute('viewBox', '0 0 30 30');
  this.playButton_.appendChild(this.bufferingSpinner_);

  // These coordinates are relative to the SVG viewBox above.  This is distinct
  // from the actual display size in the page, since the "S" is for "Scalable."
  // The radius of 14.5 is so that the edges of the 1-px-wide stroke will touch
  // the edges of the viewBox.
  const spinnerCircle = document.createElementNS(xmlns, 'circle');
  spinnerCircle.setAttribute('class', 'shaka-spinner-path');
  spinnerCircle.setAttribute('cx', '15');
  spinnerCircle.setAttribute('cy', '15');
  spinnerCircle.setAttribute('r', '14.5');
  spinnerCircle.setAttribute('fill', 'none');
  spinnerCircle.setAttribute('stroke-width', '1');
  spinnerCircle.setAttribute('stroke-miterlimit', '10');
  this.bufferingSpinner_.appendChild(spinnerCircle);
};


/**
 * @private
 */
shaka.ui.Controls.prototype.addControlsButtonPanel_ = function() {
  /** @private {!HTMLElement} */
  this.controlsButtonPanel_ = shaka.ui.Utils.createHTMLElement('div');
  this.controlsButtonPanel_.classList.add('shaka-controls-button-panel');
  this.controlsButtonPanel_.classList.add('shaka-no-propagation');
  this.controlsButtonPanel_.classList.add('shaka-show-controls-on-mouse-over');
  this.controlsContainer_.appendChild(this.controlsButtonPanel_);

  // Create the elements specified by controlPanelElements
  for (let i = 0; i < this.config_.controlPanelElements.length; i++) {
    const name = this.config_.controlPanelElements[i];
    if (shaka.ui.Controls.elementNamesToFactories_.get(name)) {
      if (shaka.ui.Controls.controlPanelElements_.indexOf(name) == -1) {
        // Not a control panel element, skip
        shaka.log.warning('Element is not part of control panel ' +
          'elements and will be skipped', name);
        continue;
      }
      const factory = shaka.ui.Controls.elementNamesToFactories_.get(name);
      this.elements_.push(factory.create(this.controlsButtonPanel_, this));
    }
  }
};


/**
 * @private
 */
shaka.ui.Controls.prototype.addEventListeners_ = function() {
  // TODO: Convert adding event listers to the "() =>" form.

  this.player_.addEventListener(
      'buffering', this.onBufferingStateChange_.bind(this));

  // Listen for key down events to detect tab and enable outline
  // for focused elements.
  this.eventManager_.listen(window, 'keydown', this.onKeyDown_.bind(this));

  this.video_.addEventListener(
      'play', this.onPlayStateChange_.bind(this));
  this.video_.addEventListener(
      'pause', this.onPlayStateChange_.bind(this));

  // Since videos go into a paused state at the end, Chrome and Edge both fire
  // the 'pause' event when a video ends.  IE 11 only fires the 'ended' event.
  this.video_.addEventListener(
      'ended', this.onPlayStateChange_.bind(this));

  if (this.seekBar_) {
    this.seekBar_.addEventListener(
        'mousedown', this.onSeekStart_.bind(this));
    this.seekBar_.addEventListener(
        'touchstart', this.onSeekStart_.bind(this), {passive: true});
    this.seekBar_.addEventListener(
        'input', this.onSeekInput_.bind(this));
    this.seekBar_.addEventListener(
        'touchend', this.onSeekEnd_.bind(this));
    this.seekBar_.addEventListener(
        'mouseup', this.onSeekEnd_.bind(this));
  }

  this.controlsContainer_.addEventListener(
      'touchstart', this.onContainerTouch_.bind(this), {passive: false});
  this.controlsContainer_.addEventListener(
      'click', this.onContainerClick_.bind(this));

  // Elements that should not propagate clicks (controls panel, menus)
  const noPropagationElements = this.videoContainer_.getElementsByClassName(
      'shaka-no-propagation');
  for (let i = 0; i < noPropagationElements.length; i++) {
    let element = noPropagationElements[i];
    element.addEventListener(
      'click', function(event) { event.stopPropagation(); });
  }

  // Keep showing controls if one of those elements is hovered
  let showControlsElements = this.videoContainer_.getElementsByClassName(
      'shaka-show-controls-on-mouse-over');
  for (let i = 0; i < showControlsElements.length; i++) {
    let element = showControlsElements[i];
    element.addEventListener(
      'mouseover', () => {
        this.overrideCssShowControls_ = true;
      });

    element.addEventListener(
      'mouseleave', () => {
       this.overrideCssShowControls_ = false;
      });
  }

  this.videoContainer_.addEventListener(
      'mousemove', this.onMouseMove_.bind(this));
  this.videoContainer_.addEventListener(
      'touchmove', this.onMouseMove_.bind(this), {passive: true});
  this.videoContainer_.addEventListener(
      'touchend', this.onMouseMove_.bind(this), {passive: true});
  this.videoContainer_.addEventListener(
      'mouseleave', this.onMouseLeave_.bind(this));

  // Overflow menus are supposed to hide once you click elsewhere
  // on the video element. The code in onContainerClick_ ensures that.
  // However, clicks on controls panel don't propagate to the container,
  // so we have to explicitely hide the menus onclick here.
  this.controlsButtonPanel_.addEventListener('click', () => {
    this.hideSettingsMenusTimer_.tick();
  });

  this.castProxy_.addEventListener(
      'caststatuschanged', (e) => {
        this.onCastStatusChange_(e);
      });

  this.videoContainer_.addEventListener('keyup', this.onKeyUp_.bind(this));

  this.localization_.addEventListener(
      shaka.ui.Localization.LOCALE_UPDATED,
      (e) => this.updateLocalizedStrings_());

  this.localization_.addEventListener(
      shaka.ui.Localization.LOCALE_CHANGED,
      (e) => this.updateLocalizedStrings_());
};


/**
 * @private
 */
shaka.ui.Controls.prototype.addSeekBar_ = function() {
  // 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.ui.Utils.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.controlsContainer_.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, we use events both
 * in and out of fullscreen mode.
 * Showing the control bar when a key is pressed, and hiding it after some time.
 * @param {!Event} event
 * @private
 */
shaka.ui.Controls.prototype.onMouseMove_ = function(event) {
  // Disable blue outline for focused elements for mouse navigation.
  if (event.type == 'mousemove') {
    this.controlsContainer_.classList.remove('shaka-keyboard-navigation');
  }
  if (event.type == 'touchstart' || event.type == 'touchmove' ||
      event.type == 'touchend' || event.type == 'keyup') {
    this.lastTouchEventTime_ = Date.now();
  } else if (this.lastTouchEventTime_ + 1000 < Date.now()) {
    // It has been a while since the last touch event, this is probably a real
    // mouse moving, so treat it like a mouse.
    this.lastTouchEventTime_ = null;
  }

  // When there is a touch, we can get a 'mousemove' event after touch events.
  // This should be treated as part of the touch, which has already been handled
  if (this.lastTouchEventTime_ && event.type == 'mousemove') {
    return;
  }

  // Use the cursor specified in the CSS file.
  this.videoContainer_.style.cursor = '';

  // 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_();

  // Hide the cursor when the mouse stops moving.
  // Only applies while the cursor is over the video container.
  this.mouseStillTimer_.stop();

  // Only start a timeout on 'touchend' or for 'mousemove' with no touch events.
  if (event.type == 'touchend' ||
      event.type == 'keyup'|| !this.lastTouchEventTime_) {
    this.mouseStillTimer_.start(/* seconds= */ 3, /* repeating= */ false);
  }
};


/** @private */
shaka.ui.Controls.prototype.onMouseLeave_ = function() {
  // We sometimes get 'mouseout' events with touches.  Since we can never leave
  // the video element when touching, ignore.
  if (this.lastTouchEventTime_) return;

  // Stop the timer and invoke the callback now to hide the controls.  If we
  // don't, the opacity style we set in onMouseMove_ will continue to override
  // the opacity in CSS and force the controls to stay visible.
  this.mouseStillTimer_.tick();
};


/**
 * 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
 * |mouseStillTimer_|. If this behaviour needs to be invoked directly, use
 * |mouseStillTimer_.tick()|.
 *
 * @private
 */
shaka.ui.Controls.prototype.onMouseStill_ = function() {
  // Hide the cursor.  (NOTE: not supported on IE)
  this.videoContainer_.style.cursor = 'none';

  // Keep showing the controls if video is paused or one of the control menus
  // is hovered.
  if ((this.video_.paused && !this.isSeeking_) ||
       this.overrideCssShowControls_) {
    this.setControlsOpacity_(shaka.ui.Enums.Opacity.OPAQUE);
  } else {
    this.setControlsOpacity_(shaka.ui.Enums.Opacity.TRANSPARENT);
  }
};


/**
 * @param {!Event} event
 * @private
 */
shaka.ui.Controls.prototype.onContainerTouch_ = function(event) {
  if (!this.video_.duration) {
    // Can't play yet.  Ignore.
    return;
  }

  if (this.isOpaque_()) {
    this.lastTouchEventTime_ = Date.now();
    // The controls are showing.
    // Let this event continue and become a click.
  } else {
    // The controls are hidden, so show them.
    this.onMouseMove_(event);
    // Stop this event from becoming a click event.
    event.preventDefault();
  }
};


/**
 * @param {!Event} event
 * @private
 */
shaka.ui.Controls.prototype.onContainerClick_ = function(event) {
  if (!this.enabled_) return;

  if (this.anySettingsMenusAreOpen()) {
    this.hideSettingsMenusTimer_.tick();
  } else {
    this.onPlayPauseClick_();
  }
};


/** @private */
shaka.ui.Controls.prototype.onPlayPauseClick_ = function() {
  if (!this.enabled_) return;

  if (!this.video_.duration) {
    // Can't play yet.  Ignore.
    return;
  }

  this.player_.cancelTrickPlay();

  if (this.video_.paused) {
    this.video_.play();
  } else {
    this.video_.pause();
  }
};


/**
 * @param {Event} event
 * @private
 */
shaka.ui.Controls.prototype.onCastStatusChange_ = function(event) {
  const isCasting = this.castProxy_.isCasting();
  this.dispatchEvent(new shaka.util.FakeEvent('caststatuschanged', {
    newStatus: isCasting,
  }));

  if (isCasting) {
    this.controlsContainer_.setAttribute('casting', 'true');
  } else {
    this.controlsContainer_.removeAttribute('casting');
  }
};


/** @private */
shaka.ui.Controls.prototype.onPlayStateChange_ = function() {
  // On IE 11, a video may end without going into a paused state.  To correct
  // both the UI state and the state of the video tag itself, we explicitly
  // pause the video if that happens.
  if (this.video_.ended && !this.video_.paused) {
    this.video_.pause();
  }

  // Video is paused during seek, so don't show the play arrow while seeking:
  if (this.enabled_ && this.video_.paused && !this.isSeeking_) {
    this.playButton_.setAttribute('icon', 'play');
    this.playButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL,
      this.localization_.resolve(shaka.ui.Locales.Ids.ARIA_LABEL_PLAY));
  } else {
    this.playButton_.setAttribute('icon', 'pause');
    this.playButton_.setAttribute(shaka.ui.Constants.ARIA_LABEL,
      this.localization_.resolve(shaka.ui.Locales.Ids.ARIA_LABEL_PAUSE));
  }
};


/** @private */
shaka.ui.Controls.prototype.onSeekStart_ = function() {
  if (!this.enabled_) return;

  this.isSeeking_ = true;
  this.video_.pause();
};


/** @private */
shaka.ui.Controls.prototype.onSeekInput_ = function() {
  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_.start(/* seconds= */ 0.125, /* repeating */ false);
};


/** @private */
shaka.ui.Controls.prototype.onSeekEnd_ = function() {
  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_.tick();

  this.isSeeking_ = false;
  this.video_.play();
};


/**
 * Support controls with keyboard inputs.
 * @param {!Event} event
 * @private
 */
shaka.ui.Controls.prototype.onKeyUp_ = function(event) {
  let key = event.key;

  let activeElement = document.activeElement;
  let isVolumeBar = activeElement && activeElement.classList ?
      activeElement.classList.contains('shaka-volume-bar') : false;
  let isSeekBar = activeElement && activeElement.classList &&
      activeElement.classList.contains('shaka-seek-bar');
  // Show the control panel if it is on focus or any button is pressed.
  if (this.controlsContainer_.contains(activeElement)) {
    this.onMouseMove_(event);
  }

  // When the key is released, remove it from the pressed keys set.
  this.pressedKeys_.delete(event.keyCode);


  switch (key) {
    case 'ArrowLeft':
      // If it's not focused on the volume bar, move the seek time backward
      // for 5 sec. Otherwise, the volume will be adjusted automatically.
      if (!isVolumeBar) {
        this.seek_(this.video_.currentTime - 5, event);
      }
      break;
    case 'ArrowRight':
      // If it's not focused on the volume bar, move the seek time forward
      // for 5 sec. Otherwise, the volume will be adjusted automatically.
      if (!isVolumeBar) {
        this.seek_(this.video_.currentTime + 5, event);
      }
      break;
    // Jump to the beginning of the video's seek range.
    case 'Home':
      this.seek_(this.player_.seekRange().start, event);
      break;
    // Jump to the end of the video's seek range.
    case 'End':
      this.seek_(this.player_.seekRange().end, event);
      break;
    // Pause or play by pressing space on the seek bar.
    case ' ':
      if (isSeekBar) {
        this.onPlayPauseClick_();
      }
      break;
    }
};


/**
 * @param {!Event} event
 * @private
 */
shaka.ui.Controls.prototype.onBufferingStateChange_ = function(event) {
  // Using [] notation to access buffering property to work around
  // a compiler error.
  const isBuffering = event['buffering'];

  // Don't use setDisplay_ here, since the SVG spinner doesn't have classList
  // on IE.
  if (isBuffering) {
    this.bufferingSpinner_.setAttribute(
        'class', 'shaka-spinner-svg');
  } else {
    this.bufferingSpinner_.setAttribute(
        'class', 'shaka-spinner-svg shaka-hidden');
  }
};


/**
 * @return {boolean}
 * @private
 */
shaka.ui.Controls.prototype.isOpaque_ = function() {
  if (!this.enabled_) return false;

  // TODO: refactor into a single property
  // While you are casting, the UI is always opaque.
  if (this.castProxy_ && this.castProxy_.isCasting()) return true;

  return this.controlsContainer_.getAttribute('shown') != null;
};


/**
 * Update the video's current time based on the keyboard operations.
 * @param {number} currentTime
 * @param {!Event} event
 * @private
 */
shaka.ui.Controls.prototype.seek_ = function(currentTime, event) {
  this.video_.currentTime = currentTime;
  this.updateTimeAndSeekRange_();
};


/**
 * Called when the seek range or current time need to be updated.
 * @private
 */
shaka.ui.Controls.prototype.updateTimeAndSeekRange_ = function() {
  // Suppress updates if the controls are hidden.
  if (!this.isOpaque_()) {
    return;
  }

  this.dispatchEvent(new shaka.util.FakeEvent('timeandseekrangeupdated'));

  const Constants = shaka.ui.Constants;
  let displayTime = this.isSeeking_ ?
      Number(this.seekBar_.value) :
      Number(this.video_.currentTime);
  let bufferedLength = this.video_.buffered.length;
  let bufferedStart = bufferedLength ? this.video_.buffered.start(0) : 0;
  let bufferedEnd =
      bufferedLength ? this.video_.buffered.end(bufferedLength - 1) : 0;
  let seekRange = this.player_.seekRange();
  let seekRangeSize = seekRange.end - seekRange.start;

  if (this.seekBar_) {
    this.seekBar_.min = seekRange.start;
    this.seekBar_.max = seekRange.end;
  }

  if (this.player_.isLive()) {
    // The amount of time we are behind the live edge.
    let 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 (seekWindow < Constants.MIN_SEEK_WINDOW_TO_SHOW_SEEKBAR) {
      shaka.ui.Controls.setDisplay(this.seekBarContainer_, false);
      for (let menu of this.settingsMenus_) {
        menu.classList.add('shaka-low-position');
      }
    } else {
      shaka.ui.Controls.setDisplay(this.seekBarContainer_, true);
      for (let menu of this.settingsMenus_) {
        menu.classList.remove('shaka-low-position');
      }

      let 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) + '%');
      }
      this.seekBarContainer_.style.background =
          'linear-gradient(' + gradient.join(',') + ')';
    }
  }
};


/**
 * Add behaviors for keyboard navigation.
 * 1. Add blue outline for focused elements.
 * 2. Allow exiting overflow settings menus by pressing Esc key.
 * 3. When navigating on overflow settings menu by pressing Tab
 *    key or Shift+Tab keys keep the focus inside overflow menu.
 *
 * @param {!Event} event
 * @private
 */
shaka.ui.Controls.prototype.onKeyDown_ = function(event) {
  // Add the key code to the pressed keys set when it's pressed.
  this.pressedKeys_.add(event.keyCode);

  const anySettingsMenusAreOpen = this.anySettingsMenusAreOpen();

  if (event.keyCode == shaka.ui.Constants.KEYCODE_TAB) {
    // Enable blue outline for focused elements for keyboard
    // navigation.
    this.controlsContainer_.classList.add('shaka-keyboard-navigation');
    this.eventManager_.listen(window, 'mousedown',
                              this.onMouseDown_.bind(this));
  }

  // If escape key was pressed, close any open settings menus.
  if (event.keyCode == shaka.ui.Constants.KEYCODE_ESCAPE) {
    this.hideSettingsMenusTimer_.tick();
  }

  if (anySettingsMenusAreOpen &&
        this.pressedKeys_.has(shaka.ui.Constants.KEYCODE_TAB)) {
      // If Tab key or Shift+Tab keys are pressed when navigating through
      // an overflow settings menu, keep the focus to loop inside the
      // overflow menu.
      this.keepFocusInMenu_(event);
  }
};


/**
 * 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
 * backward), make sure it's focused only on the elements of the overflow
 * panel.
 * This is called by onKeyDown_() function, when there's a settings overflow
 * menu open, and the Tab key / Shift+Tab keys are pressed.
 * @param {!Event} event
 * @private
 */
shaka.ui.Controls.prototype.keepFocusInMenu_ = function(event) {
  const openSettingsMenus = this.settingsMenus_.filter(
      (menu) => menu.classList.contains('shaka-displayed'));
  const settingsMenu = openSettingsMenus[0];
  if (settingsMenu.childNodes.length) {
    // Get the first and the last displaying child element from the overflow
    // menu.
    let firstShownChild = settingsMenu.firstElementChild;
    while (firstShownChild &&
           firstShownChild.classList.contains('shaka-hidden')) {
      firstShownChild = firstShownChild.nextElementSibling;
    }

    let lastShownChild = settingsMenu.lastElementChild;
    while (lastShownChild &&
           lastShownChild.classList.contains('shaka-hidden')) {
      lastShownChild = lastShownChild.previousElementSibling;
    }

    const activeElement = document.activeElement;
    // When only Tab key is pressed, navigate to the next elememnt.
    // If it's currently focused on the last shown child element of the
    // overflow menu, let the focus move to the first child element of the
    // menu.
    // When Tab + Shift keys are pressed at the same time, navigate to the
    // previous element. If it's currently focused on the first shown child
    // element of the overflow menu, let the focus move to the last child
    // element of the menu.
    if (this.pressedKeys_.has(shaka.ui.Constants.KEYCODE_SHIFT)) {
      if (activeElement == firstShownChild) {
        event.preventDefault();
        lastShownChild.focus();
      }
    } else {
      if (activeElement == lastShownChild) {
        event.preventDefault();
        firstShownChild.focus();
      }
    }
  }
};


/**
 * Removes class for keyboard navigation if mouse navigation
 * is active.
 * @private
 */
shaka.ui.Controls.prototype.onMouseDown_ = function() {
  this.eventManager_.unlisten(window, 'mousedown');
  this.eventManager_.listen(window, 'keydown', this.onKeyDown_.bind(this));
};


/**
 * @param {!shaka.ui.Enums.Opacity} opacity
 * @private
 */
shaka.ui.Controls.prototype.setControlsOpacity_ = function(opacity) {
  if (opacity == shaka.ui.Enums.Opacity.OPAQUE) {
    this.controlsContainer_.setAttribute('shown', 'true');
  } else {
    this.controlsContainer_.removeAttribute('shown');
    // If there's an overflow menu open, keep it this way for a couple of
    // seconds in case a user immidiately initiaites another mouse move to
    // interact with the menus. If that didn't happen, go ahead and hide
    // the menus.
    this.hideSettingsMenusTimer_.start(/* seconds= */ 2,
                                       /* repeating= */ false);
  }
};


/**
 * Create a localization instance already pre-loaded with all the locales that
 * we support.
 *
 * @return {!shaka.ui.Localization}
 * @private
 */
shaka.ui.Controls.createLocalization_ = function() {
  /** @type {string} */
  const fallbackLocale = 'en';

  /** @type {!shaka.ui.Localization} */
  const localization = new shaka.ui.Localization(fallbackLocale);
  shaka.ui.Locales.apply(localization);
  localization.changeLocale(navigator.languages || []);

  return localization;
};


/** @private {!Array.<string>} */
shaka.ui.Controls.controlPanelElements_ = [
  'time_and_duration',
  'mute',
  'volume',
  'fullscreen',
  'overflow_menu',
  'rewind',
  'fast_forward',
  'spacer',
];