diff --git a/.changeset/ten-zoos-sing.md b/.changeset/ten-zoos-sing.md new file mode 100644 index 00000000..c05c0c19 --- /dev/null +++ b/.changeset/ten-zoos-sing.md @@ -0,0 +1,5 @@ +--- +"@theoplayer/adscript-connector-web": minor +--- + +Initial release. diff --git a/.idea/prettier.xml b/.idea/prettier.xml index b0c1c68f..0c83ac4e 100644 --- a/.idea/prettier.xml +++ b/.idea/prettier.xml @@ -2,5 +2,6 @@ \ No newline at end of file diff --git a/.idea/web-connectors.iml b/.idea/web-connectors.iml index 447c428e..c2d8285e 100644 --- a/.idea/web-connectors.iml +++ b/.idea/web-connectors.iml @@ -10,6 +10,7 @@ + diff --git a/README.md b/README.md index 8ef22b61..e30f04b7 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,14 @@ Using the available connectors allows you to augment the features delivered thro ## Available Connectors -| Connector | npm package | Source code | -|:----------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------| -| CMCD | [![@theoplayer/cmcd-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fcmcd-connector-web?label=%40theoplayer%2Fcmcd-connector-web)](https://npmjs.com/package/@theoplayer/cmcd-connector-web) | [cmcd](https://github.com/THEOplayer/web-connectors/tree/main/cmcd) | -| Comscore | [![@theoplayer/comscore-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fcomscore-connector-web?label=%40theoplayer%2Fcomscore-connector-web)](https://npmjs.com/package/@theoplayer/comscore-connector-web) | [comscore](https://github.com/THEOplayer/web-connectors/tree/main/comscore) | -| Conviva | [![@theoplayer/conviva-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fconviva-connector-web?label=%40theoplayer%2Fconviva-connector-web)](https://npmjs.com/package/@theoplayer/conviva-connector-web) | [conviva](https://github.com/THEOplayer/web-connectors/tree/main/conviva) | -| Nielsen | [![@theoplayer/nielsen-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fnielsen-connector-web?label=%40theoplayer%2Fnielsen-connector-web)](https://npmjs.com/package/@theoplayer/nielsen-connector-web) | [nielsen](https://github.com/THEOplayer/web-connectors/tree/main/nielsen) | -| Yospace | [![@theoplayer/yospace-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fyospace-connector-web?label=%40theoplayer%2Fyospace-connector-web)](https://npmjs.com/package/@theoplayer/yospace-connector-web) | [yospace](https://github.com/THEOplayer/web-connectors/tree/main/yospace) | +| Connector | npm package | Source code | +|:----------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------| +| AdScript | [![@theoplayer/adscript-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fadscript-connector-web?label=%40theoplayer%2Fadscript-connector-web)](https://npmjs.com/package/@theoplayer/adscript-connector-web) | [adscript](https://github.com/THEOplayer/web-connectors/tree/main/adscript) | +| CMCD | [![@theoplayer/cmcd-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fcmcd-connector-web?label=%40theoplayer%2Fcmcd-connector-web)](https://npmjs.com/package/@theoplayer/cmcd-connector-web) | [cmcd](https://github.com/THEOplayer/web-connectors/tree/main/cmcd) | +| Comscore | [![@theoplayer/comscore-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fcomscore-connector-web?label=%40theoplayer%2Fcomscore-connector-web)](https://npmjs.com/package/@theoplayer/comscore-connector-web) | [comscore](https://github.com/THEOplayer/web-connectors/tree/main/comscore) | +| Conviva | [![@theoplayer/conviva-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fconviva-connector-web?label=%40theoplayer%2Fconviva-connector-web)](https://npmjs.com/package/@theoplayer/conviva-connector-web) | [conviva](https://github.com/THEOplayer/web-connectors/tree/main/conviva) | +| Nielsen | [![@theoplayer/nielsen-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fnielsen-connector-web?label=%40theoplayer%2Fnielsen-connector-web)](https://npmjs.com/package/@theoplayer/nielsen-connector-web) | [nielsen](https://github.com/THEOplayer/web-connectors/tree/main/nielsen) | +| Yospace | [![@theoplayer/yospace-connector-web](https://img.shields.io/npm/v/%40theoplayer%2Fyospace-connector-web?label=%40theoplayer%2Fyospace-connector-web)](https://npmjs.com/package/@theoplayer/yospace-connector-web) | [yospace](https://github.com/THEOplayer/web-connectors/tree/main/yospace) | ## License diff --git a/adscript/.gitignore b/adscript/.gitignore new file mode 100644 index 00000000..92b3d00f --- /dev/null +++ b/adscript/.gitignore @@ -0,0 +1,10 @@ +# Node artifact files +node_modules/ +lib/ +dist/ + +# Generated by MacOS +.DS_Store + +# Generated by Windows +Thumbs.db diff --git a/adscript/README.md b/adscript/README.md new file mode 100644 index 00000000..99d4adf4 --- /dev/null +++ b/adscript/README.md @@ -0,0 +1,100 @@ +# adscript-connector-web + +The AdScript connector provides an AdScript integration for THEOplayer. + +## Installation + +Install using your favorite package manager for Node (such as `npm` or `yarn`): + +### npm + +```bash +npm install @theoplayer/adscript-connector-web +``` + +### yarn + +```bash +yarn add @theoplayer/adscript-connector-web +``` + +## Usage + +First you need to add the AdScript connector to your app : + +* Add as a regular script + +```html + + + +``` + +* Add as an ES2015 module + +```html + + +``` + +## Updating metadata + +If the metadata has changed during playback, you can update it with: + +```javascript +adScriptConnector.updateMetadata(newMetadata); +``` + +## Updating userInfo + +If the user info has changed during playback, you can update it with: + +```javascript +adScriptConnector.updateUser(i12n); +``` \ No newline at end of file diff --git a/adscript/package.json b/adscript/package.json new file mode 100644 index 00000000..ef07ea4c --- /dev/null +++ b/adscript/package.json @@ -0,0 +1,47 @@ +{ + "name": "@theoplayer/adscript-connector-web", + "version": "0.0.1", + "description": "A connector implementing AdScript with THEOplayer", + "main": "dist/adscript-connector.umd.js", + "module": "dist/adscript-connector.esm.js", + "types": "dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/adscript-connector.esm.js", + "require": "./dist/adscript-connector.umd.js" + }, + "./dist/*": "./dist/*", + "./package": "./package.json", + "./package.json": "./package.json" + }, + "scripts": { + "clean": "rimraf lib dist", + "bundle": "rollup -c rollup.config.mjs", + "watch": "npm run bundle -- --watch", + "build": "npm run clean && npm run bundle", + "serve": "http-server ./.. -o /adscript/test/pages/main_esm.html", + "test": "echo \"No tests yet\"" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/THEOplayer/web-connectors.git", + "directory": "adscript" + }, + "author": "THEO Technologies NV", + "license": "MIT", + "bugs": { + "url": "https://github.com/THEOplayer/web-connectors/issues" + }, + "homepage": "https://github.com/THEOplayer/web-connectors/tree/main/adscript#readme", + "files": [ + "dist/", + "CHANGELOG.md", + "README.md", + "LICENSE.md", + "package.json" + ], + "peerDependencies": { + "theoplayer": "^7.0.0" + } +} diff --git a/adscript/rollup.config.mjs b/adscript/rollup.config.mjs new file mode 100644 index 00000000..49f0009a --- /dev/null +++ b/adscript/rollup.config.mjs @@ -0,0 +1,15 @@ +import fs from "node:fs"; +import {getSharedBuildConfiguration} from "../tools/build.mjs"; + + +const {version} = JSON.parse(fs.readFileSync("./package.json", "utf8")); + +const fileName = "adscript-connector"; +const globalName = "THEOplayerAdScriptConnector"; + +const banner = ` +/** + * THEOplayer AdScript Web Connector v${version} + */`.trim(); + +export default getSharedBuildConfiguration({fileName, globalName, banner}); diff --git a/adscript/src/adscript/AdScript.d.ts b/adscript/src/adscript/AdScript.d.ts new file mode 100644 index 00000000..e64ae1b0 --- /dev/null +++ b/adscript/src/adscript/AdScript.d.ts @@ -0,0 +1,57 @@ +export type JHMTApiProtocol = 'https:' | 'http:' | 'file:'; + +interface I12n { + i1: string; + i2: string; + i3: string; + i4: string; + i5: string; +} + +export type MainVideoContentType = 'content'; +export type EmbeddedContentType = 'preroll' | 'midroll' | 'postroll'; +export type StaticContentType = 'static'; + +interface StaticContentMetadata { + assetid: string; + type: StaticContentType; + sec1: string; + sec2: string; + sec3: string; + sec4: string; + ref: string; +} + +export interface PlayerState { + muted: number; + volume: number; + triggeredByUser: number; + normalSpeed: number; + fullscreen: number; + visibility: number; + width: number; + height: number; +} + +interface JHMTArray extends Array { + i12n: I12n; + contentMetadata: ContentMetadata; + playerState: PlayerState; + push: (item: any) => number; // Type of the push function +} + +declare global { + interface Window { + JHMT: JHMTArray; + JHMTApi: typeof JHMTApi; + JHMTApiProtocol: JHMTApiProtocol; + } +} + +export interface JHMTApi { + setI12n(i12n: I12n); + + setContentMetadata(contentMetadata: ContentMetadata); + + setPlayerState(playerState: PlayerState); +} diff --git a/adscript/src/index.ts b/adscript/src/index.ts new file mode 100644 index 00000000..da9e700c --- /dev/null +++ b/adscript/src/index.ts @@ -0,0 +1,2 @@ +export { AdScriptConnector } from './integration/AdScriptConnector'; +export * from './integration/AdScriptConfiguration'; diff --git a/adscript/src/integration/AdScriptConfiguration.ts b/adscript/src/integration/AdScriptConfiguration.ts new file mode 100644 index 00000000..4c0581be --- /dev/null +++ b/adscript/src/integration/AdScriptConfiguration.ts @@ -0,0 +1,62 @@ +import type { Ad } from 'theoplayer'; + +/** + * The main content information settings. + * For more information, see the [main content information settings](https://adscript.admosphere.cz/en_adScript_browser.html) section in the AdScript documentation. + */ +export interface MainVideoContentMetadata { + assetid: string; + type: 'content'; + program: string; + title: string; + length: string; + crossId: string; + livestream: string; + channelId: string; + attributes: string; +} + +/** + * The embedded content metadata, about the currently playing ad. + */ +export interface EmbeddedContentMetadata { + assetid: string; + type: 'preroll' | 'midroll' | 'postroll'; + length: string; + title: string; + asmea: string; + attributes: string; +} + +/** + * The configuration for the AdScript Connector. + */ +export interface AdScriptConfiguration { + /** + * Integration ID you received from your Nielsen representative. + */ + implementationId: string; + + /** + * The initial main content information settings. + * Metadata of the main video content needs to be set before the first measured event occurs. + * For more information, see the [main content information settings](https://adscript.admosphere.cz/en_adScript_browser.html) section in the AdScript documentation. + */ + metadata: MainVideoContentMetadata; + + /** + * Additional information about the logged-in user (customerID, deviceID, profileID) from the client´s database. + * For more information, see the [Additional Information Settings](https://adscript.admosphere.cz/en_adScript_browser.html) section. + */ + i12n?: { [key: string]: string }; + + /** + * An optional advertisement processor to receive metadata about the Ad. + */ + adProcessor?: (ad: Ad) => EmbeddedContentMetadata; + + /** + * Whether the connector should log all actions. + */ + debug?: boolean; +} diff --git a/adscript/src/integration/AdScriptConnector.ts b/adscript/src/integration/AdScriptConnector.ts new file mode 100644 index 00000000..31d61ae8 --- /dev/null +++ b/adscript/src/integration/AdScriptConnector.ts @@ -0,0 +1,86 @@ +import type { ChromelessPlayer } from 'theoplayer'; +import { AdScriptTHEOIntegration } from './AdScriptTHEOIntegration'; +import { AdScriptConfiguration, MainVideoContentMetadata } from './AdScriptConfiguration'; +import { loadAdScriptSDK } from './LoadAdScriptSDK'; + +export class AdScriptConnector { + private readonly player: ChromelessPlayer; + private readonly initialLoadTime: number; + private readonly configuration: AdScriptConfiguration; + + private metadata: MainVideoContentMetadata; + private i12n: { [key: string]: string } | undefined; + + private adScriptIntegration: AdScriptTHEOIntegration | undefined; + private destroyed = false; + + /** + * Constructor for the THEOplayer AdScript connector. + * @param player a THEOplayer instance reference + * @param configuration a configuration object for the AdScript connector + * @returns + */ + constructor(player: ChromelessPlayer, configuration: AdScriptConfiguration) { + this.player = player; + this.configuration = configuration; + this.metadata = configuration.metadata; + this.i12n = configuration.i12n; + + // This loads the external AdScript SDK script. This is not immediately available, so we start a timer. + this.initialLoadTime = new Date().getTime(); + loadAdScriptSDK(configuration.implementationId); + + this.createAdScriptIntegrationWhenApiIsAvailable(); + } + + private readonly createAdScriptIntegrationWhenApiIsAvailable = () => { + if (this.destroyed) { + // The connector was destroyed before the API became available. + // Don't bother creating the integration. + return; + } + if (new Date().getTime() > this.initialLoadTime + 5_000) { + console.error('JHMT API not found, make sure you included the script to initialize AdScript Measurement.'); + return; + } + if (typeof window.JHMTApi === 'object') { + this.adScriptIntegration = new AdScriptTHEOIntegration(this.player, this.configuration); + if (this.i12n) { + this.adScriptIntegration.updateUser(this.i12n); + } + this.adScriptIntegration.updateMetadata(this.metadata); + this.adScriptIntegration.start(); + return; + } + setTimeout(this.createAdScriptIntegrationWhenApiIsAvailable, 20); + }; + + /** + * Update the main content information settings. + * This method must be called every time the main video content on the currently displayed page changes. + * For more information, see the [main content information settings](https://adscript.admosphere.cz/en_adScript_browser.html) section in the AdScript documentation. + * @param metadata The MainVideoContentMetadata. + */ + updateMetadata(metadata: MainVideoContentMetadata): void { + this.metadata = metadata; + this.adScriptIntegration?.updateMetadata(metadata); + } + + /** + * Updates the additional information about the logged-in user (customerID, deviceID, profileID, ...) from the client´s database. + * For more information, see the [Additional Information Settings](https://adscript.admosphere.cz/en_adScript_browser.html) section. + * @param i12n The Additional Information + */ + updateUser(i12n: { [key: string]: string }): void { + this.i12n = i12n; + this.adScriptIntegration?.updateUser(i12n); + } + + /** + * Destroy the connector. + */ + destroy(): void { + this.destroyed = true; + this.adScriptIntegration?.destroy(); + } +} diff --git a/adscript/src/integration/AdScriptTHEOIntegration.ts b/adscript/src/integration/AdScriptTHEOIntegration.ts new file mode 100644 index 00000000..7360a7ff --- /dev/null +++ b/adscript/src/integration/AdScriptTHEOIntegration.ts @@ -0,0 +1,303 @@ +import type { + Ad, + AdBreakEvent, + AdEvent, + ChromelessPlayer, + DurationChangeEvent, + EndedEvent, + Event, + GoogleImaAd, + PlayEvent, + RateChangeEvent, + SourceChangeEvent, + TimeUpdateEvent, + VolumeChangeEvent +} from 'theoplayer'; +import { AdScriptConfiguration, EmbeddedContentMetadata, MainVideoContentMetadata } from './AdScriptConfiguration'; +import { EmbeddedContentType } from './../adscript/AdScript'; +import { Logger } from '../utils/Logger'; + +interface LogPoint { + offset: number; + name: string; + reported: boolean; +} + +export class AdScriptTHEOIntegration { + private player: ChromelessPlayer; + private logger: Logger; + private readonly adProcessor: ((ad: Ad) => EmbeddedContentMetadata) | undefined; + private mainContentMetadata: MainVideoContentMetadata | undefined; + private mainContentLogPoints: LogPoint[] = []; + private mainContentDuration: number | undefined; + private currentAdMetadata: EmbeddedContentMetadata | undefined; + private currentAdLogPoints: LogPoint[] = []; + + private JHMTApi = window.JHMTApi; + private JHMT = window.JHMT; + + constructor(player: ChromelessPlayer, configuration: AdScriptConfiguration) { + this.player = player; + this.logger = new Logger(Boolean(configuration.debug)); + this.adProcessor = configuration?.adProcessor; + } + + public start() { + if (this.mainContentMetadata === undefined) { + throw Error('Metadata of the main video content needs to be set before the first measured event occurs.'); + } + this.reportPlayerState(); + this.addListeners(); + } + + public updateMetadata(metadata: MainVideoContentMetadata) { + this.mainContentMetadata = metadata; + this.logger.onSetMainVideoContentMetadata(this.mainContentMetadata); + this.JHMTApi.setContentMetadata(this.mainContentMetadata); + } + + public updateUser(i12n: { [key: string]: string }): void { + // Set the additional information about the logged user. + for (const id in i12n) { + this.logger.onSetI12N(id, i12n[id]); + this.JHMTApi.setI12n(id, i12n[id]); + } + } + + public destroy() { + this.removeListeners(); + } + + private addListeners(): void { + this.player.addEventListener('playing', this.onFirstMainContentPlaying); + this.player.addEventListener('durationchange', this.onDurationChange); + this.player.addEventListener('sourcechange', this.onSourceChange); + this.player.addEventListener('timeupdate', this.onTimeUpdate); + this.player.addEventListener('play', this.onPlay); // TODO + this.player.addEventListener('ended', this.onEnded); // TODO + this.player.addEventListener('volumechange', this.onVolumeChange); + this.player.addEventListener('ratechange', this.onRateChange); + this.player.addEventListener('presentationmodechange', this.onPresentationModeChange); + window.addEventListener('resize', this.reportPlayerState); + window.addEventListener('blur', this.reportPlayerState); + window.addEventListener('focus', this.reportPlayerState); + document.addEventListener('scroll', this.reportPlayerState); + document.addEventListener('visibilitychange', this.reportPlayerState); + if (this.player.ads) { + this.player.ads.addEventListener('adbreakend', this.onAdBreakEnd); //TODO + this.player.ads.addEventListener('adbegin', this.onAdBegin); //TODO + this.player.ads.addEventListener('adfirstquartile', this.onAdFirstQuartile); + this.player.ads.addEventListener('admidpoint', this.onAdMidpoint); + this.player.ads.addEventListener('adthirdquartile', this.onAdTirdQuartile); + this.player.ads.addEventListener('adend', this.onAdEnd); + } + } + + private removeListeners(): void { + this.player.removeEventListener('playing', this.onFirstMainContentPlaying); + this.player.removeEventListener('durationchange', this.onDurationChange); + this.player.removeEventListener('sourcechange', this.onSourceChange); + this.player.removeEventListener('timeupdate', this.onTimeUpdate); // TODO + this.player.removeEventListener('play', this.onPlay); // TODO + this.player.removeEventListener('ended', this.onEnded); // TODO + this.player.removeEventListener('volumechange', this.onVolumeChange); + this.player.removeEventListener('ratechange', this.onRateChange); + this.player.removeEventListener('presentationmodechange', this.onPresentationModeChange); + window.removeEventListener('resize', this.reportPlayerState); + window.removeEventListener('blur', this.reportPlayerState); + window.removeEventListener('focus', this.reportPlayerState); + document.removeEventListener('scroll', this.reportPlayerState); + document.removeEventListener('visibilitychange', this.reportPlayerState); + if (this.player.ads) { + this.player.ads.removeEventListener('adbreakend', this.onAdBreakEnd); //TODO + this.player.ads.removeEventListener('adbegin', this.onAdBegin); //TODO + this.player.ads.removeEventListener('adfirstquartile', this.onAdFirstQuartile); + this.player.ads.removeEventListener('admidpoint', this.onAdMidpoint); + this.player.ads.removeEventListener('adthirdquartile', this.onAdTirdQuartile); + this.player.ads.removeEventListener('adend', this.onAdEnd); + } + } + + private onDurationChange = (event: DurationChangeEvent) => { + if (this.player.ads?.playing || this.mainContentLogPoints.length) return; + const { duration } = event; + if (isNaN(duration)) return; + const firstSecondOfMainContent = this.player.ads?.dai?.streamTimeForContentTime(1); + const useDAITimeline = firstSecondOfMainContent && firstSecondOfMainContent !== 1; + this.mainContentDuration = duration; + if (duration === Infinity) { + this.mainContentLogPoints = [{ reported: false, offset: this.player.currentTime + 1, name: 'progress1' }]; + } else { + this.mainContentLogPoints = [ + { reported: false, offset: duration * 0.75, name: 'thirdQuartile' }, + { reported: false, offset: duration * 0.5, name: 'midpoint' }, + { reported: false, offset: duration * 0.25, name: 'firstQuartile' }, + { reported: false, offset: useDAITimeline ? firstSecondOfMainContent : 1, name: 'progress1' } + ]; + } + }; + + private onSourceChange = (event: SourceChangeEvent) => { + this.logger.onEvent(event); + this.player.removeEventListener('playing', this.onFirstMainContentPlaying); + this.player.addEventListener('playing', this.onFirstMainContentPlaying); + this.mainContentLogPoints = []; + this.currentAdLogPoints = []; + this.currentAdMetadata = undefined; + }; + + private onTimeUpdate = (event: TimeUpdateEvent) => { + const { currentTime } = event; + if (this.currentAdMetadata) { + this.maybeReportLogPoint(currentTime, this.currentAdMetadata, this.currentAdLogPoints); + } else { + this.maybeReportLogPoint(currentTime, this.mainContentMetadata!, this.mainContentLogPoints); + } + }; + + private onFirstMainContentPlaying = () => { + const isBeforePreroll = this.player.ads?.scheduledAdBreaks.find((adBreak) => adBreak.timeOffset === 0); + if (this.player.ads?.playing || isBeforePreroll) return; + this.logger.onAdScriptEvent('start', this.mainContentMetadata); + this.JHMT.push(['start', this.mainContentMetadata]); + this.player.removeEventListener('playing', this.onFirstMainContentPlaying); + }; + + private onPlay = (event: PlayEvent) => { + this.logger.onEvent(event); + this.reportPlayerState(); + }; + + private onEnded = (event: EndedEvent) => { + this.logger.onEvent(event); + this.logger.onAdScriptEvent('complete', this.mainContentMetadata); + this.JHMT.push(['complete', this.mainContentMetadata]); + }; + + private onVolumeChange = (event: VolumeChangeEvent) => { + this.logger.onEvent(event); + this.reportPlayerState(); + }; + + private onRateChange = (event: RateChangeEvent) => { + this.logger.onEvent(event); + this.reportPlayerState(); + }; + + private onPresentationModeChange = (event: Event) => { + this.logger.onEvent(event); + this.reportPlayerState(); + }; + + private onAdBreakEnd = (event: AdBreakEvent<'adbreakend'>) => { + this.logger.onEvent(event); + const { adBreak } = event; + const { timeOffset, integration } = adBreak; + this.currentAdLogPoints = []; + this.currentAdMetadata = undefined; + if (integration === 'google-dai' && timeOffset === 0) { + this.onFirstMainContentPlaying(); + } + }; + + private onAdFirstQuartile = (event: AdEvent<'adfirstquartile'>) => { + this.logger.onEvent(event); + this.logger.onAdScriptEvent('firstquartile', this.currentAdMetadata); + this.JHMT.push(['firstquartile', this.currentAdMetadata]); + }; + private onAdMidpoint = (event: AdEvent<'admidpoint'>) => { + this.logger.onEvent(event); + this.logger.onAdScriptEvent('midpoint', this.currentAdMetadata); + this.JHMT.push(['midpoint', this.currentAdMetadata]); + }; + private onAdTirdQuartile = (event: AdEvent<'adthirdquartile'>) => { + this.logger.onEvent(event); + this.logger.onAdScriptEvent('thirdquartile', this.currentAdMetadata); + this.JHMT.push(['thirdquartile', this.currentAdMetadata]); + }; + private onAdEnd = (event: AdEvent<'adend'>) => { + this.logger.onEvent(event); + this.logger.onAdScriptEvent('complete', this.currentAdMetadata); + this.JHMT.push(['complete', this.currentAdMetadata]); + }; + + private onAdBegin = (event: AdEvent<'adbegin'>) => { + this.logger.onEvent(event); + if (event.ad.type !== 'linear') return; + this.currentAdMetadata = this.buildAdMetadataObject(event); + this.currentAdLogPoints = this.buildAdLogPoints(event.ad); + this.logger.onAdScriptEvent('start', this.currentAdMetadata); + this.JHMT.push(['start', this.currentAdMetadata]); + }; + + private buildAdLogPoints = (ad: Ad) => { + const { duration } = ad; + if (ad.adBreak.integration === 'theo' && duration) { + return [ + { reported: false, offset: duration * 0.75, name: 'thirdQuartile' }, + { reported: false, offset: duration * 0.5, name: 'midpoint' }, + { reported: false, offset: duration * 0.25, name: 'firstQuartile' }, + { reported: false, offset: 1, name: 'progress1' } + ]; + } + return [{ reported: false, offset: 1, name: 'progress1' }]; + }; + + private buildAdMetadataObject = (event: AdEvent<'adbegin'>): EmbeddedContentMetadata => { + const { ad } = event; + const { adBreak } = ad; + if (this.adProcessor) { + return { + ...this.adProcessor(ad), + type: this.getAdType(adBreak.timeOffset, this.player.duration, adBreak.integration), + length: ad.duration?.toString() ?? '' + }; + } + return { + assetid: ad.id ?? '', + type: this.getAdType(adBreak.timeOffset, this.player.duration, adBreak.integration), + length: ad.duration?.toString() ?? '', + title: ad.integration?.includes('google') ? (ad as GoogleImaAd).title ?? '' : '', + asmea: '', + attributes: '' + }; + }; + + private getAdType = (offset: number, duration: number, integration: string | undefined): EmbeddedContentType => { + if (offset === 0) return 'preroll'; + if (offset === -1) return 'postroll'; + if (duration - offset < 1 && integration === 'google-dai') return 'postroll'; + if (this.mainContentDuration && this.mainContentDuration - offset < 1) return 'postroll'; + return 'midroll'; + }; + + private reportPlayerState = () => { + const playerState = { + muted: this.player.muted ? 1 : 0, + volume: this.player.volume * 100, + triggeredByUser: this.player.autoplay ? 1 : 0, + normalSpeed: this.player.playbackRate === 1 ? 1 : 0, + fullscreen: this.player.presentation.currentMode === 'fullscreen' ? 1 : 0, + visibility: this.player.visibility.ratio * 100, + width: this.player.element.clientWidth, + height: this.player.element.clientHeight + }; + this.logger.onPlayerStateChange(playerState); + this.JHMTApi.setPlayerState(playerState); + }; + + private maybeReportLogPoint = ( + currentTime: number, + metadata: MainVideoContentMetadata | EmbeddedContentMetadata, + logPoints: LogPoint[] + ) => { + logPoints.forEach((logPoint) => { + const { reported, offset, name } = logPoint; + if (!reported && currentTime >= offset && currentTime < offset + 1) { + logPoint.reported = true; + this.logger.onAdScriptEvent(name, metadata); + this.JHMT.push([name, metadata]); + } + }); + }; +} diff --git a/adscript/src/integration/LoadAdScriptSDK.ts b/adscript/src/integration/LoadAdScriptSDK.ts new file mode 100644 index 00000000..06eb224b --- /dev/null +++ b/adscript/src/integration/LoadAdScriptSDK.ts @@ -0,0 +1,55 @@ +// @ts-nocheck + +/** + * This function loads the AdScript SDK. + */ +export function loadAdScriptSDK(implementationId: string) { + loadAdScriptInternal(window, document, implementationId); +} + +/** + * This comes directly from the AdScript documentation: https://adscript.admosphere.cz/en_adScript_browser.html + * It has minimal changes to not use arguments.callee. + */ +function loadAdScriptInternal(j, h, m, t, c, z) { + c = c || 'JHMT'; + j[c] = j[c] || []; + j['JHMTApiProtocol'] = 'https:'; + z = z || 3; + + var i = (z % 3) + 1, + b = h.createElement('script'); + + (b.async = !0), + b.readyState + ? (b.onreadystatechange = function () { + ('loaded' !== b.readyState && 'complete' !== b.readyState) || + ((b.onreadystataechange = null), j.JHMTApi.init(c, m, t)); + }) + : (b.onload = function () { + j.JHMTApi.init(c, m, t); + }), + (b.src = j['JHMTApiProtocol'] + '//cm' + i + '.jhmt.cz/api.js'), + (b.onerror = function () { + b.parentNode.removeChild(b); + z++; + i = (z % 3) + 1; + loadAdScriptInternal(j, h, m, t, c, i); + }), + h.getElementsByTagName('head')[0].appendChild(b); + + try { + var it = setInterval(function () { + if (typeof j.JHMTApi !== 'undefined') { + clearInterval(it); + } else { + b.parentNode.removeChild(b); + z++; + i = (z % 3) + 1; + loadAdScriptInternal(j, h, m, t, c, i); + } + }, 1e3); + } catch (e) { + console.log('JHMT: ' + e); + } +} diff --git a/adscript/src/utils/Logger.ts b/adscript/src/utils/Logger.ts new file mode 100644 index 00000000..0bfc23d8 --- /dev/null +++ b/adscript/src/utils/Logger.ts @@ -0,0 +1,31 @@ +import type { Event } from 'theoplayer'; +import { PlayerState } from '../adscript/AdScript'; +import { EmbeddedContentMetadata, MainVideoContentMetadata } from '../integration/AdScriptConfiguration'; + +export class Logger { + private readonly debug: boolean; + + constructor(debug: boolean = false) { + this.debug = debug; + } + + onEvent = (event: Event) => { + if (this.debug) console.log(`[ADSCRIPT - THEOplayer EVENTS] ${event.type} event`); + }; + + onPlayerStateChange = (playerState: PlayerState) => { + if (this.debug) console.log(`[ADSCRIPT - setPlayerState]`, playerState); + }; + + onSetMainVideoContentMetadata = (metadata: MainVideoContentMetadata) => { + if (this.debug) console.log(`[ADSCRIPT - setContentMetadata]`, metadata); + }; + + onAdScriptEvent = (name: string, metadata: MainVideoContentMetadata | EmbeddedContentMetadata | undefined) => { + if (this.debug) console.log(`[ADSCRIPT - EVENT] ${name}`, metadata); + }; + + onSetI12N = (id: string, value: string) => { + if (this.debug) console.log(`[ADSCRIPT - SET I12N] ${id}: ${value}`); + }; +} diff --git a/adscript/test/pages/main_esm.html b/adscript/test/pages/main_esm.html new file mode 100644 index 00000000..de856e8e --- /dev/null +++ b/adscript/test/pages/main_esm.html @@ -0,0 +1,106 @@ + + + + + Connector test page + + + + + + + + + + +
+
+
+ +
+
+ +
+
+
+
+ + + + + + + diff --git a/adscript/test/pages/main_umd.html b/adscript/test/pages/main_umd.html new file mode 100644 index 00000000..243c972d --- /dev/null +++ b/adscript/test/pages/main_umd.html @@ -0,0 +1,96 @@ + + + + + Connector test page + + + + + + + + + + + +
+
+
+ +
+
+ +
+
+
+
+ + + + + + diff --git a/adscript/test/pages/preroll-empty.xml b/adscript/test/pages/preroll-empty.xml new file mode 100644 index 00000000..6850c951 --- /dev/null +++ b/adscript/test/pages/preroll-empty.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/adscript/test/pages/test-assets.json b/adscript/test/pages/test-assets.json new file mode 100644 index 00000000..8dcf0ba5 --- /dev/null +++ b/adscript/test/pages/test-assets.json @@ -0,0 +1,225 @@ +[ + { + "label": "VOD (DASH) - VAST (IMA)", + "source": { + "sources": [ + { + "src": "https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd", + "useCredentials": false + } + ], + "ads": [ + { + "integration": "google-ima", + "timeOffset": "start", + "sources": "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_preroll_skippable&sz=640x480&ciu_szs=300x250%2C728x90&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&impl=s&correlator=" + } + ] + }, + "metadata": { + "assetid": "v0000001", + "type": "content", + "program": "Big Buck Bunny", + "title": "Sample Video - Extended", + "length": "635", + "crossId": "000 111 22222", + "livestream": "0", + "channelId": "", + "attribute": "1" + } + }, + { + "label": "VOD (HLS)", + "source": { + "sources": [ + { + "src": "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8", + "type": "application/x-mpegurl" + } + ] + }, + "metadata": { + "assetid": "v0000002", + "type": "content", + "program": "Big Buck Bunny", + "title": "Sample Video", + "length": "596", + "crossId": "000 111 22222", + "livestream": "0", + "channelId": "", + "attribute": "1" + } + }, + { + "label": "VOD (HLS) - VMAP (IMA)", + "source": { + "sources": [ + { + "src": "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8", + "type": "application/x-mpegurl" + } + ], + "ads": [ + { + "integration": "google-ima", + "sources": "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostpod&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=" + } + ] + }, + "metadata": { + "assetid": "v0000002", + "type": "content", + "program": "Big Buck Bunny", + "title": "Sample Video", + "length": "596", + "crossId": "000 111 22222", + "livestream": "0", + "channelId": "", + "attribute": "1" + } + }, + { + "label": "VOD (HLS) - VMAP (THEOAds)", + "source": { + "sources": [ + { + "src": "https://cdn.theoplayer.com/video/big_buck_bunny/big_buck_bunny.m3u8", + "type": "application/x-mpegurl" + } + ], + "ads": [ + { + "integration": "theo", + "sources": "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostpod&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=" + } + ] + }, + "metadata": { + "assetid": "v0000002", + "type": "content", + "program": "Big Buck Bunny", + "title": "Sample Video", + "length": "596", + "crossId": "000 111 22222", + "livestream": "0", + "channelId": "", + "attribute": "1" + } + }, + { + "label": "LIVE (DASH) - VAST pre-roll (IMA)", + "source": { + "sources": [ + { + "src": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "useCredentials": false + } + ], + "ads": [ + { + "integration": "google-ima", + "timeOffset": "start", + "sources": "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_preroll_skippable&sz=640x480&ciu_szs=300x250%2C728x90&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&impl=s&correlator=" + } + ] + }, + "metadata": { + "assetid": "v0000003", + "type": "content", + "program": "DASHIF Test Assets", + "title": "Livesim", + "length": "86400", + "crossId": "000 111 333333", + "livestream": "1", + "channelId": "DASHIF1", + "attribute": "1" + } + }, + { + "label": "LIVE (DASH) - empty VAST pre-roll (IMA)", + "source": { + "sources": [ + { + "src": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "useCredentials": false + } + ], + "ads": [ + { + "integration": "google-ima", + "timeOffset": "start", + "sources": "http://localhost:8081/adscript/test/pages/preroll-empty.xml" + } + ] + }, + "metadata": { + "assetid": "v0000003", + "type": "content", + "program": "DASHIF Test Assets", + "title": "Livesim", + "length": "86400", + "crossId": "000 111 333333", + "livestream": "1", + "channelId": "DASHIF1", + "attribute": "1" + } + }, + { + "label": "LIVE (DASH) - VAST pre-roll (THEOAds)", + "source": { + "sources": [ + { + "src": "https://livesim2.dashif.org/livesim2/testpic_2s/Manifest.mpd", + "useCredentials": false + } + ], + "ads": [ + { + "integration": "theo", + "timeOffset": "start", + "sources": "https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/single_preroll_skippable&sz=640x480&ciu_szs=300x250%2C728x90&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&impl=s&correlator=" + } + ] + }, + "metadata": { + "assetid": "v0000003", + "type": "content", + "program": "DASHIF Test Assets", + "title": "Livesim", + "length": "86400", + "crossId": "000 111 333333", + "livestream": "1", + "channelId": "DASHIF1", + "attribute": "1" + } + }, + { + "label": "VOD - Google DAI", + "source": { + "sources": [ + { + "type": "application/x-mpegurl", + "ssai": { + "integration": "google-dai", + "availabilityType": "vod", + "contentSourceID": "2548831", + "videoID": "tears-of-steel", + "assetKey": "", + "apiKey": "" + } + } + ] + }, + "metadata": { + "assetid": "v0000004", + "type": "content", + "program": "DAI VOD Samples", + "title": "Tears Of Steel", + "length": "784", + "crossId": "000 111 333333", + "livestream": "0", + "channelId": "", + "attribute": "1" + } + } +] \ No newline at end of file diff --git a/adscript/tsconfig.json b/adscript/tsconfig.json new file mode 100644 index 00000000..c9804acc --- /dev/null +++ b/adscript/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declarationDir": "dist/types" + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file diff --git a/adscript/typedoc.json b/adscript/typedoc.json new file mode 100644 index 00000000..0d320987 --- /dev/null +++ b/adscript/typedoc.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "extends": [ + "../typedoc.base.json" + ], + "entryPoints": [ + "src/index.ts" + ], + "tsconfig": "tsconfig.json", + "readme": "README.md", + "name": "AdScript Connector" +} diff --git a/package-lock.json b/package-lock.json index 2d017d80..4babe749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "conviva", "nielsen", "cmcd", - "comscore" + "comscore", + "adscript" ], "devDependencies": { "@changesets/cli": "^2.27.1", @@ -42,6 +43,14 @@ "typescript-eslint": "^7.5.0" } }, + "adscript": { + "name": "@theoplayer/adscript-connector-web", + "version": "0.0.1", + "license": "MIT", + "peerDependencies": { + "theoplayer": "^7.0.0" + } + }, "cmcd": { "name": "@theoplayer/cmcd-connector-web", "version": "1.0.2", @@ -2396,6 +2405,10 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@theoplayer/adscript-connector-web": { + "resolved": "adscript", + "link": true + }, "node_modules/@theoplayer/cmcd-connector-web": { "resolved": "cmcd", "link": true diff --git a/package.json b/package.json index 14a91e11..b56f2b8f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "conviva", "nielsen", "cmcd", - "comscore" + "comscore", + "adscript" ], "scripts": { "changeset:version": "changeset version && node .changeset/post-process.js",