Source: lib/ads/interstitial_ad_session.js

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


goog.provide('shaka.ads.InterstitialAdSession');

goog.require('shaka.ads.Utils');
goog.require('shaka.util.Dom');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.IReleasable');

goog.requireType('shaka.ads.InterstitialAd');
goog.requireType('shaka.net.NetworkingEngine');


/**
 * Owns the playback clock and measurement state of one interstitial. State
 * belongs to the session, so a callback from an earlier ad cannot track the
 * next ad in a pod. Format specific measurement is delegated to an optional
 * tracker.
 *
 * @implements {shaka.util.IReleasable}
 */
shaka.ads.InterstitialAdSession = class {
  /**
   * @param {!HTMLMediaElement} video
   * @param {!shaka.extern.AdInterstitial} interstitial
   * @param {!shaka.ads.InterstitialAd} ad
   * @param {function(string)} onEvent
   * @param {function():number=} contentPosition
   */
  constructor(video, interstitial, ad, onEvent,
      contentPosition = () => video.currentTime) {
    /** @private {!HTMLMediaElement} */
    this.video_ = video;
    /** @private {!shaka.extern.AdInterstitial} */
    this.interstitial_ = interstitial;
    /** @private {!shaka.ads.InterstitialAd} */
    this.ad_ = ad;
    /** @private {function(string)} */
    this.onEvent_ = onEvent;
    /** @private {function():number} */
    this.contentPosition_ = contentPosition;
    /** @private {?shaka.ads.InterstitialAdSession.Tracker} */
    this.tracker_ = null;
    /** @private {boolean} */
    this.canSkip_ = false;
    /** @private {!Array<{start: number, end: number}>} */
    this.playedRanges_ = [];
    /** @private {!Set<string>} */
    this.quartiles_ = new Set();
    /** @private {?number} */
    this.lastPosition_ = null;
    /** @private {boolean} */
    this.started_ = false;
    /** @private {boolean} */
    this.paused_ = false;
    /** @private {boolean} */
    this.muted_ = video.muted;
    /** @private {boolean} */
    this.completed_ = false;
    /** @private {boolean} */
    this.seeked_ = false;
    /** @private {boolean} */
    this.released_ = false;
    /** @private {!shaka.util.EventManager} */
    this.eventManager_ = new shaka.util.EventManager();
    this.eventManager_.listen(video, 'seeking', () => {
      // A seek establishes a new clock origin; it never represents watched
      // media between the old and new playhead positions.
      this.lastPosition_ = null;
      this.completed_ = false;
      this.seeked_ = true;
    });
    this.eventManager_.listen(video, 'timeupdate', () => this.update());
    this.eventManager_.listen(video, 'playing', () => this.update());
  }

  /**
   * Attaches the format specific tracker, if any, and resolves its deferred
   * data. Data that becomes available later is delivered without replaying
   * events that were already sent.
   *
   * @param {?shaka.ads.InterstitialAdSession.Tracker} tracker
   */
  setTracker(tracker) {
    this.tracker_ = tracker;
    if (!tracker) {
      return;
    }
    shaka.util.Dom.listenForPlayerExpansion(this.eventManager_, this.video_,
        (expanded) => {
          this.notify(expanded ? 'playerExpand' : 'playerCollapse');
        });
    tracker.resolve().then(() => this.refresh());
  }

  /** @override */
  release() {
    this.released_ = true;
    this.eventManager_.release();
    if (this.tracker_) {
      this.tracker_.release();
      this.tracker_ = null;
    }
  }

  /** @return {!shaka.util.EventManager} */
  getEventManager() {
    return this.eventManager_;
  }

  /**
   * The playhead position relative to the start of the interstitial.
   *
   * @return {number}
   */
  getPosition() {
    return this.interstitial_.playbackMode == shaka.ads.Utils.EMBEDDED ?
        this.video_.currentTime - this.interstitial_.startTime :
        this.video_.currentTime;
  }

  /**
   * Whether the user seeked during the interstitial. Browsers fire
   * timeupdate before seeked, so the seeked event alone is not enough to tell
   * a skip from the natural end of the interstitial.
   *
   * @return {boolean}
   */
  hasSeeked() {
    return this.seeked_;
  }

  /** @return {boolean} */
  canComplete() {
    this.update();
    return this.completed_;
  }

  /**
   * Advances measurement only over media played at normal speed. Quartiles
   * and offset events crossed by seeks or fast playback are not backfilled.
   */
  update() {
    if (this.released_ || this.video_.readyState < 2 ||
        this.ad_.getDuration() <= 0) {
      return;
    }
    const position = this.getPosition();
    const duration = this.ad_.getDuration();
    if (this.video_.seeking ||
        (this.video_.paused && !this.video_.ended && position < duration)) {
      this.lastPosition_ = null;
      return;
    }
    const Utils = shaka.ads.Utils;
    if (!this.started_) {
      this.started_ = true;
      if (position < 0.1) {
        this.lastPosition_ = 0;
      }
      this.notify(Utils.AD_LOADED);
      this.notify(Utils.AD_IMPRESSION);
      this.notify(Utils.AD_STARTED);
    }
    if (this.interstitial_.playbackMode == shaka.ads.Utils.EMBEDDED &&
        this.canSkip_ != this.ad_.canSkipNow()) {
      this.canSkip_ = this.ad_.canSkipNow();
      this.onEvent_(Utils.AD_SKIP_STATE_CHANGED);
    }
    if (this.video_.playbackRate != 1) {
      this.lastPosition_ = null;
      return;
    }
    const previous = this.lastPosition_;
    this.lastPosition_ = position;
    if (previous == null || position < previous) {
      return;
    }
    const lastRange = this.playedRanges_[this.playedRanges_.length - 1];
    if (lastRange && lastRange.end == previous) {
      lastRange.end = position;
    } else {
      this.playedRanges_.push({start: previous, end: position});
    }
    const quartiles = [
      {fraction: 0.25, type: Utils.AD_FIRST_QUARTILE},
      {fraction: 0.5, type: Utils.AD_MIDPOINT},
      {fraction: 0.75, type: Utils.AD_THIRD_QUARTILE},
    ];
    for (const quartile of quartiles) {
      const threshold = duration * quartile.fraction;
      if (previous <= threshold && position >= threshold &&
          !this.quartiles_.has(quartile.type)) {
        this.quartiles_.add(quartile.type);
        this.onEvent_(quartile.type);
      }
    }
    if (this.tracker_) {
      this.tracker_.onProgress(previous, position);
    }
    // Reaching the end of the media while measuring is a completion, even
    // when the media is slightly shorter than the signaled duration.
    this.completed_ = this.completed_ || this.video_.ended ||
        (position >= duration && previous < duration);
  }

  /**
   * Forwards an ad event to the tracker. Events describing playback are only
   * forwarded once playback has actually started.
   *
   * @param {string} type
   */
  notify(type) {
    const Utils = shaka.ads.Utils;
    if (!this.tracker_ || this.released_) {
      return;
    }
    // AD_STARTED announces the ad object before external media has loaded.
    // Start and impression are measured on actual playback instead.
    if (!this.started_ && type != Utils.AD_LOADED && type != Utils.AD_ERROR) {
      return;
    }
    if (type == Utils.AD_PAUSED) {
      this.paused_ = true;
    } else if (type == Utils.AD_RESUMED) {
      if (!this.paused_) {
        return;
      }
      this.paused_ = false;
    } else if (type == Utils.AD_MUTED || type == Utils.AD_VOLUME_CHANGED) {
      if (this.video_.muted == this.muted_) {
        return;
      }
      this.muted_ = this.video_.muted;
    }
    this.tracker_.onEvent(type);
  }

  /**
   * Delivers data that became valid while it was being resolved, without
   * replaying events that were already delivered.
   */
  refresh() {
    if (this.released_) {
      return;
    }
    if (this.started_ && this.tracker_) {
      const Utils = shaka.ads.Utils;
      this.tracker_.onEvent(Utils.AD_LOADED);
      this.tracker_.onEvent(Utils.AD_IMPRESSION);
      this.tracker_.onEvent(Utils.AD_STARTED);
      for (const type of this.quartiles_) {
        this.tracker_.onEvent(type);
      }
      for (const range of this.playedRanges_) {
        this.tracker_.onProgress(range.start, range.end);
      }
    }
    this.update();
  }

  /**
   * Expands known VAST macros and uses the VAST unavailable value for context
   * the player cannot supply. Apps may still add context via request filters.
   *
   * @param {string} uri
   * @return {string}
   */
  expandUri(uri) {
    const pad = (value) => (value < 10 ? '0' : '') + value;
    const time = (seconds) => {
      const value = Math.max(0, seconds);
      return pad(Math.floor(value / 3600)) + ':' +
          pad(Math.floor(value / 60 % 60)) + ':' +
          (value % 60 < 10 ? '0' : '') + (value % 60).toFixed(3);
    };
    // Eight random digits, keeping the leading zeros.
    const random = String(1e8 + Math.floor(Math.random() * 1e8)).slice(1);
    const macros = {
      'CACHEBUSTING': random,
      'RANDOM': random,
      'TIMESTAMP': new Date().toISOString(),
      'ADPLAYHEAD': time(this.getPosition()),
      'CONTENTPLAYHEAD': time(this.contentPosition_()),
      'ASSETURI': this.interstitial_.uri || this.video_.currentSrc,
      'ADTYPE': this.ad_.isLinear() ? '1' : '2',
      'ADID': this.ad_.getAdId(),
      'PLAYERSIZE': this.video_.clientWidth + ',' + this.video_.clientHeight,
      'PLAYERSTATE': this.video_.paused ? 'paused' : 'playing',
    };
    return uri.replace(/\[([A-Z0-9_]+)\]|%5B([A-Z0-9_]+)%5D/gi,
        (match, plain, encoded) => encodeURIComponent(
            macros[(plain || encoded).toUpperCase()] || '-1'));
  }
};


/**
 * Format specific measurement of one interstitial.
 *
 * @interface
 * @extends {shaka.util.IReleasable}
 */
shaka.ads.InterstitialAdSession.Tracker = class {
  /**
   * Called for every ad event of the interstitial. May be called again with
   * the same event when deferred data is resolved.
   *
   * @param {string} type
   */
  onEvent(type) {}

  /**
   * Called when media between two positions (relative to the start of the
   * interstitial) was played at normal speed.
   *
   * @param {number} previous
   * @param {number} position
   */
  onProgress(previous, position) {}

  /**
   * Resolves data required for measurement that is not available yet.
   *
   * @return {!Promise}
   */
  resolve() {}
};


/**
 * Creates the tracker of an interstitial, or null if it has nothing to track.
 * It is given a function that sends a tracking beacon, expanding its macros,
 * and a function that fetches a resource.
 *
 * @typedef {function(!shaka.extern.AdInterstitial,
 *     !shaka.ads.InterstitialAdSession, function(string),
 *     function(string):!shaka.net.NetworkingEngine.PendingRequest):
 *     ?shaka.ads.InterstitialAdSession.Tracker}
 */
shaka.ads.InterstitialAdSession.TrackerFactory;