UNPKG

shaka-player

Version:
602 lines (552 loc) 17 kB
/*! @license * Shaka Player * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.ads.SvtaAdManager'); goog.require('goog.asserts'); goog.require('shaka.log'); goog.require('shaka.Player'); goog.require('shaka.ads.SvtaAd'); goog.require('shaka.ads.Utils'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.IReleasable'); goog.require('shaka.util.NumberUtils'); goog.require('shaka.util.Timer'); goog.require('shaka.util.TXml'); /** * A class responsible for SVTA2053-2: Ad Creative Signaling in DASH and HLS. * * @implements {shaka.util.IReleasable} */ shaka.ads.SvtaAdManager = class { /** * @param {shaka.Player} player * @param {function(!shaka.util.FakeEvent)} onEvent */ constructor(player, onEvent) { /** @private {?shaka.extern.AdsConfiguration} */ this.config_ = null; /** @private {?shaka.Player} */ this.player_ = player; /** @private {function(!shaka.util.FakeEvent)} */ this.onEvent_ = onEvent; /** @private {?HTMLMediaElement} */ this.video_ = null; /** @private {boolean} */ this.playbackStarted_ = false; /** @private {Map<string, shaka.extern.AdTrackingInfo>} */ this.trackings_ = new Map(); /** @private {?shaka.extern.AdTrackingInfo} */ this.currentTracking_ = null; /** @private {shaka.util.EventManager} */ this.eventManager_ = new shaka.util.EventManager(); /** @private {shaka.util.EventManager} */ this.playbackEventManager_ = new shaka.util.EventManager(); /** @private {shaka.util.EventManager} */ this.trackingEventManager_ = new shaka.util.EventManager(); this.eventManager_.listen(this.player_, 'loading', () => { this.playbackStart_(); }); this.eventManager_.listen(this.player_, 'unloading', () => { this.playbackEnd_(); }); /** @private {shaka.util.Timer} */ this.pollTimer_ = new shaka.util.Timer(() => { if (this.trackings_.size) { const currentLoadMode = this.player_.getLoadMode(); if (currentLoadMode == shaka.Player.LoadMode.DESTROYED || currentLoadMode == shaka.Player.LoadMode.NOT_LOADED) { return; } let cuepointsChanged = false; const trackings = Array.from(this.trackings_.values()); const seekRange = this.player_.seekRange(); for (const tracking of trackings) { if (tracking == this.currentTracking_) { continue; } const comparisonTime = tracking.endTime || tracking.startTime; if ((seekRange.start - comparisonTime) >= 1) { const id = JSON.stringify(tracking); this.trackings_.delete(id); cuepointsChanged = true; } } if (cuepointsChanged) { this.cuepointsChanged_(); } } }); if (this.player_.getAssetUri()) { this.playbackStart_(); } } /** * Called by the AdManager to provide an updated configuration any time it * changes. * * @param {shaka.extern.AdsConfiguration} config */ configure(config) { this.config_ = config; } /** * Resets the SVTA manager and removes any continuous polling. */ stop() { this.playbackEnd_(); } /** * @override */ release() { this.stop(); this.player_ = null; if (this.eventManager_) { this.eventManager_.release(); } if (this.playbackEventManager_) { this.playbackEventManager_.release(); } if (this.trackingEventManager_) { this.trackingEventManager_.release(); } if (this.pollTimer_) { this.pollTimer_.stop(); this.pollTimer_ = null; } } /** * @param {shaka.extern.HLSMetadata} metadata */ addMetadata(metadata) { if (!this.playbackStarted_) { return; } if (!shaka.ads.SvtaAdManager.validSchemes_.has(metadata.type)) { return; } const signaling = metadata.values.find((v) => v.key == 'X-AD-CREATIVE-SIGNALING'); if (!signaling) { return; } const base64 = /** @type {string} */(signaling.data); /** @type {?shaka.extern.AdCreativeSignaling.CarriageEnvelope} */ let json; try { json = /** @type {!shaka.extern.AdCreativeSignaling.CarriageEnvelope} */ ( JSON.parse(window.atob(base64))); } catch (e) { return; } if (!json.payload) { return; } const trackings = []; for (let i = 0; i < json.payload.length; i++) { const payloadSlot = json.payload[i]; goog.asserts.assert(typeof metadata.startTime == 'number', 'startTime must not be null!'); const startTime = metadata.startTime + payloadSlot.start || 0; const endTime = payloadSlot.duration ? metadata.startTime + payloadSlot.duration : metadata.endTime; if (payloadSlot && payloadSlot.tracking) { const Utils = shaka.ads.Utils; /** @type {shaka.extern.AdTrackingInfo} */ const trackingInfo = { startTime, endTime, tracking: Utils.createTrackingFromEvents(payloadSlot.tracking), position: i + 1, sequenceLength: json.payload.length, }; trackings.push(trackingInfo); } } this.addTracking_(trackings); } /** * @param {shaka.extern.TimelineRegionInfo} region */ addRegion(region) { const TXml = shaka.util.TXml; if (!this.playbackStarted_) { return; } if (!shaka.ads.SvtaAdManager.validSchemes_.has(region.schemeIdUri) || !region.eventNode) { return; } const text = TXml.getTextContents(region.eventNode); if (!text) { return; } /** @type {?shaka.extern.AdCreativeSignaling.CarriageEnvelope} */ let json; try { json = /** @type {!shaka.extern.AdCreativeSignaling.CarriageEnvelope} */ ( JSON.parse(text)); } catch (e) { return; } if (!json.payload) { return; } const trackings = []; for (let i = 0; i < json.payload.length; i++) { const payloadSlot = json.payload[i]; const startTime = region.startTime + payloadSlot.start || 0; const endTime = payloadSlot.duration ? region.startTime + payloadSlot.duration : region.endTime; if (payloadSlot && payloadSlot.tracking) { const Utils = shaka.ads.Utils; /** @type {shaka.extern.AdTrackingInfo} */ const trackingInfo = { startTime, endTime, tracking: Utils.createTrackingFromEvents(payloadSlot.tracking), position: i + 1, sequenceLength: json.payload.length, }; trackings.push(trackingInfo); } } this.addTracking_(trackings); } /** * @param {!Array<shaka.extern.AdTrackingInfo>} trackings * @private */ addTracking_(trackings) { if (!this.playbackStarted_ || !trackings.length) { return; } if (!this.player_.isFullyLoaded()) { this.playbackEventManager_.listenOnce(this.player_, 'loaded', () => { this.addTracking_(trackings); }); return; } let cuepointsChanged = false; for (const tracking of trackings) { const id = JSON.stringify(tracking); if (this.trackings_.getOrInsert(id, tracking) === tracking) { cuepointsChanged = true; } } if (cuepointsChanged) { this.cuepointsChanged_(); } this.setupCurrentTracking_(); this.playbackEventManager_.listen( this.video_, 'timeupdate', () => this.setupCurrentTracking_()); if (this.pollTimer_ && this.player_.isLive()) { this.pollTimer_.tickEvery(/* seconds= */ 1); } } /** * @private */ playbackStart_() { if (this.playbackStarted_) { return; } this.playbackStarted_ = true; this.video_ = this.player_.getMediaElement(); } /** * @private */ playbackEnd_() { if (!this.playbackStarted_) { return; } this.playbackStarted_ = false; this.video_ = null; this.playbackEventManager_.removeAll(); this.trackings_.clear(); this.unSetupCurrentTracking_(); this.trackingEventManager_.removeAll(); if (this.pollTimer_) { this.pollTimer_.stop(); } } /** * @private */ setupCurrentTracking_() { if (!this.trackings_.size || this.currentTracking_ || !this.video_.duration) { return; } const currentTime = this.video_.currentTime; for (const tracking of this.trackings_.values()) { if (tracking.startTime <= currentTime && (tracking.endTime && currentTime <= tracking.endTime) && !this.player_.isEnded()) { this.currentTracking_ = tracking; break; } } if (!this.currentTracking_) { return; } shaka.log.info('Found tracking', this.currentTracking_); let singleSetup = false; if (!this.player_.isLive()) { const seekRange = this.player_.seekRange(); singleSetup = seekRange.start === this.currentTracking_.startTime && seekRange.end === this.currentTracking_.endTime; } this.createBaseSetupTracking_(); if (singleSetup) { this.createSingleSetupTracking_(); } else { this.createSlidingSetupTracking_(); } } /** * @param {boolean=} complete * @private */ unSetupCurrentTracking_(complete = false) { if (!this.currentTracking_) { return; } if (!complete) { this.sendEvent_(shaka.ads.Utils.AD_SKIPPED); this.sendEvent_(shaka.ads.Utils.AD_STOPPED); } this.trackingEventManager_.removeAll(); this.currentTracking_ = null; } /** * @private */ createBaseSetupTracking_() { goog.asserts.assert(this.currentTracking_, 'AdTrackingInfo must not be null!'); const ad = new shaka.ads.SvtaAd(this.video_, this.currentTracking_); this.sendEvent_(shaka.ads.Utils.AD_IMPRESSION); this.sendEvent_(shaka.ads.Utils.AD_STARTED, (new Map()).set('ad', ad)); this.trackingEventManager_.listen(this.player_, 'error', (e) => { this.sendEvent_(shaka.ads.Utils.AD_ERROR, (new Map()).set('originalEvent', e)); }); this.trackingEventManager_.listen(this.video_, 'play', () => { this.sendEvent_(shaka.ads.Utils.AD_RESUMED); }); this.trackingEventManager_.listen(this.video_, 'pause', () => { this.sendEvent_(shaka.ads.Utils.AD_PAUSED); }); this.trackingEventManager_.listen(this.video_, 'volumechange', () => { if (this.video_.muted) { this.sendEvent_(shaka.ads.Utils.AD_MUTED); } else { this.sendEvent_(shaka.ads.Utils.AD_VOLUME_CHANGED); } }); } /** * @private */ createSingleSetupTracking_() { this.trackingEventManager_.listenOnce(this.player_, 'firstquartile', () => { this.sendEvent_(shaka.ads.Utils.AD_FIRST_QUARTILE); }); this.trackingEventManager_.listenOnce(this.player_, 'midpoint', () => { this.sendEvent_(shaka.ads.Utils.AD_MIDPOINT); }); this.trackingEventManager_.listenOnce(this.player_, 'thirdquartile', () => { this.sendEvent_(shaka.ads.Utils.AD_THIRD_QUARTILE); }); this.trackingEventManager_.listenOnce(this.player_, 'complete', () => { this.sendEvent_(shaka.ads.Utils.AD_STOPPED); this.sendEvent_(shaka.ads.Utils.AD_COMPLETE); this.unSetupCurrentTracking_(/* complete= */ true); }); } /** * @private */ createSlidingSetupTracking_() { let endTime = this.currentTracking_.endTime; if (!this.player_.isLive() || !endTime) { const seekRange = this.player_.seekRange(); endTime = Math.min( seekRange.end, this.currentTracking_.endTime || Infinity); } const duration = endTime - this.currentTracking_.startTime; let completionPercent_ = -1; const isQuartile = (quartilePercent, currentPercent) => { const NumberUtils = shaka.util.NumberUtils; if ((NumberUtils.isFloatEqual(quartilePercent, currentPercent) || currentPercent > quartilePercent) && completionPercent_ < quartilePercent) { completionPercent_ = quartilePercent; return true; } return false; }; this.trackingEventManager_.listen(this.video_, 'timeupdate', () => { const currentTime = this.video_.currentTime - this.currentTracking_.startTime; const completionRatio = currentTime / duration; if (isNaN(completionRatio)) { return; } const percent = completionRatio * 100; if (isQuartile(25, percent)) { this.sendEvent_(shaka.ads.Utils.AD_FIRST_QUARTILE); } else if (isQuartile(50, percent)) { this.sendEvent_(shaka.ads.Utils.AD_MIDPOINT); } else if (isQuartile(75, percent)) { this.sendEvent_(shaka.ads.Utils.AD_THIRD_QUARTILE); } else if (isQuartile(100, percent) || percent > 100) { this.sendEvent_(shaka.ads.Utils.AD_STOPPED); this.sendEvent_(shaka.ads.Utils.AD_COMPLETE); this.unSetupCurrentTracking_(/* complete= */ true); } }); } /** * @param {string} type * @param {Map<string, Object>=} dict * @private */ sendEvent_(type, dict) { shaka.log.info('SVTA event', type); 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.currentTracking_ && this.currentTracking_.tracking; if (tracking) { let urls; switch (type) { case shaka.ads.Utils.AD_IMPRESSION: urls = tracking.impression; break; case shaka.ads.Utils.AD_CLICKED: urls = tracking.clickTracking; break; case shaka.ads.Utils.AD_STARTED: urls = tracking.start; break; case shaka.ads.Utils.AD_FIRST_QUARTILE: urls = tracking.firstQuartile; break; case shaka.ads.Utils.AD_MIDPOINT: urls = tracking.midpoint; break; case shaka.ads.Utils.AD_THIRD_QUARTILE: urls = tracking.thirdQuartile; break; case shaka.ads.Utils.AD_COMPLETE: urls = tracking.complete; break; case shaka.ads.Utils.AD_SKIPPED: urls = tracking.skip; break; case shaka.ads.Utils.AD_ERROR: urls = tracking.error; break; case shaka.ads.Utils.AD_RESUMED: urls = tracking.resume; break; case shaka.ads.Utils.AD_PAUSED: urls = tracking.pause; break; case shaka.ads.Utils.AD_MUTED: urls = tracking.mute; break; case shaka.ads.Utils.AD_VOLUME_CHANGED: urls = tracking.unmute; break; } if (urls) { for (const url of urls) { this.sendTrackingEvent_(url); } } } } /** * @param {string} url * @private */ sendTrackingEvent_(url) { const NetworkingEngine = shaka.net.NetworkingEngine; const context = { type: NetworkingEngine.AdvancedRequestType.TRACKING_EVENT, }; const type = shaka.net.NetworkingEngine.RequestType.ADS; const request = shaka.net.NetworkingEngine.makeRequest( [url], shaka.net.NetworkingEngine.defaultRetryParameters()); request.method = 'POST'; this.player_.getNetworkingEngine().request(type, request, context); } /** * @private */ cuepointsChanged_() { /** @type {!Array<!shaka.extern.AdCuePoint>} */ const cuePoints = []; if (this.trackings_.size) { for (const tracking of this.trackings_.values()) { /** @type {shaka.extern.AdCuePoint} */ const shakaCuePoint = { start: tracking.startTime, end: tracking.endTime, }; const isValid = !cuePoints.find((c) => { return shakaCuePoint.start == c.start && shakaCuePoint.end == c.end; }); if (isValid) { cuePoints.push(shakaCuePoint); } } } this.sendEvent_(shaka.ads.Utils.CUEPOINTS_CHANGED, (new Map()).set('cuepoints', cuePoints)); } /** * @param {shaka.extern.TimelineRegionInfo} region * @return {boolean} */ static isValidRegion(region) { if (!shaka.ads.SvtaAdManager.validSchemes_.has(region.schemeIdUri) || !region.eventNode) { return false; } return true; } /** * @param {shaka.extern.HLSMetadata} metadata * @return {boolean} */ static isValidMetadata(metadata) { if (!shaka.ads.SvtaAdManager.validSchemes_.has(metadata.type)) { return false; } return true; } }; /** * @const {!Set<string>} * @private */ shaka.ads.SvtaAdManager.validSchemes_ = new Set() .add('urn:svta:advertising-wg:ad-id-signaling') .add('urn:svta:advertising-wg:ad-creative-signaling');