/*! @license
* Shaka Player
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview
* @suppress {missingRequire} TODO(b/152540451): this shouldn't be needed
*/
goog.provide('shaka.ads.ServerSideAdManager');
goog.require('goog.asserts');
goog.require('shaka.ads.ServerSideAd');
goog.require('shaka.log');
goog.require('shaka.util.IReleasable');
/**
* A class responsible for server-side ad interactions.
* @implements {shaka.util.IReleasable}
*/
shaka.ads.ServerSideAdManager = class {
/**
* @param {HTMLElement} adContainer
* @param {HTMLMediaElement} video
* @param {string} locale
* @param {function(!shaka.util.FakeEvent)} onEvent
*/
constructor(adContainer, video, locale, onEvent) {
/** @private {HTMLElement} */
this.adContainer_ = adContainer;
/** @private {HTMLMediaElement} */
this.video_ = video;
/** @private {?shaka.extern.AdsConfiguration} */
this.config_ = null;
/** @private
{?shaka.util.PublicPromise.<string>} */
this.streamPromise_ = null;
/** @private {number} */
this.streamRequestStartTime_ = NaN;
/** @private {function(!shaka.util.FakeEvent)} */
this.onEvent_ = onEvent;
/** @private {boolean} */
this.isLiveContent_ = false;
/**
* Time to seek to after an ad if that ad was played as the result of
* snapback.
* @private {?number}
*/
this.snapForwardTime_ = null;
/** @private {shaka.ads.ServerSideAd} */
this.ad_ = null;
/** @private {?google.ima.dai.api.AdProgressData} */
this.adProgressData_ = null;
/** @private {string} */
this.backupUrl_ = '';
/** @private {!Array.<!shaka.extern.AdCuePoint>} */
this.currentCuePoints_ = [];
/** @private {shaka.util.EventManager} */
this.eventManager_ = new shaka.util.EventManager();
/** @private {google.ima.dai.api.UiSettings} */
const uiSettings = new google.ima.dai.api.UiSettings();
uiSettings.setLocale(locale);
/** @private {google.ima.dai.api.StreamManager} */
this.streamManager_ = new google.ima.dai.api.StreamManager(
this.video_, this.adContainer_, uiSettings);
this.onEvent_(new shaka.util.FakeEvent(
shaka.ads.AdManager.IMA_STREAM_MANAGER_LOADED,
(new Map()).set('imaStreamManager', this.streamManager_)));
// Events
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.LOADED, (e) => {
shaka.log.info('Ad SS Loaded');
this.onLoaded_(
/** @type {!google.ima.dai.api.StreamEvent} */ (e));
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.ERROR, () => {
shaka.log.info('Ad SS Error');
this.onError_();
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.AD_BREAK_STARTED, () => {
shaka.log.info('Ad Break Started');
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.STARTED, (e) => {
shaka.log.info('Ad Started');
this.onAdStart_(/** @type {!google.ima.dai.api.StreamEvent} */ (e));
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.AD_BREAK_ENDED, () => {
shaka.log.info('Ad Break Ended');
this.onAdBreakEnded_();
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.AD_PROGRESS, (e) => {
this.onAdProgress_(
/** @type {!google.ima.dai.api.StreamEvent} */ (e));
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.FIRST_QUARTILE, () => {
shaka.log.info('Ad event: First Quartile');
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.AdManager.AD_FIRST_QUARTILE));
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.MIDPOINT, () => {
shaka.log.info('Ad event: Midpoint');
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.AdManager.AD_MIDPOINT));
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.THIRD_QUARTILE, () => {
shaka.log.info('Ad event: Third Quartile');
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.AdManager.AD_THIRD_QUARTILE));
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.COMPLETE, () => {
shaka.log.info('Ad event: Complete');
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.AdManager.AD_COMPLETE));
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.AdManager.AD_STOPPED));
this.adContainer_.removeAttribute('ad-active');
this.ad_ = null;
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.SKIPPED, () => {
shaka.log.info('Ad event: Skipped');
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.AdManager.AD_SKIPPED));
this.onEvent_(
new shaka.util.FakeEvent(shaka.ads.AdManager.AD_STOPPED));
});
this.eventManager_.listen(this.streamManager_,
google.ima.dai.api.StreamEvent.Type.CUEPOINTS_CHANGED, (e) => {
shaka.log.info('Ad event: Cue points changed');
this.onCuePointsChanged_(
/** @type {!google.ima.dai.api.StreamEvent} */ (e));
});
}
/**
* Called by the AdManager to provide an updated configuration any time it
* changes.
*
* @param {shaka.extern.AdsConfiguration} config
*/
configure(config) {
this.config_ = config;
}
/**
* @param {!google.ima.dai.api.StreamRequest} streamRequest
* @param {string=} backupUrl
* @return {!Promise.<string>}
*/
streamRequest(streamRequest, backupUrl) {
if (this.streamPromise_) {
return Promise.reject(new shaka.util.Error(
shaka.util.Error.Severity.RECOVERABLE,
shaka.util.Error.Category.ADS,
shaka.util.Error.Code.CURRENT_DAI_REQUEST_NOT_FINISHED));
}
if (streamRequest instanceof google.ima.dai.api.LiveStreamRequest) {
this.isLiveContent_ = true;
}
this.streamPromise_ = new shaka.util.PublicPromise();
this.streamManager_.requestStream(streamRequest);
this.backupUrl_ = backupUrl || '';
this.streamRequestStartTime_ = Date.now() / 1000;
return this.streamPromise_;
}
/**
* @param {Object} adTagParameters
*/
replaceAdTagParameters(adTagParameters) {
this.streamManager_.replaceAdTagParameters(adTagParameters);
}
/**
* Resets the stream manager and removes any continuous polling.
*/
stop() {
// TODO:
// For SS DAI streams, if a different asset gets unloaded as
// part of the process
// of loading a DAI asset, stream manager state gets reset and we
// don't get any ad events.
// We need to figure out if it makes sense to stop the SS
// manager on unload, and, if it does, find
// a way to do it safely.
// this.streamManager_.reset();
this.backupUrl_ = '';
this.snapForwardTime_ = null;
this.currentCuePoints_ = [];
}
/** @override */
release() {
this.stop();
if (this.eventManager_) {
this.eventManager_.release();
}
}
/**
* @param {string} type
* @param {Uint8Array|string} data
* Comes as string in DASH and as Uint8Array in HLS.
* @param {number} timestamp (in seconds)
*/
onTimedMetadata(type, data, timestamp) {
this.streamManager_.processMetadata(type, data, timestamp);
}
/**
* @param {shaka.extern.MetadataFrame} value
*/
onCueMetadataChange(value) {
// Native HLS over Safari/iOS/iPadOS
// For live event streams, the stream needs some way of informing the SDK
// that an ad break is coming up or ending. In the IMA DAI SDK, this is
// done through timed metadata. Timed metadata is carried as part of the
// DAI stream content and carries ad break timing information used by the
// SDK to track ad breaks.
if (value.key && value.data) {
const metadata = {};
metadata[value.key] = value.data;
this.streamManager_.onTimedMetadata(metadata);
}
}
/**
* @return {!Array.<!shaka.extern.AdCuePoint>}
*/
getCuePoints() {
return this.currentCuePoints_;
}
/**
* If a seek jumped over the ad break, return to the start of the
* ad break, then complete the seek after the ad played through.
* @private
*/
checkForSnapback_() {
const currentTime = this.video_.currentTime;
if (currentTime == 0) {
return;
}
this.streamManager_.streamTimeForContentTime(currentTime);
const previousCuePoint =
this.streamManager_.previousCuePointForStreamTime(currentTime);
// The cue point gets marked as 'played' as soon as the playhead hits it
// (at the start of an ad), so when we come back to this method as a result
// of seeking back to the user-selected time, the 'played' flag will be set.
if (previousCuePoint && !previousCuePoint.played) {
shaka.log.info('Seeking back to the start of the ad break at ' +
previousCuePoint.start + ' and will return to ' + currentTime);
this.snapForwardTime_ = currentTime;
this.video_.currentTime = previousCuePoint.start;
}
}
/**
* @param {!google.ima.dai.api.StreamEvent} e
* @private
*/
onAdStart_(e) {
goog.asserts.assert(this.streamManager_,
'Should have a stream manager at this point!');
const imaAd = e.getAd();
this.ad_ = new shaka.ads.ServerSideAd(imaAd, this.video_);
// Ad object and ad progress data come from two different IMA events.
// It's a race, and we don't know, which one will fire first - the
// event that contains an ad object (AD_STARTED) or the one that
// contains ad progress info (AD_PROGRESS).
// If the progress event fired first, we must've saved the progress
// info and can now add it to the ad object.
if (this.adProgressData_) {
this.ad_.setProgressData(this.adProgressData_);
}
this.onEvent_(new shaka.util.FakeEvent(shaka.ads.AdManager.AD_STARTED,
(new Map()).set('ad', this.ad_)));
this.adContainer_.setAttribute('ad-active', 'true');
}
/**
* @private
*/
onAdBreakEnded_() {
this.adContainer_.removeAttribute('ad-active');
const currentTime = this.video_.currentTime;
// If the ad break was a result of snapping back (a user seeked over
// an ad break and was returned to it), seek forward to the point,
// originally chosen by the user.
if (this.snapForwardTime_ && this.snapForwardTime_ > currentTime) {
this.video_.currentTime = this.snapForwardTime_;
this.snapForwardTime_ = null;
}
}
/**
* @param {!google.ima.dai.api.StreamEvent} e
* @private
*/
onLoaded_(e) {
const now = Date.now() / 1000;
const loadTime = now - this.streamRequestStartTime_;
this.onEvent_(new shaka.util.FakeEvent(shaka.ads.AdManager.ADS_LOADED,
(new Map()).set('loadTime', loadTime)));
const streamData = e.getStreamData();
const url = streamData.url;
this.streamPromise_.resolve(url);
this.streamPromise_ = null;
if (!this.isLiveContent_) {
this.eventManager_.listen(this.video_, 'seeked', () => {
this.checkForSnapback_();
});
}
}
/**
* @private
*/
onError_() {
if (!this.backupUrl_.length) {
this.streamPromise_.reject('IMA Stream request returned an error ' +
'and there was no backup asset uri provided.');
this.streamPromise_ = null;
return;
}
shaka.log.warning('IMA stream request returned an error. ' +
'Falling back to the backup asset uri.');
this.streamPromise_.resolve(this.backupUrl_);
this.streamPromise_ = null;
}
/**
* @param {!google.ima.dai.api.StreamEvent} e
* @private
*/
onAdProgress_(e) {
const streamData = e.getStreamData();
const adProgressData = streamData.adProgressData;
this.adProgressData_ = adProgressData;
if (this.ad_) {
this.ad_.setProgressData(this.adProgressData_);
}
}
/**
* @param {!google.ima.dai.api.StreamEvent} e
* @private
*/
onCuePointsChanged_(e) {
const streamData = e.getStreamData();
/** @type {!Array.<!shaka.extern.AdCuePoint>} */
const cuePoints = [];
for (const point of streamData.cuepoints) {
/** @type {shaka.extern.AdCuePoint} */
const shakaCuePoint = {
start: point.start,
end: point.end,
};
cuePoints.push(shakaCuePoint);
}
this.currentCuePoints_ = cuePoints;
this.onEvent_(new shaka.util.FakeEvent(
shaka.ads.AdManager.CUEPOINTS_CHANGED,
(new Map()).set('cuepoints', cuePoints)));
}
};