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.ads.VastInterstitialParser');
goog.require('shaka.device.DeviceFactory');
goog.require('shaka.device.IDevice');
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.Functional');
goog.require('shaka.util.FakeEvent');
goog.require('shaka.util.IReleasable');
goog.require('shaka.util.NumberUtils');
goog.require('shaka.util.StringUtils');
goog.require('shaka.util.Timer');
goog.require('shaka.util.TXml');
goog.require('shaka.util.URL');
goog.require('shaka.util.VideoFrameCallbackHandler');


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

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

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

    /** @private {HTMLMediaElement} */
    this.baseVideo_ = player.getMediaElement();

    /** @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.hlsMetadataIds_ = new Set();

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

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

    /**
     * Cache of interstitials_ sorted by descending start time, invalidated
     * (set to null) whenever interstitials_ changes. Avoids re-sorting on every
     * getCurrentInterstitial_ call (which runs per frame).
     * @private {?Array<shaka.extern.AdInterstitial>}
     */
    this.sortedInterstitials_ = null;

    /**
     * Asset lists (HLS X-ASSET-LIST) whose resolution has been deferred until
     * playback approaches them. This avoids resolving every ad decision at
     * parse time, which would otherwise create a burst of concurrent requests.
     * See https://github.com/shaka-project/shaka-player/issues/10191
     * @private {!Set<shaka.ads.InterstitialAdManager.AssetListDescriptor>}
     */
    this.unresolvedAssetLists_ = new Set();

    /**
     * HLS preload hints (RFC 8216bis Appendix F): maps the target Date Range ID
     * to the start time of its "com.apple.hls.preload" Date Range, which tells
     * us how early the target's resources may be resolved.
     * @private {!Map<string, number>}
     */
    this.preloadOffsets_ = new Map();

    /**
     * @private {!Map<shaka.extern.AdInterstitial,
                      shaka.ads.InterstitialPreloadTask>} */
    this.preloadTasks_ = new Map();

    /**
     * @private {!Map<shaka.extern.AdInterstitial, !Array<!HTMLLinkElement>>}
     */
    this.preloadOnDomElements_ = 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.isEnded_ = false;

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

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

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

    /**
     * Wall-clock time (ms) when the current ad break started playing. Used to
     * re-arm the playout-limit timer when a live EXT-X-DATERANGE update
     * shortens an interstitial that is already playing.
     * @private {?number}
     */
    this.playingAdStartTime_ = null;

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

    /** @private {?function()} */
    this.lastOnSkip_ = null;

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

    /** @private {number} */
    this.videoCallbackId_ = -1;

    /** @private {?shaka.util.VideoFrameCallbackHandler} */
    this.videoFrameCallbackHandler_ = null;

    /** @private {?string} */
    this.sessionId_ = null;

    /**
     * Stores the intra-asset seek offset for interstitials that start
     * mid-asset due to _HLS_start_offset. Maps interstitial ID to the
     * number of seconds to seek into that asset.
     * @private {!Map<string, number>}
     */
    this.assetStartOffsets_ = new Map();

    // Note: checkForInterstitials_ and onTimeUpdate_ are defined here because
    // we use it on listener callback, and for unlisten is necessary use the
    // same callback.

    const allowPlayInterstitialNow = (interstitial) => {
      if (!interstitial) {
        return false;
      }
      if (interstitial.overlay) {
        return true;
      }
      if (this.isEnded_) {
        return interstitial.post;
      }
      if (this.baseVideo_.paused) {
        return false;
      }
      return true;
    };

    /** @private {function()} */
    this.checkForInterstitials_ = () => {
      if (this.playingAd_ || !this.lastTime_ ||
          this.basePlayer_.isRemotePlayback()) {
        return;
      }
      this.isEnded_ = this.baseVideo_.ended;
      this.lastTime_ = this.baseVideo_.currentTime;
      const currentInterstitial = this.getCurrentInterstitial_();
      if (currentInterstitial &&
          allowPlayInterstitialNow(currentInterstitial)) {
        this.setupAd_(currentInterstitial, /* sequenceLength= */ 1,
            /* adPosition= */ 1, /* initialTime= */ Date.now());
      }
    };

    /** @private {function()} */
    this.onTimeUpdate_ = () => {
      if (this.playingAd_ || this.lastTime_ ||
          this.basePlayer_.isRemotePlayback()) {
        return;
      }
      this.isEnded_ = this.baseVideo_.ended;
      if (!this.baseVideo_.paused) {
        this.lastTime_ = this.baseVideo_.currentTime;
      }
      let currentInterstitial;
      if (!this.lastPlayedAd_) {
        currentInterstitial =
            this.getCurrentInterstitial_(/* needPreRoll= */ true);
      }
      if (!currentInterstitial) {
        currentInterstitial = this.getCurrentInterstitial_();
      }
      if (currentInterstitial &&
          allowPlayInterstitialNow(currentInterstitial)) {
        this.setupAd_(currentInterstitial, /* sequenceLength= */ 1,
            /* adPosition= */ 1, /* initialTime= */ Date.now());
      }
    };

    /** @private {function()} */
    this.onSeeked_ = () => {
      if (this.playingAd_ || !this.lastTime_ ||
          this.basePlayer_.isRemotePlayback()) {
        return;
      }
      this.isEnded_ = this.baseVideo_.ended;
      const currentTime = this.baseVideo_.currentTime;
      // Remove last played ad when the new time is before the ad time.
      if (this.lastPlayedAd_ &&
          !this.lastPlayedAd_.pre && !this.lastPlayedAd_.post &&
          currentTime < this.lastPlayedAd_.startTime) {
        this.lastPlayedAd_ = null;
      }
      this.maybeResetAssetListsOnSeek_(currentTime);
    };

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


    /** @private {shaka.util.Timer} */
    this.pollTimer_ = new shaka.util.Timer(() => {
      if (!this.playingAd_ && this.lastTime_ != null &&
          (this.interstitials_.size || this.unresolvedAssetLists_.size)) {
        const currentLoadMode = this.basePlayer_.getLoadMode();
        if (currentLoadMode == shaka.Player.LoadMode.DESTROYED ||
            currentLoadMode == shaka.Player.LoadMode.NOT_LOADED) {
          return;
        }
        let cuepointsChanged = false;
        const seekRange = this.basePlayer_.seekRange();
        for (const descriptor of Array.from(this.unresolvedAssetLists_)) {
          const comparisonTime = descriptor.endTime || descriptor.startTime;
          if ((seekRange.start - comparisonTime) >= 1) {
            // The ad break has fallen out of the seekable window; drop it.
            this.unresolvedAssetLists_.delete(descriptor);
            this.removeEventListeners_();
            cuepointsChanged = true;
          } else if (!descriptor.resolving && !descriptor.resolved &&
              this.shouldResolveAssetListNow_(descriptor)) {
            descriptor.resolving = true;
            this.resolveAssetListDescriptor_(descriptor);
          }
        }
        const interstitials = Array.from(this.interstitials_);
        for (const interstitial of interstitials) {
          if (interstitial == this.lastPlayedAd_) {
            continue;
          }
          const comparisonTime = interstitial.endTime || interstitial.startTime;
          if ((seekRange.start - comparisonTime) >= 1) {
            this.removeInterstitial_(interstitial);
            this.removeEventListeners_();
            if (!interstitial.overlay) {
              cuepointsChanged = true;
            }
          } else {
            if (this.isWithinPreloadWindow_(interstitial)) {
              if (!this.preloadTasks_.has(interstitial) &&
                  this.isPreloadAllowed_(interstitial)) {
                goog.asserts.assert(this.player_, 'Need a player');
                const task = new shaka.ads.InterstitialPreloadTask(
                    this.player_, interstitial, (type, dict) => {
                      this.sendEvent_(type, dict);
                    });
                this.preloadTasks_.set(interstitial, task);
              }
              this.checkPreloadOnDomElements_(interstitial);
            }
          }
        }
        if (cuepointsChanged) {
          this.cuepointsChanged_();
        }
      }
    });

    this.configure(this.basePlayer_.getConfiguration().ads);
  }

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

  /**
   * @private
   */
  addEventListeners_() {
    if (this.usingListeners_ ||
        (!this.interstitials_.size && !this.unresolvedAssetLists_.size)) {
      return;
    }
    this.eventManager_.listenMulti(
        this.baseVideo_, ['playing', 'timeupdate'], this.onTimeUpdate_);
    this.eventManager_.listen(
        this.baseVideo_, 'seeked', this.onSeeked_);
    this.eventManager_.listen(
        this.baseVideo_, 'ended', this.checkForInterstitials_);
    let useTimer = true;
    if (!this.isSmartTV_()) {
      this.videoFrameCallbackHandler_?.release();
      const baseVideo = /** @type {!HTMLVideoElement} */ (this.baseVideo_);
      this.videoFrameCallbackHandler_ =
          new shaka.util.VideoFrameCallbackHandler(baseVideo);
      const ret = this.videoFrameCallbackHandler_.start(() => {
        this.checkForInterstitials_();
      });
      useTimer = !ret;
    }
    if (useTimer) {
      this.timeUpdateTimer_.tickEvery(/* seconds= */ 0.025);
    }

    if (this.pollTimer_) {
      this.pollTimer_.tickEvery(/* seconds= */ 1); ;
    }
    this.usingListeners_ = true;
  }

  /**
   * @private
   */
  removeEventListeners_() {
    if (!this.usingListeners_ ||
        this.interstitials_.size || this.unresolvedAssetLists_.size) {
      return;
    }
    this.eventManager_.unlisten(
        this.baseVideo_, 'playing', this.onTimeUpdate_);
    this.eventManager_.unlisten(
        this.baseVideo_, 'timeupdate', this.onTimeUpdate_);
    this.eventManager_.unlisten(
        this.baseVideo_, 'seeked', this.onSeeked_);
    this.eventManager_.unlisten(
        this.baseVideo_, 'ended', this.checkForInterstitials_);
    this.videoFrameCallbackHandler_?.release();
    this.videoFrameCallbackHandler_ = null;
    this.timeUpdateTimer_?.stop();
    this.pollTimer_?.stop();
    this.usingListeners_ = false;
  }

  /**
   * Sets usingBaseVideo_ to true if the ad can be played with the base
   * video. Then, it either creates or destroys the adVideo_, as
   * appropriate.
   * @param {boolean=} force If true, re-create the adVideo_ if it is
   *   appropriate for playback.
   * @private
   */
  determineIfUsingBaseVideo_(force = false) {
    if (!this.adContainer_ || !this.config_) {
      this.usingBaseVideo_ = true;
      return;
    }
    let supportsMultipleMediaElements =
        this.config_.supportsMultipleMediaElements;
    const video = /** @type {HTMLVideoElement} */(this.baseVideo_);
    if (video.controls) {
      supportsMultipleMediaElements = false;
    } else if (video.webkitPresentationMode &&
        video.webkitPresentationMode !== 'inline') {
      supportsMultipleMediaElements = false;
    } else if (video.webkitDisplayingFullscreen) {
      supportsMultipleMediaElements = false;
    }
    if (!force && 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 (force && this.adVideo_) {
        if (this.adVideo_.parentElement) {
          this.adContainer_.removeChild(this.adVideo_);
        }
        this.adVideo_ = null;
      }
      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.hlsMetadataIds_.clear();
    this.interstitialIds_.clear();
    this.interstitials_.clear();
    this.sortedInterstitials_ = null;
    this.unresolvedAssetLists_.clear();
    this.preloadOffsets_.clear();
    this.player_.destroyAllPreloads();
    const tasks = Array.from(this.preloadTasks_.values());
    for (const task of tasks) {
      task.release();
    }
    this.preloadTasks_.clear();
    if (this.preloadOnDomElements_.size) {
      const interstitials = Array.from(this.preloadOnDomElements_.keys());
      for (const interstitial of interstitials) {
        this.removePreloadOnDomElements_(interstitial);
      }
    }
    this.preloadOnDomElements_.clear();
    this.assetStartOffsets_.clear();
    this.player_.detach();
    this.isEnded_ = false;
    this.playingAd_ = false;
    this.lastTime_ = null;
    this.lastPlayedAd_ = null;
    this.playingAdStartTime_ = null;
    this.usingBaseVideo_ = true;
    this.video_ = this.baseVideo_;
    this.adVideo_ = null;
    this.sessionId_ = null;
    this.removeBaseStyles_();
    this.removeEventListeners_();
    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();
  }

  /**
   * @return {shaka.Player}
   */
  getPlayer() {
    return this.player_;
  }

  /**
   * @param {shaka.extern.HLSMetadata} hlsMetadata
   */
  async addMetadata(hlsMetadata) {
    const id = this.getMetadataValue_(hlsMetadata, 'ID');
    if (id) {
      if (this.hlsMetadataIds_.has(id)) {
        // A subsequent EXT-X-DATERANGE with the same ID augments the existing
        // Date Range with additional attributes (RFC 8216bis Section 4.4.5.1).
        // Consolidate the newly added attributes onto the known interstitial.
        this.updateInterstitials_(hlsMetadata, id);
        return;
      }
      this.hlsMetadataIds_.add(id);
    }
    this.updatePlayerConfig_();
    let adInterstitials = [];
    if (this.getMetadataValue_(hlsMetadata, 'X-OVERLAY-ID') != null) {
      adInterstitials = this.getOverlaysInfo_(hlsMetadata);
    } else {
      adInterstitials = await this.getInterstitialsInfo_(hlsMetadata);
    }
    if (adInterstitials.length) {
      await this.addInterstitials(adInterstitials);
    }
  }

  /**
   * Consolidates a subsequent EXT-X-DATERANGE that shares its ID with an
   * already-known interstitial by augmenting it with newly added attributes,
   * as permitted by RFC 8216bis Section 4.4.5.1. Per the spec, attributes that
   * were already present are left unchanged; only attributes that are newly
   * introduced are applied. This currently supports X-PLAYOUT-LIMIT, which is
   * used to shorten (early-return from) an interstitial in live streams.
   *
   * @param {shaka.extern.HLSMetadata} hlsMetadata
   * @param {string} id
   * @private
   */
  updateInterstitials_(hlsMetadata, id) {
    const playout = this.getMetadataValue_(hlsMetadata, 'X-PLAYOUT-LIMIT');
    if (playout == null) {
      return;
    }
    const playoutLimit = parseFloat(playout);
    if (isNaN(playoutLimit)) {
      return;
    }
    for (const interstitial of this.interstitials_) {
      if (interstitial.id === id || interstitial.groupId === id) {
        this.applyUpdatedPlayoutLimit_(interstitial, playoutLimit);
      }
    }
    // The asset list may not have been resolved yet; update the descriptor so
    // the resolved interstitials inherit the new playout limit.
    for (const descriptor of this.unresolvedAssetLists_) {
      if (descriptor.id === id && descriptor.playoutLimit == null) {
        descriptor.playoutLimit = playoutLimit;
      }
    }
  }

  /**
   * Applies a newly introduced X-PLAYOUT-LIMIT to an interstitial. If the
   * interstitial is currently playing as a video ad, the playout-limit timer is
   * re-armed so the running ad is truncated.
   *
   * @param {!shaka.extern.AdInterstitial} interstitial
   * @param {number} playoutLimit
   * @private
   */
  applyUpdatedPlayoutLimit_(interstitial, playoutLimit) {
    // The spec requires attributes present in both tags to keep the same value,
    // so we only set a playout limit that was not previously defined.
    if (interstitial.playoutLimit != null) {
      return;
    }
    interstitial.playoutLimit = playoutLimit;

    const isPlaying = this.playingAd_ && this.lastPlayedAd_ != null &&
        (this.lastPlayedAd_ === interstitial ||
        (interstitial.groupId != null &&
         this.lastPlayedAd_.groupId === interstitial.groupId));
    // Static/overlay ads read interstitial.playoutLimit live on each timer
    // tick, so updating the value above is enough for them. Video ads use a
    // one-shot timer that must be re-armed.
    const isVideoAd = !interstitial.overlay &&
        !(interstitial.mimeType &&
          (interstitial.mimeType.startsWith('image/') ||
           interstitial.mimeType === 'text/html'));
    if (!isPlaying || !isVideoAd || this.playingAdStartTime_ == null) {
      return;
    }
    const elapsed = (Date.now() - this.playingAdStartTime_) / 1000;
    const remaining = playoutLimit - elapsed;
    this.playoutLimitTimer_?.stop();
    this.playoutLimitTimer_ = null;
    if (remaining <= 0) {
      if (this.lastOnSkip_) {
        this.lastOnSkip_();
      }
    } else {
      this.playoutLimitTimer_ = new shaka.util.Timer(() => {
        if (this.lastOnSkip_) {
          this.lastOnSkip_();
        }
      }).tickAfter(remaining);
      this.player_.configure('playRangeEnd', playoutLimit);
    }
  }

  /**
   * Handles a "com.apple.hls.preload" EXT-X-DATERANGE (RFC 8216bis Appendix F),
   * which advises the client to preload another Date Range's resources early.
   * We translate it into a per-interstitial resolutionTimeOffset, computed as
   * the gap between the target's start time and the preload Date Range's start
   * time.
   *
   * @param {shaka.extern.HLSMetadata} hlsMetadata
   */
  addPreloadMetadata(hlsMetadata) {
    const id = this.getMetadataValue_(hlsMetadata, 'ID');
    if (id) {
      if (this.hlsMetadataIds_.has(id)) {
        return;
      }
      this.hlsMetadataIds_.add(id);
    }
    const targetId = this.getMetadataValue_(hlsMetadata, 'X-TARGET-ID');
    if (!targetId) {
      return;
    }
    this.preloadOffsets_.set(targetId, hlsMetadata.startTime);
    // Apply the hint to interstitials and asset lists that are already known.
    for (const interstitial of this.interstitials_) {
      this.applyPreloadOffset_(interstitial);
    }
    for (const descriptor of this.unresolvedAssetLists_) {
      this.applyPreloadOffset_(descriptor);
    }
  }

  /**
   * Sets resolutionTimeOffset from a matching HLS preload hint, if any.
   *
   * @param {{id: ?string, groupId: ?string,
   *          startTime: number,
   *          resolutionTimeOffset: (number|undefined)}} item
   * @private
   */
  applyPreloadOffset_(item) {
    let preloadStart = null;
    if (item.id != null && this.preloadOffsets_.has(item.id)) {
      preloadStart = this.preloadOffsets_.get(item.id);
    } else if (item.groupId != null && this.preloadOffsets_.has(item.groupId)) {
      preloadStart = this.preloadOffsets_.get(item.groupId);
    }
    if (preloadStart != null) {
      item.resolutionTimeOffset = Math.max(0, item.startTime - preloadStart);
    }
  }

  /**
   * @param {string} url
   * @return {!Promise}
   */
  async addAdUrlInterstitial(url) {
    const NetworkingEngine = shaka.net.NetworkingEngine;
    const context = {
      type: NetworkingEngine.AdvancedRequestType.INTERSTITIAL_AD_URL,
    };
    const response = await this.makeAdRequest_(url, context).promise;
    const data = shaka.util.TXml.parseXml(response.data, '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);
    }
    /** @type {!Array<shaka.extern.AdInterstitial>} */
    let interstitials = [];
    if (data.tagName == 'VAST') {
      interstitials = shaka.ads.VastInterstitialParser.parseVastToInterstitials(
          data, this.lastTime_);
    } else if (data.tagName == 'vmap:VMAP') {
      const vastProcessing = async (ad) => {
        const vastResponse = await this.makeAdRequest_(ad.uri, context).promise;
        const vast = shaka.util.TXml.parseXml(vastResponse.data, '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.VastInterstitialParser.parseVastToInterstitials(
                vast, ad.time));
      };
      const promises = [];
      for (const ad of shaka.ads.VastInterstitialParser.parseVMAP(data)) {
        promises.push(vastProcessing(ad));
      }
      if (promises.length) {
        await Promise.all(promises);
      }
    }
    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 = this.interstitialId_(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);
      this.sortedInterstitials_ = null;
      this.applyPreloadOffset_(interstitial);
      if (this.isWithinPreloadWindow_(interstitial)) {
        if (!this.preloadTasks_.has(interstitial) &&
            this.isPreloadAllowed_(interstitial)) {
          goog.asserts.assert(this.player_, 'Need a player');
          const task = new shaka.ads.InterstitialPreloadTask(
              this.player_, interstitial, (type, dict) => {
                this.sendEvent_(type, dict);
              });
          this.preloadTasks_.set(interstitial, task);
        }
        this.checkPreloadOnDomElements_(interstitial);
      }
    }
    if (cuepointsChanged) {
      this.cuepointsChanged_();
    }
    this.addEventListeners_();
  }

  /**
   * @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.display = 'none';
    video.setAttribute('playsinline', '');
    return video;
  }


  /**
   * Returns interstitials_ sorted by descending start time, using a cache that
   * is invalidated whenever interstitials_ changes.
   *
   * @return {!Array<shaka.extern.AdInterstitial>}
   * @private
   */
  getSortedInterstitials_() {
    if (!this.sortedInterstitials_) {
      this.sortedInterstitials_ = Array.from(this.interstitials_).sort(
          (a, b) => b.startTime - a.startTime);
    }
    return this.sortedInterstitials_;
  }

  /**
   * @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 interstitials = this.getSortedInterstitials_();
      const roundDecimals = (number) => {
        return Math.round(number * 1000) / 1000;
      };
      let interstitialsToCheck = interstitials;
      if (needPreRoll) {
        interstitialsToCheck = interstitials.filter((i) => i.pre);
      } else if (this.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 (this.isEnded_) {
          isValid = interstitial.post;
        } else if (!interstitial.pre && !interstitial.post) {
          const difference =
              this.lastTime_ - roundDecimals(interstitial.startTime);
          let maxDifference = 1;
          if (this.config_.allowStartInMiddleOfInterstitial &&
              interstitial.endTime && interstitial.endTime != Infinity) {
            maxDifference = interstitial.endTime - interstitial.startTime;
          }
          if ((difference > 0 || (difference == 0 && this.lastTime_ == 0)) &&
              (difference <= maxDifference || !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.usingBaseVideo_ && this.adContainer_ &&
        !this.video_.parentElement) {
      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);
      this.sortedInterstitials_ = null;
      this.removeEventListeners_();
      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;
        }
        shaka.log.info('Starting interstitial', interstitial);
        this.setupStaticAd_(interstitial, sequenceLength, adPosition,
            oncePlayed);
        return;
      }
    }
    if (this.usingBaseVideo_ && interstitial.overlay) {
      shaka.log.alwaysWarn('Unsupported interstitial', interstitial);
      return;
    }
    shaka.log.info('Starting interstitial', interstitial);
    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 timeOffset = this.getTimeOffset_(interstitial);

    if (!this.playingAd_) {
      this.playingAdStartTime_ = Date.now();
      const data = (new Map())
          .set('timeOffset', timeOffset)
          .set('startedAt', this.lastTime_);
      this.sendEvent_(shaka.ads.Utils.AD_BREAK_STARTED, data);
    }

    this.playingAd_ = true;

    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';

    this.setBaseStyles_(interstitial);

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

      if (!this.playingAd_) {
        this.sendEvent_(shaka.ads.Utils.AD_BREAK_ENDED,
            (new Map()).set('timeOffset', timeOffset));
      }
    };

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

    this.sendEvent_(shaka.ads.Utils.AD_IMPRESSION);
    this.sendEvent_(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.sendEvent_(
            shaka.ads.Utils.AD_ERROR, (new Map()).set('originalEvent', e));
        basicTask();
      };
    }

    // Special case for VAST non-linear ads
    if (overlay.viewport.x == 0 && overlay.viewport.y == 0) {
      htmlElement.width = overlay.size.x;
      htmlElement.height = overlay.size.y;
      htmlElement.style.bottom = '10%';
      htmlElement.style.left = '0';
      htmlElement.style.right = '0';
      htmlElement.style.width = '100%';
      if (!overlay.size.y && tagName == 'iframe') {
        htmlElement.style.height = 'auto';
      }
    } else {
      this.applyOverlayPosition_(htmlElement, overlay);
    }
    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.sendEvent_(shaka.ads.Utils.AD_COMPLETE);
        basicTask();
      } else if (interstitial.endTime &&
          this.baseVideo_.currentTime > interstitial.endTime) {
        this.sendEvent_(shaka.ads.Utils.AD_COMPLETE);
        basicTask();
      } else if (this.baseVideo_.currentTime < interstitial.startTime) {
        this.sendEvent_(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);
    }
    this.adEventManager_.listen(this.baseVideo_, 'seeked', () => {
      const currentTime = this.baseVideo_.currentTime;
      if (currentTime < interstitial.startTime ||
          (interstitial.endTime && currentTime > interstitial.endTime)) {
        if (this.playoutLimitTimer_) {
          this.playoutLimitTimer_.stop();
        }
        this.sendEvent_(shaka.ads.Utils.AD_SKIPPED);
        basicTask();
      }
    });
    if (interstitial.clickThroughUrl) {
      this.adEventManager_.listen(htmlElement, 'click', (e) => {
        if (!interstitial.clickThroughUrl) {
          return;
        }
        this.sendEvent_(shaka.ads.Utils.AD_CLICKED);
        window.open(interstitial.clickThroughUrl, '_blank');
      });
    }
  }


  /**
   * @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();

    const timeOffset = this.getTimeOffset_(interstitial);

    if (!this.playingAd_) {
      this.playingAdStartTime_ = Date.now();
      const data = (new Map())
          .set('timeOffset', timeOffset)
          .set('startedAt', this.lastTime_);
      this.sendEvent_(shaka.ads.Utils.AD_BREAK_STARTED, data);
    }

    this.playingAd_ = true;

    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 (isSkip, isBadHttpStatus) => {
      if (!isBadHttpStatus) {
        updateBaseVideoTime();
      }
      // Optimization to avoid returning to main content when there is another
      // interstitial below.
      let nextCurrentInterstitial = this.getCurrentInterstitial_(
          interstitial.pre, adPosition - oncePlayed);
      if (isSkip && interstitial.groupId) {
        while (nextCurrentInterstitial &&
            nextCurrentInterstitial.groupId == interstitial.groupId) {
          adPosition++;
          nextCurrentInterstitial = this.getCurrentInterstitial_(
              interstitial.pre, adPosition - oncePlayed);
        }
      }
      if (this.playoutLimitTimer_ && (!interstitial.groupId ||
          (nextCurrentInterstitial &&
            nextCurrentInterstitial.groupId != interstitial.groupId))) {
        this.playoutLimitTimer_.stop();
        this.playoutLimitTimer_ = null;
      }
      this.removeBaseStyles_(interstitial);
      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.sendEvent_(
              shaka.ads.Utils.AD_CONTENT_RESUME_REQUESTED,
              (new Map()).set('offset', offset));
        } else if (this.basePlayer_.isLive()) {
          if (interstitial.resumeOffset != null &&
              interstitial.resumeOffset != 0) {
            this.baseVideo_.currentTime += interstitial.resumeOffset;
          }
        }
        this.sendEvent_(shaka.ads.Utils.AD_STOPPED);
        this.adEventManager_.removeAll();
        this.playingAd_ = false;
        if (!this.usingBaseVideo_) {
          this.video_.style.display = 'none';
          if (!isBadHttpStatus) {
            updateBaseVideoTime();
          }
          if (!this.isEnded_) {
            this.baseVideo_.play();
          }
        } else {
          this.cuepointsChanged_();
        }
      }
      if (nextCurrentInterstitial &&
          this.usingBaseVideo_ && this.isSmartTV_()) {
        await this.player_.detach();
        this.determineIfUsingBaseVideo_(/* force= */ true);
      } else {
        this.determineIfUsingBaseVideo_();
      }
      if (nextCurrentInterstitial) {
        this.sendEvent_(shaka.ads.Utils.AD_STOPPED);
        this.adEventManager_.removeAll();
        this.setupAd_(nextCurrentInterstitial, sequenceLength,
            ++adPosition, initialTime, oncePlayed);
      }
      if (!this.playingAd_) {
        this.sendEvent_(shaka.ads.Utils.AD_BREAK_ENDED,
            (new Map()).set('timeOffset', timeOffset));
        this.playoutLimitTimer_?.stop();
        this.playoutLimitTimer_ = null;
      }
    };

    /**
     * @param {!shaka.util.Error} e
     * @param {boolean} initial Indicate whether the error is from the initial
     *                          load or in the middle of the stream.
     */
    const error = async (e, initial) => {
      if (unloadingInterstitial) {
        return;
      }
      unloadingInterstitial = true;
      const isBadHttpStatus =
          initial && e.code === shaka.util.Error.Code.BAD_HTTP_STATUS;
      this.sendEvent_(shaka.ads.Utils.AD_ERROR,
          (new Map()).set('originalEvent', e));
      await basicTask(/* isSkip= */ false, isBadHttpStatus);
    };
    const complete = async () => {
      if (unloadingInterstitial) {
        return;
      }
      unloadingInterstitial = true;
      await basicTask(/* isSkip= */ false, /* isBadHttpStatus= */ false);
      this.sendEvent_(shaka.ads.Utils.AD_COMPLETE);
    };
    this.lastOnSkip_ = async () => {
      if (unloadingInterstitial) {
        return;
      }
      unloadingInterstitial = true;
      this.sendEvent_(shaka.ads.Utils.AD_SKIPPED);
      await basicTask(/* isSkip= */ true, /* isBadHttpStatus= */ false);
    };

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

    this.sendEvent_(shaka.ads.Utils.AD_IMPRESSION);
    this.sendEvent_(shaka.ads.Utils.AD_STARTED, (new Map()).set('ad', ad));

    let prevCanSkipNow = ad.canSkipNow();
    if (prevCanSkipNow) {
      this.sendEvent_(shaka.ads.Utils.AD_SKIP_STATE_CHANGED);
    }
    if (this.preloadTasks_.has(interstitial)) {
      const task = this.preloadTasks_.get(interstitial);
      const initialError = task.getInitialError();
      if (initialError) {
        this.preloadTasks_.delete(interstitial);
        error(initialError, /* initial= */ true);
        return;
      }
    }
    this.adEventManager_.listenOnce(this.player_, 'error', (e) => {
      error(e['detail'], /* initial= */ false);
    });
    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.sendEvent_(shaka.ads.Utils.AD_SKIP_STATE_CHANGED);
      }
      prevCanSkipNow = currentCanSkipNow;
      if (!this.usingBaseVideo_ && !interstitial.overlay &&
          interstitial.resumeOffset == null && interstitial.timelineRange &&
          interstitial.endTime && interstitial.endTime != Infinity &&
          this.baseVideo_.currentTime != interstitial.endTime) {
        const baseSeekRange = this.basePlayer_.seekRange();
        if (baseSeekRange.end >= interstitial.endTime) {
          this.baseVideo_.currentTime = interstitial.endTime;
        }
      }
    });
    this.adEventManager_.listenOnce(this.player_, 'firstquartile', () => {
      updateBaseVideoTime();
      this.sendEvent_(shaka.ads.Utils.AD_FIRST_QUARTILE);
    });
    this.adEventManager_.listenOnce(this.player_, 'midpoint', () => {
      updateBaseVideoTime();
      this.sendEvent_(shaka.ads.Utils.AD_MIDPOINT);
    });
    this.adEventManager_.listenOnce(this.player_, 'thirdquartile', () => {
      updateBaseVideoTime();
      this.sendEvent_(shaka.ads.Utils.AD_THIRD_QUARTILE);
    });
    this.adEventManager_.listenOnce(this.player_, 'complete', complete);
    let adPlayingFired = false;
    this.adEventManager_.listen(this.video_, 'play', () => {
      if (!adPlayingFired) {
        adPlayingFired = true;
        this.sendEvent_(shaka.ads.Utils.AD_PLAYING,
            (new Map()).set('ad', ad));
      } else {
        this.sendEvent_(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.sendEvent_(shaka.ads.Utils.AD_PAUSED);
    });
    this.adEventManager_.listen(this.video_, 'volumechange', () => {
      if (this.video_.muted) {
        this.sendEvent_(shaka.ads.Utils.AD_MUTED);
      } else {
        this.sendEvent_(shaka.ads.Utils.AD_VOLUME_CHANGED);
      }
      if (!this.usingBaseVideo_) {
        this.baseVideo_.volume = this.video_.volume;
        this.baseVideo_.muted = this.video_.muted;
      }
    });
    if (interstitial.clickThroughUrl) {
      const adContainer = this.adContainer_ || this.video_;
      this.adEventManager_.listen(adContainer, 'click', (e) => {
        if (!interstitial.clickThroughUrl) {
          return;
        }
        if (!ad.isPaused()) {
          ad.pause();
        }
        window.open(interstitial.clickThroughUrl, '_blank');
      });
    }

    if (this.usingBaseVideo_ && adPosition == 1) {
      this.sendEvent_(shaka.ads.Utils.AD_CONTENT_PAUSE_REQUESTED,
          (new Map()).set('saveLivePosition', true));
      const detachBasePlayerPromise = Promise.withResolvers();
      const checkState = async (e) => {
        if (e['state'] == 'detach') {
          if (this.isSmartTV_()) {
            await shaka.util.Functional.delay(0.1);
          }
          detachBasePlayerPromise.resolve();
          this.adEventManager_.unlisten(
              this.basePlayer_, 'onstatechange', checkState);
        }
      };
      this.adEventManager_.listen(
          this.basePlayer_, 'onstatechange', checkState);
      await detachBasePlayerPromise.promise;
    }
    this.setBaseStyles_(interstitial);
    if (!this.usingBaseVideo_) {
      this.video_.style.display = '';
      if (interstitial.overlay) {
        this.video_.loop = interstitial.loop;
        this.applyOverlayPosition_(
            /** @type {!HTMLElement} */ (this.video_), interstitial.overlay);
      } else {
        this.baseVideo_.pause();
        if (!this.basePlayer_.isLive()) {
          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%';
      }
    }

    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);
        }
      }
      let playerStartTime = null;
      if (adPosition == 1 && !interstitial.pre && !interstitial.post &&
          this.config_.allowStartInMiddleOfInterstitial &&
          this.lastTime_ != null &&
          interstitial.startTime <= this.lastTime_ &&
          (!interstitial.endTime || interstitial.endTime > this.lastTime_)) {
        if (interstitial.id &&
            this.assetStartOffsets_.has(interstitial.id)) {
          const offset = this.assetStartOffsets_.get(interstitial.id);
          this.assetStartOffsets_.delete(interstitial.id);
          if (Math.abs(offset) > 0.25) {
            playerStartTime = offset;
          }
        } else {
          const newPosition = this.lastTime_ - interstitial.startTime;
          if (Math.abs(newPosition) > 0.25) {
            playerStartTime = newPosition;
          }
        }
      }
      if (adPosition == 1) {
        this.playoutLimitTimer_?.stop();
        this.playoutLimitTimer_ = null;
      }
      let playoutLimit = interstitial.playoutLimit;
      if (playoutLimit && !this.playoutLimitTimer_) {
        if (playerStartTime) {
          playoutLimit -= playerStartTime;
        }
        this.playoutLimitTimer_ = new shaka.util.Timer(() => {
          this.lastOnSkip_();
        }).tickAfter(playoutLimit);
        this.player_.configure('playRangeEnd', playoutLimit);
      }
      if (this.player_.getMediaElement() !== this.video_) {
        await this.player_.attach(this.video_);
      }
      if (this.preloadTasks_.has(interstitial)) {
        const task = this.preloadTasks_.get(interstitial);
        this.preloadTasks_.delete(interstitial);
        const error = task.getInitialError();
        if (error) {
          throw error;
        }
        const preloadManager = task.getPreloadManager();
        if (preloadManager) {
          await this.player_.load(
              preloadManager,
              playerStartTime);
        } else {
          await this.player_.load(
              interstitial.uri,
              playerStartTime,
              interstitial.mimeType || undefined);
        }
      } else {
        await this.player_.load(
            interstitial.uri,
            playerStartTime,
            interstitial.mimeType || undefined);
      }
      if (!interstitial.overlay || !this.baseVideo_.paused) {
        this.video_.play();
      } else {
        this.video_.pause();
      }
      const loadTime = (Date.now() - startTime) / 1000;
      this.sendEvent_(shaka.ads.Utils.ADS_LOADED,
          (new Map()).set('loadTime', loadTime));
      if (this.usingBaseVideo_) {
        this.baseVideo_.play();
      }
      if (interstitial.overlay) {
        if (!interstitial.pre && !interstitial.post &&
            (!this.basePlayer_.isLive() || interstitial.startTime > 0)) {
          const setPosition = () => {
            const newPosition =
                this.baseVideo_.currentTime - interstitial.startTime;
            if (Math.abs(newPosition - this.video_.currentTime) > 0.1) {
              this.video_.currentTime = newPosition;
            }
          };
          this.adEventManager_.listenOnce(this.video_, 'playing', setPosition);
          this.adEventManager_.listen(this.baseVideo_, 'seeking', setPosition);
        }
        this.adEventManager_.listen(this.baseVideo_, 'seeked', () => {
          const currentTime = this.baseVideo_.currentTime;
          if (currentTime < interstitial.startTime ||
              (interstitial.endTime && currentTime > interstitial.endTime)) {
            this.lastOnSkip_();
          }
        });
      }
    } catch (e) {
      if (!this.playingAd_) {
        return;
      }
      error(e, /* initial= */ true);
    }
  }

  /**
   * Positions an element within the ad container according to an overlay's
   * viewport/topLeft/size, expressed as CSS percentages.
   *
   * @param {!HTMLElement} element
   * @param {!shaka.extern.AdPositionInfo} overlay
   * @private
   */
  applyOverlayPosition_(element, overlay) {
    const viewport = overlay.viewport;
    const topLeft = overlay.topLeft;
    const size = overlay.size;
    element.style.height = (size.y / viewport.y * 100) + '%';
    element.style.left = (topLeft.x / viewport.x * 100) + '%';
    element.style.top = (topLeft.y / viewport.y * 100) + '%';
    element.style.width = (size.x / viewport.x * 100) + '%';
  }

  /**
   * @param {shaka.extern.AdInterstitial} interstitial
   * @private
   */
  setBaseStyles_(interstitial) {
    if (interstitial.displayOnBackground) {
      this.baseVideo_.style.zIndex = '1';
    }
    if (interstitial.currentVideo != null) {
      const currentVideo = interstitial.currentVideo;
      this.baseVideo_.style.transformOrigin = 'top left';
      let addTransition = true;
      const transforms = [];
      const translateX = currentVideo.topLeft.x / currentVideo.viewport.x * 100;
      if (translateX > 0 && translateX <= 100) {
        transforms.push(`translateX(${translateX}%)`);
        // In the case of double box ads we do not want transitions.
        addTransition = false;
      }
      const translateY = currentVideo.topLeft.y / currentVideo.viewport.y * 100;
      if (translateY > 0 && translateY <= 100) {
        transforms.push(`translateY(${translateY}%)`);
        // In the case of double box ads we do not want transitions.
        addTransition = false;
      }
      const scaleX = currentVideo.size.x / currentVideo.viewport.x;
      if (scaleX < 1) {
        transforms.push(`scaleX(${scaleX})`);
      }
      const scaleY = currentVideo.size.y / currentVideo.viewport.y;
      if (scaleX < 1) {
        transforms.push(`scaleY(${scaleY})`);
      }
      if (transforms.length) {
        this.baseVideo_.style.transform = transforms.join(' ');
      }
      if (addTransition) {
        this.baseVideo_.style.transition = 'transform 250ms';
      }
    }
    if (this.adContainer_) {
      if (interstitial.clickThroughUrl) {
        this.adContainer_.setAttribute('ad-active', 'true');
        this.adContainer_.style.pointerEvents = '';
      } else {
        this.adContainer_.style.pointerEvents = 'none';
      }
      if (interstitial.background) {
        this.adContainer_.style.background = interstitial.background;
      }
    }
    if (this.adVideo_) {
      if (interstitial.overlay) {
        this.adVideo_.style.background = '';
      } else {
        this.adVideo_.style.background = 'rgb(0, 0, 0)';
      }
    }
  }

  /**
   * @param {?shaka.extern.AdInterstitial=} interstitial
   * @private
   */
  removeBaseStyles_(interstitial) {
    if (!interstitial || interstitial.displayOnBackground) {
      this.baseVideo_.style.zIndex = '';
    }
    if (!interstitial || interstitial.currentVideo != null) {
      this.baseVideo_.style.transformOrigin = '';
      this.baseVideo_.style.transition = '';
      this.baseVideo_.style.transform = '';
    }
    if (this.adContainer_) {
      this.adContainer_.removeAttribute('ad-active');
      this.adContainer_.style.pointerEvents = '';
      if (!interstitial || interstitial.background) {
        this.adContainer_.style.background = '';
      }
    }
    if (this.adVideo_) {
      this.adVideo_.style.background = '';
    }
  }

  /**
   * @param {shaka.extern.HLSMetadata} hlsMetadata
   * @return {!Promise<!Array<shaka.extern.AdInterstitial>>}
   * @private
   */
  async getInterstitialsInfo_(hlsMetadata) {
    const NumberUtils = shaka.util.NumberUtils;

    const interstitialsAd = [];
    if (!hlsMetadata) {
      return interstitialsAd;
    }
    const assetUri = this.getMetadataValue_(hlsMetadata, 'X-ASSET-URI');
    const assetList = this.getMetadataValue_(hlsMetadata, 'X-ASSET-LIST');
    if (!assetUri && !assetList) {
      return interstitialsAd;
    }
    const id = this.getMetadataValue_(hlsMetadata, 'ID');
    const {startTime, endTime} = this.getInterstitialTimes_(hlsMetadata, id);
    const restrict = this.getMetadataValue_(hlsMetadata, 'X-RESTRICT');
    let isSkippable = true;
    let canJump = true;
    if (restrict != null) {
      isSkippable = !restrict.includes('SKIP');
      canJump = !restrict.includes('JUMP');
    }
    let skipOffset = isSkippable ? 0 : null;
    const skipControlOffset =
        this.getMetadataValue_(hlsMetadata, 'X-SKIP-CONTROL-OFFSET');
    if (skipControlOffset != null) {
      skipOffset = parseFloat(skipControlOffset);
      if (isNaN(skipOffset)) {
        skipOffset = isSkippable ? 0 : null;
      }
    }
    let skipFor = null;
    const skipControlDuration =
        this.getMetadataValue_(hlsMetadata, 'X-SKIP-CONTROL-DURATION');
    if (skipControlDuration != null) {
      skipFor = parseFloat(skipControlDuration);
      if (isNaN(skipOffset)) {
        skipFor = null;
      }
    }
    let resumeOffset = null;
    const resume = this.getMetadataValue_(hlsMetadata, 'X-RESUME-OFFSET');
    if (resume != null) {
      resumeOffset = parseFloat(resume);
      if (isNaN(resumeOffset)) {
        resumeOffset = null;
      }
    }
    if (resumeOffset != null && resumeOffset != 0 && endTime &&
        endTime != Infinity &&
        NumberUtils.isFloatEqual(startTime + resumeOffset, endTime)) {
      resumeOffset = null;
    }
    let playoutLimit = null;
    const playout = this.getMetadataValue_(hlsMetadata, 'X-PLAYOUT-LIMIT');
    if (playout != null) {
      playoutLimit = parseFloat(playout);
      if (isNaN(playoutLimit)) {
        playoutLimit = null;
      }
    }
    const {once, pre, post} = this.parseCue_(hlsMetadata);
    let timelineRange = false;
    const timelineOccupies =
        this.getMetadataValue_(hlsMetadata, 'X-TIMELINE-OCCUPIES');
    if (timelineOccupies != null) {
      timelineRange = timelineOccupies.includes('RANGE');
    } else if (resume == null && this.basePlayer_.isLive()) {
      timelineRange = !pre && !post;
    }
    if (assetUri != null) {
      if (!assetUri) {
        return interstitialsAd;
      }
      interstitialsAd.push({
        id,
        groupId: null,
        startTime,
        endTime,
        uri: this.getUriWithHlsParams_(assetUri),
        mimeType: null,
        isSkippable,
        skipOffset,
        skipFor,
        canJump,
        resumeOffset,
        playoutLimit,
        once,
        pre,
        post,
        timelineRange,
        loop: false,
        overlay: null,
        displayOnBackground: false,
        currentVideo: null,
        background: null,
        clickThroughUrl: null,
        tracking: null,
      });
    } else if (assetList != null) {
      if (!assetList) {
        return interstitialsAd;
      }
      /** @type {shaka.ads.InterstitialAdManager.AssetListDescriptor} */
      const descriptor = {
        id,
        groupId: null,
        startTime,
        endTime,
        assetListUri: assetList,
        isSkippable,
        skipOffset,
        skipFor,
        canJump,
        resumeOffset,
        playoutLimit,
        once,
        pre,
        post,
        timelineRange,
        resolving: false,
        resolved: false,
      };
      // A matching HLS preload Date Range may already tell us how early to
      // resolve this asset list.
      this.applyPreloadOffset_(descriptor);
      // Defer resolving the asset list (and therefore the ad decision request)
      // until playback approaches the interstitial. Pre/post-rolls are resolved
      // eagerly because they play at the content boundaries with no lead time.
      // This avoids a burst of concurrent requests at parse time.
      // See https://github.com/shaka-project/shaka-player/issues/10191
      if (this.shouldResolveAssetListNow_(descriptor)) {
        const resolved = await this.resolveAssetList_(descriptor);
        if (resolved.length) {
          // The cue point is emitted by addInterstitials, not here.
          this.maybeRetainResolvedAssetList_(descriptor);
        }
        return resolved;
      }
      this.addUnresolvedAssetList_(descriptor);
    }
    return interstitialsAd;
  }

  /**
   * Resolves an HLS X-ASSET-LIST: performs the ad decision request and expands
   * it into the individual interstitials it contains.
   *
   * @param {shaka.ads.InterstitialAdManager.AssetListDescriptor} descriptor
   * @return {!Promise<!Array<shaka.extern.AdInterstitial>>}
   * @private
   */
  async resolveAssetList_(descriptor) {
    /** @type {!Array<shaka.extern.AdInterstitial>} */
    const interstitialsAd = [];
    // skipOffset and skipFor may be overridden by the SKIP-CONTROL block below;
    // the rest of the metadata is read directly from the descriptor.
    let skipOffset = descriptor.skipOffset;
    let skipFor = descriptor.skipFor;
    try {
      const NetworkingEngine = shaka.net.NetworkingEngine;
      const context = {
        type: NetworkingEngine.AdvancedRequestType.INTERSTITIAL_ASSET_LIST,
      };
      let hlsStartOffset = 0;
      if (!descriptor.pre && !descriptor.post &&
          this.config_.allowStartInMiddleOfInterstitial &&
          this.basePlayer_.isLive()) {
        const currentTime = this.lastTime_ ?? this.baseVideo_.currentTime;
        hlsStartOffset = currentTime - descriptor.startTime;
      }
      const assetListUri =
          this.getUriWithHlsParams_(descriptor.assetListUri, hlsStartOffset);
      const response = await this.makeAdRequest_(
          assetListUri, context).promise;
      const data = shaka.util.StringUtils.fromUTF8(response.data);
      /** @type {!shaka.ads.InterstitialAdManager.AssetsList} */
      const dataAsJson =
      /** @type {!shaka.ads.InterstitialAdManager.AssetsList} */ (
          JSON.parse(data));
      const skipControl = dataAsJson['SKIP-CONTROL'];
      if (skipControl) {
        const skipControlOffsetList = skipControl['OFFSET'];
        if ((typeof skipControlOffsetList) == 'number') {
          skipOffset = parseFloat(skipControlOffsetList);
          if (isNaN(skipControlOffsetList)) {
            skipOffset = descriptor.isSkippable ? 0 : null;
          }
        }
        const skipControlDurationList = skipControl['DURATION'];
        if ((typeof skipControlDurationList) == 'number') {
          skipFor = parseFloat(skipControlDurationList);
          if (isNaN(skipFor)) {
            skipFor = null;
          }
        }
      }
      let cumulativeDuration = 0;
      for (let i = 0; i < dataAsJson['ASSETS'].length; i++) {
        const asset = dataAsJson['ASSETS'][i];
        const assetUri = asset['URI'];
        const assetDuration = parseFloat(asset['DURATION']) || 0;
        if (hlsStartOffset > 0 && assetDuration > 0) {
          const assetEnd = cumulativeDuration + assetDuration;
          if (assetEnd <= hlsStartOffset) {
            cumulativeDuration = assetEnd;
            continue;
          }
        }
        if (assetUri) {
          const resolvedUri = shaka.util.URL.resolve(response.uri, assetUri);
          const interstitial = {
            id: descriptor.id + '_shaka_asset_' + i,
            groupId: descriptor.id,
            startTime: descriptor.startTime,
            endTime: descriptor.endTime,
            uri: this.getUriWithHlsParams_(resolvedUri),
            mimeType: null,
            isSkippable: descriptor.isSkippable,
            skipOffset,
            skipFor,
            canJump: descriptor.canJump,
            resumeOffset: descriptor.resumeOffset,
            playoutLimit: descriptor.playoutLimit,
            once: descriptor.once,
            pre: descriptor.pre,
            post: descriptor.post,
            timelineRange: descriptor.timelineRange,
            loop: false,
            overlay: null,
            displayOnBackground: false,
            currentVideo: null,
            background: null,
            clickThroughUrl: null,
            tracking: null,
          };
          const adCreativeSignaling = asset['X-AD-CREATIVE-SIGNALING'];
          if (adCreativeSignaling) {
            const payloadSlot =
                adCreativeSignaling.payload && adCreativeSignaling.payload[0];
            if (payloadSlot) {
              interstitial.clickThroughUrl = payloadSlot.clickThrough;
              if (payloadSlot.tracking) {
                const Utils = shaka.ads.Utils;
                interstitial.tracking =
                    Utils.createTrackingFromEvents(payloadSlot.tracking);
              }
            }
          }
          if (hlsStartOffset > 0 &&
              cumulativeDuration < hlsStartOffset) {
            const intraAssetOffset =
                hlsStartOffset - cumulativeDuration;
            this.assetStartOffsets_.set(
                interstitial.id, intraAssetOffset);
          }
          interstitialsAd.push(interstitial);
        }
        cumulativeDuration += assetDuration;
      }
    } catch (e) {
      // The request failed (network error, non-2xx response, or invalid JSON).
      // A successful response with an empty ASSETS array does not reach here;
      // it simply yields an empty interstitial list. Log so failed resolutions
      // are distinguishable from legitimately empty asset lists.
      shaka.log.warning(
          'Failed to resolve interstitial asset list',
          descriptor.assetListUri, e);
    }
    return interstitialsAd;
  }

  /**
   * Stores an asset list descriptor whose resolution has been deferred, and
   * ensures the polling that will eventually resolve it is running.
   *
   * @param {shaka.ads.InterstitialAdManager.AssetListDescriptor} descriptor
   * @private
   */
  addUnresolvedAssetList_(descriptor) {
    this.unresolvedAssetLists_.add(descriptor);
    // Asset list interstitials are never overlays, so they always contribute a
    // cue point. Surface it now so the timeline UI is not delayed.
    this.cuepointsChanged_();
    this.addEventListeners_();
  }

  /**
   * Resolves a deferred asset list descriptor and adds the resulting
   * interstitials. Invoked from the poll timer; not awaited.
   *
   * @param {shaka.ads.InterstitialAdManager.AssetListDescriptor} descriptor
   * @private
   */
  async resolveAssetListDescriptor_(descriptor) {
    const interstitials = await this.resolveAssetList_(descriptor);
    descriptor.resolving = false;
    if (interstitials.length) {
      this.maybeRetainResolvedAssetList_(descriptor);
      await this.addInterstitials(interstitials);
    } else {
      // The asset list yielded nothing (empty or failed). Drop its cue point
      // and stop listening if there is nothing left to do.
      this.unresolvedAssetLists_.delete(descriptor);
      this.cuepointsChanged_();
      this.removeEventListeners_();
    }
  }

  /**
   * After an asset list resolves, either retain its descriptor (so a later seek
   * back into a live break can re-request it with an updated _HLS_start_offset)
   * or drop it. Retaining is only useful for live streams that allow starting
   * in the middle of an interstitial.
   *
   * @param {shaka.ads.InterstitialAdManager.AssetListDescriptor} descriptor
   * @private
   */
  maybeRetainResolvedAssetList_(descriptor) {
    if (this.shouldRetainAssetList_()) {
      descriptor.resolved = true;
      this.unresolvedAssetLists_.add(descriptor);
    } else {
      this.unresolvedAssetLists_.delete(descriptor);
    }
  }

  /**
   * Whether resolved asset lists should be retained so they can be re-requested
   * after a seek. Only meaningful for live streams that allow starting in the
   * middle of an interstitial, where the _HLS_start_offset would differ.
   *
   * @return {boolean}
   * @private
   */
  shouldRetainAssetList_() {
    return !!this.config_ && this.config_.allowStartInMiddleOfInterstitial &&
        this.basePlayer_.isLive();
  }

  /**
   * On a seek into a live break, an already-resolved asset list must be
   * re-requested with an updated _HLS_start_offset that reflects the new
   * playhead position. Reset the cache of any resolved asset list whose range
   * now contains the playhead so the poll resolves it again.
   *
   * @param {number} currentTime
   * @private
   */
  maybeResetAssetListsOnSeek_(currentTime) {
    if (!this.shouldRetainAssetList_()) {
      return;
    }
    for (const descriptor of this.unresolvedAssetLists_) {
      if (!descriptor.resolved) {
        continue;
      }
      const endTime = descriptor.endTime;
      const inRange = currentTime >= descriptor.startTime &&
          (endTime == null || endTime == Infinity || currentTime < endTime);
      if (!inRange) {
        continue;
      }
      // Allow the asset list to be resolved again with the new offset.
      descriptor.resolved = false;
      this.removeResolvedAssetListGroup_(descriptor.id);
    }
  }

  /**
   * Removes the interstitials previously resolved from the asset list with the
   * given group id, so the asset list can be resolved again.
   *
   * @param {?string} groupId
   * @private
   */
  removeResolvedAssetListGroup_(groupId) {
    if (groupId == null) {
      return;
    }
    for (const interstitial of Array.from(this.interstitials_)) {
      if (interstitial.groupId === groupId) {
        this.removeInterstitial_(interstitial);
      }
    }
  }

  /**
   * Removes an interstitial and releases any resources associated with it
   * (preload tasks, preload-on-DOM link elements and bookkeeping sets).
   *
   * @param {shaka.extern.AdInterstitial} interstitial
   * @private
   */
  removeInterstitial_(interstitial) {
    if (this.preloadTasks_.has(interstitial)) {
      this.preloadTasks_.get(interstitial).release();
      this.preloadTasks_.delete(interstitial);
    }
    this.removePreloadOnDomElements_(interstitial);
    const interstitialId = this.interstitialId_(interstitial);
    this.interstitialIds_.delete(interstitialId);
    this.interstitials_.delete(interstitial);
    this.sortedInterstitials_ = null;
    if (this.lastPlayedAd_ === interstitial) {
      this.lastPlayedAd_ = null;
    }
  }

  /**
   * The look-ahead time (in seconds) used to decide how early an interstitial
   * is resolved/preloaded: the per-interstitial resolutionTimeOffset (DASH
   * earliestResolutionTimeOffset / HLS preload Date Range) when set, otherwise
   * the configured default.
   *
   * @param {{resolutionTimeOffset: (number|undefined)}} item
   * @return {number}
   * @private
   */
  resolutionAheadTime_(item) {
    return item.resolutionTimeOffset ||
        this.config_.interstitialPreloadAheadTime;
  }

  /**
   * Whether the given asset list should be resolved immediately rather than
   * deferred until playback approaches it.
   *
   * @param {shaka.ads.InterstitialAdManager.AssetListDescriptor} descriptor
   * @return {boolean}
   * @private
   */
  shouldResolveAssetListNow_(descriptor) {
    // Pre/post-rolls play at the content boundaries with no look-ahead, and are
    // singular, so they are resolved eagerly without causing request bursts.
    if (descriptor.pre || descriptor.post) {
      return true;
    }
    // Forced ad at the very start.
    if (descriptor.startTime == 0 && !descriptor.canJump) {
      return true;
    }
    // Compare against the current playhead. Before the first time update
    // lastTime_ is null, in which case the media element's currentTime is a
    // good enough proxy (typically 0 at load, so only imminent ad breaks
    // resolve eagerly and the rest stay deferred).
    const currentTime =
        this.lastTime_ != null ? this.lastTime_ : this.baseVideo_.currentTime;
    let baseVideoDuration = Infinity;
    if (!this.playingAd_ && this.baseVideo_.duration) {
      baseVideoDuration = this.baseVideo_.duration;
    }
    const startTime = Math.min(descriptor.startTime, baseVideoDuration);
    // Still further ahead than the look-ahead window: keep deferring.
    if (startTime - currentTime > this.resolutionAheadTime_(descriptor)) {
      return false;
    }
    // Already past the end of a finite range, e.g. we joined a live stream
    // after the ad break ended: it can no longer be played.
    const endTime = descriptor.endTime;
    if (endTime != null && endTime != Infinity && currentTime >= endTime) {
      return false;
    }
    return true;
  }

  /**
   * @param {shaka.extern.HLSMetadata} hlsMetadata
   * @return {!Array<shaka.extern.AdInterstitial>}
   * @private
   */
  getOverlaysInfo_(hlsMetadata) {
    const interstitialsAd = [];
    if (!hlsMetadata) {
      return interstitialsAd;
    }
    const uri = this.getMetadataValue_(hlsMetadata, 'X-ASSET-URI');
    if (!uri) {
      return interstitialsAd;
    }
    const id = this.getMetadataValue_(hlsMetadata, 'X-OVERLAY-ID');
    const {startTime, endTime} = this.getInterstitialTimes_(hlsMetadata, id);
    const {once, pre, post} = this.parseCue_(hlsMetadata);
    const mimeType = this.getMetadataValue_(hlsMetadata, 'X-ASSET-MIMETYPE');
    const loop = this.getMetadataValue_(hlsMetadata, 'X-LOOP') == 'YES';
    let z = 1;
    const depth = this.getMetadataValue_(hlsMetadata, 'X-DEPTH');
    if (depth != null) {
      z = parseFloat(depth);
      if (isNaN(z)) {
        z = 1;
      }
    }
    const background = this.getMetadataValue_(hlsMetadata, 'X-BACKGROUND');

    const viewport = {
      x: 1920,
      y: 1080,
    };

    const viewportValue = this.getMetadataValue_(hlsMetadata, 'X-VIEWPORT');
    if (viewportValue != null) {
      const size = viewportValue.split('x');
      if (size.length != 2) {
        return interstitialsAd;
      }
      viewport.x = parseFloat(size[0]);
      viewport.y = parseFloat(size[1]);
    }

    /** @type {!shaka.extern.AdPositionInfo} */
    const overlay = {
      viewport: {
        x: viewport.x,
        y: viewport.y,
      },
      topLeft: {
        x: 0,
        y: 0,
      },
      size: {
        x: viewport.x,
        y: viewport.y,
      },
    };

    const overlayPosition =
        this.getMetadataValue_(hlsMetadata, 'X-OVERLAY-POSITION');
    if (overlayPosition != null) {
      const position = overlayPosition.split('x');
      if (position.length != 2) {
        return interstitialsAd;
      }
      overlay.topLeft.x = parseFloat(position[0]);
      overlay.topLeft.y = parseFloat(position[1]);
    }

    const overlaySize = this.getMetadataValue_(hlsMetadata, 'X-OVERLAY-SIZE');
    if (overlaySize != null) {
      const size = overlaySize.split('x');
      if (size.length != 2) {
        return interstitialsAd;
      }
      overlay.size.x = parseFloat(size[0]);
      overlay.size.y = parseFloat(size[1]);
    }

    /** @type {?shaka.extern.AdPositionInfo} */
    let currentVideo = null;
    const squeezeCurrent =
        this.getMetadataValue_(hlsMetadata, 'X-SQUEEZECURRENT');
    if (squeezeCurrent != null) {
      let percentage = parseFloat(squeezeCurrent);
      if (isNaN(percentage)) {
        percentage = 1;
      }
      currentVideo = {
        viewport: {
          x: 1920,
          y: 1080,
        },
        topLeft: {
          x: 0,
          y: 0,
        },
        size: {
          x: 1920 * percentage,
          y: 1080 * percentage,
        },
      };
      const squeezeCurrentPosition =
          this.getMetadataValue_(hlsMetadata, 'X-SQUEEZECURRENT-POSITION');
      if (squeezeCurrentPosition != null) {
        const position = squeezeCurrentPosition.split('x');
        if (position.length != 2) {
          return interstitialsAd;
        }
        currentVideo.topLeft.x = parseFloat(position[0]);
        currentVideo.topLeft.y = parseFloat(position[1]);
      }
    }

    interstitialsAd.push({
      id,
      groupId: null,
      startTime,
      endTime,
      uri: this.getUriWithHlsParams_(uri),
      mimeType,
      isSkippable: false,
      skipOffset: null,
      skipFor: null,
      canJump: true,
      resumeOffset: null,
      playoutLimit: null,
      once,
      pre,
      post,
      timelineRange: true,
      loop,
      overlay,
      displayOnBackground: z == -1,
      currentVideo,
      background,
      clickThroughUrl: null,
      tracking: null,
    });
    return interstitialsAd;
  }

  /**
   * @param {string} uri
   * @param {number=} offset
   * @return {string}
   * @private
   */
  getUriWithHlsParams_(uri, offset = 0) {
    if (uri.startsWith('data:')) {
      return uri;
    }
    if (!this.sessionId_) {
      this.sessionId_ = window.crypto.randomUUID();
    }
    const params = new Map();
    params.set('_HLS_primary_id', this.sessionId_);
    if (offset > 0) {
      const roundOffset = Math.round(offset * 1000) / 1000;
      params.set('_HLS_start_offset', String(roundOffset));
    }
    return shaka.util.URL.appendParams(uri, params);
  }


  /**
   * @private
   */
  cuepointsChanged_() {
    /** @type {!Array<!shaka.extern.AdCuePoint>} */
    const cuePoints = [];
    /**
     * @param {number} start
     * @param {?number} end
     */
    const addCuepoint = (start, end) => {
      const isValid = !cuePoints.find((c) => {
        return start == c.start && end == c.end;
      });
      if (isValid) {
        cuePoints.push({start, end});
      }
    };
    for (const interstitial of this.interstitials_) {
      if (interstitial.overlay) {
        continue;
      }
      if (interstitial.pre) {
        addCuepoint(0, null);
      } else if (interstitial.post) {
        addCuepoint(-1, null);
      } else if (interstitial.timelineRange) {
        addCuepoint(interstitial.startTime, interstitial.endTime);
      } else {
        addCuepoint(interstitial.startTime, null);
      }
    }
    // Include asset lists that have not been resolved yet, so the timeline UI
    // shows the upcoming ad breaks without waiting for their deferred
    // resolution.
    for (const descriptor of this.unresolvedAssetLists_) {
      if (descriptor.pre) {
        addCuepoint(0, null);
      } else if (descriptor.post) {
        addCuepoint(-1, null);
      } else if (descriptor.timelineRange) {
        addCuepoint(descriptor.startTime, descriptor.endTime);
      } else {
        addCuepoint(descriptor.startTime, null);
      }
    }

    this.sendEvent_(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);
    this.player_.configure('playRangeEnd', Infinity);
    const netEngine = this.player_.getNetworkingEngine();
    goog.asserts.assert(netEngine, 'Need networking engine');
    this.basePlayer_.getNetworkingEngine().copyFiltersInto(netEngine);
  }

  /**
   * @param {string} url
   * @param {shaka.extern.RequestContext=} context
   * @return {!shaka.net.NetworkingEngine.PendingRequest}
   * @private
   */
  makeAdRequest_(url, context) {
    const type = shaka.net.NetworkingEngine.RequestType.ADS;
    const request = shaka.net.NetworkingEngine.makeRequest(
        [url],
        shaka.net.NetworkingEngine.defaultRetryParameters());
    return this.basePlayer_.getNetworkingEngine()
        .request(type, request, context);
  }

  /**
   * Returns the string data of the HLS metadata frame with the given key, or
   * null if there is no such frame.
   *
   * @param {shaka.extern.HLSMetadata} hlsMetadata
   * @param {string} key
   * @return {?string}
   * @private
   */
  getMetadataValue_(hlsMetadata, key) {
    const frame = hlsMetadata.values.find((v) => v.key == key);
    return frame ? /** @type {string} */ (frame.data) : null;
  }

  /**
   * Computes the start/end times of an interstitial from its HLS metadata. When
   * the Date Range has no ID, the times are floored to a tenth of a second.
   *
   * @param {shaka.extern.HLSMetadata} hlsMetadata
   * @param {?string} id
   * @return {{startTime: number, endTime: ?number}}
   * @private
   */
  getInterstitialTimes_(hlsMetadata, id) {
    const startTime = id == null ?
        Math.floor(hlsMetadata.startTime * 10) / 10 :
        hlsMetadata.startTime;
    let endTime = hlsMetadata.endTime;
    if (hlsMetadata.endTime && hlsMetadata.endTime != Infinity &&
        typeof(hlsMetadata.endTime) == 'number') {
      endTime = id == null ?
          Math.floor(hlsMetadata.endTime * 10) / 10 :
          hlsMetadata.endTime;
    }
    return {startTime, endTime};
  }

  /**
   * Parses the CUE attribute (ONCE/PRE/POST) from HLS metadata.
   *
   * @param {shaka.extern.HLSMetadata} hlsMetadata
   * @return {{once: boolean, pre: boolean, post: boolean}}
   * @private
   */
  parseCue_(hlsMetadata) {
    const cue = this.getMetadataValue_(hlsMetadata, 'CUE');
    return {
      once: cue != null && cue.includes('ONCE'),
      pre: cue != null && cue.includes('PRE'),
      post: cue != null && cue.includes('POST'),
    };
  }

  /**
   * @param {shaka.extern.AdInterstitial} interstitial
   * @return {string}
   * @private
   */
  interstitialId_(interstitial) {
    return interstitial.id || JSON.stringify(interstitial);
  }

  /**
   * The AD_BREAK_STARTED/ENDED time offset for an interstitial: 0 for pre-roll,
   * -1 for post-roll, otherwise its start time.
   *
   * @param {shaka.extern.AdInterstitial} interstitial
   * @return {number}
   * @private
   */
  getTimeOffset_(interstitial) {
    if (interstitial.pre) {
      return 0;
    }
    if (interstitial.post) {
      return -1;
    }
    return interstitial.startTime;
  }

  /**
   * @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_);
  }

  /**
   * @return {boolean}
   * @private
   */
  isSmartTV_() {
    const device = shaka.device.DeviceFactory.getDevice();
    const deviceType = device.getDeviceType();
    if (deviceType == shaka.device.IDevice.DeviceType.TV ||
        deviceType == shaka.device.IDevice.DeviceType.CONSOLE ||
        deviceType == shaka.device.IDevice.DeviceType.CAST) {
      return true;
    }
    return false;
  }

  /**
   * @param {!shaka.extern.AdInterstitial} interstitial
   * @private
   */
  checkPreloadOnDomElements_(interstitial) {
    if (this.preloadOnDomElements_.has(interstitial) ||
        (this.config_ && !this.config_.allowPreloadOnDomElements)) {
      return;
    }
    const createAndAddLink = (url) => {
      const link = /** @type {HTMLLinkElement} */(
        document.createElement('link'));
      link.rel = 'preload';
      link.href = url;
      link.as = 'image';
      document.head.appendChild(link);
      return link;
    };
    const links = [];
    if (interstitial.background) {
      const urlRegExp = /url\(('|")?([^'"()]+)('|")\)?/;
      const match = interstitial.background.match(urlRegExp);
      if (match) {
        links.push(createAndAddLink(match[2]));
      }
    }
    if (interstitial.mimeType &&
        interstitial.mimeType.startsWith('image/')) {
      links.push(createAndAddLink(interstitial.uri));
    }
    this.preloadOnDomElements_.set(interstitial, links);
  }


  /**
   * @param {!shaka.extern.AdInterstitial} interstitial
   * @private
   */
  removePreloadOnDomElements_(interstitial) {
    if (!this.preloadOnDomElements_.has(interstitial)) {
      return;
    }
    const links = this.preloadOnDomElements_.get(interstitial);
    for (const link of links) {
      link.parentNode.removeChild(link);
    }
    this.preloadOnDomElements_.delete(interstitial);
  }


  /**
   * @param {string} type
   * @param {Map<string, Object>=} dict
   * @private
   */
  sendEvent_(type, dict) {
    shaka.log.info('Interstitial event', type, dict);
    this.onEvent_(new shaka.util.FakeEvent(type, dict));
    this.processTrackingEvent_(type);
  }

  /**
   * @param {string} type
   * @private
   */
  processTrackingEvent_(type) {
    if (this.config_.disableTrackingEvents) {
      return;
    }
    const tracking = this.lastPlayedAd_ && this.lastPlayedAd_.tracking;
    shaka.ads.Utils.fireTrackingEvents(
        tracking, type, this.basePlayer_.getNetworkingEngine());
  }

  /**
   * @param {!shaka.extern.AdInterstitial} interstitial
   * @return {boolean}
   * @private
   */
  isWithinPreloadWindow_(interstitial) {
    if (interstitial.pre && this.lastTime_ == null) {
      return true;
    } else if (interstitial.startTime == 0 && !interstitial.canJump) {
      return true;
    } else if (this.lastTime_ != null) {
      let baseVideoDuration = Infinity;
      if (!this.playingAd_ && this.baseVideo_.duration) {
        baseVideoDuration = this.baseVideo_.duration;
      }
      const difference =
          Math.min(interstitial.startTime, baseVideoDuration) - this.lastTime_;
      if (difference > 0 &&
          difference <= this.resolutionAheadTime_(interstitial)) {
        return true;
      }
    }
    return false;
  }
};


