prebid/Prebid.js

View on GitHub
modules/videojsVideoProvider.js

Summary

Maintainability
F
1 wk
Test Coverage
import {
  SETUP_COMPLETE, SETUP_FAILED, DESTROYED,
  PLAYLIST, PLAYBACK_REQUEST, CONTENT_LOADED, PLAY, PAUSE, TIME, SEEK_START, SEEK_END, MUTE, VOLUME, ERROR, COMPLETE,
  FULLSCREEN, PLAYER_RESIZE,
  AD_REQUEST, AD_IMPRESSION, AD_TIME, AD_COMPLETE, AD_SKIPPED, AD_CLICK, AD_STARTED, AD_ERROR, AD_LOADED, AD_PLAY, AD_PAUSE
} from '../libraries/video/constants/events.js';
// missing events: , AD_BREAK_START, , AD_BREAK_END, VIEWABLE, BUFFER, CAST, PLAYLIST_COMPLETE, RENDITION_UPDATE, PLAY_ATTEMPT_FAILED, AUTOSTART_BLOCKED
import {
  PROTOCOLS, API_FRAMEWORKS, VIDEO_MIME_TYPE, PLAYBACK_METHODS, PLACEMENT, VPAID_MIME_TYPE, AD_POSITION, PLAYBACK_END
} from '../libraries/video/constants/ortb.js';
import { VIDEO_JS_VENDOR } from '../libraries/video/constants/vendorCodes.js';
import { submodule } from '../src/hook.js';
import stateFactory from '../libraries/video/shared/state.js';
import { PLAYBACK_MODE } from '../libraries/video/constants/constants.js';
import { getEventHandler } from '../libraries/video/shared/eventHandler.js';

/*
Plugins of interest:
https://www.npmjs.com/package/videojs-chromecast
https://www.npmjs.com/package/@silvermine/videojs-airplay
https://www.npmjs.com/package/videojs-airplay
https://www.npmjs.com/package/@silvermine/videojs-chromecast
https://www.npmjs.com/package/videojs-ima
https://github.com/googleads/videojs-ima
https://github.com/videojs/videojs-playlist
https://github.com/videojs/videojs-contrib-ads
https://github.com/videojs/videojs-errors
https://github.com/videojs/videojs-overlay
https://github.com/videojs/videojs-playlist-ui

inspiration:
https://github.com/Conviva/conviva-js-videojs/blob/master/conviva-videojs-module.js
 */

const setupFailMessage = 'Failed to instantiate the player';
const AD_MANAGER_EVENTS = [AD_LOADED, AD_STARTED, AD_IMPRESSION, AD_PLAY, AD_PAUSE, AD_TIME, AD_COMPLETE, AD_SKIPPED];

