slider_slider.js

/**
 * @file slider.js
 */
import Component from '../component.js';
import * as Dom from '../utils/dom.js';
import {IS_CHROME} from '../utils/browser.js';
import {clamp} from '../utils/num.js';

/** @import Player from '../player' */

/**
 * The base functionality for a slider. Can be vertical or horizontal.
 * For instance the volume bar or the seek bar on a video is a slider.
 *
 * @extends Component
 */
class Slider extends Component {

  /**
 * Create an instance of this class
 *
 * @param {Player} player
 *        The `Player` that this class should be attached to.
 *
 * @param {Object} [options]
 *        The key/value store of player options.
 */
  constructor(player, options) {
    super(player, options);

    this.handleMouseDown_ = (e) => this.handleMouseDown(e);
    this.handleMouseUp_ = (e) => this.handleMouseUp(e);
    this.handleKeyDown_ = (e) => this.handleKeyDown(e);
    this.handleClick_ = (e) => this.handleClick(e);
    this.handleMouseMove_ = (e) => this.handleMouseMove(e);
    this.update_ = (e) => this.update(e);

    // Set property names to bar to match with the child Slider class is looking for
    this.bar = this.getChild(this.options_.barName);

    // Set a horizontal or vertical class on the slider depending on the slider type
    this.vertical(!!this.options_.vertical);

    this.enable();
  }

  /**
   * Are controls are currently enabled for this slider or not.
   *
   * @return {boolean}
   *         true if controls are enabled, false otherwise
   */
  enabled() {
    return this.enabled_;
  }

  /**
   * Enable controls for this slider if they are disabled
   */
  enable() {
    if (this.enabled()) {
      return;
    }

    this.on('mousedown', this.handleMouseDown_);
    this.on('touchstart', this.handleMouseDown_);
    this.on('keydown', this.handleKeyDown_);
    this.on('click', this.handleClick_);

    // TODO: deprecated, controlsvisible does not seem to be fired
    this.on(this.player_, 'controlsvisible', this.update);

    if (this.playerEvent) {
      this.on(this.player_, this.playerEvent, this.update);
    }

    this.removeClass('disabled');
    this.setAttribute('tabindex', 0);

    this.enabled_ = true;
  }

  /**
   * Disable controls for this slider if they are enabled
   */
  disable() {
    if (!this.enabled()) {
      return;
    }
    const doc = this.bar.el_.ownerDocument;

    this.off('mousedown', this.handleMouseDown_);
    this.off('touchstart', this.handleMouseDown_);
    this.off('keydown', this.handleKeyDown_);
    this.off('click', this.handleClick_);
    this.off(this.player_, 'controlsvisible', this.update_);
    this.off(doc, 'mousemove', this.handleMouseMove_);
    this.off(doc, 'mouseup', this.handleMouseUp_);
    this.off(doc, 'touchmove', this.handleMouseMove_);
    this.off(doc, 'touchend', this.handleMouseUp_);
    this.removeAttribute('tabindex');

    this.addClass('disabled');

    if (this.playerEvent) {
      this.off(this.player_, this.playerEvent, this.update);
    }
    this.enabled_ = false;
  }

  /**
   * Create the `Slider`s DOM element.
   *
   * @param {string} type
   *        Type of element to create.
   *
   * @param {Object} [props={}]
   *        List of properties in Object form.
   *
   * @param {Object} [attributes={}]
   *        list of attributes in Object form.
   *
   * @return {Element}
   *         The element that gets created.
   */
  createEl(type, props = {}, attributes = {}) {
    // Add the slider element class to all sub classes
    props.className = props.className + ' vjs-slider';
    props = Object.assign({
      tabIndex: 0
    }, props);

    attributes = Object.assign({
      'role': 'slider',
      'aria-valuenow': 0,
      'aria-valuemin': 0,
      'aria-valuemax': 100
    }, attributes);

    return super.createEl(type, props, attributes);
  }

  /**
   * Handle `mousedown` or `touchstart` events on the `Slider`.
   *
   * @param {MouseEvent} event
   *        `mousedown` or `touchstart` event that triggered this function
   *
   * @listens mousedown
   * @listens touchstart
   * @fires Slider#slideractive
   */
  handleMouseDown(event) {
    const doc = this.bar.el_.ownerDocument;

    if (event.type === 'mousedown') {
      event.preventDefault();
    }
    // Do not call preventDefault() on touchstart in Chrome
    // to avoid console warnings. Use a 'touch-action: none' style
    // instead to prevent unintended scrolling.
    // https://developers.google.com/web/updates/2017/01/scrolling-intervention
    if (event.type === 'touchstart' && !IS_CHROME) {
      event.preventDefault();
    }
    Dom.blockTextSelection();

    this.addClass('vjs-sliding');
    /**
     * Triggered when the slider is in an active state
     *
     * @event Slider#slideractive
     * @type {MouseEvent}
     */
    this.trigger('slideractive');

    this.on(doc, 'mousemove', this.handleMouseMove_);
    this.on(doc, 'mouseup', this.handleMouseUp_);
    this.on(doc, 'touchmove', this.handleMouseMove_);
    this.on(doc, 'touchend', this.handleMouseUp_);

    this.handleMouseMove(event, true);
  }