/**
 * Holds the parsed metadata of an HLS X-ASSET-LIST interstitial whose
 * resolution has been deferred until playback approaches it.
 *
 * @typedef {{
 *   id: ?string,
 *   groupId: ?string,
 *   startTime: number,
 *   endTime: ?number,
 *   assetListUri: string,
 *   isSkippable: boolean,
 *   skipOffset: ?number,
 *   skipFor: ?number,
 *   canJump: boolean,
 *   resumeOffset: ?number,
 *   playoutLimit: ?number,
 *   once: boolean,
 *   pre: boolean,
 *   post: boolean,
 *   timelineRange: boolean,
 *   resolutionTimeOffset: (number|undefined),
 *   resolving: boolean,
 *   resolved: boolean,
 * }}
 *
 * @property {?string} id
 * @property {?string} groupId
 * @property {number} startTime
 * @property {?number} endTime
 * @property {string} assetListUri
 * @property {boolean} isSkippable
 * @property {?number} skipOffset
 * @property {?number} skipFor
 * @property {boolean} canJump
 * @property {?number} resumeOffset
 * @property {?number} playoutLimit
 * @property {boolean} once
 * @property {boolean} pre
 * @property {boolean} post
 * @property {boolean} timelineRange
 * @property {(number|undefined)} resolutionTimeOffset
 *   The offset in seconds before startTime at which the asset list may be
 *   resolved. Undefined or 0 means use the interstitialPreloadAheadTime
 *   default.
 * @property {boolean} resolving
 *   Whether a deferred resolution request is already in flight.
 * @property {boolean} resolved
 *   Whether the asset list has already been resolved. Retained (only for live
 *   streams that allow starting mid-interstitial) so that seeking back into the
 *   break can re-request the asset list with an updated _HLS_start_offset.
 */