export function VideojsProvider(providerConfig, vjs_, adState_, timeState_, callbackStorage_, utils) {
  let vjs = vjs_;
  // Supplied callbacks are typically wrapped by handlers
  // we use this dict to keep track of these pairings
  const callbackToHandler = {};

  const adState = adState_;
  const timeState = timeState_;
  let player = null;
  let playerVersion = null;
  let playerIsSetup = false;
  const {playerConfig, divId} = providerConfig;
  let isMuted;
  let previousLastTimePosition = 0;
  let lastTimePosition = 0;

  let setupCompleteCallbacks = [];
  let setupFailedCallbacks = [];
  let setupFailedEventHandlers = [];

  // TODO: test with older videojs versions
  let minimumSupportedPlayerVersion = '7.17.0';

  function init() {
    if (!vjs) {
      triggerSetupFailure(-1, setupFailMessage + ': Videojs not present')
      return;
    }

    playerVersion = vjs.VERSION;
    if (playerVersion < minimumSupportedPlayerVersion) {
      triggerSetupFailure(-2, setupFailMessage + ': Videojs version not supported');
      return;
    }

    if (!document.getElementById(divId)) {
      triggerSetupFailure(-3, setupFailMessage + ': No div found with id ' + divId);
      return;
    }

    const instantiatedPlayers = vjs.players;
    if (instantiatedPlayers && instantiatedPlayers[divId]) {
      // already instantiated
      player = instantiatedPlayers[divId];
      onReady();
      return;
    }

    setupPlayer(playerConfig);

    if (!player) {
      triggerSetupFailure(-4, setupFailMessage);
    }
  }

  function getId() {
    return divId;
  }

  function getOrtbVideo() {
    if (!player) {
      return;
    }

    let playBackMethod = PLAYBACK_METHODS.CLICK_TO_PLAY;
    // returns a boolean or a string with the autoplay strategy
    const autoplay = player.autoplay();
    const muted = player.muted() || autoplay === 'muted';
    // check if autoplay is truthy since it may be a bool or string
    if (autoplay) {
      playBackMethod = muted ? PLAYBACK_METHODS.AUTOPLAY_MUTED : PLAYBACK_METHODS.AUTOPLAY;
    }
    const supportedMediaTypes = Object.values(VIDEO_MIME_TYPE).filter(
      // Follows w3 spec https://www.w3.org/TR/2011/WD-html5-20110113/video.html#dom-navigator-canplaytype
      type => player.canPlayType(type) !== ''
    )

    // IMA supports vpaid unless its expliclty turned off
    // TODO: needs a reference to the imaOptions used at setup to determine if vpaid can be used
    // if (imaOptions && imaOptions.vpaidMode !== 0) {
    supportedMediaTypes.push(VPAID_MIME_TYPE);
    // }

    const video = {
      mimes: supportedMediaTypes,
      // Based on the protocol support provided by the videojs-ima plugin
      // https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/compatibility
      // Need to check for the plugins
      protocols: [
        PROTOCOLS.VAST_2_0,
      ],
      api: [
        API_FRAMEWORKS.VPAID_2_0 // TODO: needs a reference to the imaOptions used at setup to determine if vpaid can be used
      ],
      // TODO: Make sure this returns dimensions in DIPS
      h: player.currentHeight(),
      w: player.currentWidth(),
      // TODO: implement startdelay since its reccomend param
      // both linearity forms are supported so the param is excluded
      // sequence - TODO not yet supported
      maxextended: -1,
      boxingallowed: 1,
      playbackmethod: [ playBackMethod ],
      playbackend: PLAYBACK_END.VIDEO_COMPLETION,
      // Per ortb 7.4 skip is omitted since neither the player nor ima plugin imposes a skip button, or a skipmin/max
    };

    // TODO: Determine placement may not be in stream if videojs is only used to serve ad content
    // ~ Sort of resolved check if the player has a source to tell if the placement is instream
    // Still cannot reliably check what type of placement the player is if its outstream
    // i.e. we can't tell if its interstitial, in article, etc.
    if (player.src()) {
      video.placement = PLACEMENT.INSTREAM;
    }

    // Placement according to IQG Guidelines 4.2.8
    // https://cdn2.hubspot.net/hubfs/2848641/TrustworthyAccountabilityGroup_May2017/Docs/TAG-Inventory-Quality-Guidelines-v2_2-10-18-2016.pdf?t=1509469105938
    const findPosition = vjs.dom.findPosition;
    if (player.isFullscreen()) {
      video.pos = AD_POSITION.FULL_SCREEN;
    } else if (findPosition) {
      video.pos = utils.getPositionCode(findPosition(player.el()));
    }

    return video;
  }

  function getOrtbContent() {
    if (!player) {
      return;
    }

    const content = {
      // id:, TODO: find a suitable id for videojs sources
      url: player.currentSrc()
    };
    // Only include length if player is ready
    // player.readyState() returns a level of readiness from 0 to 4
    // https://docs.videojs.com/player#readyState
    if (player.readyState()) {
      content.len = Math.round(player.duration());
    }

    const mediaItem = utils.getMedia(player);
    if (mediaItem) {
      for (let param of ['id', 'title', 'description', 'album', 'artist']) {
        if (mediaItem[param]) {
          content[param] = mediaItem[param];
        }
      }
    }

    const contentUrl = utils.getValidMediaUrl(mediaItem && mediaItem.src, player.src)
    if (contentUrl) {
      content.url = contentUrl;
    }

    return content;
  }

  // Plugins to integrate: https://github.com/googleads/videojs-ima
  function setAdTagUrl(adTagUrl, options) {
    if (!player.ima || !adTagUrl) {
      return;
    }

    // The VideoJS IMA plugin version 1.11.0 will throw when the ad is empty.
    try {
      player.ima.changeAdTag(adTagUrl);
      player.ima.requestAds();
    } catch (e) {
      /*
      Handling is not required; ad errors are emitted automatically by video.js
       */
    }
  }

  function onEvent(type, callback, payload) {
    registerSetupListeners(type, callback, payload);

    if (!player) {
      return;
    }

    player.ready(() => {
      registerListeners(type, callback, payload);
    });
  }

  function registerSetupListeners(externalEventName, callback, basePayload) {
    // no point in registering for setup failures if already setup.
    if (playerIsSetup) {
      return;
    }

    if (externalEventName === SETUP_COMPLETE) {
      setupCompleteCallbacks.push(callback);
    } else if (externalEventName === SETUP_FAILED) {
      setupFailedCallbacks.push(callback);
      registerSetupErrorListener()
    }
  }

  function registerSetupErrorListener() {
    if (!player) {
      return
    }

    const eventHandler = () => {
      /*
      Videojs has no specific setup error handler
      so we imitate it by hooking to the general error
      handler and checking to see if the player has been setup
       */
      if (playerIsSetup) {
        return;
      }

      const error = player.error();
      triggerSetupFailure(error.code, error.message, error);
    };

    player.on(ERROR, eventHandler);
    setupFailedEventHandlers.push(eventHandler)
  }

  function registerListeners(externalEventName, callback, basePayload) {
    if (externalEventName === MUTE) {
      const eventHandler = () => {
        if (isMuted !== player.muted()) {
          basePayload.mute = isMuted = !isMuted;
          callback(externalEventName, basePayload);
        }
      };
      player.on(utils.getVideojsEventName(VOLUME), eventHandler);
      return;
    }

    let getEventPayload;

    switch (externalEventName) {
      case PLAY:
      case PAUSE:
      case DESTROYED:
        break;

      case PLAYBACK_REQUEST:
        getEventPayload = e => ({ playReason: 'unknown' });
        break;

      case AD_REQUEST:
        getEventPayload = e => {
          const adTagUrl = e.AdsRequest.adTagUrl;
          adState.updateState({ adTagUrl });
          return { adTagUrl };
        };
        break

      case AD_LOADED:
        getEventPayload = (e) => {
          const imaAd = e.getAdData && e.getAdData();
          adState.updateForEvent(imaAd);
          timeState.clearState();
          return adState.getState();
        };
        break

      case AD_STARTED:
      case AD_PLAY:
      case AD_PAUSE:
        getEventPayload = () => adState.getState();
        break

      case AD_IMPRESSION:
      case AD_CLICK:
        getEventPayload = () => Object.assign({}, adState.getState(), timeState.getState());
        break

      case AD_TIME:
        getEventPayload = (e) => {
          const adTimeEvent = e && e.getAdData && e.getAdData();
          timeState.updateForTimeEvent(adTimeEvent);
          return Object.assign({}, adState.getState(), timeState.getState());
        };
        break

      case AD_COMPLETE:
        getEventPayload = () => {
          const currentState = adState.getState();
          adState.clearState();
          return currentState;
        };
        break

      case AD_SKIPPED:
        getEventPayload = () => {
          const currentState = Object.assign({}, adState.getState(), timeState.getState());
          adState.clearState();
          return currentState;
        };
        break

      case AD_ERROR:
        getEventPayload = e => {
          const imaAdError = e.data && e.data.AdError;
          const extraPayload = Object.assign({
            playerErrorCode: imaAdError.getErrorCode(),
            vastErrorCode: imaAdError.getVastErrorCode(),
            errorMessage: imaAdError.getMessage(),
            sourceError: imaAdError.getInnerError()
            // timeout
          }, adState.getState(), timeState.getState());
          adState.clearState();
          return extraPayload;
        };
        break

      case PLAYLIST:
        getEventPayload = e => ({
          playlistItemCount: utils.getPlaylistCount(player),
          autostart: player.autoplay()
        });
        break

      case CONTENT_LOADED:
        getEventPayload = e => {
          const media = utils.getMedia(player);
          const contentUrl = utils.getValidMediaUrl(media && media.src, player.src, e && e.target && e.target.currentSrc)
          return {
            contentId: media && media.id,
            contentUrl,
            title: media && media.title,
            description: media && media.description,
            playlistIndex: utils.getCurrentPlaylistIndex(player),
            contentTags: media && media.contentTags
          };
        };
        break;

      case TIME:
        // TODO: might want to check seeking() and/or scrubbing()
        getEventPayload = e => {
          previousLastTimePosition = lastTimePosition;
          const currentTime = player.currentTime();
          const duration = player.duration();
          timeState.updateForTimeEvent({ currentTime, duration });
          lastTimePosition = currentTime;
          return {
            position: lastTimePosition,
            duration
          };
        };
        break;

      case SEEK_START:
        getEventPayload = e => {
          return {
            position: previousLastTimePosition,
            destination: player.currentTime(),
            duration: player.duration()
          };
        }
        break;

      case SEEK_END:
        getEventPayload = () => ({
          position: player.currentTime(),
          duration: player.duration()
        });
        break;

      case VOLUME:
        getEventPayload = e => ({ volumePercentage: player.volume() * 100 });
        break;

      case ERROR:
        getEventPayload = e => {
          const error = player.error();
          return {
            sourceError: error,
            errorCode: error.code,
            errorMessage: error.message,
          };
        };
        break;

      case COMPLETE:
        getEventPayload = e => {
          previousLastTimePosition = lastTimePosition = 0;
          timeState.clearState();
        };
        break;

      case FULLSCREEN:
        getEventPayload = e => ({ fullscreen: player.isFullscreen() });
        break;

      case PLAYER_RESIZE:
        getEventPayload = e => ({
          height: player.currentHeight(),
          width: player.currentWidth(),
        });
        break;

      default:
        return;
    }

    const eventHandler = getEventHandler(externalEventName, callback, basePayload, getEventPayload);

    if (externalEventName === PLAYLIST) {
      registerPlaylistEventListener(eventHandler);
      return;
    }

    const videojsEventName = utils.getVideojsEventName(externalEventName);

    if (AD_MANAGER_EVENTS.includes(externalEventName)) {
      player.on('ads-manager', () => player.ima.addEventListener(videojsEventName, eventHandler));
    } else {
      player.on(videojsEventName, eventHandler);
    }
  }

  function registerPlaylistEventListener(eventHandler) {
    if (player.playlist) {
      // force a playlist event on first item load
      player.one('loadstart', eventHandler);
      player.on('playlistchange', eventHandler);
    } else {
      // When playlist plugin is not used, treat each media item as a single item playlist
      player.on('loadstart', eventHandler);
    }
  }

  function offEvent(event, callback) {
    const videojsEvent = utils.getVideojsEventName(event)
    if (!callback) {
      player.off(videojsEvent);
      return;
    }

    const eventHandler = callbackToHandler[event];// callbackStorage.getCallback(event, callback);
    if (eventHandler) {
      player.off(videojsEvent, eventHandler);
    }
  }

  function destroy() {
    if (!player) {
      return;
    }
    player.remove();
    player = null;
  }

  return {
    init,
    getId,
    getOrtbVideo,
    getOrtbContent,
    setAdTagUrl,
    onEvent,
    offEvent,
    destroy
  };

  function setupPlayer(config) {
    const setupConfig = utils.getSetupConfig(config);
    player = vjs(divId, setupConfig, onReady);
  }

  function onReady() {
    try {
      setupAds();
    } catch (e) {
      triggerSetupFailure(-5, e.message);
      return;
    }
    triggerSetupComplete();
  }

  // TODO: consider supporting https://www.npmjs.com/package/videojs-vast-vpaid as well
  function setupAds() {
    if (!player.ima) {
      throw new Error(setupFailMessage + ': ima plugin is missing');
    }

    if (typeof player.ima !== 'function') {
      // when player.ima is already instantiated, it is an object. Early abort if already instantiated.
      return;
    }

    const adConfig = utils.getAdConfig(playerConfig);
    player.ima(adConfig);
  }

  function triggerSetupFailure(errorCode, msg, sourceError) {
    const payload = {
      divId,
      playerVersion,
      type: SETUP_FAILED,
      errorCode,
      errorMessage: msg,
      sourceError: sourceError
    };
    setupFailedCallbacks.forEach(setupFailedCallback => setupFailedCallback(SETUP_FAILED, payload));
    setupFailedCallbacks = [];
  }

  function triggerSetupComplete() {
    playerIsSetup = true;
    const payload = {
      divId,
      playerVersion,
      type: SETUP_COMPLETE,
    };

    setupCompleteCallbacks.forEach(callback => callback(SETUP_COMPLETE, payload));
    setupCompleteCallbacks = [];

    isMuted = player.muted();

    setupFailedEventHandlers.forEach(eventHandler => player.off('error', eventHandler));
    setupFailedEventHandlers = [];
  }
}

