Source: lib/ads/server_side_ad_manager.js

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


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

goog.require('goog.asserts');
goog.require('shaka.ads.Utils');
goog.require('shaka.ads.ServerSideAd');
goog.require('shaka.log');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.Error');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.PublicPromise');


/**
 * A class responsible for server-side ad interactions.
 * @implements {shaka.util.IReleasable}
 */
shaka.ads.ServerSideAdManager = class {
  /**
   * @param {HTMLElement} adContainer
   * @param {HTMLMediaElement} video
   * @param {string} locale
   * @param {function(!shaka.util.FakeEvent)} onEvent
   */
  constructor(adContainer, video, locale, onEvent) {
    /** @private {HTMLElement} */
    this.adContainer_ = adContainer;

    /** @private {HTMLMediaElement} */
    this.video_ = video;

    /** @private {?shaka.extern.AdsConfiguration} */
    this.config_ = null;

    /** @private
        {?shaka.util.PublicPromise.<string>} */
    this.streamPromise_ = null;

    /** @private {number} */
    this.streamRequestStartTime_ = NaN;

    /** @private {function(!shaka.util.FakeEvent)} */
    this.onEvent_ = onEvent;

    /** @private {boolean} */
    this.isLiveContent_ = false;

    /**
     * Time to seek to after an ad if that ad was played as the result of
     * snapback.
     * @private {?number}
     */
    this.snapForwardTime_ = null;

    /** @private {shaka.ads.ServerSideAd} */
    this.ad_ = null;

    /** @private {?google.ima.dai.api.AdProgressData} */
    this.adProgressData_ = null;

    /** @private {string} */
    this.backupUrl_ = '';

    /** @private {!Array.<!shaka.extern.AdCuePoint>} */
    this.currentCuePoints_ = [];

    /** @private {shaka.util.EventManager} */
    this.eventManager_ = new shaka.util.EventManager();

    /** @private {google.ima.dai.api.UiSettings} */
    const uiSettings = new google.ima.dai.api.UiSettings();
    uiSettings.setLocale(locale);

    /** @private {google.ima.dai.api.StreamManager} */
    this.streamManager_ = new google.ima.dai.api.StreamManager(
        this.video_, this.adContainer_, uiSettings);

    this.onEvent_(new shaka.util.FakeEvent(
        shaka.ads.Utils.IMA_STREAM_MANAGER_LOADED,
        (new Map()).set('imaStreamManager', this.streamManager_)));

    // Events
    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.LOADED, (e) => {
          shaka.log.info('Ad SS Loaded');
          this.onLoaded_(
              /** @type {!google.ima.dai.api.StreamEvent} */ (e));
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.ERROR, () => {
          shaka.log.info('Ad SS Error');
          this.onError_();
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.AD_BREAK_STARTED, () => {
          shaka.log.info('Ad Break Started');
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.STARTED, (e) => {
          shaka.log.info('Ad Started');
          this.onAdStart_(/** @type {!google.ima.dai.api.StreamEvent} */ (e));
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.AD_BREAK_ENDED, () => {
          shaka.log.info('Ad Break Ended');
          this.onAdBreakEnded_();
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.AD_PROGRESS, (e) => {
          this.onAdProgress_(
              /** @type {!google.ima.dai.api.StreamEvent} */ (e));
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.FIRST_QUARTILE, () => {
          shaka.log.info('Ad event: First Quartile');
          this.onEvent_(
              new shaka.util.FakeEvent(shaka.ads.Utils.AD_FIRST_QUARTILE));
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.MIDPOINT, () => {
          shaka.log.info('Ad event: Midpoint');
          this.onEvent_(
              new shaka.util.FakeEvent(shaka.ads.Utils.AD_MIDPOINT));
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.THIRD_QUARTILE, () => {
          shaka.log.info('Ad event: Third Quartile');
          this.onEvent_(
              new shaka.util.FakeEvent(shaka.ads.Utils.AD_THIRD_QUARTILE));
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.COMPLETE, () => {
          shaka.log.info('Ad event: Complete');
          this.onEvent_(
              new shaka.util.FakeEvent(shaka.ads.Utils.AD_COMPLETE));
          this.onEvent_(
              new shaka.util.FakeEvent(shaka.ads.Utils.AD_STOPPED));
          this.adContainer_.removeAttribute('ad-active');
          this.ad_ = null;
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.SKIPPED, () => {
          shaka.log.info('Ad event: Skipped');
          this.onEvent_(
              new shaka.util.FakeEvent(shaka.ads.Utils.AD_SKIPPED));
          this.onEvent_(
              new shaka.util.FakeEvent(shaka.ads.Utils.AD_STOPPED));
        });

    this.eventManager_.listen(this.streamManager_,
        google.ima.dai.api.StreamEvent.Type.CUEPOINTS_CHANGED, (e) => {
          shaka.log.info('Ad event: Cue points changed');
          this.onCuePointsChanged_(
              /** @type {!google.ima.dai.api.StreamEvent} */ (e));
        });
  }

  /**
   * Called by the AdManager to provide an updated configuration any time it
   * changes.
   *
   * @param {shaka.extern.AdsConfiguration} config
   */
  configure(config) {
    this.config_ = config;
  }

  /**
   * @param {!google.ima.dai.api.StreamRequest} streamRequest
   * @param {string=} backupUrl
   * @return {!Promise.<string>}
   */
  streamRequest(streamRequest, backupUrl) {
    if (this.streamPromise_) {
      return Promise.reject(new shaka.util.Error(
          shaka.util.Error.Severity.RECOVERABLE,
          shaka.util.Error.Category.ADS,
          shaka.util.Error.Code.CURRENT_DAI_REQUEST_NOT_FINISHED));
    }
    if (streamRequest instanceof google.ima.dai.api.LiveStreamRequest) {
      this.isLiveContent_ = true;
    }

    this.streamPromise_ = new shaka.util.PublicPromise();
    this.streamManager_.requestStream(streamRequest);
    this.backupUrl_ = backupUrl || '';

    this.streamRequestStartTime_ = Date.now() / 1000;

    return this.streamPromise_;
  }

  /**
   * @param {Object} adTagParameters
   */
  replaceAdTagParameters(adTagParameters) {
    this.streamManager_.replaceAdTagParameters(adTagParameters);
  }

  /**
   * Resets the stream manager and removes any continuous polling.
   */
  stop() {
    // TODO:
    // For SS DAI streams, if a different asset gets unloaded as
    // part of the process
    // of loading a DAI asset, stream manager state gets reset and we
    // don't get any ad events.
    // We need to figure out if it makes sense to stop the SS
    // manager on unload, and, if it does, find
    // a way to do it safely.
    // this.streamManager_.reset();
    this.backupUrl_ = '';
    this.snapForwardTime_ = null;
    this.currentCuePoints_ = [];
  }

  /** @override */
  release() {
    this.stop();
    if (this.eventManager_) {
      this.eventManager_.release();
    }
  }

  /**
   * @param {string} type
   * @param {Uint8Array|string} data
   *   Comes as string in DASH and as Uint8Array in HLS.
   * @param {number} timestamp (in seconds)
   */
  onTimedMetadata(type, data, timestamp) {
    this.streamManager_.processMetadata(type, data, timestamp);
  }

  /**
   * @param {shaka.extern.MetadataFrame} value
   */
  onCueMetadataChange(value) {
    // Native HLS over Safari/iOS/iPadOS
    // For live event streams, the stream needs some way of informing the SDK
    // that an ad break is coming up or ending. In the IMA DAI SDK, this is
    // done through timed metadata. Timed metadata is carried as part of the
    // DAI stream content and carries ad break timing information used by the
    // SDK to track ad breaks.
    if (value.key && value.data) {
      const metadata = {};
      metadata[value.key] = value.data;
      this.streamManager_.onTimedMetadata(metadata);
    }
  }

  /**
   * @return {!Array.<!shaka.extern.AdCuePoint>}
   */
  getCuePoints() {
    return this.currentCuePoints_;
  }

  /**
   * If a seek jumped over the ad break, return to the start of the
   * ad break, then complete the seek after the ad played through.
   * @private
   */
  checkForSnapback_() {
    const currentTime = this.video_.currentTime;
    if (currentTime == 0) {
      return;
    }

    this.streamManager_.streamTimeForContentTime(currentTime);
    const previousCuePoint =
        this.streamManager_.previousCuePointForStreamTime(currentTime);
    // The cue point gets marked as 'played' as soon as the playhead hits it
    // (at the start of an ad), so when we come back to this method as a result
    // of seeking back to the user-selected time, the 'played' flag will be set.
    if (previousCuePoint && !previousCuePoint.played) {
      shaka.log.info('Seeking back to the start of the ad break at ' +
          previousCuePoint.start + ' and will return to ' + currentTime);
      this.snapForwardTime_ = currentTime;
      this.video_.currentTime = previousCuePoint.start;
    }
  }

  /**
   * @param {!google.ima.dai.api.StreamEvent} e
   * @private
   */
  onAdStart_(e) {
    goog.asserts.assert(this.streamManager_,
        'Should have a stream manager at this point!');

    const imaAd = e.getAd();
    this.ad_ = new shaka.ads.ServerSideAd(imaAd, this.video_);

    // Ad object and ad progress data come from two different IMA events.
    // It's a race, and we don't know, which one will fire first - the
    // event that contains an ad object (AD_STARTED) or the one that
    // contains ad progress info (AD_PROGRESS).
    // If the progress event fired first, we must've saved the progress
    // info and can now add it to the ad object.
    if (this.adProgressData_) {
      this.ad_.setProgressData(this.adProgressData_);
    }

    this.onEvent_(new shaka.util.FakeEvent(shaka.ads.Utils.AD_STARTED,
        (new Map()).set('ad', this.ad_)));
    this.adContainer_.setAttribute('ad-active', 'true');
  }

  /**
   * @private
   */
  onAdBreakEnded_() {
    this.adContainer_.removeAttribute('ad-active');
    const currentTime = this.video_.currentTime;
    // If the ad break was a result of snapping back (a user seeked over
    // an ad break and was returned to it), seek forward to the point,
    // originally chosen by the user.
    if (this.snapForwardTime_ && this.snapForwardTime_ > currentTime) {
      this.video_.currentTime = this.snapForwardTime_;
      this.snapForwardTime_ = null;
    }
  }

  /**
   * @param {!google.ima.dai.api.StreamEvent} e
   * @private
   */
  onLoaded_(e) {
    const now = Date.now() / 1000;
    const loadTime = now - this.streamRequestStartTime_;
    this.onEvent_(new shaka.util.FakeEvent(shaka.ads.Utils.ADS_LOADED,
        (new Map()).set('loadTime', loadTime)));

    const streamData = e.getStreamData();
    const url = streamData.url;
    this.streamPromise_.resolve(url);
    this.streamPromise_ = null;

    if (!this.isLiveContent_) {
      this.eventManager_.listen(this.video_, 'seeked', () => {
        this.checkForSnapback_();
      });
    }
  }

  /**
   * @private
   */
  onError_() {
    if (!this.backupUrl_.length) {
      this.streamPromise_.reject('IMA Stream request returned an error ' +
          'and there was no backup asset uri provided.');
      this.streamPromise_ = null;
      return;
    }

    shaka.log.warning('IMA stream request returned an error. ' +
        'Falling back to the backup asset uri.');
    this.streamPromise_.resolve(this.backupUrl_);
    this.streamPromise_ = null;
  }


  /**
   * @param {!google.ima.dai.api.StreamEvent} e
   * @private
   */
  onAdProgress_(e) {
    const streamData = e.getStreamData();
    const adProgressData = streamData.adProgressData;
    this.adProgressData_ = adProgressData;
    if (this.ad_) {
      this.ad_.setProgressData(this.adProgressData_);
    }
  }


  /**
   * @param {!google.ima.dai.api.StreamEvent} e
   * @private
   */
  onCuePointsChanged_(e) {
    const streamData = e.getStreamData();

    /** @type {!Array.<!shaka.extern.AdCuePoint>} */
    const cuePoints = [];
    for (const point of streamData.cuepoints) {
      /** @type {shaka.extern.AdCuePoint} */
      const shakaCuePoint = {
        start: point.start,
        end: point.end,
      };
      cuePoints.push(shakaCuePoint);
    }

    this.currentCuePoints_ = cuePoints;

    this.onEvent_(new shaka.util.FakeEvent(
        shaka.ads.Utils.CUEPOINTS_CHANGED,
        (new Map()).set('cuepoints', cuePoints)));
  }
};