UNPKG

shaka-player

Version:
1,450 lines (1,345 loc) 90.7 kB
/*! @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(