export const utils = {
  getSetupConfig: function (config) {
    if (!config) {
      return;
    }

    const params = config.params || {};
    const videojsConfig = params.vendorConfig || {};

    if (videojsConfig.autostart === undefined && config.autostart !== undefined) {
      videojsConfig.autostart = config.autostart
    }

    if (videojsConfig.muted === undefined && config.mute !== undefined) {
      videojsConfig.muted = config.mute;
    }

    return videojsConfig;
  },

  getAdConfig: function (config) {
    const params = config && config.params;
    if (!params) {
      return {};
    }

    return params.adPluginConfig || {}; // TODO: add adPluginConfig to spec
  },

  getPositionCode: function({left, top, width, height}) {
    const bottom = window.innerHeight - top - height;
    const right = window.innerWidth - left - width;

    if (left < 0 || right < 0 || top < 0) {
      return AD_POSITION.UNKNOWN;
    }

    return bottom >= 0 ? AD_POSITION.ABOVE_THE_FOLD : AD_POSITION.BELOW_THE_FOLD;
  },

  getVideojsEventName: function(eventName) {
    switch (eventName) {
      case SETUP_COMPLETE:
        return 'ready';
      case SETUP_FAILED:
        return 'error';
      case DESTROYED:
        return 'dispose';
      case AD_REQUEST:
        return 'ads-request';
      case AD_LOADED:
        return 'loaded'
      case AD_STARTED:
        return 'start';
      case AD_IMPRESSION:
        return 'impression';
      case AD_PLAY:
        return 'resume'
      case AD_PAUSE:
        return PAUSE;
      case AD_TIME:
        return 'adProgress';
      case AD_CLICK:
        return 'click';
      case AD_COMPLETE:
        return COMPLETE;
      case AD_SKIPPED:
        return 'skip';
      case AD_ERROR:
        return 'adserror';
      case CONTENT_LOADED:
        return 'loadstart';
      case ERROR:
        return ['error', 'aderror', 'contenterror'];
      case PLAY:
        return PLAY + 'ing';
      case PLAYBACK_REQUEST:
        return PLAY;
      case SEEK_START:
        return 'seeking';
      case SEEK_END:
        return 'seeked';
      case TIME:
        return TIME + 'update';
      case VOLUME:
        return VOLUME + 'change';
      case MUTE:
        return MUTE + 'change';
      case PLAYER_RESIZE:
        return 'playerresize';
      case FULLSCREEN:
        return FULLSCREEN + 'change';
      case COMPLETE:
        return 'ended';
      default:
        return eventName;
    }
    /*
    The following video.js events might map to an event in our spec
    'loadstart',
      'progress', buffer load ?
      'suspend',
      'abort',
      'error',
      'emptied',
      'stalled',
      'loadedmetadata', meta
      'loadeddata', meta
      'canplay',
      'canplaythrough',
      'waiting', buffer?
      'durationchange', meta-duration
      'ratechange',
     */
  },

  getMedia: function(player) {
    const playlistItem = this.getCurrentPlaylistItem(player);
    if (playlistItem) {
      return playlistItem.sources[0];
    }

    return player.getMedia();
  },

  getValidMediaUrl: function(mediaSrc, playerSrc, eventTargetSrc) {
    return this.getMediaUrl(mediaSrc) || this.getMediaUrl(playerSrc) || this.getMediaUrl(eventTargetSrc);
  },

  getMediaUrl: function(source) {
    if (!source) {
      return;
    }

    if (Array.isArray(source) && source.length) {
      return this.parseSource(source[0]);
    }

    return this.parseSource(source)
  },

  parseSource: function (source) {
    const type = typeof source;
    if (type === 'string') {
      return source;
    } else if (type === 'object') {
      return source.src;
    }
  },

  getPlaylistCount: function (player) {
    const playlist = player.playlist; // has playlist plugin
    if (!playlist) {
      return 1;
    }
    return playlist.lastIndex && playlist.lastIndex() + 1;
  },

  getCurrentPlaylistIndex: function (player) {
    const playlist = player.playlist; // has playlist plugin
    if (!playlist) {
      return 0;
    }
    return playlist.currentIndex && playlist.currentIndex();
  },

  getCurrentPlaylistItem: function(player) {
    const playlist = player.playlist; // has playlist plugin
    if (!playlist) {
      return;
    }

    const currentIndex = this.getCurrentPlaylistIndex(player);
    if (!currentIndex) {
      return
    }

    const item = playlist()[currentIndex];
    return item;
  }
};

