video-ad-sdk
Version:
VAST/VPAID SDK that allows video ads to be played on top of any player
472 lines (405 loc) • 12 kB
text/typescript
import {linearEvents} from '../tracker'
import {getViewable} from '../vastSelectors'
import type {VastChain, VastIcon} from '../types'
import {VideoAdContainer} from '../adContainer'
import {finish} from './adUnitEvents'
import {
onElementVisibilityChange,
onElementResize
} from './helpers/dom/elementObservers'
import {preventManualProgress} from './helpers/dom/preventManualProgress'
import {Emitter, type Listener} from './helpers/Emitter'
import {retrieveIcons} from './helpers/icons/retrieveIcons'
import {addIcons, type AddedIcons} from './helpers/icons/addIcons'
import {viewmode} from './helpers/vpaid/viewmode'
import {safeCallback} from './helpers/safeCallback'
import {AdUnitError} from './helpers/adUnitError'
const {start, viewable, notViewable, viewUndetermined, iconClick, iconView} =
linearEvents
const VIEWABLE_IMPRESSION_TIMEOUT = 2000
const _private = Symbol('_private')
export const _protected = Symbol('_protected')
interface Size {
width: number
height: number
viewmode: string
}
interface Private {
addIcons(): void
setupViewableImpression(): void
setupViewability(): void
setupResponsive(): void
handleViewableImpression(event: string): void
}
interface Protected extends Partial<AddedIcons> {
started: boolean
viewable: boolean
finished: boolean
size?: Size
finish(): void
onErrorCallbacks: Listener[]
onFinishCallbacks: Listener[]
throwIfCalled(): void
throwIfFinished(): void
}
/**
* Options map to create a {@link VideoAdUnit}
*/
export interface VideoAdUnitOptions {
/**
* Optional logger instance. Must comply to the [Console interface](https://developer.mozilla.org/es/docs/Web/API/Console).
* Defaults to `window.console`
*/
logger?: Console
/**
* if true it will pause the ad whenever is not visible for the viewer.
* Defaults to `false`
*/
viewability?: boolean
/**
* if true it will resize the ad unit whenever the ad container changes sizes
* Defaults to `false`
*/
responsive?: boolean
/**
* if true it will pause the ad unit whenever a user click on the ad
* Defaults to `true`
*/
pauseOnAdClick?: boolean
}
/**
* This class provides shared logic among all the ad units.
*/
export class VideoAdUnit extends Emitter {
private [_private]: Private = {
addIcons: () => {
if (!this.icons) {
return
}
const {drawIcons, hasPendingIconRedraws, removeIcons} = addIcons(
this.icons,
{
logger: this.logger,
onIconClick: (icon) =>
this.emit(iconClick, {
adUnit: this,
data: icon,
type: iconClick
}),
onIconView: (icon) =>
this.emit(iconView, {
adUnit: this,
data: icon,
type: iconView
}),
videoAdContainer: this.videoAdContainer
}
)
this[_protected].drawIcons = drawIcons
this[_protected].removeIcons = removeIcons
this[_protected].hasPendingIconRedraws = hasPendingIconRedraws
this[_protected].onFinishCallbacks.push(removeIcons)
},
setupViewableImpression: () => {
let timeoutId: number
const unsubscribe = onElementVisibilityChange(
this.videoAdContainer.element,
(visible) => {
if (this.isFinished() || this[_protected].viewable) {
return
}
if (typeof visible !== 'boolean') {
this[_private].handleViewableImpression(viewUndetermined)
return
}
if (visible) {
timeoutId = window.setTimeout(
this[_private].handleViewableImpression,
VIEWABLE_IMPRESSION_TIMEOUT,
viewable
)
} else {
clearTimeout(timeoutId)
}
},
{viewabilityOffset: 0.5}
)
this[_protected].onFinishCallbacks.push(() => {
unsubscribe()
clearTimeout(timeoutId)
if (!this[_protected].viewable) {
this[_private].handleViewableImpression(notViewable)
}
})
},
handleViewableImpression: (event) => {
this[_protected].viewable = Boolean(event)
this.emit(event, {
adUnit: this,
type: event
})
},
setupViewability: () => {
const unsubscribe = onElementVisibilityChange(
this.videoAdContainer.element,
(visible) => {
if (this.isFinished()) {
return
}
if (typeof visible === 'boolean') {
if (visible) {
this.resume()
} else {
this.pause()
}
}
}
)
this[_protected].onFinishCallbacks.push(unsubscribe)
},
setupResponsive: () => {
const {element} = this.videoAdContainer
this[_protected].size = {
height: element.clientHeight,
viewmode: viewmode(element.clientWidth, element.clientHeight),
width: element.clientWidth
}
const unsubscribe = onElementResize(element, () => {
if (this.isFinished()) {
return
}
const previousSize = this[_protected].size
const height = element.clientHeight
const width = element.clientWidth
if (height !== previousSize?.height || width !== previousSize?.width) {
this.resize(width, height, viewmode(width, height))
}
})
this[_protected].onFinishCallbacks.push(unsubscribe)
}
}
protected [_protected]: Protected = {
finished: false,
started: false,
viewable: false,
onErrorCallbacks: [],
onFinishCallbacks: [],
finish: () => {
if (!this.isFinished()) {
this[_protected].finished = true
this[_protected].onFinishCallbacks.forEach((callback) => callback())
this.emit(finish, {
adUnit: this,
type: finish
})
}
},
throwIfCalled: () => {
throw new Error('VideoAdUnit method must be implemented on child class')
},
throwIfFinished: () => {
if (this.isFinished()) {
throw new Error('VideoAdUnit is finished')
}
}
}
/** Ad unit type */
public type?: string
/** If an error occurs it will contain the reference to the error otherwise it will be bull */
public error?: AdUnitError
/** If an error occurs it will contain the Vast Error code of the error */
public errorCode?: number
public vastChain: VastChain
public videoAdContainer: VideoAdContainer
public icons?: VastIcon[]
public pauseOnAdClick: boolean
/**
* Creates a {@link VideoAdUnit}.
*
* @param vastChain The {@link VastChain} with all the {@link VastResponse}
* @param videoAdContainer container instance to place the ad
* @param options Options Map. The allowed properties are:
*/
public constructor(
vastChain: VastChain,
videoAdContainer: VideoAdContainer,
{
viewability = false,
responsive = false,
logger = console,
pauseOnAdClick = true
}: VideoAdUnitOptions = {}
) {
super(logger)
/** Reference to the {@link VastChain} used to load the ad. */
this.vastChain = vastChain
/** Reference to the {@link VideoAdContainer} that contains the ad. */
this.videoAdContainer = videoAdContainer
/** Array of {@link VastIcon} definitions to display from the passed {@link VastChain} or undefined if there are no icons.*/
this.icons = retrieveIcons(vastChain)
this.pauseOnAdClick = pauseOnAdClick
this[_protected].onFinishCallbacks.push(
preventManualProgress(this.videoAdContainer.videoElement)
)
this[_private].addIcons()
const viewableImpression = vastChain.some(({ad}) => ad && getViewable(ad))
if (viewableImpression) {
this.once(start, this[_private].setupViewableImpression)
}
if (viewability) {
this.once(start, this[_private].setupViewability)
}
if (responsive) {
this.once(start, this[_private].setupResponsive)
}
}
/*
* Starts the ad unit.
*
* @throws if called twice.
* @throws if ad unit is finished.
*/
public start(): void {
this[_protected].throwIfCalled()
}
/**
* Resumes a previously paused ad unit.
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*/
public resume(): void {
this[_protected].throwIfCalled()
}
/**
* Pauses the ad unit.
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*/
public pause(): void {
this[_protected].throwIfCalled()
}
/**
* Skips the ad unit.
*
* @throws if ad unit is not started.
* @throws if ad unit is finished.
*/
public skip(): void {
this[_protected].throwIfCalled()
}
/**
* 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[_protected].throwIfCalled()
}
/**
* 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(): void {
this[_protected].throwIfCalled()
}
/**
* Cancels the ad unit.
*
* @throws if ad unit is finished.
*/
public cancel(): void {
this[_protected].throwIfCalled()
}
/**
* Returns the duration of the ad Creative or 0 if there is no creative.
*
* @returns the duration of the ad unit.
*/
public duration(): void {
this[_protected].throwIfCalled()
}
/**
* Returns true if the ad is paused and false otherwise
*/
public paused(): void {
this[_protected].throwIfCalled()
}
/**
* Returns the current time of the ad Creative or 0 if there is no creative.
*
* @returns the current time of the ad unit.
*/
public currentTime(): void {
this[_protected].throwIfCalled()
}
/**
* Register a callback function that will be called whenever the ad finishes. No matter if it was finished because de ad ended, or cancelled or there was an error playing the ad.
*
* @throws if ad unit is finished.
*
* @param callback will be called once the ad unit finished
*/
public onFinish(callback: Listener): void {
if (typeof callback !== 'function') {
throw new TypeError('Expected a callback function')
}
this[_protected].onFinishCallbacks.push(safeCallback(callback, this.logger))
}
/**
* Register a callback function that will be called if there is an error while running the ad.
*
* @throws if ad unit is finished.
*
* @param callback will be called on ad unit error passing the Error instance and an object with the adUnit and the {@link VastChain}.
*/
public onError(callback: Listener): void {
if (typeof callback !== 'function') {
throw new TypeError('Expected a callback function')
}
this[_protected].onErrorCallbacks.push(safeCallback(callback, this.logger))
}
/**
* @returns true if the ad unit is finished and false otherwise
*/
public isFinished(): boolean {
return this[_protected].finished
}
/**
* @returns true if the ad unit has started and false otherwise
*/
public isStarted(): boolean {
return this[_protected].started
}
/**
* 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,
mode: string
): Promise<void> {
this[_protected].size = {
height,
viewmode: mode,
width
}
if (this.isStarted() && !this.isFinished() && this.icons) {
await this[_protected].removeIcons?.()
await this[_protected].drawIcons?.()
}
}
}