adUnit/VastAdUnit.js

/* eslint-disable promise/prefer-await-to-callbacks */
import {linearEvents} from '../tracker';
import findBestMedia from './helpers/media/findBestMedia';
import once from './helpers/dom/once';
import setupMetricHandlers from './helpers/metrics/setupMetricHandlers';
import updateMedia from './helpers/media/updateMedia';
import VideoAdUnit, {_protected} from './VideoAdUnit';

const {
  complete,
  error: errorEvt,
  skip
} = linearEvents;

// eslint-disable-next-line id-match
const _private = Symbol('_private');

/**
 * @class
 * @extends VideoAdUnit
 * @alias VastAdUnit
 * @implements LinearEvents
 * @description This class provides everything necessary to run a Vast ad.
 */
class VastAdUnit extends VideoAdUnit {
  [_private] = {
    handleMetric: (event, data) => {
      switch (event) {
      case complete: {
        this[_protected].finish();
        break;
      }
      case errorEvt: {
        this.error = data;
        this.errorCode = this.error && this.error.code ? this.error.code : 405;
        this[_protected].onErrorCallbacks.forEach((callback) => callback(this.error, {
          adUnit: this,
          vastChain: this.vastChain
        }));
        this[_protected].finish();
        break;
      }
      case skip: {
        this.cancel();
        break;
      }
      }

      this.emit(event, {
        adUnit: this,
        type: event
      });
    }
  };

  assetUri = null;

  /** Ad unit type. Will be `VAST` for VastAdUnit */
  type='VAST';

  /**
   * Creates a {VastAdUnit}.
   *
   * @param {VastChain} vastChain - The {@link VastChain} with all the {@link VastResponse}
   * @param {VideoAdContainer} videoAdContainer - container instance to place the ad
   * @param {Object} [options] - Options Map. The allowed properties are:
   * @param {Console} [options.logger] - Optional logger instance. Must comply to the [Console interface]{@link https://developer.mozilla.org/es/docs/Web/API/Console}.
   * Defaults to `window.console`
   * @param {Object} [options.hooks] - Optional map with hooks to configure the behaviour of the ad.
   * @param {Function} [options.hooks.createSkipControl] - If provided it will be called to generate the skip control. Must return a clickable [HTMLElement](https://developer.mozilla.org/es/docs/Web/API/HTMLElement) that is detached from the DOM.
   * @param {boolean} [options.viewability] - if true it will pause the ad whenever is not visible for the viewer.
   * Defaults to `false`
   * @param {boolean} [options.responsive] - if true it will resize the ad unit whenever the ad container changes sizes.
   * Defaults to `false`
   */
  constructor (vastChain, videoAdContainer, options = {}) {
    super(vastChain, videoAdContainer, options);

    const {onFinishCallbacks} = this[_protected];
    const {handleMetric} = this[_private];

    this.hooks = options.hooks || {};

    const removeMetricHandlers = setupMetricHandlers({
      hooks: this.hooks,
      vastChain: this.vastChain,
      videoAdContainer: this.videoAdContainer
    }, handleMetric);

    onFinishCallbacks.push(removeMetricHandlers);
  }

  /**
   * Starts the ad unit.
   *
   * @throws if called twice.
   * @throws if ad unit is finished.
   */
  async start () {
    this[_protected].throwIfFinished();

    if (this.isStarted()) {
      throw new Error('VastAdUnit already started');
    }

    const inlineAd = this.vastChain[0].ad;
    const {videoElement, element} = this.videoAdContainer;
    const media = findBestMedia(inlineAd, videoElement, element);

    if (Boolean(media)) {
      if (this.icons) {
        const drawIcons = async () => {
          if (this.isFinished()) {
            return;
          }

          await this[_protected].drawIcons();

          if (this[_protected].hasPendingIconRedraws() && !this.isFinished()) {
            once(videoElement, 'timeupdate', drawIcons);
          }
        };

        await drawIcons();
      }

      videoElement.src = media.src;
      this.assetUri = media.src;
      videoElement.play();
    } else {
      const adUnitError = new Error('Can\'t find a suitable media to play');

      adUnitError.code = 403;
      this[_private].handleMetric(errorEvt, adUnitError);
    }

    this[_protected].started = true;
  }

  /**
   * Resumes a previously paused ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   */
  resume () {
    this.videoAdContainer.videoElement.play();
  }

  /**
   * Pauses the ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   */
  pause () {
    this.videoAdContainer.videoElement.pause();
  }

  /**
   * Returns true if the ad is paused and false otherwise
   */
  paused () {
    return this.videoAdContainer.videoElement.paused;
  }

  /**
   * Sets the volume of the ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   *
   * @param {number} volume - must be a value between 0 and 1;
   */
  setVolume (volume) {
    this.videoAdContainer.videoElement.volume = volume;
  }

  /**
   * Gets the volume of the ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   *
   * @returns {number} - the volume of the ad unit.
   */
  getVolume () {
    return this.videoAdContainer.videoElement.volume;
  }

  /**
   * Cancels the ad unit.
   *
   * @throws if ad unit is finished.
   */
  cancel () {
    this[_protected].throwIfFinished();

    this.videoAdContainer.videoElement.pause();

    this[_protected].finish();
  }

  /**
   * Returns the duration of the ad Creative or 0 if there is no creative.
   *
   * @returns {number} - the duration of the ad unit.
   */
  duration () {
    if (!this.isStarted()) {
      return 0;
    }

    return this.videoAdContainer.videoElement.duration;
  }

  /**
   * Returns the current time of the ad Creative or 0 if there is no creative.
   *
   * @returns {number} - the current time of the ad unit.
   */
  currentTime () {
    if (!this.isStarted()) {
      return 0;
    }

    return this.videoAdContainer.videoElement.currentTime;
  }

  /**
   * This method resizes the ad unit to fit the available space in the passed {@link VideoAdContainer}
   *
   * @param width {number} - the new width of the ad container.
   * @param height {number} - the new height of the ad container.
   * @param viewmode {string} - fullscreen | normal | thumbnail
   * @returns {Promise} - that resolves once the unit was resized
   */
  async resize (width, height, viewmode) {
    await super.resize(width, height, viewmode);

    if (this.isStarted() && !this.isFinished()) {
      const inlineAd = this.vastChain[0].ad;
      const {videoElement, element} = this.videoAdContainer;
      const media = findBestMedia(inlineAd, videoElement, element);

      if (Boolean(media) && videoElement.src !== media.src) {
        updateMedia(videoElement, media);
      }
    }
  }
}

export default VastAdUnit;