shaka.ads.InterstitialAdManager.AssetListDescriptor;


/* eslint-disable @stylistic/max-len */
/**
 * @typedef {{
 *   ASSETS: !Array<shaka.ads.InterstitialAdManager.Asset>,
 *   SKIP-CONTROL: ?shaka.ads.InterstitialAdManager.SkipControl,
 *   X-AD-CREATIVE-SIGNALING: ?shaka.extern.AdCreativeSignaling.CarriageEnvelope,
 * }}
 *
 * @property {!Array<shaka.ads.InterstitialAdManager.Asset>} ASSETS
 * @property {?shaka.ads.InterstitialAdManager.SkipControl} SKIP-CONTROL
 * @property {?shaka.extern.AdCreativeSignaling.CarriageEnvelope} X-AD-CREATIVE-SIGNALING
 */
shaka.ads.InterstitialAdManager.AssetsList;
/* eslint-enable @stylistic/max-len */


/* eslint-disable @stylistic/max-len */
/**
 * @typedef {{
 *   URI: string,
 *   DURATION: number,
 *   X-AD-CREATIVE-SIGNALING: ?shaka.extern.AdCreativeSignaling.CarriageEnvelope,
 * }}
 *
 * @property {string} URI
 * @property {number} DURATION
 * @property {?shaka.extern.AdCreativeSignaling.CarriageEnvelope} X-AD-CREATIVE-SIGNALING
 */
