adUnit/VpaidAdUnit.js

/* eslint-disable promise/prefer-await-to-callbacks, class-methods-use-this, import/no-named-as-default-member */
import linearEvents from '../tracker/linearEvents';
import {
  acceptInvitation,
  creativeView,
  adCollapse
} from '../tracker/nonLinearEvents';
import {getClickThrough} from '../vastSelectors';
import {volumeChanged, adProgress} from './adUnitEvents';
import loadCreative from './helpers/vpaid/loadCreative';
import {
  adLoaded,
  adStarted,
  adStopped,
  adPlaying,
  adPaused,
  startAd,
  stopAd,
  resumeAd,
  pauseAd,
  setAdVolume,
  getAdVolume,
  getAdDuration,
  resizeAd,
  adSizeChange,
  adError,
  adVideoComplete,
  adSkipped,
  EVENTS,
  adVolumeChange,
  adImpression,
  adVideoStart,
  adVideoFirstQuartile,
  adVideoMidpoint,
  adVideoThirdQuartile,
  adUserAcceptInvitation,
  adUserMinimize,
  adUserClose,
  adDurationChange,
  adRemainingTimeChange,
  adClickThru,
  getAdIcons,
  getAdRemainingTime
} from './helpers/vpaid/api';
import waitFor from './helpers/vpaid/waitFor';
import callAndWait from './helpers/vpaid/callAndWait';
import handshake from './helpers/vpaid/handshake';
import initAd from './helpers/vpaid/initAd';
import VideoAdUnit, {_protected} from './VideoAdUnit';

const {
  complete,
  mute,
  unmute,
  skip,
  start,
  firstQuartile,
  pause,
  resume,
  impression,
  midpoint,
  thirdQuartile,
  clickThrough,
  error: errorEvt,
  closeLinear
} = linearEvents;

// NOTE some ads only allow one handler per event and we need to subscribe to the adLoaded to know the creative is loaded.
const VPAID_EVENTS = EVENTS.filter((event) => event !== adLoaded);

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

const vpaidGeneralError = (payload) => {
  const error = payload instanceof Error ? payload : new Error('VPAID general error');

  if (!error.code) {
    error.code = 901;
  }

  return error;
};

/**
 * @class
 * @alias VpaidAdUnit
 * @extends VideoAdUnit
 * @implements NonLinearEvents
 * @implements LinearEvents
 * @description This class provides everything necessary to run a Vpaid ad.
 */
class VpaidAdUnit extends VideoAdUnit {
  [_private] = {
    evtHandler: {
      [adClickThru]: (url, id, playerHandles) => {
        if (playerHandles) {
          if (this.paused()) {
            this.resume();
          } else {
            const clickThroughUrl = typeof url === 'string' && url.length > 0 ? url : getClickThrough(this.vastChain[0].ad);

            this.pause();
            window.open(clickThroughUrl, '_blank');
          }
        }

        this.emit(clickThrough, {
          adUnit: this,
          type: clickThrough
        });
      },
      [adDurationChange]: () => {
        this.emit(adProgress, {
          adUnit: this,
          type: adProgress
        });
      },
      [adError]: (payload) => {
        this.error = vpaidGeneralError(payload);
        this.errorCode = this.error.code;

        this[_protected].onErrorCallbacks.forEach((callback) => callback(this.error, {
          adUnit: this,
          vastChain: this.vastChain
        }));

        this[_protected].finish();

        this.emit(errorEvt, {
          adUnit: this,
          type: errorEvt
        });
      },
      [adImpression]: () => {
        // NOTE: some ads forget to trigger the adVideoStart event. :(
        if (!this[_private].videoStart) {
          this[_private].handleVpaidEvt(adVideoStart);
        }

        this.emit(impression, {
          adUnit: this,
          type: impression
        });
      },
      [adPaused]: () => {
        this[_private].paused = true;
        this.emit(pause, {
          adUnit: this,
          type: pause
        });
      },
      [adPlaying]: () => {
        this[_private].paused = false;
        this.emit(resume, {
          adUnit: this,
          type: resume
        });
      },
      [adRemainingTimeChange]: () => {
        this.emit(adProgress, {
          adUnit: this,
          type: adProgress
        });
      },
      [adSkipped]: () => {
        this.cancel();
        this.emit(skip, {
          adUnit: this,
          type: skip
        });
      },
      [adStarted]: () => {
        this.emit(creativeView, {
          adUnit: this,
          type: creativeView
        });
      },
      [adStopped]: () => {
        this.emit(adStopped, {
          adUnit: this,
          type: adStopped
        });

        this[_protected].finish();
      },
      [adUserAcceptInvitation]: () => {
        this.emit(acceptInvitation, {
          adUnit: this,
          type: acceptInvitation
        });
      },
      [adUserClose]: () => {
        this.emit(closeLinear, {
          adUnit: this,
          type: closeLinear
        });

        this[_protected].finish();
      },
      [adUserMinimize]: () => {
        this.emit(adCollapse, {
          adUnit: this,
          type: adCollapse
        });
      },
      [adVideoComplete]: () => {
        this.emit(complete, {
          adUnit: this,
          type: complete
        });

        this[_protected].finish();
      },
      [adVideoFirstQuartile]: () => {
        this.emit(firstQuartile, {
          adUnit: this,
          type: firstQuartile
        });
      },
      [adVideoMidpoint]: () => {
        this.emit(midpoint, {
          adUnit: this,
          type: midpoint
        });
      },
      [adVideoStart]: () => {
        if (!this[_private].videoStart) {
          this[_private].videoStart = true;
          this[_private].paused = false;
          this.emit(start, {
            adUnit: this,
            type: start
          });
        }
      },
      [adVideoThirdQuartile]: () => {
        this.emit(thirdQuartile, {
          adUnit: this,
          type: thirdQuartile
        });
      },
      [adVolumeChange]: () => {
        const volume = this.getVolume();

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

        if (volume === 0 && !this[_private].muted) {
          this[_private].muted = true;
          this.emit(mute, {
            adUnit: this,
            type: mute
          });
        }

        if (volume > 0 && this[_private].muted) {
          this[_private].muted = false;
          this.emit(unmute, {
            adUnit: this,
            type: unmute
          });
        }
      }
    },
    handleVpaidEvt: (event, ...args) => {
      const handler = this[_private].evtHandler[event];

      if (handler) {
        handler(...args);
      }

      this.emit(event, {
        adUnit: this,
        type: event
      });
    },
    muted: false,
    paused: true
  };

