@glomex/vast-ima-player
Version:
Convenience wrapper for advertising video/audio content with Google IMA
1,136 lines (1,064 loc) • 37.6 kB
text/typescript
/**
* tslint:disable:max-classes-per-file
*
* @format
*/
import type { ImaSdk, google } from '@alugha/ima';
import CustomEvent from '@ungap/custom-event';
import { CustomPlayhead } from './custom-playhead';
import { DelegatedEventTarget } from './delegated-event-target';
const IGNORE_UNTIL_CURRENT_TIME = 0.5;
const ADS_MANAGER_LOADED_TIMEOUT = 5000;
const REQUEST_ADS_TIMEOUT = 10000;
const MEDIA_ELEMENT_EVENTS = [
'abort',
'canplay',
'canplaythrough',
'durationchange',
'emptied',
'ended',
'error',
'loadeddata',
'loadedmetadata',
'loadstart',
'pause',
'play',
'playing',
'progress',
'ratechange',
'seeked',
'seeking',
'stalled',
'suspend',
'timeupdate',
'volumechange',
'waiting'
];
/**
* Additional media events that help managing the
* lifecycle of a media file playback.
*/
enum AdditionalMediaEvent {
/** Fired when initial media file play happened. */
MEDIA_START = 'MediaStart',
/** Fired when the first frame of the media file is played after linear preroll. */
MEDIA_IMPRESSION = 'MediaImpression',
/** Fired when the media file playback finished after potential postroll. */
MEDIA_STOP = 'MediaStop',
/** Fired when ad break cue points change. */
MEDIA_CUE_POINTS_CHANGE = 'MediaCuePointsChange',
/**
* Fired when media resumes playback. CONTENT_RESUME_REQUESTED
* is sometimes triggered before playback and this event is only
* triggered after ad is finished or when content resumes.
*/
MEDIA_RESUMED = 'MediaResumed'
}
/**
* Adjusted enum of https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/reference/js/google.ima.AdEvent
* to follow VPAID spec event names.
*/
enum ImaToAdEventMap {
// most relevant Ad events
/**
* Fired when an ad error occurred (standalone ad or ad within an ad rule).
* IMA provides these events on different objects and this event normalizes it.
*/
AD_ERROR = 'AdError',
/** Fired when the ad has stalled playback to buffer. */
AD_BUFFERING = 'AdBuffering',
/** Fired when ad data is available. */
LOADED = 'AdLoaded',
/** Fired when the impression URL has been pinged. */
IMPRESSION = 'AdImpression',
/** Fired when the ad starts playing. */
STARTED = 'AdStarted',
/** Fired when the ad playhead crosses first quartile. */
FIRST_QUARTILE = 'AdFirstQuartile',
/** Fired when the ad playhead crosses midpoint. */
MIDPOINT = 'AdMidpoint',
/** Fired when the ad playhead crosses third quartile. */
THIRD_QUARTILE = 'AdThirdQuartile',
/** Fired when the ad's current time value changes. */
AD_PROGRESS = 'AdProgress',
/** Fired when the ad completes playing. */
COMPLETE = 'AdComplete',
/** Fired when the ad is clicked. */
CLICK = 'AdClick',
/** Fired when the ad is paused. */
PAUSED = 'AdPaused',
/** Fired when the ad is resumed. */
RESUMED = 'AdResumed',
/** Fired when the ad is skipped by the user. */
SKIPPED = 'AdSkipped',
/** Fired when the displayed ads skippable state is changed. */
SKIPPABLE_STATE_CHANGED = 'AdSkippableStateChanged',
/** Fired when the ad volume has changed. */
VOLUME_CHANGED = 'AdVolumeChanged',
/** Fired when the ad volume has been muted. */
VOLUME_MUTED = 'AdMuted',
// Ad lifecycle events
/** Fired when an ads list is loaded. This is when ad rule cuePoints are available. */
AD_METADATA = 'AdMetadata',
/** Fired when an ad rule or a VMAP ad break would have played if autoPlayAdBreaks is false. */
AD_BREAK_READY = 'AdBreakReady',
/** Fired when content should be paused. This usually happens right before an ad is about to cover the content. */
CONTENT_PAUSE_REQUESTED = 'AdContentPauseRequested',
/** Fired when content should be resumed. This usually happens when an ad finishes or collapses. */
CONTENT_RESUME_REQUESTED = 'AdContentResumeRequested',
/** Fired when the ads manager is done playing all the ads. */
ALL_ADS_COMPLETED = 'AdAllAdsCompleted',
// VPAID events
/** Fired when the ad's duration changes. */
DURATION_CHANGE = 'AdDurationChange',
/** Fired when an ad triggers the interaction callback. Ad interactions contain an interaction ID string in the ad data. */
INTERACTION = 'AdInteraction',
/** Fired when the displayed ad changes from linear to nonlinear, or vice versa. */
LINEAR_CHANGED = 'AdLinearChanged',
/** Fired when a non-fatal error is encountered. The user need not take any action since the SDK will continue with the same or next ad playback depending on the error situation. */
LOG = 'AdLog',
/** Fired when the ad is closed by the user. */
USER_CLOSE = 'AdUserClose',
// Undocumented events
/** Not document by IMA */
AD_CAN_PLAY = 'AdCanPlay',
/** Not document by IMA */
EXPANDED_CHANGED = 'AdExpandedChanged',
/** Not document by IMA */
VIEWABLE_IMPRESSION = 'AdViewableImpression'
}
type PlayerEvent = AdditionalMediaEvent | ImaToAdEventMap;
/**
* Available events of the VAST-IMA-Player.
*
* Also see https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/reference/js/google.ima.AdEvent
*
* The player also triggers the normal media element events
* (https://developer.mozilla.org/en-US/docs/Web/Guide/Events/Media_events)
* when the content playback happens. This is useful when
* "disableCustomPlaybackForIOS10Plus = false" (default) is configured and
* the same media element is used on iOS to render both ad and content.
* Those event names are not enumerated here because they are known.
*/
export const PlayerEvent = {
...ImaToAdEventMap,
...AdditionalMediaEvent
};
export class PlayerOptions {
/** Sets whether to disable custom playback on iOS 10+ browsers. If true, ads will play inline if the content video is inline. This enables TrueView skippable ads. However, the ad will stay inline and not support iOS's native fullscreen. */
disableCustomPlaybackForIOS10Plus: boolean = false;
/** Enables or disables auto resizing of adsManager. If enabled it also resizes non-linear ads. */
autoResize: boolean = true;
/** Allows to have a separate 'Learn More' click tracking element on mobile. */
clickTrackingElement?: HTMLElement;
}
export class PlayerError extends Error {
errorCode: number;
innerError: Error;
type: string;
vastErrorCode: number;
static ERROR_CODE_ADS_MANAGER_LOADED_TIMEOUT = 9000;
static ERROR_CODE_REQUEST_ADS_TIMEOUT = 9001;
constructor(...args) {
super(...args);
}
}
type StartAd = {
start: () => void;
startWithoutReset: () => void;
ad?: google.ima.Ad;
adBreakTime?: number;
};
type StartAdCallback = (startAd: StartAd) => void;
/**
* Convenience player wrapper for the Google IMA HTML5 SDK
*/
export class Player extends DelegatedEventTarget {
#mediaElement: HTMLVideoElement;
#adElement: HTMLElement;
#customPlayhead: CustomPlayhead;
#adsRenderingSettings: google.ima.AdsRenderingSettings;
#ima: ImaSdk;
#adDisplayContainer: google.ima.AdDisplayContainer | undefined;
#adsManager: google.ima.AdsManager | undefined;
#width: number;
#height: number;
#adsLoader: google.ima.AdsLoader;
#playerOptions: PlayerOptions;
#resizeObserver: any;
#currentAd: google.ima.Ad;
#loadedAd: google.ima.Ad;
#mediaStartTriggered: boolean = false;
#mediaImpressionTriggered: boolean = false;
#mediaInActivation: boolean = false;
#customPlaybackTimeAdjustedOnEnded: boolean = false;
#cuePoints: number[] = [];
#adCurrentTime: number;
#adDuration: number;
#startAdCallback: StartAdCallback;
#adsManagerLoadedTimeout: number;
#requestAdsTimeout: number;
#wasExternallyPaused: boolean = false;
#lastNonZeroAdVolume: number = 1;
#activatePromise = Promise.resolve();
constructor(
ima: ImaSdk,
mediaElement: HTMLVideoElement,
adElement: HTMLElement,
adsRenderingSettings: google.ima.AdsRenderingSettings = new ima.AdsRenderingSettings(),
options: PlayerOptions = new PlayerOptions()
) {
super();
this.#mediaElement = mediaElement;
this.#adElement = adElement;
this.#ima = ima;
this.#playerOptions = options;
this.#adsRenderingSettings = adsRenderingSettings;
// for iOS to reset to initial content state
this.#adsRenderingSettings.restoreCustomPlaybackStateOnAdBreakComplete =
true;
if (
options.disableCustomPlaybackForIOS10Plus &&
!this.#mediaElement.hasAttribute('playsinline')
) {
// assign "playsinline" when on iOS two video elements
// will be used for content and ad playback
this.#mediaElement.setAttribute('playsinline', '');
}
this.#ima.settings.setDisableCustomPlaybackForIOS10Plus(
options.disableCustomPlaybackForIOS10Plus
);
// later used for to determine playhead for triggering midrolls
// and to determine whether the player is currently playing content
this.#customPlayhead = new CustomPlayhead(this.#mediaElement);
this._handleMediaElementEvents = this._handleMediaElementEvents.bind(this);
MEDIA_ELEMENT_EVENTS.forEach((eventName) => {
this.#mediaElement.addEventListener(
eventName,
this._handleMediaElementEvents
);
});
this._onAdsManagerLoaded = this._onAdsManagerLoaded.bind(this);
this._onAdsLoaderError = this._onAdsLoaderError.bind(this);
// initial synchronization of width / height
const { offsetHeight, offsetWidth } = this.#mediaElement;
this.#width = offsetWidth;
this.#height = offsetHeight;
if (options.autoResize) {
// @ts-ignore
if (window.ResizeObserver) {
// @ts-ignore
this.#resizeObserver = new window.ResizeObserver((entries) =>
this._resizeObserverCallback(entries)
);
this.#resizeObserver.observe(this.#mediaElement);
}
}
}
/**
* This allows synchronous activation of the media element
* and the Google IMA ad-display-container. Useful when you
* have to do async work before calling "playAds".
*/
activate() {
if (this.#mediaStartTriggered || this.#mediaInActivation) return;
this.#mediaInActivation = true;
if (this.#mediaElement.paused) {
const ready = () => {
this.#mediaElement.pause();
return new Promise<void>((resolve) => {
setTimeout(() => {
// We don't want to expose the activation detail
// to the outside and ignore events during activation phase.
// Waiting a little so that "pause" got emitted from above pause() call
this.#mediaInActivation = false;
resolve();
}, 1);
});
};
this.#activatePromise = new Promise((resolve) =>
resolve(this.#mediaElement.play())
)
.then(ready)
.catch(ready);
}
}
/**
* This is the entry point to start ad playback. It can be used
* as such:
*
* - With a single VAST at the beginning to play a preroll
* - Anyhwere during content playback with a single VAST
* - With a single VMAP at the beginning
*/
playAds(adsRequest: google.ima.AdsRequest) {
this._requestAds(adsRequest);
}
/**
* Similar to "playAds" method but with the difference
* that it allows to first load the ad and start it separately
* within the given callback.
*
* When a VAST or a VMAP ad break is given the callback is called
* with a "start" method which either starts playing the individual
* VAST ad or starts the VMAP ad break. If "start" method is not called
* it won't play the ad.
*/
loadAds(adsRequest: google.ima.AdsRequest, startAdCallback: StartAdCallback) {
this._requestAds(adsRequest, false, startAdCallback);
}
private _mediaElementPlay() {
return this.#activatePromise.then(
() => new Promise((resolve) => resolve(this.#mediaElement.play()))
);
}
private _requestAds(
adsRequest: google.ima.AdsRequest,
autoPlayAdBreaks: boolean = true,
startAdCallback?: StartAdCallback
) {
this.reset();
this._setupIma();
// it is important that this is set after
// a new AdsLoader and AdDisplayContainer
// was created because preloading ads wouldn't
// work as expected for multiple ads
this.#ima.settings.setAutoPlayAdBreaks(autoPlayAdBreaks);
// in case of replay we go back to start
if (this.#mediaElement.ended) {
this.#customPlayhead.reset();
this.#mediaElement.currentTime = 0;
}
this.#startAdCallback = startAdCallback;
adsRequest.linearAdSlotWidth = this.#width;
adsRequest.linearAdSlotHeight = this.#height;
adsRequest.nonLinearAdSlotWidth = this.#width;
adsRequest.nonLinearAdSlotHeight = this.#height;
// see version 3.442.0 changelog about postroll prefetching
// fixes issue with a disappearing nonlinear preroll that gets
// followed by a linear postroll
// https://developers.google.com/interactive-media-ads/docs/sdks/html5/client-side/history
if (adsRequest.contentDuration == null) {
adsRequest.contentDuration = -3;
}
// trigger an error after 5s in case adsManagerLoaded
// does not come up, so that content playback starts
this.#adsManagerLoadedTimeout = window.setTimeout(() => {
const error = new PlayerError(
`No adsManagerLoadedEvent within ${ADS_MANAGER_LOADED_TIMEOUT}ms.`
);
error.errorCode = PlayerError.ERROR_CODE_ADS_MANAGER_LOADED_TIMEOUT;
this._onAdError(error);
}, ADS_MANAGER_LOADED_TIMEOUT);
// trigger an error when no error / ad break ready / ... event
// occurred within 5s after requesting ads
this.#requestAdsTimeout = window.setTimeout(() => {
const error = new PlayerError(
`No response for ads-request within ${REQUEST_ADS_TIMEOUT}ms.`
);
error.errorCode = PlayerError.ERROR_CODE_REQUEST_ADS_TIMEOUT;
this._onAdError(error);
}, REQUEST_ADS_TIMEOUT);
this.#adsLoader.requestAds(adsRequest);
}
private _setupIma() {
this.#adDisplayContainer = new this.#ima.AdDisplayContainer(
this.#adElement,
// used as single element for linear ad playback on iOS
this.#playerOptions.disableCustomPlaybackForIOS10Plus
? undefined
: this.#mediaElement,
// allows to override the 'Learn More' button on mobile
this.#playerOptions.clickTrackingElement
);
this.#adsLoader = new this.#ima.AdsLoader(this.#adDisplayContainer);
this.#adsLoader.addEventListener(
this.#ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,
this._onAdsManagerLoaded,
false
);
this.#adsLoader.addEventListener(
this.#ima.AdErrorEvent.Type.AD_ERROR,
this._onAdsLoaderError,
false
);
}
skipAd() {
if (this.#adsManager) {
this.#adsManager.skip();
}
}
discardAdBreak() {
if (this.#adsManager) {
this.#adsManager.discardAdBreak();
}
}
/**
* Starts playback of either content or ad element.
*/
play() {
this.#wasExternallyPaused = false;
if (!this.#customPlayhead.enabled && this.#adsManager) {
this.#adsManager.resume();
} else {
this._mediaElementPlay()
.then(() => {
// empty
})
.catch(() => {
// just in case we don't receive a "pause" event
this.dispatchEvent(new CustomEvent('pause'));
});
}
}
/**
* Pauses playback of either content or ad element.
*/
pause() {
this.#wasExternallyPaused = true;
if (!this.#customPlayhead.enabled && this.#adsManager) {
this.#adsManager.pause();
} else {
this.#mediaElement.pause();
}
}
/**
* Sets volume of either content or ad element.
*/
set volume(volume: number) {
if (!this.#customPlayhead.enabled && this.#adsManager) {
this.#adsManager.setVolume(volume);
}
this.#mediaElement.volume = volume;
}
/**
* Returns volume of either content or ad element.
*/
get volume() {
if (!this.#customPlayhead.enabled && this.#adsManager) {
return this.#adsManager.getVolume();
}
return this.#mediaElement.volume;
}
/**
* Sets muted state on either content or ad element.
*/
set muted(muted: boolean) {
if (!this.#customPlayhead.enabled && this.#adsManager) {
this.#adsManager.setVolume(muted ? 0 : this.#lastNonZeroAdVolume);
}
this.#mediaElement.muted = muted;
}
/**
* Returns muted state of either content or ad element.
*/
get muted() {
if (!this.#customPlayhead.enabled && this.#adsManager) {
return this.#adsManager.getVolume() === 0;
}
return this.#mediaElement.muted;
}
/**
* Sets current time of content element when not in ad playback mode.
*/
set currentTime(currentTime: number) {
if (this.#customPlayhead.enabled) {
this.#mediaElement.currentTime = currentTime;
}
}
/**
* Returns current playhead time of either content or ad element.
*/
get currentTime() {
if (this.#adCurrentTime !== undefined) {
return this.#adCurrentTime;
}
return this.#mediaElement.currentTime;
}
/**
* Returns current duration of either content or ad element.
*/
get duration() {
if (this.#adDuration !== undefined) {
return this.#adDuration;
}
return this.#mediaElement.duration;
}
/**
* Returns list of ad break cue points that weren't played yet.
* Only available after "AdMetadata" event when VMAP is passed in playAds.
*/
get cuePoints() {
return [...this.#cuePoints];
}
private _setCuePoints(cuePoints: number[]) {
this.#cuePoints = [...cuePoints];
this.dispatchEvent(
new CustomEvent(PlayerEvent.MEDIA_CUE_POINTS_CHANGE, {
detail: { cuePoints: [...this.#cuePoints] }
})
);
}
/**
* Remove already played cuepoints
*
* @param timeOffset offset in seconds as defined in VMAP or 0 for preroll and -1 for postroll
*/
private _adjustCuePoints(timeOffset) {
const cuePointIndex = this.cuePoints.indexOf(timeOffset);
if (cuePointIndex > -1) {
this.#cuePoints.splice(cuePointIndex, 1);
this._setCuePoints(this.#cuePoints);
}
}
/**
* Allows resizing the ad element. Useful when options.autoResize = false.
*/
resizeAd(width: number, height: number) {
this.#width = width;
this.#height = height;
if (this.#adsManager) {
this.#adsManager.resize(width, height, this._getViewMode());
}
this.#adElement.style.width = `${width}px`;
this.#adElement.style.height = `${height}px`;
}
/**
* Cleans up current ad and ad manager session or the complete IMA (via force).
*
* Externally call this function with "force = true" when you want to switch
* the content source or move the player to another DOM node before doing
* another "playAds" or "loadAds", so that it does a full cleanup.
*
* @param force - enforce a full cleanup
* @returns a promise which resolves after all the cleanup work is done
*/
reset(force: boolean = false) {
if (force) {
this.#mediaImpressionTriggered = false;
this.#mediaStartTriggered = false;
this.#customPlaybackTimeAdjustedOnEnded = false;
}
this._resetAd();
this.#cuePoints = [];
this.#wasExternallyPaused = false;
this.#startAdCallback = undefined;
// always destroy ads-manager
if (this.#adsManager) {
this.#adsManager.destroy();
}
this.#adsManager = undefined;
// also reset autoplay ad breaks
// because setting it to false again
// in _setupIma would not properly
// reset IMA
this.#ima.settings.setAutoPlayAdBreaks(true);
if (force) {
this._resetIma();
}
this.#adElement.style.display = 'none';
}
private _resetIma() {
if (this.#adsLoader) {
this.#adsLoader.removeEventListener(
this.#ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,
this._onAdsManagerLoaded,
false
);
this.#adsLoader.removeEventListener(
this.#ima.AdErrorEvent.Type.AD_ERROR,
this._onAdsLoaderError,
false
);
this.#adsLoader.destroy();
}
if (this.#adDisplayContainer) {
this.#adDisplayContainer.destroy();
}
}
/**
* Completely destroys this instance. It is unusable after that.
*/
destroy() {
this.reset(true);
this.#customPlayhead.destroy();
MEDIA_ELEMENT_EVENTS.forEach((eventName) => {
this.#mediaElement.removeEventListener(
eventName,
this._handleMediaElementEvents
);
});
this.#adsLoader?.removeEventListener(
this.#ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,
this._onAdsManagerLoaded,
false
);
this.#adDisplayContainer?.destroy();
this.#adsLoader?.destroy();
this.#mediaImpressionTriggered = false;
this.#customPlaybackTimeAdjustedOnEnded = false;
this.#mediaStartTriggered = false;
this.#resizeObserver?.disconnect();
}
isCustomPlaybackUsed() {
const { settings } = this.#ima;
return (
settings.getDisableCustomPlaybackForIOS10Plus() === false &&
!this.#adElement.querySelector('video')
);
}
private _resetAd() {
window.clearTimeout(this.#adsManagerLoadedTimeout);
window.clearTimeout(this.#requestAdsTimeout);
this.#currentAd = undefined;
this.#adCurrentTime = undefined;
this.#adDuration = undefined;
this.#adElement.style.display = 'none';
this.#adElement.classList.remove('nonlinear');
if (this.#adsManager) {
// just ensure to start from defined width/height
this._resizeAdsManager();
}
}
private _handleMediaElementEvents(event: Event) {
// always forward volumechange events
if (!this.#customPlayhead.enabled && event.type !== 'volumechange') return;
if (event.type === 'timeupdate') {
// ignoring first timeupdate after play
// because we can be in ad state too early
if (this.#mediaElement.currentTime < IGNORE_UNTIL_CURRENT_TIME) {
return;
}
if (this.#adsManager) {
const cuePointsAfterJump = this.#adsManager
.getCuePoints()
.filter((cuePoint) => {
return cuePoint >= 0 && cuePoint < this.#customPlayhead.currentTime;
});
const cuePointToRemove = cuePointsAfterJump.pop();
// in case the ad-break lead to an error it cannot be detected which
// ad break was affected because IMA could've preloaded an ad-break
// without emitting an event for it
this._adjustCuePoints(cuePointToRemove);
}
if (!this.#mediaImpressionTriggered && this.#mediaStartTriggered) {
this.dispatchEvent(new CustomEvent(PlayerEvent.MEDIA_IMPRESSION));
this.#mediaImpressionTriggered = true;
}
}
if (event.type === 'play' && !this.#mediaStartTriggered) {
this.dispatchEvent(new CustomEvent(PlayerEvent.MEDIA_START));
this.#mediaStartTriggered = true;
}
if (event.type === 'ended') {
if (
this.isCustomPlaybackUsed() &&
this.#mediaElement.currentTime === this.#mediaElement.duration &&
this.#cuePoints.indexOf(-1) > -1
) {
/* Fixing a bug with postroll on iOS
* when a postroll gets started via "contentComplete"
* and a single video-tag is used IMA does not reset
* back to the content after the postroll finished.
* Setting the time of the video tag a little shorter
* than the duration, so that the video-tag is not set to "ended".
*/
this.#mediaElement.currentTime = this.#mediaElement.duration - 0.00001;
this.#customPlaybackTimeAdjustedOnEnded = true;
}
this.#adsLoader.contentComplete();
if (!this.#adsManager) {
this._mediaStop();
}
}
// @ts-ignore
if (
!window.ResizeObserver &&
this.#playerOptions.autoResize &&
event.type === 'loadedmetadata'
) {
// in case ResizeObserver is not supported we want
// to use at least the size after the media element got
// loaded
const { offsetHeight, offsetWidth } = this.#mediaElement;
this.#width = offsetWidth;
this.#height = offsetHeight;
this._resizeAdsManager();
}
if (!this.#mediaInActivation || event.type === 'volumechange') {
this.dispatchEvent(new CustomEvent(event.type));
}
}
private _handleAdsManagerEvents(event: google.ima.AdEvent) {
const { AdEvent } = this.#ima;
// @ts-ignore
switch (event.type) {
case AdEvent.Type.LOADED:
// For an individual VAST ad inform on "LOADED" to
// to allow starting the ad manually.
// In case of VMAP it could preload ads before the
// actual ad break. For VMAP it allows starting the ad
// on "AD_BREAK_READY" instead.
const loadedAd = event.getAd();
if (this.#startAdCallback && this.#cuePoints.length === 0) {
this.#startAdCallback({
ad: loadedAd,
start: () => {
this._startAdsManager();
this.#startAdCallback = undefined;
},
startWithoutReset: () => {
this._startAdsManager();
}
});
} else {
this.#loadedAd = loadedAd;
}
break;
case AdEvent.Type.AD_BREAK_READY:
this._resetAd();
// for a VMAP schedule
if (this.#startAdCallback) {
this.#startAdCallback({
ad: this.#loadedAd,
adBreakTime: event.getAdData().adBreakTime,
start: () => {
this._startAdsManager();
// we reset after we received the first
// start() after preroll
this.#startAdCallback = undefined;
},
startWithoutReset: () => {
this._startAdsManager();
}
});
this.#loadedAd = undefined;
} else {
this._startAdsManager();
}
break;
case AdEvent.Type.STARTED:
const ad = (this.#currentAd = event.getAd());
// when playing an ad-pod IMA uses two video-tags
// and switches between those but does not set volume
// on both video-tags. Calling setVolume with the
// previously stored volume synchronizes volume with
// the other video-tag
if (ad.getAdPodInfo().getAdPosition() > 1) {
this.#adsManager.setVolume(this.#adsManager.getVolume());
}
this.#adElement.classList.remove('nonlinear');
this._resizeAdsManager();
// single or non-linear ads
if (!ad.isLinear()) {
this.#adElement.classList.add('nonlinear');
this._playContent();
} else {
this.#customPlayhead.disable();
this.#adDuration = ad.getDuration();
this.#adCurrentTime = 0;
}
this.#adElement.style.display = '';
if (this.#wasExternallyPaused) {
this.#wasExternallyPaused = false;
this.#adsManager.pause();
}
break;
case AdEvent.Type.ALL_ADS_COMPLETED:
if (this.#customPlaybackTimeAdjustedOnEnded) {
return;
}
if (
this.isCustomPlaybackUsed() &&
Boolean(this.#currentAd) &&
this.#currentAd.getAdPodInfo().getTimeOffset() !== -1
) {
// on iOS it sometimes only triggers ALL_ADS_COMPLETED
// before CONTENT_RESUME_REQUESTED
this._playContent();
}
this.reset();
break;
case AdEvent.Type.CONTENT_PAUSE_REQUESTED:
this._resetAd();
this.#currentAd = event.getAd();
this.#adElement.style.display = '';
this.#mediaElement.pause();
this._resizeAdsManager();
if (this.#currentAd) {
this._adjustCuePoints(this.#currentAd.getAdPodInfo().getTimeOffset());
}
// synchronize volume state because IMA does not do that
this.#adsManager.setVolume(
this.#mediaElement.muted ? 0 : this.#mediaElement.volume
);
// pause requested is a signal that an adbreak with a linear ad got started
this.#customPlayhead.disable();
this.#adDuration = this.#currentAd.getDuration();
this.#adCurrentTime = 0;
break;
case AdEvent.Type.CONTENT_RESUME_REQUESTED:
const adPlayedPreviously = Boolean(this.#currentAd);
// synchronize ad volume state back to content
// because IMA does not do that
if (adPlayedPreviously) {
const adVolume = this.#adsManager.getVolume();
if (adVolume === 0) {
this.#mediaElement.muted = true;
} else {
this.#mediaElement.muted = false;
}
this.#mediaElement.volume = this.#lastNonZeroAdVolume;
}
if (this.#customPlaybackTimeAdjustedOnEnded) {
// Fixing the issue on iOS and postroll where we have to
// adjust current time.
// We jump back to the content end, so that "ended" is assigned again.
this.#mediaElement.currentTime = this.#mediaElement.duration + 1;
this.#customPlaybackTimeAdjustedOnEnded = false;
}
if (this.#mediaElement.ended) {
// after postroll
this.reset();
this._mediaStop();
} else {
this._resetAd();
}
// only start playback when there previously was an ad
// CONTENT_RESUME_REQUESTED also gets triggered when "start"
// is not called on preroll when "loadAds" is used
if (adPlayedPreviously) {
this._playContent();
}
break;
case AdEvent.Type.AD_METADATA:
this._setCuePoints(this.#adsManager.getCuePoints());
if (this.#cuePoints.indexOf(0) === -1) {
if (!this.#startAdCallback) {
this._playContent();
} else {
this.#startAdCallback({
start: () => {
this._playContent();
this.#startAdCallback = undefined;
},
startWithoutReset: () => {
this._playContent();
}
});
}
}
break;
case AdEvent.Type.AD_PROGRESS:
const adDataProgress = event.getAdData();
this.#adCurrentTime = adDataProgress.currentTime;
this.#adDuration = adDataProgress.duration;
break;
case AdEvent.Type.LOG:
const adData = event.getAdData();
// called when an error occurred in VMAP (e.g. empty preroll)
if (this.#startAdCallback) {
this.#startAdCallback({
start: () => {
this._playContent();
this.#startAdCallback = undefined;
},
startWithoutReset: () => {
this._playContent();
}
});
} else if (adData.adError && !this.#currentAd) {
this._playContent();
}
break;
case AdEvent.Type.VOLUME_CHANGED:
const currentVolume = this.#adsManager.getVolume();
if (currentVolume > 0) {
this.#lastNonZeroAdVolume = currentVolume;
}
break;
}
}
private _onAdsLoaderError(event: google.ima.AdErrorEvent) {
this._onAdError(this._createPlayerErrorFromImaErrorEvent(event));
}
private _onAdsManagerLoaded(loadedEvent: google.ima.AdsManagerLoadedEvent) {
const {
AdEvent,
AdErrorEvent: {
Type: { AD_ERROR }
}
} = this.#ima;
window.clearTimeout(this.#adsManagerLoadedTimeout);
const adsManager = (this.#adsManager = loadedEvent.getAdsManager(
this.#customPlayhead,
this.#adsRenderingSettings
));
Object.keys(AdEvent.Type).forEach((imaEventName) => {
adsManager.addEventListener(AdEvent.Type[imaEventName], (event) => {
this._handleAdsManagerEvents(event);
if (PlayerEvent[imaEventName]) {
const isEventWithAdData =
['LOG', 'AD_PROGRESS'].indexOf(imaEventName) > -1;
this.dispatchEvent(
new CustomEvent(PlayerEvent[imaEventName], {
detail: {
ad: event.getAd() || this.#currentAd,
adData: isEventWithAdData ? event.getAdData() : {}
}
})
);
}
});
});
adsManager.addEventListener(AD_ERROR, (event) =>
this._onAdError(this._createPlayerErrorFromImaErrorEvent(event))
);
// start ad playback
try {
adsManager.init(this.#width, this.#height, this._getViewMode());
// initial sync of volume so that muted autoplay works
adsManager.setVolume(
this.#mediaElement.muted ? 0 : this.#mediaElement.volume
);
// ensure to initialize the ad container at least once before
// starting it. For playback with sound it requires a synchronous
// .activate()
this.#adDisplayContainer?.initialize();
if (!this.#startAdCallback) {
this._startAdsManager();
}
} catch (adError) {
this._onAdError(new PlayerError(adError.message));
}
}
private _startAdsManager() {
if (!this.#mediaStartTriggered) {
this.dispatchEvent(new CustomEvent(PlayerEvent.MEDIA_START));
this.#mediaStartTriggered = true;
}
if (this.#adsManager) {
try {
this.#adsManager.start();
} catch (error) {
this._onAdError(new PlayerError(error.message));
}
} else {
this._playContent();
}
}
private _mediaStop() {
setTimeout(() => {
this.#mediaImpressionTriggered = false;
this.#mediaStartTriggered = false;
this.#customPlaybackTimeAdjustedOnEnded = false;
this.dispatchEvent(new CustomEvent(PlayerEvent.MEDIA_STOP));
}, 1);
}
private _resizeObserverCallback(entries) {
for (const entry of entries) {
if (entry.contentBoxSize && entry.contentBoxSize.length === 1) {
this.#width = entry.contentBoxSize[0].inlineSize;
this.#height = entry.contentBoxSize[0].blockSize;
} else if (entry.contentBoxSize && entry.contentBoxSize.inlineSize) {
this.#width = entry.contentBoxSize.inlineSize;
this.#height = entry.contentBoxSize.blockSize;
} else {
this.#width = entry.contentRect.width;
this.#height = entry.contentRect.height;
}
}
this._resizeAdsManager();
}
private _resizeAdsManager() {
if (!this.#playerOptions.autoResize || !this.#adsManager) return;
const ad = this.#currentAd;
const viewMode = this._getViewMode();
const isNonLinearAd = ad && !ad.isLinear();
if (!isNonLinearAd) {
this.resizeAd(this.#width, this.#height);
} else if (ad) {
if (ad.getWidth() > this.#width || ad.getHeight() > this.#height) {
this.resizeAd(this.#width, this.#height);
} else {
// in case we won't add pixels in height here it triggers a VAST error
// that there is not enough space to render the nonlinear ad
// when "useStyledNonLinearAds" is given the height needs to be higher so
// that the close button fits in
this.#adsManager.resize(ad.getWidth(), ad.getHeight() + 20, viewMode);
this.#adElement.style.width = `${ad.getWidth()}px`;
this.#adElement.style.height = `${ad.getHeight() + 20}px`;
}
}
}
private _getViewMode() {
if (
document.fullscreenElement || // Standard syntax
// @ts-ignore
document.webkitFullscreenElement || // Chrome, Safari and Opera synta
// @ts-ignore
document.mozFullScreenElement || // Firefox syntax
// @ts-ignore
document.msFullscreenElement || // IE/Edge syntax
// @ts-ignore
this.#mediaElement.webkitDisplayingFullscreen // Video in fullscreen, e.g. Safari iOS
) {
return this.#ima.ViewMode.FULLSCREEN;
}
return this.#ima.ViewMode.NORMAL;
}
private _playContent() {
this.#adElement.style.display = 'none';
if (!this.#mediaElement.ended) {
this.#customPlayhead.enable();
if (!this.#wasExternallyPaused) {
this._mediaElementPlay()
.then(() => {
// empty
})
.catch(() => {
// just in case we don't receive a "pause" event
this.dispatchEvent(new CustomEvent('pause'));
});
this.dispatchEvent(new CustomEvent('play'));
this.dispatchEvent(new CustomEvent(PlayerEvent.MEDIA_RESUMED));
} else {
this.#mediaElement.pause();
// somehow the above "pause()" does not send out pause event
this.dispatchEvent(new CustomEvent('pause'));
}
this.#wasExternallyPaused = false;
}
}
private _createPlayerErrorFromImaErrorEvent(event: google.ima.AdErrorEvent) {
const error = event.getError();
const playerError = new PlayerError(error.getMessage());
playerError.type = error.getType();
playerError.errorCode = error.getErrorCode();
playerError.vastErrorCode =
error.getVastErrorCode && error.getVastErrorCode();
playerError.innerError = error.getInnerError();
return playerError;
}
private _onAdError(error: PlayerError) {
this.dispatchEvent(
new CustomEvent(PlayerEvent.AD_ERROR, {
detail: { error }
})
);
this._resetAd();
if (this.#startAdCallback) {
this.#startAdCallback({
start: () => {
this._playContent();
this.#startAdCallback = undefined;
},
startWithoutReset: () => {
this._playContent();
}
});
} else {
this._playContent();
}
}
}