shaka.ads.InterstitialAdManager.Asset;
/* eslint-enable @stylistic/max-len */


/**
 * @typedef {{
 *   OFFSET: number,
 *   DURATION: number,
 * }}
 *
 * @property {number} OFFSET
 * @property {number} DURATION
 */
shaka.ads.InterstitialAdManager.SkipControl;


/**
 * Helper class to manage a single interstitial preload operation.
 *
 * @implements {shaka.util.IReleasable}
 */
shaka.ads.InterstitialPreloadTask = class {
  /**
   * @param {!shaka.Player} player
   * @param {!shaka.extern.AdInterstitial} interstitial
   * @param {?function(string, Map<string, Object>=)=} sendEvent
   */
  constructor(player, interstitial, sendEvent = null) {
    /** @private {!shaka.Player} */
    this.player_ = player;

    /** @private {?shaka.media.PreloadManager} */
    this.preloadManager_ = null;

    /** @private {?shaka.util.Error} */
    this.initialError_ = null;

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

    /** @private {?function(string, Map<string, Object>=)} */
    this.sendEvent_ = sendEvent;

    this.start_(interstitial);
  }

  /**
   * @param {!shaka.extern.AdInterstitial} interstitial
   * @private
   */
  async start_(interstitial) {
    try {
      if (this.sendEvent_) {
        this.sendEvent_(shaka.ads.Utils.AD_INTERSTITIAL_PRELOAD,
            (new Map()).set('interstitial', interstitial));
      }

      const preloadManager = await this.player_.preload(
          interstitial.uri,
          /* startTime= */ null,
          interstitial.mimeType || undefined);

      if (this.released_) {
        // Preload finished after destroy: clean immediately
        if (preloadManager) {
          try {
            await preloadManager.destroy();
          } catch (err) {
            shaka.log.error('Error destroying preloadManager', err);
          }
        }
        return;
      }

      this.preloadManager_ = preloadManager;
      if (preloadManager && this.sendEvent_) {
        this.sendEvent_(shaka.ads.Utils.AD_INTERSTITIAL_PRELOADED,
            (new Map()).set('interstitial', interstitial));
      }
    } catch (e) {
      // Store only the initial error
      this.initialError_ =
          e instanceof shaka.util.Error ? e : null;
    }
  }

  /**
   * Returns the PreloadManager if preload succeeded.
   *
   * @return {?shaka.media.PreloadManager}
   */
  getPreloadManager() {
    return this.preloadManager_;
  }

  /**
   * Returns the initial preload error, if any.
   *
   * @return {?shaka.util.Error}
   */
  getInitialError() {
    return this.initialError_;
  }

  /** @override */
  release() {
    this.released_ = true;

    if (this.preloadManager_) {
      this.preloadManager_.destroy();
      this.preloadManager_ = null;
    }
  }
};