  /** Ad unit type. Will be `VPAID` for VpaidAdUnit */
  type='VPAID';

  /** Reference to the Vpaid Creative ad unit. Will be null before the ad unit starts. */
  creativeAd = null;

  /**
   * Creates a {VpaidAdUnit}.
   *
   * @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 {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`
   * Defaults to `window.console`
   */
  constructor (vastChain, videoAdContainer, options = {}) {
    super(vastChain, videoAdContainer, options);

    this[_private].loadCreativePromise = loadCreative(vastChain, videoAdContainer);
  }

  /**
   * 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('VpaidAdUnit already started');
    }

    try {
      this.creativeAd = await this[_private].loadCreativePromise;
      const adLoadedPromise = waitFor(this.creativeAd, adLoaded);

      for (const creativeEvt of VPAID_EVENTS) {
        this.creativeAd.subscribe(this[_private].handleVpaidEvt.bind(this, creativeEvt), creativeEvt);
      }

      if (this.creativeAd[getAdIcons] && !this.creativeAd[getAdIcons]()) {
        this.icons = null;
      }

      handshake(this.creativeAd, '2.0');
      initAd(this.creativeAd, this.videoAdContainer, this.vastChain);

      await adLoadedPromise;

      // if the ad timed out while trying to load the videoAdContainer will be destroyed
      if (!this.videoAdContainer.isDestroyed()) {
        try {
          const {videoElement} = this.videoAdContainer;

          if (videoElement.muted) {
            this[_private].muted = true;
            this.setVolume(0);
          } else {
            this.setVolume(videoElement.volume);
          }

          await callAndWait(this.creativeAd, startAd, adStarted);

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

              await this[_protected].drawIcons();

              if (this[_protected].hasPendingIconRedraws() && !this.isFinished()) {
                setTimeout(drawIcons, 500);
              }
            };

            await drawIcons();
          }

          this[_protected].started = true;
        } catch (error) {
          this.cancel();
        }
      }

      return this;
    } catch (error) {
      this[_private].handleVpaidEvt(adError, error);
      throw error;
    }
  }

  /**
   * Resumes a previously paused ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   */
  resume () {
    this.creativeAd[resumeAd]();
  }

  /**
   * Pauses the ad unit.
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   */
  pause () {
    this.creativeAd[pauseAd]();
  }

  /**
   * Returns true if the ad is paused and false otherwise
   */
  paused () {
    return this.isFinished() || this[_private].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.creativeAd[setAdVolume](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.creativeAd[getAdVolume]();
  }

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

    try {
      const adStoppedPromise = waitFor(this.creativeAd, adStopped, 3000);

      this.creativeAd[stopAd]();
      await adStoppedPromise;
    } catch (error) {
      this[_protected].finish();
    }
  }

  /**
   * Returns the duration of the ad Creative or 0 if there is no creative.
   *
   * Note: if the user has engaged with the ad, the duration becomes unknown and it will return 0;
   *
   * @returns {number} - the duration of the ad unit.
   */
  duration () {
    if (!this.creativeAd) {
      return 0;
    }

    const duration = this.creativeAd[getAdDuration]();

    if (duration < 0) {
      return 0;
    }

    return duration;
  }

  /**
   * Returns the current time of the ad Creative or 0 if there is no creative.
   *
   * Note: if the user has engaged with the ad, the currentTime becomes unknown and it will return 0;
   *
   * @returns {number} - the current time of the ad unit.
   */
  currentTime () {
    if (!this.creativeAd) {
      return 0;
    }

    const remainingTime = this.creativeAd[getAdRemainingTime]();

    if (remainingTime < 0) {
      return 0;
    }

    return this.duration() - remainingTime;
  }

  /**
   * This method resizes the ad unit to fit the available space in the passed {@link VideoAdContainer}
   *
   * @throws if ad unit is not started.
   * @throws if ad unit is finished.
   *
   * @returns {Promise} - that resolves once the unit was resized
   */
  async resize (width, height, viewmode) {
    await super.resize(width, height, viewmode);

    return callAndWait(this.creativeAd, resizeAd, adSizeChange, width, height, viewmode);
  }
}

export default VpaidAdUnit;