const videojsSubmoduleFactory = function (config) {
  const adState = adStateFactory();
  const timeState = timeStateFactory();
  const callbackStorage = null;
  // videojs factory is stored to window by default
  const vjs = window.videojs;
  return VideojsProvider(config, vjs, adState, timeState, callbackStorage, utils);
}

videojsSubmoduleFactory.vendorCode = VIDEO_JS_VENDOR;
submodule('video', videojsSubmoduleFactory);
export default videojsSubmoduleFactory;

// STATE

/**
 * @returns {State}
 */
export function adStateFactory() {
  const adState = Object.assign({}, stateFactory());

  function updateForEvent(event) {
    if (!event) {
      return;
    }

    const skippable = event.skippable;
    // TODO: possibly can check traffickingParameters to determine if winning bid is passed
    const updates = {
      adId: event.adId,
      adServer: event.adSystem,
      advertiserName: event.advertiserName,
      redirectUrl: event.clickThroughUrl,
      creativeId: event.creativeId || event.creativeAdId,
      dealId: event.dealId,
      adDescription: event.description,
      linear: event.linear,
      creativeUrl: event.mediaUrl,
      adTitle: event.title,
      universalAdId: event.universalAdIdValue,
      creativeType: event.contentType,
      wrapperAdIds: event.adWrapperIds,
      skip: skippable ? 1 : 0,
      // missing fields:
      // loadTime
      // advertiserId - TODO: does this even exist ? If not, remove from spec
      // vastVersion
      // adCategories
      // campaignId
      // waterfallIndex
      // waterfallCount
      // skipmin
      // adTagUrl - for now, only has request ad tag
      // adPlacementType
    };

    const adPodInfo = event.adPodInfo;
    if (adPodInfo && adPodInfo.podIndex > -1) {
      updates.adPodCount = adPodInfo.totalAds;
      updates.adPodIndex = adPodInfo.adPosition - 1; // Per IMA docs, adPosition is 1 based.
    }

    if (adPodInfo && adPodInfo.timeOffset) {
      switch (adPodInfo.timeOffset) {
        case -1:
          updates.offset = 'post';
          break

        case 0:
          // TODO: Defaults to 0 if this ad is not part of a pod, or the pod is not part of an ad playlist. - need to check if loaded dynamically and pass last content time update
          updates.offset = 'pre';
          break

        default:
          updates.offset = '' + adPodInfo.timeOffset;
      }
    }

    if (skippable) {
      updates.skipafter = event.skipTimeOffset;
    }

    this.updateState(updates);
  }

  adState.updateForEvent = updateForEvent;

  return adState;
}

export function timeStateFactory() {
  const timeState = Object.assign({}, stateFactory());

  function updateForTimeEvent(event) {
    const { currentTime, duration } = event;
    this.updateState({
      time: currentTime,
      duration,
      playbackMode: getPlaybackMode(duration)
    });
  }

  timeState.updateForTimeEvent = updateForTimeEvent;

  function getPlaybackMode(duration) {
    if (duration > 0) {
      return PLAYBACK_MODE.VOD;
    } else if (duration < 0) {
      return PLAYBACK_MODE.DVR;
    }

    return PLAYBACK_MODE.LIVE;
  }

  return timeState;
}