Source: lib/ads/interstitial_ad_manager.js

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


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

goog.require('goog.asserts');
goog.require('shaka.Player');
goog.require('shaka.ads.InterstitialAd');
goog.require('shaka.ads.InterstitialStaticAd');
goog.require('shaka.ads.Utils');
goog.require('shaka.log');
goog.require('shaka.media.PreloadManager');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.net.NetworkingUtils');
goog.require('shaka.util.Dom');
goog.require('shaka.util.Error');
goog.require('shaka.util.EventManager');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.Platform');
goog.require('shaka.util.PublicPromise');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.Timer');
goog.require('shaka.util.TXml');


/**
 * A class responsible for Interstitial ad interactions.
 *
 * @implements {shaka.util.IReleasable}
 */
shaka.ads.InterstitialAdManager = class {
  /**
   * @param {HTMLElement} adContainer
   * @param {shaka.Player} basePlayer
   * @param {HTMLMediaElement} baseVideo
   * @param {function(!shaka.util.FakeEvent)} onEvent
   */
  constructor(adContainer, basePlayer, baseVideo, onEvent) {
    /** @private {?shaka.extern.AdsConfiguration} */
    this.config_ = null;

    /** @private {HTMLElement} */
    this.adContainer_ = adContainer;

    /** @private {shaka.Player} */
    this.basePlayer_ = basePlayer;

    /** @private {HTMLMediaElement} */
    this.baseVideo_ = baseVideo;

    /** @private {?HTMLMediaElement} */
    this.adVideo_ = null;

    /** @private {boolean} */
    this.usingBaseVideo_ = true;

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

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

    /** @private {!Set.<string>} */
    this.interstitialIds_ = new Set();

    /** @private {!Set.<shaka.extern.AdInterstitial>} */
    this.interstitials_ = new Set();

    /**
     * @private {!Map.<shaka.extern.AdInterstitial,
     *                 Promise<?shaka.media.PreloadManager>>}
     */
    this.preloadManagerInterstitials_ = new Map();

    /** @private {shaka.Player} */
    this.player_ = new shaka.Player();

    this.updatePlayerConfig_();

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

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

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

    /** @private {?number} */
    this.lastTime_ = null;

    /** @private {?shaka.extern.AdInterstitial} */
    this.lastPlayedAd_ = null;

    /** @private {?shaka.util.Timer} */
    this.playoutLimitTimer_ = null;

    this.eventManager_.listen(this.baseVideo_, 'timeupdate', () => {
      if (this.playingAd_ || this.lastTime_ ||
          this.basePlayer_.isRemotePlayback()) {
        return;
      }
      this.lastTime_ = this.baseVideo_.currentTime;
      const currentInterstitial = this.getCurrentInterstitial_(
          /* needPreRoll= */ true);
      if (currentInterstitial) {
        this.setupAd_(currentInterstitial, /* sequenceLength= */ 1,
            /* adPosition= */ 1, /* initialTime= */ Date.now());
      }
    });

    const checkForInterstitials = () => {
      if (this.playingAd_ || !this.lastTime_ ||
          this.basePlayer_.isRemotePlayback()) {
        return;
      }
      this.lastTime_ = this.baseVideo_.currentTime;
      // Remove last played add when the new time is before to the ad time.
      if (this.lastPlayedAd_ &&
          !this.lastPlayedAd_.pre && !this.lastPlayedAd_.post &&
          this.lastTime_ < this.lastPlayedAd_.startTime) {
        this.lastPlayedAd_ = null;
      }
      const currentInterstitial = this.getCurrentInterstitial_();
      if (currentInterstitial) {
        this.setupAd_(currentInterstitial, /* sequenceLength= */ 1,
            /* adPosition= */ 1, /* initialTime= */ Date.now());
      }
    };

    this.eventManager_.listen(this.baseVideo_, 'ended', () => {
      checkForInterstitials();
    });

    /** @private {shaka.util.Timer} */
    this.timeUpdateTimer_ = new shaka.util.Timer(checkForInterstitials);

    if ('requestVideoFrameCallback' in this.baseVideo_ &&
        !shaka.util.Platform.isSmartTV()) {
      const baseVideo = /** @type {!HTMLVideoElement} */ (this.baseVideo_);
      const videoFrameCallback = () => {
        checkForInterstitials();
        baseVideo.requestVideoFrameCallback(videoFrameCallback);
      };
      baseVideo.requestVideoFrameCallback(videoFrameCallback);
    } else {
      this.timeUpdateTimer_.tickEvery(/* seconds= */ 0.025);
    }

    /** @private {shaka.util.Timer} */
    this.pollTimer_ = new shaka.util.Timer(async () => {
      if (this.interstitials_.size && this.lastTime_ != null) {
        const currentLoadMode = this.basePlayer_.getLoadMode();
        if (currentLoadMode == shaka.Player.LoadMode.DESTROYED ||
            currentLoadMode == shaka.Player.LoadMode.NOT_LOADED) {
          return;
        }
        let cuepointsChanged = false;
        const interstitials = Array.from(this.interstitials_);
        const seekRange = this.basePlayer_.seekRange();
        for (const interstitial of interstitials) {
          if (interstitial == this.lastPlayedAd_) {
            continue;
          }
          const comparisonTime = interstitial.endTime || interstitial.startTime;
          if ((seekRange.start - comparisonTime) >= 1) {
            if (this.preloadManagerInterstitials_.has(interstitial)) {
              const preloadManager =
                  // eslint-disable-next-line no-await-in-loop
                  await this.preloadManagerInterstitials_.get(interstitial);
              if (preloadManager) {
                preloadManager.destroy();
              }
              this.preloadManagerInterstitials_.delete(interstitial);
            }
            const interstitialId = JSON.stringify(interstitial);
            if (this.interstitialIds_.has(interstitialId)) {
              this.interstitialIds_.delete(interstitialId);
            }
            this.interstitials_.delete(interstitial);
            if (!interstitial.overlay) {
              cuepointsChanged = true;
            }
          } else {
            const difference = interstitial.startTime - this.lastTime_;
            if (difference > 0 && difference <= 10) {
              if (!this.preloadManagerInterstitials_.has(interstitial) &&
                  this.isPreloadAllowed_(interstitial)) {
                this.preloadManagerInterstitials_.set(
                    interstitial, this.player_.preload(
                        interstitial.uri,
                        /* startTime= */ null,
                        interstitial.mimeType || undefined));
              }
            }
          }
        }
        if (cuepointsChanged) {
          this.cuepointsChanged_();
        }
      }
    }).tickEvery(/* seconds= */ 1);
  }

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

  /**
   * @private
   */
  determineIfUsingBaseVideo_() {
    if (!this.adContainer_ || !this.config_ || this.playingAd_) {
      return;
    }
    let supportsMultipleMediaElements =
        this.config_.supportsMultipleMediaElements;
    const video = /** @type {HTMLVideoElement} */(this.baseVideo_);
    if (video.webkitSupportsFullscreen && video.webkitDisplayingFullscreen) {
      supportsMultipleMediaElements = false;
    }
    if (this.usingBaseVideo_ != supportsMultipleMediaElements) {
      return;
    }
    this.usingBaseVideo_ = !supportsMultipleMediaElements;
    if (this.usingBaseVideo_) {
      this.video_ = this.baseVideo_;
      if (this.adVideo_) {
        if (this.adVideo_.parentElement) {
          this.adContainer_.removeChild(this.adVideo_);
        }
        this.adVideo_ = null;
      }
    } else {
      if (!this.adVideo_) {
        this.adVideo_ = this.createMediaElement_();
      }
      this.video_ = this.adVideo_;
    }
  }


  /**
   * Resets the Interstitial manager and removes any continuous polling.
   */
  stop() {
    if (this.adEventManager_) {
      this.adEventManager_.removeAll();
    }
    this.interstitialIds_.clear();
    this.interstitials_.clear();
    this.player_.destroyAllPreloads();
    this.preloadManagerInterstitials_.clear();
    this.player_.detach();
    this.playingAd_ = false;
    this.lastTime_ = null;
    this.lastPlayedAd_ = null;
    this.usingBaseVideo_ = true;
    this.video_ = this.baseVideo_;
    this.adVideo_ = null;
    if (this.adContainer_) {
      shaka.util.Dom.removeAllChildren(this.adContainer_);
    }
    if (this.playoutLimitTimer_) {
      this.playoutLimitTimer_.stop();
      this.playoutLimitTimer_ = null;
    }
  }

  /** @override */
  release() {
    this.stop();
    if (this.eventManager_) {
      this.eventManager_.release();
    }
    if (this.adEventManager_) {
      this.adEventManager_.release();
    }
    if (this.timeUpdateTimer_) {
      this.timeUpdateTimer_.stop();
      this.timeUpdateTimer_ = null;
    }
    if (this.pollTimer_) {
      this.pollTimer_.stop();
      this.pollTimer_ = null;
    }
    this.player_.destroy();
  }


  /**
   * @param {shaka.extern.HLSInterstitial} hlsInterstitial
   */
  async addMetadata(hlsInterstitial) {
    this.updatePlayerConfig_();
    const adInterstitials = await this.getInterstitialsInfo_(hlsInterstitial);
    if (adInterstitials.length) {
      this.addInterstitials(adInterstitials);
    } else {
      shaka.log.alwaysWarn('Unsupported HLS interstitial', hlsInterstitial);
    }
  }

  /**
   * @param {shaka.extern.TimelineRegionInfo} region
   */
  addRegion(region) {
    let alternativeMPDUri;
    for (const node of region.eventNode.children) {
      if (node.tagName == 'AlternativeMPD') {
        const uri = node.attributes['uri'];
        if (uri) {
          alternativeMPDUri = uri;
          break;
        }
      }
    }
    if (!alternativeMPDUri) {
      shaka.log.alwaysWarn('Unsupported MPD alternate', region);
      return;
    }
    const isReplace =
        region.schemeIdUri == 'urn:mpeg:dash:event:alternativeMPD:replace:2025';
    const isInsert =
        region.schemeIdUri == 'urn:mpeg:dash:event:alternativeMPD:insert:2025';
    if (!isReplace && !isInsert) {
      shaka.log.warning('Unsupported MPD alternate', region);
      return;
    }

    /** @type {!shaka.extern.AdInterstitial} */
    const interstitial = {
      id: region.id,
      startTime: region.startTime,
      endTime: region.endTime,
      uri: alternativeMPDUri,
      mimeType: null,
      isSkippable: false,
      skipOffset: null,
      skipFor: null,
      canJump: true,
      resumeOffset: isInsert ? 0 : null,
      playoutLimit: null,
      once: false,
      pre: false,
      post: false,
      timelineRange: isReplace && !isInsert,
      loop: false,
      overlay: null,
    };
    this.addInterstitials([interstitial]);
  }

  /**
   * @param {shaka.extern.TimelineRegionInfo} region
   */
  addOverlayRegion(region) {
    const TXml = shaka.util.TXml;

    goog.asserts.assert(region.eventNode, 'Need a region eventNode');
    const overlayEvent = TXml.findChild(region.eventNode, 'OverlayEvent');
    const uri = overlayEvent.attributes['uri'];
    const mimeType = overlayEvent.attributes['mimeType'];
    const loop = overlayEvent.attributes['loop'] == 'true';
    if (!uri || !mimeType) {
      shaka.log.warning('Unsupported OverlayEvent', region);
      return;
    }

    /** @type {!shaka.extern.AdInterstitialOverlay} */
    let overlay = {
      viewport: {
        x: 1920,
        y: 1080,
      },
      topLeft: {
        x: 0,
        y: 0,
      },
      size: {
        x: 1920,
        y: 1080,
      },
    };

    const viewport = TXml.findChild(overlayEvent, 'Viewport');
    const topLeft = TXml.findChild(overlayEvent, 'TopLeft');
    const size = TXml.findChild(overlayEvent, 'Size');
    if (viewport && topLeft && size) {
      const viewportX = TXml.parseAttr(viewport, 'x', TXml.parseInt);
      if (viewportX == null) {
        shaka.log.warning('Unsupported OverlayEvent', region);
        return;
      }
      const viewportY = TXml.parseAttr(viewport, 'y', TXml.parseInt);
      if (viewportY == null) {
        shaka.log.warning('Unsupported OverlayEvent', region);
        return;
      }
      const topLeftX = TXml.parseAttr(topLeft, 'x', TXml.parseInt);
      if (topLeftX == null) {
        shaka.log.warning('Unsupported OverlayEvent', region);
        return;
      }
      const topLeftY = TXml.parseAttr(topLeft, 'y', TXml.parseInt);
      if (topLeftY == null) {
        shaka.log.warning('Unsupported OverlayEvent', region);
        return;
      }
      const sizeX = TXml.parseAttr(size, 'x', TXml.parseInt);
      if (sizeX == null) {
        shaka.log.warning('Unsupported OverlayEvent', region);
        return;
      }
      const sizeY = TXml.parseAttr(size, 'y', TXml.parseInt);
      if (sizeY == null) {
        shaka.log.warning('Unsupported OverlayEvent', region);
        return;
      }
      overlay = {
        viewport: {
          x: viewportX,
          y: viewportY,
        },
        topLeft: {
          x: topLeftX,
          y: topLeftY,
        },
        size: {
          x: sizeX,
          y: sizeY,
        },
      };
    }

    /** @type {!shaka.extern.AdInterstitial} */
    const interstitial = {
      id: region.id,
      startTime: region.startTime,
      endTime: region.endTime,
      uri,
      mimeType,
      isSkippable: false,
      skipOffset: null,
      skipFor: null,
      canJump: true,
      resumeOffset: null,
      playoutLimit: null,
      once: false,
      pre: false,
      post: false,
      timelineRange: true,
      loop,
      overlay,
    };
    this.addInterstitials([interstitial]);
  }

  /**
   * @param {string} url
   * @return {!Promise}
   */
  async addAdUrlInterstitial(url) {
    const NetworkingEngine = shaka.net.NetworkingEngine;
    const context = {
      type: NetworkingEngine.AdvancedRequestType.INTERSTITIAL_AD_URL,
    };
    const responseData = await this.makeAdRequest_(url, context);
    const data = shaka.util.TXml.parseXml(responseData, 'VAST,vmap:VMAP');
    if (!data) {
      throw new shaka.util.Error(
          shaka.util.Error.Severity.CRITICAL,
          shaka.util.Error.Category.ADS,
          shaka.util.Error.Code.VAST_INVALID_XML);
    }
    let interstitials = [];
    if (data.tagName == 'VAST') {
      interstitials = shaka.ads.Utils.parseVastToInterstitials(
          data, this.lastTime_);
    } else if (data.tagName == 'vmap:VMAP') {
      for (const ad of shaka.ads.Utils.parseVMAP(data)) {
        // eslint-disable-next-line no-await-in-loop
        const vastResponseData = await this.makeAdRequest_(ad.uri, context);
        const vast = shaka.util.TXml.parseXml(vastResponseData, 'VAST');
        if (!vast) {
          throw new shaka.util.Error(
              shaka.util.Error.Severity.CRITICAL,
              shaka.util.Error.Category.ADS,
              shaka.util.Error.Code.VAST_INVALID_XML);
        }
        interstitials.push(...shaka.ads.Utils.parseVastToInterstitials(
            vast, ad.time));
      }
    }
    this.addInterstitials(interstitials);
  }


  /**
   * @param {!Array.<shaka.extern.AdInterstitial>} interstitials
   */
  async addInterstitials(interstitials) {
    let cuepointsChanged = false;
    for (const interstitial of interstitials) {
      if (!interstitial.uri) {
        shaka.log.alwaysWarn('Missing URL in interstitial', interstitial);
        continue;
      }
      if (!interstitial.mimeType) {
        try {
          const netEngine = this.player_.getNetworkingEngine();
          goog.asserts.assert(netEngine, 'Need networking engine');
          // eslint-disable-next-line no-await-in-loop
          interstitial.mimeType = await shaka.net.NetworkingUtils.getMimeType(
              interstitial.uri, netEngine,
              this.basePlayer_.getConfiguration().streaming.retryParameters);
        } catch (error) {}
      }
      const interstitialId = interstitial.id || JSON.stringify(interstitial);
      if (this.interstitialIds_.has(interstitialId)) {
        continue;
      }
      if (interstitial.loop && !interstitial.overlay) {
        shaka.log.alwaysWarn('Loop is only supported in overlay interstitials',
            interstitial);
      }
      if (!interstitial.overlay) {
        cuepointsChanged = true;
      }
      this.interstitialIds_.add(interstitialId);
      this.interstitials_.add(interstitial);
      let shouldPreload = false;
      if (interstitial.pre && this.lastTime_ == null) {
        shouldPreload = true;
      } else if (interstitial.startTime == 0 && !interstitial.canJump) {
        shouldPreload = true;
      } else if (this.lastTime_ != null) {
        const difference = interstitial.startTime - this.lastTime_;
        if (difference > 0 && difference <= 10) {
          shouldPreload = true;
        }
      }
      if (shouldPreload) {
        if (!this.preloadManagerInterstitials_.has(interstitial) &&
            this.isPreloadAllowed_(interstitial)) {
          this.preloadManagerInterstitials_.set(
              interstitial, this.player_.preload(
                  interstitial.uri,
                  /* startTime= */ null,
                  interstitial.mimeType || undefined));
        }
      }
    }
    if (cuepointsChanged) {
      this.cuepointsChanged_();
    }
  }

  /**
   * @return {!HTMLMediaElement}
   * @private
   */
  createMediaElement_() {
    const video = /** @type {!HTMLMediaElement} */(
      document.createElement(this.baseVideo_.tagName));
    video.autoplay = true;
    video.style.position = 'absolute';
    video.style.top = '0';
    video.style.left = '0';
    video.style.width = '100%';
    video.style.height = '100%';
    video.style.backgroundColor = 'rgb(0, 0, 0)';
    video.style.display = 'none';
    video.setAttribute('playsinline', '');
    return video;
  }


  /**
   * @param {boolean=} needPreRoll
   * @param {?number=} numberToSkip
   * @return {?shaka.extern.AdInterstitial}
   * @private
   */
  getCurrentInterstitial_(needPreRoll = false, numberToSkip = null) {
    let skipped = 0;
    let currentInterstitial = null;
    if (this.interstitials_.size && this.lastTime_ != null) {
      const isEnded = this.baseVideo_.ended;
      const interstitials = Array.from(this.interstitials_).sort((a, b) => {
        return b.startTime - a.startTime;
      });
      const roundDecimals = (number) => {
        return Math.round(number * 1000) / 1000;
      };
      let interstitialsToCheck = interstitials;
      if (needPreRoll) {
        interstitialsToCheck = interstitials.filter((i) => i.pre);
      } else if (isEnded) {
        interstitialsToCheck = interstitials.filter((i) => i.post);
      } else {
        interstitialsToCheck = interstitials.filter((i) => !i.pre && !i.post);
      }
      for (const interstitial of interstitialsToCheck) {
        let isValid = false;
        if (needPreRoll) {
          isValid = interstitial.pre;
        } else if (isEnded) {
          isValid = interstitial.post;
        } else if (!interstitial.pre && !interstitial.post) {
          const difference =
              this.lastTime_ - roundDecimals(interstitial.startTime);
          if (difference > 0 &&
              (difference <= 1 || !interstitial.canJump)) {
            if (numberToSkip == null && this.lastPlayedAd_ &&
                !this.lastPlayedAd_.pre && !this.lastPlayedAd_.post &&
                this.lastPlayedAd_.startTime >= interstitial.startTime) {
              isValid = false;
            } else {
              isValid = true;
            }
          }
        }
        if (isValid && (!this.lastPlayedAd_ ||
            interstitial.startTime >= this.lastPlayedAd_.startTime)) {
          if (skipped == (numberToSkip || 0)) {
            currentInterstitial = interstitial;
          } else if (currentInterstitial && !interstitial.canJump) {
            const currentStartTime =
                roundDecimals(currentInterstitial.startTime);
            const newStartTime =
                roundDecimals(interstitial.startTime);
            if (newStartTime - currentStartTime > 0.001) {
              currentInterstitial = interstitial;
              skipped = 0;
            }
          }
          skipped++;
        }
      }
    }
    return currentInterstitial;
  }


  /**
   * @param {shaka.extern.AdInterstitial} interstitial
   * @param {number} sequenceLength
   * @param {number} adPosition
   * @param {number} initialTime the clock time the ad started at
   * @param {number=} oncePlayed
   * @private
   */
  setupAd_(interstitial, sequenceLength, adPosition, initialTime,
      oncePlayed = 0) {
    shaka.log.info('Starting interstitial',
        interstitial.startTime, 'at', this.lastTime_);

    this.lastPlayedAd_ = interstitial;

    this.determineIfUsingBaseVideo_();
    goog.asserts.assert(this.video_, 'Must have video');

    if (!this.video_.parentElement && this.adContainer_) {
      this.adContainer_.appendChild(this.video_);
    }

    if (adPosition == 1 && sequenceLength == 1) {
      sequenceLength = Array.from(this.interstitials_).filter((i) => {
        if (interstitial.pre) {
          return i.pre == interstitial.pre;
        } else if (interstitial.post) {
          return i.post == interstitial.post;
        }
        return Math.abs(i.startTime - interstitial.startTime) < 0.001;
      }).length;
    }

    if (interstitial.once) {
      oncePlayed++;
      this.interstitials_.delete(interstitial);
      if (!interstitial.overlay) {
        this.cuepointsChanged_();
      }
    }

    if (interstitial.mimeType) {
      if (interstitial.mimeType.startsWith('image/') ||
          interstitial.mimeType === 'text/html') {
        if (!interstitial.overlay) {
          shaka.log.alwaysWarn('Unsupported interstitial', interstitial);
          return;
        }
        this.setupStaticAd_(interstitial, sequenceLength, adPosition,
            oncePlayed);
        return;
      }
    }
    if (this.usingBaseVideo_ && interstitial.overlay) {
      shaka.log.alwaysWarn('Unsupported interstitial', interstitial);
      return;
    }
    this.setupVideoAd_(interstitial, sequenceLength, adPosition, initialTime,
        oncePlayed);
  }


  /**
   * @param {shaka.extern.AdInterstitial} interstitial
   * @param {number} sequenceLength
   * @param {number} adPosition
   * @param {number} oncePlayed
   * @private
   */
  setupStaticAd_(interstitial, sequenceLength, adPosition, oncePlayed) {
    const overlay = interstitial.overlay;
    goog.asserts.assert(overlay, 'Must have overlay');

    const tagName = interstitial.mimeType == 'text/html' ? 'iframe' : 'img';

    const htmlElement = /** @type {!(HTMLImageElement|HTMLIFrameElement)} */ (
      document.createElement(tagName));
    htmlElement.style.objectFit = 'contain';
    htmlElement.style.position = 'absolute';
    htmlElement.style.border = 'none';

    const basicTask = () => {
      if (this.playoutLimitTimer_) {
        this.playoutLimitTimer_.stop();
        this.playoutLimitTimer_ = null;
      }
      this.adContainer_.removeChild(htmlElement);
      this.onEvent_(
          new shaka.util.FakeEvent(shaka.ads.Utils.AD_STOPPED));
      const nextCurrentInterstitial = this.getCurrentInterstitial_(
          interstitial.pre, adPosition - oncePlayed);
      if (nextCurrentInterstitial) {
        this.adEventManager_.removeAll();
        this.setupAd_(nextCurrentInterstitial, sequenceLength,
            ++adPosition, /* initialTime= */ Date.now(), oncePlayed);
      } else {
        this.playingAd_ = false;
      }
    };

    const ad = new shaka.ads.InterstitialStaticAd(sequenceLength, adPosition,
        interstitial.overlay == null);

    this.onEvent_(
        new shaka.util.FakeEvent(shaka.ads.Utils.AD_STARTED,
            (new Map()).set('ad', ad)));

    if (tagName == 'iframe') {
      htmlElement.src = interstitial.uri;
    } else {
      htmlElement.src = interstitial.uri;
      htmlElement.onerror = (e) => {
        this.onEvent_(new shaka.util.FakeEvent(shaka.ads.Utils.AD_ERROR,
            (new Map()).set('originalEvent', e)));
        basicTask();
      };
    }

    const viewport = overlay.viewport;
    const topLeft = overlay.topLeft;
    const size = overlay.size;
    // Special case for VAST non-linear ads
    if (viewport.x == 0 && viewport.y == 0) {
      htmlElement.width = interstitial.overlay.size.x;
      htmlElement.height = interstitial.overlay.size.y;
      htmlElement.style.bottom = '10%';
      htmlElement.style.left = '0';
      htmlElement.style.right = '0';
      htmlElement.style.width = '100%';
      if (!interstitial.overlay.size.y && tagName == 'iframe') {
        htmlElement.style.height = 'auto';
      }
    } else {
      htmlElement.style.height = (size.y / viewport.y * 100) + '%';
      htmlElement.style.left = (topLeft.x / viewport.x * 100) + '%';
      htmlElement.style.top = (topLeft.y / viewport.y * 100) + '%';
      htmlElement.style.width = (size.x / viewport.x * 100) + '%';
    }
    this.adContainer_.appendChild(htmlElement);

    const startTime = Date.now();
    if (this.playoutLimitTimer_) {
      this.playoutLimitTimer_.stop();
    }
    this.playoutLimitTimer_ = new shaka.util.Timer(() => {
      if (interstitial.playoutLimit &&
          (Date.now() - startTime) / 1000 > interstitial.playoutLimit) {
        this.onEvent_(
            new shaka.util.FakeEvent(shaka.ads.Utils.AD_COMPLETE));
        basicTask();
      } else if (interstitial.endTime &&
          this.baseVideo_.currentTime > interstitial.endTime) {
        this.onEvent_(
            new shaka.util.FakeEvent(shaka.ads.Utils.AD_COMPLETE));
        basicTask();
      } else if (this.baseVideo_.currentTime < interstitial.startTime) {
        this.onEvent_(
            new shaka.util.FakeEvent(shaka.ads.Utils.AD_SKIPPED));
        basicTask();
      }
    });
    if (interstitial.playoutLimit && !interstitial.endTime) {
      this.playoutLimitTimer_.tickAfter(interstitial.playoutLimit);
    } else if (interstitial.endTime) {
      this.playoutLimitTimer_.tickEvery(/* seconds= */ 0.025);
    }
  }


  /**
   * @param {shaka.extern.AdInterstitial} interstitial
   * @param {number} sequenceLength
   * @param {number} adPosition
   * @param {number} initialTime the clock time the ad started at
   * @param {number} oncePlayed
   * @private
   */
  async setupVideoAd_(interstitial, sequenceLength, adPosition, initialTime,
      oncePlayed) {
    goog.asserts.assert(this.video_, 'Must have video');
    const startTime = Date.now();

    this.playingAd_ = true;

    if (this.usingBaseVideo_ && adPosition == 1) {
      this.onEvent_(new shaka.util.FakeEvent(
          shaka.ads.Utils.AD_CONTENT_PAUSE_REQUESTED,
          (new Map()).set('saveLivePosition', true)));
      const detachBasePlayerPromise = new shaka.util.PublicPromise();
      const checkState = async (e) => {
        if (e['state'] == 'detach') {
          if (shaka.util.Platform.isSmartTV()) {
            await new Promise(
                (resolve) => new shaka.util.Timer(resolve).tickAfter(0.1));
          }
          detachBasePlayerPromise.resolve();
          this.adEventManager_.unlisten(
              this.basePlayer_, 'onstatechange', checkState);
        }
      };
      this.adEventManager_.listen(
          this.basePlayer_, 'onstatechange', checkState);
      await detachBasePlayerPromise;
    }
    if (!this.usingBaseVideo_) {
      this.video_.style.display = '';
      if (interstitial.overlay) {
        this.video_.loop = interstitial.loop;
        const viewport = interstitial.overlay.viewport;
        const topLeft = interstitial.overlay.topLeft;
        const size = interstitial.overlay.size;
        this.video_.style.height = (size.y / viewport.y * 100) + '%';
        this.video_.style.left = (topLeft.x / viewport.x * 100) + '%';
        this.video_.style.top = (topLeft.y / viewport.y * 100) + '%';
        this.video_.style.width = (size.x / viewport.x * 100) + '%';
      } else {
        this.baseVideo_.pause();
        if (interstitial.resumeOffset != null &&
            interstitial.resumeOffset != 0) {
          this.baseVideo_.currentTime += interstitial.resumeOffset;
        }
        this.video_.loop = false;
        this.video_.style.height = '100%';
        this.video_.style.left = '0';
        this.video_.style.top = '0';
        this.video_.style.width = '100%';
      }
    }

    let unloadingInterstitial = false;

    const updateBaseVideoTime = () => {
      if (!this.usingBaseVideo_ && !interstitial.overlay) {
        if (interstitial.resumeOffset == null) {
          if (interstitial.timelineRange && interstitial.endTime &&
              interstitial.endTime != Infinity) {
            if (this.baseVideo_.currentTime != interstitial.endTime) {
              this.baseVideo_.currentTime = interstitial.endTime;
            }
          } else {
            const now = Date.now();
            this.baseVideo_.currentTime += (now - initialTime) / 1000;
            initialTime = now;
          }
        }
      }
    };

    const basicTask = async () => {
      updateBaseVideoTime();
      if (this.playoutLimitTimer_) {
        this.playoutLimitTimer_.stop();
        this.playoutLimitTimer_ = null;
      }
      // Optimization to avoid returning to main content when there is another
      // interstitial below.
      const nextCurrentInterstitial = this.getCurrentInterstitial_(
          interstitial.pre, adPosition - oncePlayed);
      if (!nextCurrentInterstitial || nextCurrentInterstitial.overlay) {
        if (interstitial.post) {
          this.lastTime_ = null;
          this.lastPlayedAd_ = null;
        }
        if (this.usingBaseVideo_) {
          await this.player_.detach();
        } else {
          await this.player_.unload();
        }
        if (this.usingBaseVideo_) {
          let offset = interstitial.resumeOffset;
          if (offset == null) {
            if (interstitial.timelineRange && interstitial.endTime &&
                interstitial.endTime != Infinity) {
              offset = interstitial.endTime - (this.lastTime_ || 0);
            } else {
              offset = (Date.now() - initialTime) / 1000;
            }
          }
          this.onEvent_(new shaka.util.FakeEvent(
              shaka.ads.Utils.AD_CONTENT_RESUME_REQUESTED,
              (new Map()).set('offset', offset)));
        }
        this.onEvent_(
            new shaka.util.FakeEvent(shaka.ads.Utils.AD_STOPPED));
        this.adEventManager_.removeAll();
        this.playingAd_ = false;
        if (!this.usingBaseVideo_) {
          this.video_.style.display = 'none';
          updateBaseVideoTime();
          if (!this.baseVideo_.ended) {
            this.baseVideo_.play();
          }
        } else {
          this.cuepointsChanged_();
        }
      }
      this.determineIfUsingBaseVideo_();
      if (nextCurrentInterstitial) {
        this.onEvent_(
            new shaka.util.FakeEvent(shaka.ads.Utils.AD_STOPPED));
        this.adEventManager_.removeAll();
        this.setupAd_(nextCurrentInterstitial, sequenceLength,
            ++adPosition, initialTime, oncePlayed);
      }
    };
    const error = async (e) => {
      if (unloadingInterstitial) {
        return;
      }
      unloadingInterstitial = true;
      this.onEvent_(new shaka.util.FakeEvent(shaka.ads.Utils.AD_ERROR,
          (new Map()).set('originalEvent', e)));
      await basicTask();
    };
    const complete = async () => {
      if (unloadingInterstitial) {
        return;
      }
      unloadingInterstitial = true;
      await basicTask();
      this.onEvent_(
          new shaka.util.FakeEvent(shaka.ads.Utils.AD_COMPLETE));
    };
    const onSkip = async () => {
      if (unloadingInterstitial) {
        return;
      }
      unloadingInterstitial = true;
      this.onEvent_(new shaka.util.FakeEvent(shaka.ads.Utils.AD_SKIPPED));
      await basicTask();
    };

    const ad = new shaka.ads.InterstitialAd(this.video_,
        interstitial.isSkippable, interstitial.skipOffset,
        interstitial.skipFor, onSkip, sequenceLength, adPosition,
        !this.usingBaseVideo_, interstitial.overlay);
    if (!this.usingBaseVideo_) {
      ad.setMuted(this.baseVideo_.muted);
      ad.setVolume(this.baseVideo_.volume);
    }

    this.onEvent_(
        new shaka.util.FakeEvent(shaka.ads.Utils.AD_STARTED,
            (new Map()).set('ad', ad)));

    let prevCanSkipNow = ad.canSkipNow();
    if (prevCanSkipNow) {
      this.onEvent_(new shaka.util.FakeEvent(
          shaka.ads.Utils.AD_SKIP_STATE_CHANGED));
    }
    this.adEventManager_.listenOnce(this.player_, 'error', error);
    this.adEventManager_.listen(this.video_, 'timeupdate', () => {
      const duration = this.video_.duration;
      if (!duration) {
        return;
      }
      const currentCanSkipNow = ad.canSkipNow();
      if (prevCanSkipNow != currentCanSkipNow &&
          ad.getRemainingTime() > 0 && ad.getDuration() > 0) {
        this.onEvent_(
            new shaka.util.FakeEvent(shaka.ads.Utils.AD_SKIP_STATE_CHANGED));
      }
      prevCanSkipNow = currentCanSkipNow;
    });
    this.adEventManager_.listenOnce(this.player_, 'firstquartile', () => {
      updateBaseVideoTime();
      this.onEvent_(
          new shaka.util.FakeEvent(shaka.ads.Utils.AD_FIRST_QUARTILE));
    });
    this.adEventManager_.listenOnce(this.player_, 'midpoint', () => {
      updateBaseVideoTime();
      this.onEvent_(
          new shaka.util.FakeEvent(shaka.ads.Utils.AD_MIDPOINT));
    });
    this.adEventManager_.listenOnce(this.player_, 'thirdquartile', () => {
      updateBaseVideoTime();
      this.onEvent_(
          new shaka.util.FakeEvent(shaka.ads.Utils.AD_THIRD_QUARTILE));
    });
    this.adEventManager_.listenOnce(this.player_, 'complete', complete);
    this.adEventManager_.listen(this.video_, 'play', () => {
      this.onEvent_(
          new shaka.util.FakeEvent(shaka.ads.Utils.AD_RESUMED));
    });
    this.adEventManager_.listen(this.video_, 'pause', () => {
      // playRangeEnd in src= causes the ended event not to be fired when that
      // position is reached, instead pause event is fired.
      const currentConfig = this.player_.getConfiguration();
      if (this.video_.currentTime >= currentConfig.playRangeEnd) {
        complete();
        return;
      }
      this.onEvent_(
          new shaka.util.FakeEvent(shaka.ads.Utils.AD_PAUSED));
    });
    this.adEventManager_.listen(this.video_, 'volumechange', () => {
      if (this.video_.muted) {
        this.onEvent_(
            new shaka.util.FakeEvent(shaka.ads.Utils.AD_MUTED));
      } else {
        this.onEvent_(
            new shaka.util.FakeEvent(shaka.ads.Utils.AD_VOLUME_CHANGED));
      }
    });
    try {
      this.updatePlayerConfig_();
      if (interstitial.startTime && interstitial.endTime &&
          interstitial.endTime != Infinity &&
          interstitial.startTime != interstitial.endTime) {
        const duration = interstitial.endTime - interstitial.startTime;
        if (duration > 0) {
          this.player_.configure('playRangeEnd', duration);
        }
      }
      if (interstitial.playoutLimit) {
        this.playoutLimitTimer_ = new shaka.util.Timer(() => {
          ad.skip();
        }).tickAfter(interstitial.playoutLimit);
        this.player_.configure('playRangeEnd', interstitial.playoutLimit);
      }
      await this.player_.attach(this.video_);
      if (this.preloadManagerInterstitials_.has(interstitial)) {
        const preloadManager =
            await this.preloadManagerInterstitials_.get(interstitial);
        this.preloadManagerInterstitials_.delete(interstitial);
        if (preloadManager) {
          await this.player_.load(preloadManager);
        } else {
          await this.player_.load(
              interstitial.uri,
              /* startTime= */ null,
              interstitial.mimeType || undefined);
        }
      } else {
        await this.player_.load(
            interstitial.uri,
            /* startTime= */ null,
            interstitial.mimeType || undefined);
      }
      if (interstitial.playoutLimit) {
        if (this.playoutLimitTimer_) {
          this.playoutLimitTimer_.stop();
        }
        this.playoutLimitTimer_ = new shaka.util.Timer(() => {
          ad.skip();
        }).tickAfter(interstitial.playoutLimit);
      }
      const loadTime = (Date.now() - startTime) / 1000;
      this.onEvent_(new shaka.util.FakeEvent(shaka.ads.Utils.ADS_LOADED,
          (new Map()).set('loadTime', loadTime)));
      if (this.usingBaseVideo_) {
        this.baseVideo_.play();
      }
    } catch (e) {
      if (!this.playingAd_) {
        return;
      }
      error(e);
    }
  }


  /**
   * @param {shaka.extern.HLSInterstitial} hlsInterstitial
   * @return {!Promise.<!Array.<shaka.extern.AdInterstitial>>}
   * @private
   */
  async getInterstitialsInfo_(hlsInterstitial) {
    const interstitialsAd = [];
    if (!hlsInterstitial) {
      return interstitialsAd;
    }
    const assetUri = hlsInterstitial.values.find((v) => v.key == 'X-ASSET-URI');
    const assetList =
        hlsInterstitial.values.find((v) => v.key == 'X-ASSET-LIST');
    if (!assetUri && !assetList) {
      return interstitialsAd;
    }
    let id = null;
    const hlsInterstitialId = hlsInterstitial.values.find((v) => v.key == 'ID');
    if (hlsInterstitialId) {
      id = /** @type {string} */(hlsInterstitialId.data);
    }
    const startTime = id == null ?
        Math.floor(hlsInterstitial.startTime * 10) / 10:
        hlsInterstitial.startTime;
    let endTime = hlsInterstitial.endTime;
    if (hlsInterstitial.endTime && hlsInterstitial.endTime != Infinity &&
        typeof(hlsInterstitial.endTime) == 'number') {
      endTime = id == null ?
          Math.floor(hlsInterstitial.endTime * 10) / 10:
          hlsInterstitial.endTime;
    }
    const restrict = hlsInterstitial.values.find((v) => v.key == 'X-RESTRICT');
    let isSkippable = true;
    let canJump = true;
    if (restrict && restrict.data) {
      const data = /** @type {string} */(restrict.data);
      isSkippable = !data.includes('SKIP');
      canJump = !data.includes('JUMP');
    }
    let skipOffset = isSkippable ? 0 : null;
    const enableSkipAfter =
        hlsInterstitial.values.find((v) => v.key == 'X-ENABLE-SKIP-AFTER');
    if (enableSkipAfter) {
      const enableSkipAfterString = /** @type {string} */(enableSkipAfter.data);
      skipOffset = parseFloat(enableSkipAfterString);
      if (isNaN(skipOffset)) {
        skipOffset = isSkippable ? 0 : null;
      }
    }
    let skipFor = null;
    const enableSkipFor =
        hlsInterstitial.values.find((v) => v.key == 'X-ENABLE-SKIP-FOR');
    if (enableSkipFor) {
      const enableSkipForString = /** @type {string} */(enableSkipFor.data);
      skipFor = parseFloat(enableSkipForString);
      if (isNaN(skipOffset)) {
        skipFor = null;
      }
    }
    let resumeOffset = null;
    const resume =
        hlsInterstitial.values.find((v) => v.key == 'X-RESUME-OFFSET');
    if (resume) {
      const resumeOffsetString = /** @type {string} */(resume.data);
      resumeOffset = parseFloat(resumeOffsetString);
      if (isNaN(resumeOffset)) {
        resumeOffset = null;
      }
    }
    let playoutLimit = null;
    const playout =
        hlsInterstitial.values.find((v) => v.key == 'X-PLAYOUT-LIMIT');
    if (playout) {
      const playoutLimitString = /** @type {string} */(playout.data);
      playoutLimit = parseFloat(playoutLimitString);
      if (isNaN(playoutLimit)) {
        playoutLimit = null;
      }
    }
    let once = false;
    let pre = false;
    let post = false;
    const cue = hlsInterstitial.values.find((v) => v.key == 'CUE');
    if (cue) {
      const data = /** @type {string} */(cue.data);
      once = data.includes('ONCE');
      pre = data.includes('PRE');
      post = data.includes('POST');
    }
    let timelineRange = false;
    const timelineOccupies =
        hlsInterstitial.values.find((v) => v.key == 'X-TIMELINE-OCCUPIES');
    if (timelineOccupies) {
      const data = /** @type {string} */(timelineOccupies.data);
      timelineRange = data.includes('RANGE');
    } else if (!resume && this.basePlayer_.isLive()) {
      timelineRange = !pre && !post;
    }
    if (assetUri) {
      const uri = /** @type {string} */(assetUri.data);
      if (!uri) {
        return interstitialsAd;
      }
      interstitialsAd.push({
        id,
        startTime,
        endTime,
        uri,
        mimeType: null,
        isSkippable,
        skipOffset,
        skipFor,
        canJump,
        resumeOffset,
        playoutLimit,
        once,
        pre,
        post,
        timelineRange,
        loop: false,
        overlay: null,
      });
    } else if (assetList) {
      const uri = /** @type {string} */(assetList.data);
      if (!uri) {
        return interstitialsAd;
      }
      try {
        const NetworkingEngine = shaka.net.NetworkingEngine;
        const context = {
          type: NetworkingEngine.AdvancedRequestType.INTERSTITIAL_ASSET_LIST,
        };
        const responseData = await this.makeAdRequest_(uri, context);
        const data = shaka.util.StringUtils.fromUTF8(responseData);
        const dataAsJson =
        /** @type {!shaka.ads.InterstitialAdManager.AssetsList} */ (
            JSON.parse(data));
        const skipControl = dataAsJson['SKIP-CONTROL'];
        if (skipControl) {
          const enableSkipAfterValue = skipControl['ENABLE-SKIP-AFTER'];
          if ((typeof enableSkipAfterValue) == 'number') {
            skipOffset = parseFloat(enableSkipAfterValue);
            if (isNaN(enableSkipAfterValue)) {
              skipOffset = isSkippable ? 0 : null;
            }
          }
          const enableSkipForValue = skipControl['ENABLE-SKIP-FOR'];
          if ((typeof enableSkipForValue) == 'number') {
            skipFor = parseFloat(enableSkipForValue);
            if (isNaN(enableSkipForValue)) {
              skipFor = null;
            }
          }
        }
        for (let i = 0; i < dataAsJson['ASSETS'].length; i++) {
          const asset = dataAsJson['ASSETS'][i];
          if (asset['URI']) {
            interstitialsAd.push({
              id: id + '_asset_' + i,
              startTime,
              endTime,
              uri: asset['URI'],
              mimeType: null,
              isSkippable,
              skipOffset,
              skipFor,
              canJump,
              resumeOffset,
              playoutLimit,
              once,
              pre,
              post,
              timelineRange,
              loop: false,
              overlay: null,
            });
          }
        }
      } catch (e) {
        // Ignore errors
      }
    }
    return interstitialsAd;
  }


  /**
   * @private
   */
  cuepointsChanged_() {
    /** @type {!Array.<!shaka.extern.AdCuePoint>} */
    const cuePoints = [];
    for (const interstitial of this.interstitials_) {
      if (interstitial.overlay) {
        continue;
      }
      /** @type {shaka.extern.AdCuePoint} */
      const shakaCuePoint = {
        start: interstitial.startTime,
        end: null,
      };
      if (interstitial.pre) {
        shakaCuePoint.start = 0;
        shakaCuePoint.end = null;
      } else if (interstitial.post) {
        shakaCuePoint.start = -1;
        shakaCuePoint.end = null;
      } else if (interstitial.timelineRange) {
        shakaCuePoint.end = interstitial.endTime;
      }
      const isValid = !cuePoints.find((c) => {
        return shakaCuePoint.start == c.start && shakaCuePoint.end == c.end;
      });
      if (isValid) {
        cuePoints.push(shakaCuePoint);
      }
    }

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


  /**
   * @private
   */
  updatePlayerConfig_() {
    goog.asserts.assert(this.player_, 'Must have player');
    goog.asserts.assert(this.basePlayer_, 'Must have base player');
    this.player_.configure(this.basePlayer_.getNonDefaultConfiguration());
    this.player_.configure('ads.disableHLSInterstitial', true);
    this.player_.configure('ads.disableDASHInterstitial', true);
    const netEngine = this.player_.getNetworkingEngine();
    goog.asserts.assert(netEngine, 'Need networking engine');
    netEngine.clearAllRequestFilters();
    netEngine.clearAllResponseFilters();
    this.basePlayer_.getNetworkingEngine().copyFiltersInto(netEngine);
  }

  /**
   * @param {string} url
   * @param {shaka.extern.RequestContext=} context
   * @return {!Promise.<BufferSource>}
   * @private
   */
  async makeAdRequest_(url, context) {
    const type = shaka.net.NetworkingEngine.RequestType.ADS;
    const request = shaka.net.NetworkingEngine.makeRequest(
        [url],
        shaka.net.NetworkingEngine.defaultRetryParameters());
    const op = this.basePlayer_.getNetworkingEngine()
        .request(type, request, context);
    const response = await op.promise;
    return response.data;
  }

  /**
   * @param {!shaka.extern.AdInterstitial} interstitial
   * @return {boolean}
   * @private
   */
  isPreloadAllowed_(interstitial) {
    const interstitialMimeType = interstitial.mimeType;
    if (!interstitialMimeType) {
      return true;
    }
    return !interstitialMimeType.startsWith('image/') &&
        interstitialMimeType !== 'text/html';
  }


  /**
   * Only for testing
   *
   * @return {!Array.<shaka.extern.AdInterstitial>}
   */
  getInterstitials() {
    return Array.from(this.interstitials_);
  }
};


/**
 * @typedef {{
 *   ASSETS: !Array.<shaka.ads.InterstitialAdManager.Asset>,
 *   SKIP-CONTROL: ?shaka.ads.InterstitialAdManager.SkipControl
 * }}
 *
 * @property {!Array.<shaka.ads.InterstitialAdManager.Asset>} ASSETS
 * @property {shaka.ads.InterstitialAdManager.SkipControl} SKIP-CONTROL
 */
shaka.ads.InterstitialAdManager.AssetsList;


/**
 * @typedef {{
 *   URI: string
 * }}
 *
 * @property {string} URI
 */
shaka.ads.InterstitialAdManager.Asset;


/**
 * @typedef {{
 *   ENABLE-SKIP-AFTER: number,
 *   ENABLE-SKIP-FOR: number
 * }}
 *
 * @property {number} ENABLE-SKIP-AFTER
 * @property {number} ENABLE-SKIP-FOR
 */
shaka.ads.InterstitialAdManager.SkipControl;