Source: lib/util/event_manager.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.util.EventManager');

goog.require('goog.asserts');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.MultiMap');


/**
 * @summary
 * An EventManager maintains a collection of "event
 * bindings" between event targets and event listeners.
 *
 * @implements {shaka.util.IReleasable}
 * @export
 */
shaka.util.EventManager = class {
  /** */
  constructor() {
    /**
     * Maps an event type to an array of event bindings.
     * @private {shaka.util.MultiMap.<!shaka.util.EventManager.Binding_>}
     */
    this.bindingMap_ = new shaka.util.MultiMap();
  }


  /**
   * Detaches all event listeners.
   * @override
   * @export
   */
  release() {
    this.removeAll();
    this.bindingMap_ = null;
  }


  /**
   * Attaches an event listener to an event target.
   * @param {EventTarget} target The event target.
   * @param {string} type The event type.
   * @param {shaka.util.EventManager.ListenerType} listener The event listener.
   * @param {(boolean|!AddEventListenerOptions)=} options An object that
   *    specifies characteristics about the event listener.
   *    The passive option, if true, indicates that this function will never
   *    call preventDefault(), which improves scrolling performance.
   * @export
   */
  listen(target, type, listener, options) {
    if (!this.bindingMap_) {
      return;
    }

    const binding =
        new shaka.util.EventManager.Binding_(target, type, listener, options);
    this.bindingMap_.push(type, binding);
  }


  /**
   * Attaches an event listener to an event target.  The listener will be
   * removed when the first instance of the event is fired.
   * @param {EventTarget} target The event target.
   * @param {string} type The event type.
   * @param {shaka.util.EventManager.ListenerType} listener The event listener.
   * @param {(boolean|!AddEventListenerOptions)=} options An object that
   *    specifies characteristics about the event listener.
   *    The passive option, if true, indicates that this function will never
   *    call preventDefault(), which improves scrolling performance.
   * @export
   */
  listenOnce(target, type, listener, options) {
    // Install a shim listener that will stop listening after the first event.
    const shim = (event) => {
      // Stop listening to this event.
      this.unlisten(target, type, shim);
      // Call the original listener.
      listener(event);
    };
    this.listen(target, type, shim, options);
  }


  /**
   * Detaches an event listener from an event target.
   * @param {EventTarget} target The event target.
   * @param {string} type The event type.
   * @param {shaka.util.EventManager.ListenerType=} listener The event listener.
   * @export
   */
  unlisten(target, type, listener) {
    if (!this.bindingMap_) {
      return;
    }

    const list = this.bindingMap_.get(type) || [];

    for (const binding of list) {
      if (binding.target == target) {
        if (listener == binding.listener || !listener) {
          binding.unlisten();
          this.bindingMap_.remove(type, binding);
        }
      }
    }
  }


  /**
   * Detaches all event listeners from all targets.
   * @export
   */
  removeAll() {
    if (!this.bindingMap_) {
      return;
    }

    const list = this.bindingMap_.getAll();

    for (const binding of list) {
      binding.unlisten();
    }

    this.bindingMap_.clear();
  }
};


/**
 * @typedef {function(!Event)}
 * @export
 */
shaka.util.EventManager.ListenerType;


/**
 * Creates a new Binding_ and attaches the event listener to the event target.
 *
 * @private
 */
shaka.util.EventManager.Binding_ = class {
  /**
   * @param {EventTarget} target The event target.
   * @param {string} type The event type.
   * @param {shaka.util.EventManager.ListenerType} listener The event listener.
   * @param {(boolean|!AddEventListenerOptions)=} options An object that
   *    specifies characteristics about the event listener.
   *    The passive option, if true, indicates that this function will never
   *    call preventDefault(), which improves scrolling performance.
   */
  constructor(target, type, listener, options) {
    /** @type {EventTarget} */
    this.target = target;

    /** @type {string} */
    this.type = type;

    /** @type {?shaka.util.EventManager.ListenerType} */
    this.listener = listener;

    /** @type {(boolean|!AddEventListenerOptions)} */
    this.options =
        shaka.util.EventManager.Binding_.convertOptions_(target, options);

    this.target.addEventListener(type, listener, this.options);
  }


  /**
   * Detaches the event listener from the event target. This does nothing if
   * the event listener is already detached.
   */
  unlisten() {
    goog.asserts.assert(this.target, 'Missing target');
    this.target.removeEventListener(this.type, this.listener, this.options);

    this.target = null;
    this.listener = null;
    this.options = false;
  }

  /**
   * Converts the provided options value into a value accepted by the browser.
   * Some browsers (e.g. Tizen) don't support passing options as an
   * object.  So this detects this case and converts it.
   *
   * @param {EventTarget} target
   * @param {(boolean|!AddEventListenerOptions)=} value
   * @return {(boolean|!AddEventListenerOptions)}
   * @private
   */
  static convertOptions_(target, value) {
    if (value == undefined) {
      return false;
    } else if (typeof value == 'boolean') {
      return value;
    } else {
      // Ignore the 'passive' option since it is just an optimization and
      // doesn't affect behavior.  Assert there aren't any other settings to
      // ensure we don't have different behavior on different browsers by
      // ignoring an important option.
      const ignored = new Set(['passive', 'capture']);
      const keys = Object.keys(value).filter((k) => !ignored.has(k));
      goog.asserts.assert(
          keys.length == 0,
          'Unsupported flag(s) to addEventListener: ' + keys.join(','));

      const supports =
          shaka.util.EventManager.Binding_.doesSupportObject_(target);
      if (supports) {
        return value;
      } else {
        return value['capture'] || false;
      }
    }
  }

  /**
   * Checks whether the browser supports passing objects as the third argument
   * to addEventListener.  This caches the result value in a static field to
   * avoid a bunch of checks.
   *
   * @param {EventTarget} target
   * @return {boolean}
   * @private
   */
  static doesSupportObject_(target) {
    // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support
    let supports = shaka.util.EventManager.Binding_.supportsObject_;
    if (supports == undefined) {
      supports = false;
      try {
        const options = {};
        // This defines a getter that will set this variable if called.  So if
        // the browser gets this property, it supports using an object.  If the
        // browser doesn't get these fields, it won't support objects.
        const prop = {
          get: () => {
            supports = true;
            return false;
          },
        };
        Object.defineProperty(options, 'passive', prop);
        Object.defineProperty(options, 'capture', prop);

        const call = () => {};
        target.addEventListener('test', call, options);
        target.removeEventListener('test', call, options);
      } catch (e) {
        supports = false;
      }
      shaka.util.EventManager.Binding_.supportsObject_ = supports;
    }
    return supports || false;  // "false" fallback needed for compiler.
  }
};

/** @private {(boolean|undefined)} */
shaka.util.EventManager.Binding_.supportsObject_ = undefined;