  /**
   * Handle the `mousemove`, `touchmove`, and `mousedown` events on this `Slider`.
   * The `mousemove` and `touchmove` events will only only trigger this function during
   * `mousedown` and `touchstart`. This is due to {@link Slider#handleMouseDown} and
   * {@link Slider#handleMouseUp}.
   *
   * @param {MouseEvent} event
   *        `mousedown`, `mousemove`, `touchstart`, or `touchmove` event that triggered
   *        this function
   * @param {boolean} mouseDown this is a flag that should be set to true if `handleMouseMove` is called directly. It allows us to skip things that should not happen if coming from mouse down but should happen on regular mouse move handler. Defaults to false.
   *
   * @listens mousemove
   * @listens touchmove
   */
  handleMouseMove(event) {}

  /**
   * Handle `mouseup` or `touchend` events on the `Slider`.
   *
   * @param {MouseEvent} event
   *        `mouseup` or `touchend` event that triggered this function.
   *
   * @listens touchend
   * @listens mouseup
   * @fires Slider#sliderinactive
   */
  handleMouseUp(event) {
    const doc = this.bar.el_.ownerDocument;

    Dom.unblockTextSelection();

    this.removeClass('vjs-sliding');
    /**
     * Triggered when the slider is no longer in an active state.
     *
     * @event Slider#sliderinactive
     * @type {Event}
     */
    this.trigger('sliderinactive');

    this.off(doc, 'mousemove', this.handleMouseMove_);
    this.off(doc, 'mouseup', this.handleMouseUp_);
    this.off(doc, 'touchmove', this.handleMouseMove_);
    this.off(doc, 'touchend', this.handleMouseUp_);

    this.update();
  }

  /**
   * Update the progress bar of the `Slider`.
   *
   * @return {number}
   *          The percentage of progress the progress bar represents as a
   *          number from 0 to 1.
   */
  update() {
    // In VolumeBar init we have a setTimeout for update that pops and update
    // to the end of the execution stack. The player is destroyed before then
    // update will cause an error
    // If there's no bar...
    if (!this.el_ || !this.bar) {
      return;
    }

    // clamp progress between 0 and 1
    // and only round to four decimal places, as we round to two below
    const progress = this.getProgress();

    if (progress === this.progress_) {
      return progress;
    }

    this.progress_ = progress;

    this.requestNamedAnimationFrame('Slider#update', () => {
      // Set the new bar width or height
      const sizeKey = this.vertical() ? 'height' : 'width';

      // Convert to a percentage for css value
      this.bar.el().style[sizeKey] = (progress * 100).toFixed(2) + '%';
    });

    return progress;
  }

  /**
   * Get the percentage of the bar that should be filled
   * but clamped and rounded.
   *
   * @return {number}
   *         percentage filled that the slider is
   */
  getProgress() {
    return Number(clamp(this.getPercent(), 0, 1).toFixed(4));
  }

  /**
   * Calculate distance for slider
   *
   * @param {Event} event
   *        The event that caused this function to run.
   *
   * @return {number}
   *         The current position of the Slider.
   *         - position.x for vertical `Slider`s
   *         - position.y for horizontal `Slider`s
   */
  calculateDistance(event) {
    const position = Dom.getPointerPosition(this.el_, event);

    if (this.vertical()) {
      return position.y;
    }
    return position.x;
  }

  /**
   * Handle a `keydown` event on the `Slider`. Watches for left, right, up, and down
   * arrow keys. This function will only be called when the slider has focus. See
   * {@link Slider#handleFocus} and {@link Slider#handleBlur}.
   *
   * @param {KeyboardEvent} event
   *        the `keydown` event that caused this function to run.
   *
   * @listens keydown
   */
  handleKeyDown(event) {
    const spatialNavOptions = this.options_.playerOptions.spatialNavigation;
    const spatialNavEnabled = spatialNavOptions && spatialNavOptions.enabled;
    const horizontalSeek = spatialNavOptions && spatialNavOptions.horizontalSeek;

    if (spatialNavEnabled) {
      if ((horizontalSeek && event.key === 'ArrowLeft') ||
        (!horizontalSeek && event.key === 'ArrowDown')) {
        event.preventDefault();
        event.stopPropagation();
        this.stepBack();
      } else if ((horizontalSeek && event.key === 'ArrowRight') ||
        (!horizontalSeek && event.key === 'ArrowUp')) {
        event.preventDefault();
        event.stopPropagation();
        this.stepForward();
      } else {
        super.handleKeyDown(event);
      }

      // Left and Down Arrows
    } else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
      event.preventDefault();
      event.stopPropagation();
      this.stepBack();

      // Up and Right Arrows
    } else if (event.key === 'ArrowUp' || event.key === 'ArrowRight') {
      event.preventDefault();
      event.stopPropagation();
      this.stepForward();
    } else {

      // Pass keydown handling up for unsupported keys
      super.handleKeyDown(event);
    }
  }

  /**
   * Listener for click events on slider, used to prevent clicks
   *   from bubbling up to parent elements like button menus.
   *
   * @param {Object} event
   *        Event that caused this object to run
   */
  handleClick(event) {
    event.stopPropagation();
    event.preventDefault();
  }

  /**
   * Get/set if slider is horizontal for vertical
   *
   * @param {boolean} [bool]
   *        - true if slider is vertical,
   *        - false is horizontal
   *
   * @return {boolean}
   *         - true if slider is vertical, and getting
   *         - false if the slider is horizontal, and getting
   */
  vertical(bool) {
    if (bool === undefined) {
      return this.vertical_ || false;
    }

    this.vertical_ = !!bool;

    if (this.vertical_) {
      this.addClass('vjs-slider-vertical');
    } else {
      this.addClass('vjs-slider-horizontal');
    }
  }
}

Component.registerComponent('Slider', Slider);
export default Slider;