video-ad-sdk
Version:
VAST/VPAID SDK that allows video ads to be played on top of any player
589 lines (519 loc) • 13.7 kB
text/typescript
import {linearEvents, ErrorCode, isVastErrorCode} from '../tracker'
import {acceptInvitation, adCollapse} from '../tracker/nonLinearEvents'
import {getClickThrough} from '../vastSelectors'
import type {VastChain, VpaidCreativeAdUnit} from '../types'
import {VideoAdContainer} from '../adContainer'
import {volumeChanged, adProgress} from './adUnitEvents'
import {loadCreative} from './helpers/vpaid/loadCreative'
import {
adLoaded,
adStarted,
adStopped,
adPlaying,
adPaused,
startAd,
stopAd,
resumeAd,
pauseAd,
skipAd,
setAdVolume,
getAdVolume,
getAdDuration,
resizeAd,
adSizeChange,
adError,
adVideoComplete,
adSkipped,
EVENTS,
adVolumeChange,
adImpression,
adVideoStart,
adVideoFirstQuartile,
adVideoMidpoint,
adVideoThirdQuartile,
adUserAcceptInvitation,
adUserMinimize,
adUserClose,
adDurationChange,
adRemainingTimeChange,
adClickThru,
getAdIcons,
getAdRemainingTime
} from './helpers/vpaid/api'
import {waitFor} from './helpers/vpaid/waitFor'
import {callAndWait} from './helpers/vpaid/callAndWait'
import {handshake} from './helpers/vpaid/handshake'
import {initAd} from './helpers/vpaid/initAd'
import {AdUnitError} from './helpers/adUnitError'
import {VideoAdUnit, _protected, type VideoAdUnitOptions} from './VideoAdUnit'
const {
complete,
mute,
unmute,
skip,
start,
firstQuartile,
pause,
resume,
impression,
midpoint,
thirdQuartile,
clickThrough,
error: errorEvent,
closeLinear,
creativeView
} = linearEvents
// NOTE some ads only allow one handler per event and we need to subscribe to the adLoaded to know the creative is loaded.
const VPAID_EVENTS = EVENTS.filter((event) => event !== adLoaded)
const DRAW_ICONS_TIMEOUT = 500
const WAIT_STOPPED_TIMEOUT = 3000
const _private = Symbol('_private')
const vpaidGeneralError = (payload: Error | unknown): AdUnitError => {
const error: AdUnitError =
payload instanceof Error ? payload : new AdUnitError('VPAID general error')
if (!error.code || !isVastErrorCode(error.code)) {
error.code = ErrorCode.VPAID_ERROR
}
return error
}
interface Private {
evtHandler: Record<string, (...args: any[]) => void>
handleVpaidEvent(event: string, ...args: any[]): void
handleClickThrough(url: string): void
getIcons(): void
drawIcons(): Promise<void>
muted: boolean
paused: boolean
videoStart?: boolean
loadCreativePromise?: Promise<VpaidCreativeAdUnit>
}
/**
* Options map to create a {@link VpaidAdUnit}
*/
export type VpaidAdUnitOptions = VideoAdUnitOptions
/**
* This class provides everything necessary to run a Vpaid ad.
*/
export class VpaidAdUnit extends VideoAdUnit {
private [_private]: Private = {
evtHandler: {
[adClickThru]: (url: string, _id: string, playerHandles: boolean) => {
if (playerHandles) {
this[_private].handleClickThrough(url)
}
this.emit(clickThrough, {
adUnit: this,
type: clickThrough
})
},
[adDurationChange]: () => {
this.emit(adProgress, {
adUnit: this,
type: adProgress
})
},
[adError]: (payload: Error | unknown) => {
this.error = vpaidGeneralError(payload)
this.errorCode = this.error.code
this[_protected].onErrorCallbacks.forEach((callback) =>
callback(this.error, {
adUnit: this,
vastChain: this.vastChain
})
)
this[_protected].finish()
this.emit(errorEvent, {
adUnit: this,
type: errorEvent
})
},
[adImpression]: () => {
// NOTE: some ads forget to trigger the adVideoStart event. :(
if (!this[_private].videoStart) {
this[_private].handleVpaidEvent(adVideoStart)
}
this.emit(impression, {
adUnit: this,
type: impression
})
},
[adPaused]: () => {
this[_private].paused = true
this.emit(pause, {
adUnit: this,
type: pause
})
},
[adPlaying]: () => {
this[_private].paused = false
this.emit(resume, {
adUnit: this,
type: resume
})
},
[adRemainingTimeChange]: () => {
this.emit(adProgress, {
adUnit: this,
type: adProgress
})
},
[adSkipped]: () => {
this.cancel()
this.emit(skip, {
adUnit: this,
type: skip
})
},
[adStarted]: () => {
this.emit(creativeView, {
adUnit: this,
type: creativeView
})
},
[adStopped]: () => {
this.emit(adStopped, {
adUnit: this,
type: adStopped
})
this[_protected].finish()
},
[adUserAcceptInvitation]: () => {
this.emit(acceptInvitation, {
adUnit: this,
type: acceptInvitation
})
},
[adUserClose]: () => {
this.emit(closeLinear, {
adUnit: this,
type: closeLinear
})
this[_protected].finish()
},
[adUserMinimize]: () => {
this.emit(adCollapse, {
adUnit: this,
type: adCollapse
})
},
[adVideoComplete]: () => {
this.emit(complete, {
adUnit: this,
type: complete
})
},
[adVideoFirstQuartile]: () => {
this.emit(firstQuartile, {
adUnit: this,
type: firstQuartile
})
},
[adVideoMidpoint]: () => {
this.emit(midpoint, {
adUnit: this,
type: midpoint
})
},
[adVideoStart]: () => {
if (!this[_private].videoStart) {
this[_private].videoStart = true
this[_private].paused = false
this.emit(start, {
adUnit: this,
type: start
})
}
},
[adVideoThirdQuartile]: () => {
this.emit(thirdQuartile, {
adUnit: this,
type: thirdQuartile
})
},
[adVolumeChange]: () => {
const volume = this.getVolume()
this.emit(volumeChanged, {
adUnit: this,
type: volumeChanged
})
if (volume === 0 && !this[_private].muted) {
this[_private].muted = true
this.emit(mute, {
adUnit: this,
type: mute
})
}
if (volume > 0 && this[_private].muted) {
this[_private].muted = false
this.emit(unmute, {
adUnit: this,
type: unmute
})
}
}
},
handleVpaidEvent: (event, ...args) => {
const handler = this[_private].evtHandler[event]
if (handler) {
handler(...args)
}
this.emit(event, {
adUnit: this,
type: event
})
},
handleClickThrough: (url) => {
if (this.paused() && this.pauseOnAdClick) {
this.resume()
return
}
const inlineAd = this.vastChain[0].ad
const clickThroughUrl =
typeof url === 'string' && url.length > 0
? url
: inlineAd && getClickThrough(inlineAd)
if (this.pauseOnAdClick) {
this.pause()
}
if (clickThroughUrl) {
window.open(clickThroughUrl, '_blank')
}
},
getIcons: (): void => {
if (this.creativeAd?.[getAdIcons]) {
try {
if (!this.creativeAd[getAdIcons]()) {
delete this.icons
}
} catch {
delete this.icons
}
}
},
drawIcons: async (): Promise<void> => {
if (this.isFinished()) {
return
}
await this[_protected].drawIcons?.()
if (this[_protected].hasPendingIconRedraws?.() && !this.isFinished()) {
setTimeout(this[_private].drawIcons, DRAW_ICONS_TIMEOUT)
}
},
muted: false,
paused: true
}
/** Ad unit type. Will be `VPAID` for VpaidAdUnit */
public type = 'VPAID'
/** Reference to the Vpaid Creative ad unit. Will be null before the ad unit starts. */
public creativeAd?: VpaidCreativeAdUnit
/**
* Creates a {@link VpaidAdUnit}.
*
* @param vastChain The {@link VastChain} with all the {@link VastResponse}
* @param videoAdContainer container instance to place the ad
* @param options Options Map
*/
constructor(
vastChain: VastChain,
videoAdContainer: VideoAdContainer,
options: VpaidAdUnitOptions = {}
) {
super(vastChain, videoAdContainer, options)
this[_private].loadCreativePromise = loadCreative(
vastChain,
videoAdContainer
)
}
/**
* Starts the ad unit.
*
* @throws if called twice.
* @throws if ad unit is finished.
*/
public async start(): Promise<void> {
this[_protected].throwIfFinished()
if (this.isStarted()) {
throw new Error('VpaidAdUnit already started')
}
try {
this.creativeAd = (await this[_private]
.loadCreativePromise) as VpaidCreativeAdUnit
const adLoadedPromise = waitFor(this.creativeAd, adLoaded)
for (const creativeEvent of VPAID_EVENTS) {
this.creativeAd.subscribe(
this[_private].handleVpaidEvent.bind(this, creativeEvent),
creativeEvent
)
}
this[_private].getIcons()
handshake(this.creativeAd, '2.0')
initAd(this.creativeAd, this.videoAdContainer, this.vastChain)
await adLoadedPromise
// if the ad timed out while trying to load the videoAdContainer will be destroyed
if (this.videoAdContainer.isDestroyed()) {
return
}
try {
const {videoElement} = this.videoAdContainer
if (videoElement.muted) {
this[_private].muted = true
this.setVolume(0)
} else {
this.setVolume(videoElement.volume)
}
await callAndWait(this.creativeAd, startAd, adStarted)
if (this.icons) {
await this[_private].drawIcons()
}
this[_protected].started = true
} catch {
this.cancel()
}
} catch (error) {
this[_private].handleVpaidEvent(adError, error)
throw error
}
}
/**
* Resumes a previously paused ad unit.
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*/
public resume(): void {
this.creativeAd?.[resumeAd]()
}
/**
* Pauses the ad unit.
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*/
public pause(): void {
this.creativeAd?.[pauseAd]()
}
/**
* Skip the ad unit.
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*/
public skip(): void {
this.creativeAd?.[skipAd]()
}
/**
* Returns true if the ad is paused and false otherwise
*/
public paused(): boolean {
return this.isFinished() || this[_private].paused
}
/**
* Sets the volume of the ad unit.
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*
* @param volume must be a value between 0 and 1;
*/
public setVolume(volume: number): void {
this.creativeAd?.[setAdVolume](volume)
}
/**
* Gets the volume of the ad unit.
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*
* @returns the volume of the ad unit.
*/
public getVolume(): number {
if (!this.creativeAd) {
return 0
}
return this.creativeAd[getAdVolume]()
}
/**
* Cancels the ad unit.
*
* @throws if ad unit is finished.
*/
public async cancel(): Promise<void> {
this[_protected].throwIfFinished()
try {
const adStoppedPromise =
this.creativeAd &&
waitFor(this.creativeAd, adStopped, WAIT_STOPPED_TIMEOUT)
this.creativeAd?.[stopAd]()
await adStoppedPromise
} catch {
this[_protected].finish()
}
}
/**
* Returns the duration of the ad Creative or 0 if there is no creative.
*
* Note: if the user has engaged with the ad, the duration becomes unknown and it will return 0;
*
* @returns the duration of the ad unit.
*/
public duration(): number {
if (!this.creativeAd) {
return 0
}
const duration = this.creativeAd[getAdDuration]()
if (duration < 0) {
return 0
}
return duration
}
/**
* Returns the current time of the ad Creative or 0 if there is no creative.
*
* Note: if the user has engaged with the ad, the currentTime becomes unknown and it will return 0;
*
* @returns the current time of the ad unit.
*/
public currentTime(): number {
if (!this.creativeAd) {
return 0
}
const remainingTime = this.creativeAd[getAdRemainingTime]()
if (remainingTime < 0) {
return 0
}
return this.duration() - remainingTime
}
/**
* This method resizes the ad unit to fit the available space in the passed {@link VideoAdContainer}
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*
* @returns Promise that resolves once the unit was resized
*/
public async resize(
width: number,
height: number,
viewmode: string
): Promise<void> {
await super.resize(width, height, viewmode)
if (!this.creativeAd) {
return
}
if (this.isStarted() && !this.isFinished()) {
const slot = this.videoAdContainer.slotElement
if (slot) {
slot.style.height = `${height}px`
slot.style.width = `${width}px`
}
}
return callAndWait(
this.creativeAd,
resizeAd,
adSizeChange,
width,
height,
viewmode
)
}
}