├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── config └── puppeteer │ └── global-setup.js ├── dev ├── adpod.html ├── favicon.ico ├── index.html ├── landing-page.html ├── pixel1x1.gif ├── player.html ├── plugin-videojs7.html ├── plugin.html ├── schedule.html ├── vast-adpod.xml ├── vast-vpaid-html.xml ├── vast-vpaid.xml ├── vast-wrapper.xml ├── vast.xml ├── vpaid-html.html ├── vpaid-html.js ├── vpaid-iframe.html ├── vpaid.html ├── vpaidad.js └── wrapper.html ├── dist ├── player.js ├── videojsx.vast.css └── videojsx.vast.js ├── jest-e2e.config.mjs ├── jest.config.mjs ├── package-lock.json ├── package.json ├── src ├── ad-loader.mjs ├── ad-selector.mjs ├── event.mjs ├── tracked-ad.mjs ├── ui.mjs ├── utils.mjs ├── vast-player.css ├── vast-player.mjs ├── vast-plugin.mjs └── vpaid-handler.mjs ├── test ├── e2e │ ├── creative │ │ ├── image-200x600.webp │ │ ├── image-300x250.jpg │ │ ├── image-300x250.png │ │ ├── image-745x100.gif │ │ ├── video-960x540-5s.mp4 │ │ ├── video-960x540-5s.webm │ │ ├── video-960x540-6s-sound.mp4 │ │ └── video-960x540-9s-sound.mp4 │ ├── first.test.js │ ├── jest.config.mjs │ ├── page │ │ ├── demo.css │ │ ├── index.html │ │ └── landing-page.html │ └── vast │ │ └── sample01.xml └── unit │ ├── ad-loader.test.mjs │ ├── utils.test.mjs │ └── vast-plugin.test.mjs ├── webpack.common.js ├── webpack.config.js ├── webpack.dev.js ├── webpack.prod.js └── webpack.watch.js /.gitattributes: -------------------------------------------------------------------------------- 1 | *.mp4 filter=lfs diff=lfs merge=lfs -text 2 | *.jpg filter=lfs diff=lfs merge=lfs -text 3 | *.jpeg filter=lfs diff=lfs merge=lfs -text 4 | *.png filter=lfs diff=lfs merge=lfs -text 5 | *.gif filter=lfs diff=lfs merge=lfs -text 6 | *.webp filter=lfs diff=lfs merge=lfs -text 7 | *.webm filter=lfs diff=lfs merge=lfs -text 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | .DS_Store 3 | /node_modules 4 | npm-debug.log 5 | dist/*.gz 6 | .eslintcache 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Original work Copyright (c) 2014 The Onion 4 | Modified work Copyright (c) 2018 - 2022 Philip Watson 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # videojsx-vast-plugin 2 | 3 | 4 | Initially, the code was taken from [videojs-vast-plugin](https://github.com/theonion/videojs-vast-plugin) and made it work with videojs 8. 5 | It is very different now. 6 | 7 | This project intends to keep up to date with the videojs and its other dependencies. 8 | 9 | 10 | ## Build 11 | 12 | NodeJs and its package manager (npm) is required to build. 13 | 14 | Run `npm install` then `npm run-script build`. 15 | 16 | The build creates two independent artifacts in the `dist/` folder: 17 | 18 | | Artifact Name | Files | Description | 19 | |---------------|-------------------------------------|--------------------------------------------------------------------------| 20 | | Plugin | videojsx.vast.js, videojsx.vast.css | Standalone plugin that can be integrated to an external video.js player. | 21 | | Video Player | player.js | A file that has video.js, css and other dependencies bundled in. | 22 | 23 | Also, every JavaScript `.js` file has a compressed version `.js.gz` 24 | 25 | ## Usage 26 | 27 | ### Setting Up Plugin Scripts 28 | 29 | Include this plugin (**videojsx.vast.css** and **videojsx.vast.js**) and its dependencies. 30 | 31 | Ordering does matter. Be sure you request `video.js` first and `videojs-contrib-ads` anywhere before `videojsx.vast.js`. 32 | 33 | It will look something like this: 34 | 35 | ```html 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ``` 50 | 51 | ### Setting Up Video Player Script 52 | 53 | Put anywhere before you start using it. For example, in the head section: 54 | 55 | ```html 56 | 57 | 58 | 59 | ``` 60 | 61 | 62 | ### General Use 63 | 64 | Example: 65 | ```html 66 | 70 |
71 | 72 | 83 | ``` 84 | 85 | #### Options 86 | 87 | | Name | Optional | Default | Description | 88 | |-----------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 89 | | `url` | Yes | n/a | URL that responds with a VAST XML ad tag. Can be an array of URLs to be used as fallbacks (Ad Waterfall) - if the first URL fails to get ads, then the next URL will be tried, and so on until a VAST with ads is found. | 90 | | `xml` | Yes | n/a | The VAST XML ad tag. Use as an alternative to `url`. Can be a String or XMLDocument. | 91 | | `seekEnabled` | Yes | `false` | Enable the player seek control when advert is playing. `controlsEnabled` must be enabled also. | 92 | | `controlsEnabled` | Yes | `false` | Enable the player controls (pause, play, volume) when advert is playing | 93 | | `wrapperLimit` | Yes | `10` | Maximum number of VAST wrappers (aka VAST request redirects) allowed | 94 | | `withCredentials` | Yes | `true` | Enable third-party cookies on the VAST request | 95 | | `skip` | Yes | `0` | Number of seconds the user has to wait before the advert can be skipped | 96 | | `honorSkipOffset` | Yes | `false` | Honor the VAST creative's skipoffset attribute if it is present; in that case, the `skip` option will be ignored. | 97 | | `displayRemainingTime` | Yes | `false` | Display the remaining time until the ad ends | 98 | | `displayRemainingTimeIcons` | Yes | `false` | Display play/pause and mute/unmute icons before the remaining time message | 99 | | `messages` | Yes | `{}` | See Messages options below | 100 | | `companion` | Yes | `{}` | See Companion options below | 101 | | `vpaid` | Yes | `{}` | See VPAID options below | 102 | | `schedule` | Yes | n/a | An array of schedule items. If provided, the `url` and `xml` properties of this object will be ignored | 103 | 104 | ##### Messages Options 105 | 106 | | Name | Optional | Default | Description | 107 | |-----------------|----------|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 108 | | `skip` | Yes | `Skip` | Message displayed on the clickeable button to skip the ad | 109 | | `skipCountdown` | Yes | `Skip in {seconds}...` | Message displayed for the countdown to enable skip of the ad. `{seconds}` will be replaced with the number of seconds left to skip the ad | 110 | | `remainingTime` | Yes | `This ad will end in {seconds}` | Message displayed for the countdown to the end of the ad. `{seconds}` will be replaced with the number of seconds left to the end of the ad. Displayed only if `displayRemainingTime` is enabled | 111 | 112 | 113 | ##### Companion Options 114 | 115 | | Name | Optional | Default | Description | 116 | |-------------|----------|---------|------------------------------------------------------------------| 117 | | `elementId` | Yes | `null` | Id of the HTML element that will serve as the creative container | 118 | | `maxWidth` | Yes | `0` | The maximum width allowed for the creative | 119 | | `maxHeight` | Yes | `0` | The maximum height allowed for the creative | 120 | 121 | 122 | ##### VPAID Options 123 | 124 | | Name | Optional | Default | Description | 125 | |--------------------|----------|-------------------------|----------------------------------------------------------------------------------------------------------------| 126 | | `videoInstance` | Yes | `'same'` | Determines which video element to pass to the VPAID ad. Either `'none'` or `'same'`. | 127 | | `containerClass` | Yes | `'vjs-vpaid-container'` | The class name of the container that will house the VPAID iframe. | 128 | | `enableToggleMute` | Yes | `false` | Show a transparent icon button on the video (bottom-right) that the user can use to mute and unmute the audio. | 129 | 130 | 131 | ##### Schedule Item Options 132 | 133 | | Name | Optional | Default | Description | 134 | |----------|----------|---------|-----------------------------------------------------| 135 | | `url` | Yes | n/a | Same as the `url` option on the top level | 136 | | `xml` | Yes | n/a | Same as the `xml` option on the top level | 137 | | `offset` | Yes | `'pre'` | When to play the ad tag. See possible values below. | 138 | 139 | Offset values: 140 | * `pre`: (string) Play before the content (preroll). 141 | * `post`: (string) Play after the content (postroll). 142 | * number: (number or string) Play after the specified number of seconds. For example, `15`. 143 | * xx%: (string) Play after xx% of the content. For example, `75%` 144 | * time code: (string) Play at a specific time, in HH:MM:SS or HH:MM format. Examples: `1:30:12` (1 hour, 30 mins, 12 secs), `2:00` (2 hours), `0:25` (25 mins). 145 | 146 | 147 | ## Dev Workflow 148 | 149 | ### Setup 150 | This project uses LFS for versioning large files (e.g., mp4). Only useful for development. 151 | Please see [Git Large File Storage](https://git-lfs.github.com/) on github for details. 152 | 153 | Example setup for Mac OS: 154 | ```bash 155 | brew install git-lfs 156 | git lfs install 157 | ``` 158 | 159 | If you already cloned this repo before installing LFS and want to get the real content (replace pointer files): 160 | 161 | ```bash 162 | git lfs checkout 163 | git lfs fetch 164 | ``` 165 | 166 | ### Workflow 167 | Run `npm start` brings up a development server at port 9999 with automatic background builds. 168 | 169 | The page is http://localhost:9999/index.html 170 | 171 | The command should automatically open this page. 172 | 173 | The build will be triggered when any of the files under `src/` is modified. The currently opened page on port 9999 174 | should reload automatically. 175 | 176 | 177 | ## Testing 178 | 179 | Experimental 180 | 181 | ## Credit 182 | 183 | * This plugin is a modification of an existing plugin [videojs-vast-plugin](https://github.com/theonion/videojs-vast-plugin) by The Onion 184 | * The [Video.js Framework](http://videojs.com/) itself 185 | * Video.js's [Ad plugin](https://github.com/videojs/videojs-contrib-ads) allowing video ad integration (switching between content and pre-roll) 186 | * Dailymotion's [VAST client](https://github.com/dailymotion/vast-client-js) to parse and read VAST content 187 | * MailOnline's [VPAIDHTMLClient](https://github.com/MailOnline/VPAIDHTML5Client). A JavaScript iframe wrapper for VPAID 188 | -------------------------------------------------------------------------------- /config/puppeteer/global-setup.js: -------------------------------------------------------------------------------- 1 | const { setup: setupPuppeteer } = require('jest-environment-puppeteer'); 2 | const webpack = require('webpack'); 3 | const config = require('../../webpack.dev.js'); 4 | 5 | async function build() { 6 | const compiler = webpack(config); 7 | 8 | return new Promise(function(res, rej) { 9 | compiler.run((err, stats) => { 10 | if (err || stats.hasErrors()) { 11 | rej(Error('Error when compiling')); 12 | } 13 | else { 14 | res(); 15 | } 16 | }); 17 | }); 18 | } 19 | 20 | 21 | module.exports = async function globalSetup() { 22 | await build(); 23 | await setupPuppeteer(); 24 | }; 25 | -------------------------------------------------------------------------------- /dev/adpod.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Player Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 |
16 | 17 | 18 | 19 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /dev/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipwatson/videojsx-vast-plugin/9bd4f1f3023f2a788c089d724f8205b509a04e9b/dev/favicon.ico -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 |

Player video.js, css, plugins are bundled in one file (player.js)

3 |

Plugin standalone plugin is used with an external version of video.js and dependent plugins

4 |

Plugin with video.js 7 standalone plugin is used with an external version of video.js v7 and dependent plugins

5 |

VPAID example using player.js

6 |

VPAID HTML example using player.js

7 |

Ad Pod example using player.js

8 |

Wrapper example using player.js

9 |

Schedule example using player.js

10 | 11 | -------------------------------------------------------------------------------- /dev/landing-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Landing Page 6 | 7 | 8 |

Landing Page

9 | 10 | 11 | -------------------------------------------------------------------------------- /dev/pixel1x1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philipwatson/videojsx-vast-plugin/9bd4f1f3023f2a788c089d724f8205b509a04e9b/dev/pixel1x1.gif -------------------------------------------------------------------------------- /dev/player.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Player Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 |
16 | 17 | 18 | 19 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /dev/plugin-videojs7.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Plugin Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 27 |
28 | 29 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /dev/plugin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Plugin Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 27 |
28 | 29 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /dev/schedule.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Schedule Example 5 | 6 | 7 | 8 | 9 | 15 |
16 | 17 | 18 | 19 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /dev/vast-adpod.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Partner100 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 00:00:06 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | Partner200 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 00:00:09 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /dev/vast-vpaid-html.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Partner100 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 00:00:30 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 44 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /dev/vast-vpaid.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Partner100 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 00:00:05 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /dev/vast-wrapper.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | AdSystem101 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /dev/vast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Partner100 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 00:00:05 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /dev/vpaid-html.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Player Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 27 |
28 | 29 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /dev/vpaid-html.js: -------------------------------------------------------------------------------- 1 | var VpaidHtmlPlayer = function() { 2 | this.slot_ = null; 3 | this.iframe_ = null; 4 | this.skipBtn_ = null; 5 | this.skipTimer_ = null; 6 | this.skipDelay_ = 8; 7 | this.progressBar_ = null; 8 | this.progressBarContainer_ = null; 9 | this.eventsCallbacks_ = {}; 10 | this.attributes_ = { 11 | 'companions': '', 12 | 'desiredBitrate': 256, 13 | 'duration': 30, 14 | 'expanded': false, 15 | 'height': 0, 16 | 'icons': '', 17 | 'linear': true, 18 | 'remainingTime': 30, 19 | 'skippableState': false, 20 | 'skipTime': 0, 21 | 'clickUrl': null, 22 | 'viewMode': 'normal', 23 | 'width': 0, 24 | 'volume': 1.0 25 | }; 26 | 27 | this.quartileEvents_ = [ 28 | {event: 'AdImpression', value: 0}, 29 | {event: 'AdStarted', value: 0}, 30 | {event: 'AdVideoFirstQuartile', value: 25}, 31 | {event: 'AdVideoMidpoint', value: 50}, 32 | {event: 'AdVideoThirdQuartile', value: 75}, 33 | {event: 'AdVideoComplete', value: 100} 34 | ]; 35 | 36 | this.nextQuartileIndex_ = 0; 37 | this.parameters_ = {}; 38 | }; 39 | 40 | VpaidHtmlPlayer.prototype.handshakeVersion = function(version) { 41 | return '2.0'; 42 | }; 43 | 44 | VpaidHtmlPlayer.prototype.initAd = function(width, height, viewMode, desiredBitrate, creativeData, environmentVars) { 45 | this.attributes_['width'] = width; 46 | this.attributes_['height'] = height; 47 | this.attributes_['viewMode'] = viewMode; 48 | this.attributes_['desiredBitrate'] = desiredBitrate; 49 | 50 | this.slot_ = environmentVars.slot; 51 | 52 | try { 53 | this.parameters_ = JSON.parse(creativeData['AdParameters']); 54 | 55 | if (this.parameters_.duration) { 56 | this.attributes_['duration'] = this.parseDuration(this.parameters_.duration); 57 | this.callEvent_('AdDurationChange'); 58 | } 59 | if (this.parameters_.skipTime) { 60 | this.attributes_['skipTime'] = parseInt(this.parameters_.skipTime); 61 | } 62 | if (this.parameters_.clickUrl) { 63 | this.attributes_['clickUrl'] = this.parameters_.clickUrl; 64 | } 65 | } catch (e) { 66 | console.error('Error parsing AdParameters:', e); 67 | } 68 | 69 | this.createIframe_(); 70 | 71 | this.callEvent_('AdLoaded'); 72 | }; 73 | 74 | VpaidHtmlPlayer.prototype.parseDuration = function(durationString) { 75 | const parts = durationString.split(':').map(Number); 76 | return parts[0] * 3600 + parts[1] * 60 + parts[2]; 77 | }; 78 | 79 | VpaidHtmlPlayer.prototype.createIframe_ = function() { 80 | this.iframe_ = document.createElement('iframe'); 81 | this.iframe_.id = 'vpaid-iframe'; 82 | this.iframe_.style.border = 'none'; 83 | this.iframe_.style.width = '100%'; 84 | this.iframe_.style.height = '100%'; 85 | this.iframe_.style.position = 'absolute'; 86 | this.iframe_.src = this.parameters_.iframeUrl || 'about:blank'; 87 | this.slot_.appendChild(this.iframe_); 88 | 89 | this.skipBtn_ = document.createElement('button'); 90 | this.skipBtn_.id = 'vpaid-skip-btn'; 91 | this.skipBtn_.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; 92 | this.skipBtn_.style.color = 'white'; 93 | this.skipBtn_.style.fontSize = '13px'; 94 | this.skipBtn_.style.padding = '5px 10px'; 95 | this.skipBtn_.style.position = 'absolute'; 96 | this.skipBtn_.style.top = '10px'; 97 | this.skipBtn_.style.right = '10px'; 98 | this.skipBtn_.style.zIndex = '9999'; 99 | this.skipBtn_.style.display = 'none'; 100 | this.skipBtn_.addEventListener('click', this.skipAd.bind(this)); 101 | this.slot_.appendChild(this.skipBtn_); 102 | 103 | this.progressBarContainer_ = document.createElement('div'); 104 | this.progressBarContainer_.style.position = 'absolute'; 105 | this.progressBarContainer_.style.bottom = '0'; 106 | this.progressBarContainer_.style.left = '0'; 107 | this.progressBarContainer_.style.width = '100%'; 108 | this.progressBarContainer_.style.height = '5px'; 109 | this.progressBarContainer_.style.backgroundColor = 'rgba(255, 255, 255, 0.3)'; 110 | this.progressBarContainer_.style.zIndex = '9998'; 111 | 112 | this.progressBar_ = document.createElement('div'); 113 | this.progressBar_.style.width = '0%'; 114 | this.progressBar_.style.height = '100%'; 115 | this.progressBar_.style.backgroundColor = 'rgba(229, 204, 19, 0.8)'; 116 | this.progressBar_.style.transition = 'width 1.0s linear'; 117 | 118 | this.progressBarContainer_.appendChild(this.progressBar_); 119 | this.slot_.appendChild(this.progressBarContainer_); 120 | 121 | window.addEventListener('message', this.onMessageReceived_.bind(this), false); 122 | }; 123 | 124 | VpaidHtmlPlayer.prototype.onMessageReceived_ = function(event) { 125 | if (event.source === this.iframe_.contentWindow) { 126 | var message = event.data; 127 | if (message.type === 'vpaid' && message.event) { 128 | //this.callEvent_(message.event); 129 | if (message.event === 'AdClickThru') { 130 | this.AdClickThru(); 131 | } else { 132 | this.callEvent_(message.event); 133 | } 134 | } 135 | } 136 | }; 137 | 138 | VpaidHtmlPlayer.prototype.startAd = function() { 139 | //this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'start'}, '*'); 140 | this.callEvent_('AdStarted'); 141 | 142 | this.startSkipTimer_(); 143 | }; 144 | 145 | VpaidHtmlPlayer.prototype.AdClickThru = function() { 146 | if ('AdClickThru' in this.eventsCallbacks_) { 147 | this.eventsCallbacks_['AdClickThru'](this.attributes_['clickUrl'], '0', true); 148 | } 149 | }; 150 | 151 | VpaidHtmlPlayer.prototype.startSkipTimer_ = function() { 152 | let secondsElapsed = 0; 153 | const totalDuration = this.attributes_['duration']; 154 | const skipTime = this.attributes_['skipTime']; 155 | 156 | this.skipTimer_ = setInterval(() => { 157 | secondsElapsed++; 158 | const percentComplete = (secondsElapsed / totalDuration) * 100; 159 | 160 | this.progressBar_.style.width = `${percentComplete}%`; 161 | 162 | if (secondsElapsed < skipTime) { 163 | this.skipBtn_.innerHTML = `Skip in ${skipTime - secondsElapsed}`; 164 | this.skipBtn_.style.display = 'block'; 165 | this.skipBtn_.disabled = true; 166 | } else { 167 | this.skipBtn_.innerHTML = 'Skip Ad'; 168 | this.skipBtn_.disabled = false; 169 | if (this.attributes_['skippableState'] === false) { 170 | this.attributes_['skippableState'] = true; 171 | this.callEvent_('AdSkippableStateChange'); 172 | } 173 | } 174 | 175 | this.attributes_['remainingTime'] = Math.max(0, totalDuration - secondsElapsed); 176 | 177 | if (secondsElapsed >= totalDuration) { 178 | clearInterval(this.skipTimer_); 179 | this.stopAd(); 180 | } 181 | 182 | if (this.nextQuartileIndex_ >= this.quartileEvents_.length) { 183 | return; 184 | } 185 | 186 | if (percentComplete >= this.quartileEvents_[this.nextQuartileIndex_].value) { 187 | var lastQuartileEvent = this.quartileEvents_[this.nextQuartileIndex_].event; 188 | console.log('[JS] Calling event lastQuartileEvent', lastQuartileEvent); 189 | this.eventsCallbacks_[lastQuartileEvent](); 190 | this.nextQuartileIndex_ += 1; 191 | } 192 | 193 | }, 1000); 194 | }; 195 | 196 | VpaidHtmlPlayer.prototype.stopAd = function() { 197 | this.log('Stopping ad'); 198 | clearInterval(this.skipTimer_); 199 | this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'stop'}, '*'); 200 | this.skipBtn_.style.display = 'none'; 201 | this.progressBarContainer_.style.display = 'none'; 202 | setTimeout(this.callEvent_.bind(this), 1200, ['AdStopped']); 203 | }; 204 | 205 | VpaidHtmlPlayer.prototype.resizeAd = function(width, height, viewMode) { 206 | this.log('resizeAd ' + width + 'x' + height + ' ' + viewMode); 207 | this.attributes_['width'] = width; 208 | this.attributes_['height'] = height; 209 | this.attributes_['viewMode'] = viewMode; 210 | this.iframe_.style.width = width + 'px'; 211 | this.iframe_.style.height = height + 'px'; 212 | this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'resize', width: width, height: height}, '*'); 213 | this.callEvent_('AdSizeChange'); 214 | }; 215 | 216 | VpaidHtmlPlayer.prototype.pauseAd = function() { 217 | this.log('pauseAd'); 218 | this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'pause'}, '*'); 219 | this.callEvent_('AdPaused'); 220 | }; 221 | 222 | VpaidHtmlPlayer.prototype.resumeAd = function() { 223 | this.log('resumeAd'); 224 | this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'resume'}, '*'); 225 | this.callEvent_('AdPlaying'); 226 | }; 227 | 228 | VpaidHtmlPlayer.prototype.expandAd = function() { 229 | this.log('expandAd'); 230 | this.attributes_['expanded'] = true; 231 | this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'expand'}, '*'); 232 | this.callEvent_('AdExpanded'); 233 | }; 234 | 235 | VpaidHtmlPlayer.prototype.collapseAd = function() { 236 | this.log('collapseAd'); 237 | this.attributes_['expanded'] = false; 238 | this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'collapse'}, '*'); 239 | this.callEvent_('AdCollapsed'); 240 | }; 241 | 242 | VpaidHtmlPlayer.prototype.skipAd = function() { 243 | this.log('skipAd'); 244 | if (this.attributes_['skippableState']) { 245 | clearInterval(this.skipTimer_); 246 | this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'skip'}, '*'); 247 | this.skipBtn_.style.display = 'none'; 248 | this.progressBarContainer_.style.display = 'none'; 249 | this.callEvent_('AdSkipped'); 250 | this.stopAd(); 251 | } 252 | }; 253 | 254 | VpaidHtmlPlayer.prototype.subscribe = function(aCallback, eventName, aContext) { 255 | // this.log('Subscribe ' + eventName); 256 | var callBack = aCallback.bind(aContext); 257 | this.eventsCallbacks_[eventName] = callBack; 258 | }; 259 | 260 | VpaidHtmlPlayer.prototype.unsubscribe = function(eventName) { 261 | this.log('unsubscribe ' + eventName); 262 | this.eventsCallbacks_[eventName] = null; 263 | }; 264 | 265 | VpaidHtmlPlayer.prototype.getAdLinear = function() { 266 | return this.attributes_['linear']; 267 | }; 268 | 269 | VpaidHtmlPlayer.prototype.getAdWidth = function() { 270 | return this.attributes_['width']; 271 | }; 272 | 273 | VpaidHtmlPlayer.prototype.getAdHeight = function() { 274 | return this.attributes_['height']; 275 | }; 276 | 277 | VpaidHtmlPlayer.prototype.getAdExpanded = function() { 278 | return this.attributes_['expanded']; 279 | }; 280 | 281 | VpaidHtmlPlayer.prototype.getAdSkippableState = function() { 282 | return this.attributes_['skippableState']; 283 | }; 284 | 285 | VpaidHtmlPlayer.prototype.getAdRemainingTime = function() { 286 | return this.attributes_['remainingTime']; 287 | }; 288 | 289 | VpaidHtmlPlayer.prototype.getAdDuration = function() { 290 | return this.attributes_['duration']; 291 | }; 292 | 293 | VpaidHtmlPlayer.prototype.getAdVolume = function() { 294 | return this.attributes_['volume']; 295 | }; 296 | 297 | VpaidHtmlPlayer.prototype.getAdCompanions = function() { 298 | return this.attributes_['companions']; 299 | }; 300 | 301 | VpaidHtmlPlayer.prototype.getAdIcons = function() { 302 | return this.attributes_['icons']; 303 | }; 304 | 305 | 306 | VpaidHtmlPlayer.prototype.setAdVolume = function(value) { 307 | this.attributes_['volume'] = value; 308 | this.log('setAdVolume ' + value); 309 | this.iframe_.contentWindow.postMessage({type: 'vpaid', action: 'setVolume', value: value}, '*'); 310 | this.callEvent_('AdVolumeChange'); 311 | }; 312 | 313 | VpaidHtmlPlayer.prototype.log = function(message) { 314 | console.log(message); 315 | }; 316 | 317 | VpaidHtmlPlayer.prototype.callEvent_ = function(eventType, data) { 318 | if (eventType in this.eventsCallbacks_) { 319 | this.eventsCallbacks_[eventType]({data}); 320 | } 321 | }; 322 | 323 | var getVPAIDAd = function() { 324 | return new VpaidHtmlPlayer(); 325 | }; 326 | -------------------------------------------------------------------------------- /dev/vpaid-iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | VPAID HTML Ad 7 | 49 | 50 | 51 |
52 |
53 |

Your Ad Content Here

54 |

This is a sample VPAID HTML ad.

55 | 56 |
57 | 58 |
59 | 60 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /dev/vpaid.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Player Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 |
16 | 17 | 18 | 19 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /dev/vpaidad.js: -------------------------------------------------------------------------------- 1 | var VpaidVideoPlayer = function() { 2 | /** 3 | * The slot is the div element on the main page that the ad is supposed to 4 | * occupy. 5 | * @type {Object} 6 | * @private 7 | */ 8 | this.slot_ = null; 9 | 10 | /** 11 | * The video slot is the video element used by the ad to render video content. 12 | * @type {Object} 13 | * @private 14 | */ 15 | this.videoSlot_ = null; 16 | 17 | /** 18 | * An object containing all registered events. These events are all 19 | * callbacks for use by the VPAID ad. 20 | * @type {Object} 21 | * @private 22 | */ 23 | this.eventsCallbacks_ = {}; 24 | 25 | /** 26 | * A list of getable and setable attributes. 27 | * @type {Object} 28 | * @private 29 | */ 30 | this.attributes_ = { 31 | 'companions' : '', 32 | 'desiredBitrate' : 256, 33 | 'duration' : 27, 34 | 'expanded' : false, 35 | 'height' : 0, 36 | 'icons' : '', 37 | 'linear' : true, 38 | 'remainingTime' : 12, 39 | 'skippableState' : false, 40 | 'viewMode' : 'normal', 41 | 'width' : 0, 42 | 'volume' : 1.0 43 | }; 44 | 45 | /** 46 | * A set of ad playback events to be reported. 47 | * @type {Object} 48 | * @private 49 | */ 50 | 51 | this.quartileEvents_ = [ 52 | {event: 'AdImpression', value: 0}, 53 | {event: 'AdStarted', value: 0}, 54 | {event: 'AdVideoFirstQuartile', value: 25}, 55 | {event: 'AdVideoMidpoint', value: 50}, 56 | {event: 'AdVideoThirdQuartile', value: 75}, 57 | {event: 'AdVideoComplete', value: 100} 58 | ]; 59 | 60 | /** 61 | * @type {number} An index into what quartile was last reported. 62 | * @private 63 | */ 64 | this.nextQuartileIndex_ = 0; 65 | 66 | /** 67 | * Parameters passed in from the AdParameters section of the VAST. 68 | * Used for video URL and MIME type. 69 | * 70 | * @type {!object} 71 | * @private 72 | */ 73 | this.parameters_ = {}; 74 | }; 75 | 76 | 77 | /** 78 | * Returns the supported VPAID verion. 79 | * @param {string} version 80 | * @return {string} 81 | */ 82 | VpaidVideoPlayer.prototype.handshakeVersion = function(version) { 83 | return ('2.0'); 84 | }; 85 | 86 | 87 | /** 88 | * Initializes all attributes in the ad. The ad will not start until startAd is\ 89 | * called. 90 | * 91 | * @param {number} width The ad width. 92 | * @param {number} height The ad height. 93 | * @param {string} viewMode The ad view mode. 94 | * @param {number} desiredBitrate The desired bitrate. 95 | * @param {Object} creativeData Data associated with the creative. 96 | * @param {Object} environmentVars Runtime variables associated with the creative like the slot and video slot. 97 | */ 98 | VpaidVideoPlayer.prototype.initAd = function( 99 | width, 100 | height, 101 | viewMode, 102 | desiredBitrate, 103 | creativeData, 104 | environmentVars) { 105 | 106 | this.attributes_['width'] = width; 107 | this.attributes_['height'] = height; 108 | this.attributes_['viewMode'] = viewMode; 109 | this.attributes_['desiredBitrate'] = desiredBitrate; 110 | 111 | // slot and videoSlot are passed as part of the environmentVars 112 | this.slot_ = environmentVars.slot; 113 | this.videoSlot_ = environmentVars.videoSlot; 114 | 115 | // Parse the incoming ad parameters. 116 | this.parameters_ = JSON.parse(creativeData['AdParameters']); 117 | 118 | 119 | this.log('initAd ' + width + 'x' + height + ' ' + viewMode + ' ' + desiredBitrate); 120 | this.updateVideoSlot_(); 121 | this.videoSlot_.addEventListener( 122 | 'timeupdate', 123 | this.timeUpdateHandler_.bind(this), 124 | false); 125 | this.videoSlot_.addEventListener( 126 | 'loadedmetadata', 127 | this.loadedMetadata_.bind(this), 128 | false); 129 | this.videoSlot_.addEventListener( 130 | 'ended', 131 | this.stopAd.bind(this), 132 | false); 133 | this.slot_.addEventListener( 134 | 'click', 135 | this.clickAd_.bind(this), 136 | false); 137 | this.callEvent_('AdLoaded'); 138 | }; 139 | 140 | /** 141 | * Called when the ad is clicked. 142 | * @private 143 | */ 144 | VpaidVideoPlayer.prototype.clickAd_ = function() { 145 | if ('AdClickThru' in this.eventsCallbacks_) { 146 | this.eventsCallbacks_['AdClickThru']('','0', true); 147 | } 148 | }; 149 | 150 | /** 151 | * Called by the video element when video metadata is loaded. 152 | * @private 153 | */ 154 | VpaidVideoPlayer.prototype.loadedMetadata_ = function() { 155 | // The ad duration is not known until the media metadata is loaded. 156 | // Then, update the player with the duration change. 157 | this.attributes_['duration'] = this.videoSlot_.duration; 158 | console.log(this.videoSlot_.duration); 159 | this.callEvent_('AdDurationChange'); 160 | }; 161 | 162 | /** 163 | * Called by the video element when the video reaches specific points during 164 | * playback. 165 | * @private 166 | */ 167 | VpaidVideoPlayer.prototype.timeUpdateHandler_ = function() { 168 | if (this.nextQuartileIndex_ >= this.quartileEvents_.length) { 169 | return; 170 | } 171 | var percentPlayed = this.videoSlot_.currentTime * 100.0 / this.videoSlot_.duration; 172 | if (percentPlayed >= this.quartileEvents_[this.nextQuartileIndex_].value) { 173 | var lastQuartileEvent = this.quartileEvents_[this.nextQuartileIndex_].event; 174 | this.eventsCallbacks_[lastQuartileEvent](); 175 | this.nextQuartileIndex_ += 1; 176 | } 177 | if (this.videoSlot_.duration > 0) { 178 | this.attributes_['remainingTime'] = this.videoSlot_.duration - this.videoSlot_.currentTime; 179 | } 180 | }; 181 | 182 | 183 | /** 184 | * Creates or updates the video slot and fills it with a supported video. 185 | * @private 186 | */ 187 | VpaidVideoPlayer.prototype.updateVideoSlot_ = function() { 188 | if (!this.videoSlot_) { 189 | this.videoSlot_ = document.createElement('video'); 190 | this.videoSlot_.id = 'internal_video'; 191 | this.videoSlot_.style.cssText = 'position:absolute; top:0; z-index:2 !important; '; 192 | this.log('Warning: No video element passed to ad, creating element.'); 193 | this.slot_.appendChild(this.videoSlot_); 194 | } 195 | this.updateVideoPlayerSize_(); 196 | var foundSource = false; 197 | 198 | var videos = this.parameters_.videos || []; 199 | 200 | for (var i = 0; i < videos.length; i++) { 201 | if (this.videoSlot_.canPlayType(videos[0].mimetype) !== '') { 202 | this.videoSlot_.setAttribute('src', videos[0].url); 203 | foundSource = true; 204 | break; 205 | } 206 | } 207 | 208 | if (!foundSource) { 209 | console.log('Unable to find a source video.'); 210 | this.callEvent_('AdError'); 211 | } 212 | 213 | }; 214 | 215 | 216 | /** 217 | * Helper function to update the size of the video player. 218 | * @private 219 | */ 220 | VpaidVideoPlayer.prototype.updateVideoPlayerSize_ = function() { 221 | this.videoSlot_.setAttribute('width', this.attributes_['width']); 222 | this.videoSlot_.setAttribute('height', this.attributes_['height']); 223 | }; 224 | 225 | 226 | 227 | /** 228 | * Called by the wrapper to start the ad. 229 | */ 230 | VpaidVideoPlayer.prototype.startAd = function() { 231 | this.log('Starting ad'); 232 | this.videoSlot_.play(); 233 | this.callEvent_('AdStarted'); 234 | //this.customUI(); 235 | }; 236 | 237 | 238 | /** 239 | * Called by the wrapper to stop the ad. 240 | */ 241 | VpaidVideoPlayer.prototype.stopAd = function() { 242 | this.log('Stopping ad'); 243 | 244 | // Calling AdStopped immediately terminates the ad. Setting a timeout allows 245 | // events to go through. 246 | var callback = this.callEvent_.bind(this); 247 | setTimeout(callback, 1200, ['AdStopped']); 248 | 249 | var internalVideo = parent.document.getElementById('internal_video'); 250 | if (internalVideo) { 251 | internalVideo.style.display = 'none'; 252 | } 253 | }; 254 | 255 | 256 | /** 257 | * Called when the video player changes the width/height of the container. 258 | * 259 | * @param {number} width The new width. 260 | * @param {number} height A new height. 261 | * @param {string} viewMode A new view mode. 262 | */ 263 | VpaidVideoPlayer.prototype.resizeAd = function(width, height, viewMode) { 264 | this.log('resizeAd ' + width + 'x' + height + ' ' + viewMode); 265 | this.attributes_['width'] = width; 266 | this.attributes_['height'] = height; 267 | this.attributes_['viewMode'] = viewMode; 268 | this.updateVideoPlayerSize_(); 269 | this.callEvent_('AdSizeChange'); 270 | }; 271 | 272 | 273 | /** 274 | * Pauses the ad. 275 | */ 276 | VpaidVideoPlayer.prototype.pauseAd = function() { 277 | this.log('pauseAd'); 278 | this.videoSlot_.pause(); 279 | this.callEvent_('AdPaused'); 280 | }; 281 | 282 | 283 | /** 284 | * Resumes the ad. 285 | */ 286 | VpaidVideoPlayer.prototype.resumeAd = function() { 287 | this.log('resumeAd'); 288 | this.videoSlot_.play(); 289 | this.callEvent_('AdPlaying'); 290 | }; 291 | 292 | 293 | /** 294 | * Expands the ad. 295 | */ 296 | VpaidVideoPlayer.prototype.expandAd = function() { 297 | this.log('expandAd'); 298 | this.attributes_['expanded'] = true; 299 | this.callEvent_('AdExpanded'); 300 | }; 301 | 302 | 303 | /** 304 | * Collapses the ad. 305 | */ 306 | VpaidVideoPlayer.prototype.collapseAd = function() { 307 | this.log('collapseAd'); 308 | this.attributes_['expanded'] = false; 309 | }; 310 | 311 | 312 | /** 313 | * Skips the ad. 314 | */ 315 | VpaidVideoPlayer.prototype.skipAd = function() { 316 | this.log('skipAd'); 317 | 318 | var skippableState = this.attributes_['skippableState']; 319 | if (skippableState) { 320 | this.callEvent_('AdSkipped'); 321 | } 322 | }; 323 | 324 | 325 | /** 326 | * Registers a callback for an event. 327 | * 328 | * @param {Function} aCallback The callback function. 329 | * @param {string} eventName The callback type. 330 | * @param {Object} aContext The context for the callback. 331 | */ 332 | VpaidVideoPlayer.prototype.subscribe = function( 333 | aCallback, 334 | eventName, 335 | aContext) { 336 | this.log('Subscribe ' + eventName); 337 | var callBack = aCallback.bind(aContext); 338 | this.eventsCallbacks_[eventName] = callBack; 339 | }; 340 | 341 | 342 | /** 343 | * Removes a callback based on the eventName. 344 | * 345 | * @param {string} eventName The callback type. 346 | */ 347 | VpaidVideoPlayer.prototype.unsubscribe = function(eventName) { 348 | this.log('unsubscribe ' + eventName); 349 | this.eventsCallbacks_[eventName] = null; 350 | }; 351 | 352 | 353 | /** 354 | * Returns whether the ad is linear. 355 | * 356 | * @return {boolean} True if the ad is a linear, false for non linear. 357 | */ 358 | VpaidVideoPlayer.prototype.getAdLinear = function() { 359 | return this.attributes_['linear']; 360 | }; 361 | 362 | /** 363 | * Returns ad width. 364 | * 365 | * @return {number} The ad width. 366 | */ 367 | VpaidVideoPlayer.prototype.getAdWidth = function() { 368 | return this.attributes_['width']; 369 | }; 370 | 371 | 372 | /** 373 | * Returns ad height. 374 | * 375 | * @return {number} The ad height. 376 | */ 377 | VpaidVideoPlayer.prototype.getAdHeight = function() { 378 | return this.attributes_['height']; 379 | }; 380 | 381 | 382 | /** 383 | * Returns true if the ad is expanded. 384 | * 385 | * @return {boolean} 386 | */ 387 | VpaidVideoPlayer.prototype.getAdExpanded = function() { 388 | this.log('getAdExpanded'); 389 | return this.attributes_['expanded']; 390 | }; 391 | 392 | 393 | /** 394 | * Returns the skippable state of the ad. 395 | * 396 | * @return {boolean} 397 | */ 398 | VpaidVideoPlayer.prototype.getAdSkippableState = function() { 399 | this.log('getAdSkippableState'); 400 | return this.attributes_['skippableState']; 401 | }; 402 | 403 | 404 | /** 405 | * Returns the remaining ad time, in seconds. 406 | * 407 | * @return {number} The time remaining in the ad. 408 | */ 409 | VpaidVideoPlayer.prototype.getAdRemainingTime = function() { 410 | return this.attributes_['remainingTime']; 411 | }; 412 | 413 | 414 | /** 415 | * Returns the duration of the ad, in seconds. 416 | * 417 | * @return {number} The duration of the ad. 418 | */ 419 | VpaidVideoPlayer.prototype.getAdDuration = function() { 420 | return this.attributes_['duration']; 421 | }; 422 | 423 | 424 | /** 425 | * Returns the ad volume. 426 | * 427 | * @return {number} The volume of the ad. 428 | */ 429 | VpaidVideoPlayer.prototype.getAdVolume = function() { 430 | this.log('getAdVolume'); 431 | return this.attributes_['volume']; 432 | }; 433 | 434 | 435 | /** 436 | * Sets the ad volume. 437 | * 438 | * @param {number} value The volume in percentage. 439 | */ 440 | VpaidVideoPlayer.prototype.setAdVolume = function(value) { 441 | this.attributes_['volume'] = value; 442 | this.log('setAdVolume ' + value); 443 | this.videoSlot_.volume = value; 444 | this.callEvent_('AdVolumeChange'); 445 | }; 446 | 447 | 448 | /** 449 | * Returns a list of companion ads for the ad. 450 | * 451 | * @return {string} List of companions in VAST XML. 452 | */ 453 | VpaidVideoPlayer.prototype.getAdCompanions = function() { 454 | return this.attributes_['companions']; 455 | }; 456 | 457 | 458 | /** 459 | * Returns a list of icons. 460 | * 461 | * @return {string} A list of icons. 462 | */ 463 | VpaidVideoPlayer.prototype.getAdIcons = function() { 464 | return this.attributes_['icons']; 465 | }; 466 | 467 | 468 | /** 469 | * Logs events and messages. 470 | * 471 | * @param {string} message 472 | */ 473 | VpaidVideoPlayer.prototype.log = function(message) { 474 | console.log(message); 475 | }; 476 | 477 | /** 478 | * Calls an event if there is a callback. 479 | * 480 | * @param {string} eventType 481 | * @private 482 | */ 483 | 484 | VpaidVideoPlayer.prototype.callEvent_ = function(eventType) { 485 | if (eventType in this.eventsCallbacks_) { 486 | this.eventsCallbacks_[eventType](); 487 | } 488 | }; 489 | 490 | 491 | /** 492 | * Main function called by wrapper to get the VPAID ad. 493 | * 494 | * @return {Object} The VPAID compliant ad. 495 | */ 496 | var getVPAIDAd = function() { 497 | return new VpaidVideoPlayer(); 498 | }; 499 | -------------------------------------------------------------------------------- /dev/wrapper.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Player Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 |
16 | 17 | 18 | 19 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /dist/videojsx.vast.css: -------------------------------------------------------------------------------- 1 | /* 2 | Original @ https://github.com/theonion/videojs-vast-plugin (commit bf6ce85fa763299739f6a7c801b5be4b90b3b363) 3 | */ 4 | 5 | .vast-skip-button { 6 | display: block; 7 | position: absolute; 8 | top: 5px; 9 | right: 0; 10 | width: auto; 11 | background-color: #000; 12 | color: #AAA; 13 | font-size: 12px; 14 | font-style: italic; 15 | line-height: 12px; 16 | padding: 10px; 17 | z-index: 2; 18 | } 19 | 20 | .vast-skip-button.enabled { 21 | cursor: pointer; 22 | color: #fff; 23 | } 24 | 25 | .vast-skip-button.enabled:hover { 26 | cursor: pointer; 27 | background: #333; 28 | } 29 | 30 | .vast-remaining-time { 31 | display: block; 32 | position: absolute; 33 | bottom: 35px; 34 | left: 75px; 35 | width: auto; 36 | color: #aaa; 37 | font-size: 12px; 38 | font-style: italic; 39 | line-height: 12px; 40 | z-index: 2; 41 | } 42 | 43 | .vast-remaining-time-icon { 44 | position: absolute; 45 | color: #aaa !important; 46 | font-size: 20px !important; 47 | z-index: 2; 48 | } 49 | 50 | .vast-remaining-time-icon:focus { 51 | text-shadow: 0 0 1em #fff; 52 | } 53 | 54 | .vast-remaining-time-icon-play { 55 | bottom: 30px; 56 | left: 8px; 57 | } 58 | 59 | .vast-remaining-time-icon-mute { 60 | bottom: 30px; 61 | left: 40px; 62 | } 63 | 64 | .vast-blocker { 65 | display: block; 66 | position: absolute; 67 | margin: 0; 68 | padding: 0; 69 | height: 100%; 70 | width: 100%; 71 | top: 0; 72 | left: 0; 73 | right: 0; 74 | bottom: 0; 75 | } 76 | 77 | .vpaid-control-bar { 78 | width: 100%; 79 | position: absolute; 80 | height: 4.0em; 81 | bottom: 0; 82 | left: 0; 83 | z-index: 100; 84 | } 85 | 86 | .vpaid-mute:before { 87 | font-size: 3.0em; 88 | content: "\f104"; 89 | } 90 | 91 | .vpaid-unmute:before { 92 | font-size: 3.0em; 93 | content: "\f107"; 94 | } 95 | 96 | .vpaid-toggle-mute-button { 97 | text-align: center; 98 | position: absolute; 99 | bottom: 0.5em; 100 | right: 0.5em; 101 | cursor: pointer; 102 | } 103 | 104 | .vpaid-icon-placeholder { 105 | font-family: VideoJS; 106 | text-shadow: 0 0 1px black, 0 0 1px black, 0 0 1px black, 0 0 1px black; 107 | font-size: 3.0em; 108 | color: #ffffff; 109 | opacity: 0.3; 110 | } 111 | 112 | .vpaid-icon-placeholder:before { 113 | content: "\f107"; 114 | } 115 | 116 | .mute .vpaid-icon-placeholder:before { 117 | content: "\f104"; 118 | } 119 | 120 | -------------------------------------------------------------------------------- /jest-e2e.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | transform: {}, 3 | verbose: true, 4 | testMatch: ['/test/e2e/**/*.js'], 5 | preset: 'jest-puppeteer', 6 | testTimeout: 10000 7 | }; 8 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | import e2eConfig from './jest-e2e.config.mjs' 2 | 3 | const unitConfig = { 4 | transform: {}, 5 | verbose: true, 6 | testMatch: ['/test/unit/**/*.mjs'], 7 | testEnvironment: 'jest-environment-jsdom', 8 | testEnvironmentOptions: { 9 | html: '', 10 | } 11 | }; 12 | 13 | let config = unitConfig; 14 | 15 | // Make it easy to run any test in WebStorm IDE 16 | const index = process.argv.indexOf('--runTestsByPath'); 17 | if (index > -1) { 18 | const value = process.argv[index + 1]; 19 | if (value && value.includes('test/e2e')) { 20 | config = e2eConfig; 21 | } 22 | } 23 | 24 | export default config; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "videojsx-vast-plugin", 3 | "version": "1.0.0", 4 | "description": "HTML5 video player with VAST support", 5 | "main": "dist/player.js", 6 | "vjsstandard": { 7 | "ignore": [ 8 | "dist", 9 | "test", 10 | "config", 11 | "/*.js", 12 | "dev" 13 | ] 14 | }, 15 | "scripts": { 16 | "build": "webpack --config webpack.prod.js", 17 | "clean": "rm -rf dist", 18 | "lint": "vjsstandard --errors", 19 | "start": "webpack-dev-server --config webpack.dev.js", 20 | "test": "node --experimental-vm-modules node_modules/.bin/jest", 21 | "watch": "webpack --config webpack.watch.js", 22 | "e2e": "node node_modules/.bin/jest --config jest-e2e.config.mjs" 23 | }, 24 | "author": "Philip Watson", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/philipwatson/videojsx-vast-plugin" 28 | }, 29 | "license": "MIT", 30 | "dependencies": { 31 | "@dailymotion/vast-client": "^4.0.1", 32 | "video.js": "^8.6.1", 33 | "videojs-contrib-ads": "^7.3.2", 34 | "vpaid-html5-client": "github:philipwatson/VPAIDHTML5Client#4d78886004aaf474f5fd2864c6f6edfab4a7f15e" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.26.0", 38 | "@babel/preset-env": "^7.26.0", 39 | "@jest/globals": "^29.7.0", 40 | "babel-loader": "^9.1.2", 41 | "compression-webpack-plugin": "^10.0.0", 42 | "cors": "^2.8.5", 43 | "css-loader": "^7.1.2", 44 | "express": "^4.21.2", 45 | "global": "^4.4.0", 46 | "jest": "^29.7.0", 47 | "jest-environment-jsdom": "^29.7.0", 48 | "jest-puppeteer": "^11.0.0", 49 | "mini-css-extract-plugin": "^2.9.2", 50 | "mustache-express": "^1.3.2", 51 | "portfinder": "^1.0.28", 52 | "puppeteer": "^23.11.1", 53 | "style-loader": "^4.0.0", 54 | "terser-webpack-plugin": "^5.3.3", 55 | "videojs-standard": "^9.0.1", 56 | "webpack": "^5.97.0", 57 | "webpack-cli": "^4.10.0", 58 | "webpack-dev-server": "^4.15.2", 59 | "webpack-merge": "^5.8.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ad-loader.mjs: -------------------------------------------------------------------------------- 1 | import window from "global"; 2 | import {companionFn, linearFn} from "./utils.mjs"; 3 | import {VASTClient, VASTParser, VASTTracker} from '@dailymotion/vast-client'; 4 | import {TrackedAd} from "./tracked-ad.mjs"; 5 | 6 | export class AdLoader { 7 | #vastClient 8 | #vastParser 9 | #options 10 | #adSelector; 11 | 12 | /** 13 | * 14 | * @param {VASTClient} vastClient 15 | * @param {VASTParser} vastParser 16 | * @param {AdSelector} adSelector 17 | * @param {object} options 18 | */ 19 | constructor(vastClient, vastParser, adSelector, options) { 20 | this.#vastClient = vastClient; 21 | this.#vastParser = vastParser; 22 | this.#adSelector = adSelector; 23 | this.#options = options; 24 | } 25 | 26 | loadAds(params = {}) { 27 | return new Promise((accept, reject) => { 28 | const {url : urlConfig, xml} = params; 29 | 30 | const urls = (Array.isArray(urlConfig) ? urlConfig : [urlConfig]) 31 | .filter(url => url != null); 32 | 33 | let promise; 34 | if (urls.length) { 35 | promise = Promise.resolve([]); 36 | urls.forEach(url => { 37 | promise = promise.then(ads => { 38 | if (ads == null || ads.length === 0) { 39 | return this.loadAdsWithUrl(url); 40 | } else { 41 | return ads; 42 | } 43 | }).catch(ignore => { 44 | return []; 45 | }); 46 | }); 47 | } else if (xml != null) { 48 | promise = this.loadAdsWithXml(xml); 49 | } else { 50 | throw new Error('xml or url must be set'); 51 | } 52 | 53 | promise.then(accept).catch(reject); 54 | }); 55 | } 56 | 57 | /** 58 | * 59 | * @param {XMLDocument|string} xml 60 | * @return Promise 61 | */ 62 | loadAdsWithXml(xml) { 63 | return new Promise((accept, reject) => { 64 | let xmlDocument; 65 | 66 | if (xml.constructor === window.XMLDocument) { 67 | xmlDocument = xml; 68 | } else if (xml.constructor === String) { 69 | xmlDocument = (new window.DOMParser()).parseFromString(xml, 'application/xml'); 70 | } else { 71 | throw new Error('xml config option must be a String or XMLDocument'); 72 | } 73 | 74 | this.#vastParser 75 | .parseVAST(xmlDocument) 76 | .then(this.#adSelector.selectAds) 77 | .then(this.#createTrackedAds) 78 | .then(accept) 79 | .catch(reject); 80 | }) 81 | } 82 | 83 | loadAdsWithUrl(url) { 84 | return new Promise((accept, reject) => { 85 | this.#vastClient 86 | .get(url, { 87 | withCredentials: this.#options.withCredentials, 88 | wrapperLimit: this.#options.wrapperLimit, 89 | }) 90 | .then(this.#adSelector.selectAds) 91 | .then(this.#createTrackedAds) 92 | .then(accept) 93 | .catch(reject); 94 | }) 95 | } 96 | 97 | /*** private methods ***/ 98 | 99 | #createTrackedAds = ads => { 100 | const createTrackedAd = ad => { 101 | const linearAdTracker = 102 | new VASTTracker(this.#vastClient, ad, ad.creatives.find(linearFn), ad.creatives.find(companionFn)); 103 | 104 | linearAdTracker.on('clickthrough', onClickThrough); 105 | 106 | let companionAdTracker = null; 107 | 108 | const companionCreative = ad.creatives.find(companionFn); 109 | 110 | if (companionCreative) { 111 | // Just pick the first suitable companion ad for now 112 | const options = this.#options; 113 | const variation = companionCreative.variations 114 | .filter(v => v.staticResource) 115 | .filter(v => v.type.indexOf('image') === 0) 116 | .find(v => parseInt(v.width, 10) <= options.companion.maxWidth && parseInt(v.height, 10) <= options.companion.maxHeight); 117 | 118 | if (variation) { 119 | companionAdTracker = new VASTTracker(this.#vastClient, ad, companionCreative, variation); 120 | companionAdTracker.on('clickthrough', onClickThrough); 121 | } 122 | } 123 | 124 | return new TrackedAd(linearAdTracker, companionAdTracker); 125 | } 126 | 127 | return ads.map(createTrackedAd); 128 | } 129 | } 130 | 131 | function onClickThrough(url) { 132 | window.open(url, '_blank'); 133 | } 134 | -------------------------------------------------------------------------------- /src/ad-selector.mjs: -------------------------------------------------------------------------------- 1 | import {linearFn} from "./utils.mjs"; 2 | 3 | export class AdSelector { 4 | /** 5 | * 6 | * @param {object} vastResponse 7 | * @return {object[]} 8 | */ 9 | selectAds(vastResponse) { 10 | if (!vastResponse.ads || vastResponse.ads.length === 0) { 11 | throw new Error('no ads found in VAST'); 12 | } 13 | 14 | const adsWithLinear = vastResponse.ads 15 | .filter(ad => ad.creatives.some(linearFn)); 16 | 17 | if (!adsWithLinear.length) { 18 | throw new Error('no linear ads found in VAST'); 19 | } 20 | 21 | const adPod = adsWithLinear.filter(ad => ad.sequence); 22 | 23 | if (adPod.length) { 24 | return adPod.sort((ad1, ad2) => ad1.sequence - ad2.sequence); 25 | } 26 | else { 27 | const standaloneAds = adsWithLinear.filter(ad => !adPod.includes(ad)); 28 | return standaloneAds.slice(0, 1); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/event.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {VASTTracker} vastTracker 4 | * @return {object|undefined} 5 | */ 6 | export function createVASTContext(vastTracker) { 7 | if (vastTracker) { 8 | const ad = vastTracker.ad; 9 | const creative = vastTracker.creative; 10 | return { 11 | mediaFiles: creative.mediaFiles.map(mediaFile => Object.assign({}, mediaFile)), 12 | adSequenceId: ad.sequence, 13 | adId: ad.id, 14 | creativeAdId: creative.id 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/tracked-ad.mjs: -------------------------------------------------------------------------------- 1 | export class TrackedAd { 2 | #linearAdTracker 3 | #companionTracker 4 | #skipAfterDuration 5 | /** 6 | * 7 | * @param {VASTTracker} linearAdTracker 8 | * @param {VASTTracker} companionTracker 9 | */ 10 | constructor(linearAdTracker, companionTracker) { 11 | this.#linearAdTracker = linearAdTracker; 12 | this.#companionTracker = companionTracker; 13 | this.#skipAfterDuration = false; 14 | } 15 | 16 | get linearCreative() { 17 | return this.#linearAdTracker.creative; 18 | } 19 | 20 | get linearAdTracker() { 21 | return this.#linearAdTracker; 22 | } 23 | 24 | get companionTracker() { 25 | return this.#companionTracker; 26 | } 27 | 28 | /** 29 | * 30 | * @return {boolean} 31 | */ 32 | get skipAfterDuration() { 33 | return this.#skipAfterDuration; 34 | } 35 | 36 | /** 37 | * @param {boolean} value 38 | */ 39 | set skipAfterDuration(value) { 40 | this.#skipAfterDuration = value; 41 | } 42 | 43 | /** 44 | * 45 | * @return {boolean} 46 | */ 47 | hasVideoMedia() { 48 | return this.linearCreative.mediaFiles.some(mediaFile => mediaFile && mediaFile.apiFramework == null); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ui.mjs: -------------------------------------------------------------------------------- 1 | import window from 'global'; 2 | import videojs from 'video.js' 3 | 4 | export class UI extends videojs.EventTarget { 5 | constructor(player, options) { 6 | super(); 7 | this.player = player; 8 | this.options = options; 9 | // duration in seconds. useful for streaming ads where `player.duration()` will always give 0. 10 | this.duration = 0; 11 | this.skipDelay = 0; 12 | 13 | /** @type {Object} */ 14 | this.originalState = { 15 | controlsEnabled: player.controls(), 16 | seekEnabled: player.controlBar.progressControl.enabled() 17 | }; 18 | } 19 | 20 | setUp() { 21 | const player = this.player; 22 | const options = this.options; 23 | 24 | const setupProgressControl = () => { 25 | player.controls(options.controlsEnabled); 26 | if (options.seekEnabled) { 27 | player.controlBar.progressControl.enable(); 28 | } else { 29 | player.controlBar.progressControl.disable(); 30 | } 31 | } 32 | 33 | const setupBlocker = () => { 34 | const blocker = this.blocker = window.document.createElement('div'); 35 | blocker.className = 'vast-blocker'; 36 | blocker.onclick = () => { 37 | if (player.paused()) { 38 | player.play(); 39 | return false; 40 | } 41 | this.trigger('click'); 42 | }; 43 | player.el().insertBefore(blocker, player.controlBar.el()); 44 | } 45 | 46 | const setupSkipButton = () => { 47 | const skipButtonElement = this.skipButtonElement = window.document.createElement('div'); 48 | skipButtonElement.className = 'vast-skip-button'; 49 | skipButtonElement.style.display = 'none'; 50 | player.el().appendChild(skipButtonElement); 51 | 52 | player.one('adplay', this.#onAdPlay); 53 | 54 | skipButtonElement.onclick = (e) => { 55 | if ((' ' + skipButtonElement.className + ' ').indexOf(' enabled ') >= 0) { 56 | this.trigger('skip'); 57 | } 58 | if (window.Event.prototype.stopPropagation !== undefined) { 59 | e.stopPropagation(); 60 | } else { 61 | return false; 62 | } 63 | }; 64 | } 65 | 66 | const setupRemainingTime = () => { 67 | if (!options.displayRemainingTime) return; 68 | 69 | const remainingTimeElement = this.remainingTimeElement = window.document.createElement('div'); 70 | remainingTimeElement.className = 'vast-remaining-time'; 71 | remainingTimeElement.style.display = 'none'; 72 | 73 | player.el().appendChild(remainingTimeElement); 74 | } 75 | 76 | const setupRemainingTimeIcon = (type) => { 77 | if (!options.displayRemainingTimeIcons) return; 78 | 79 | const config = { 80 | play: { 81 | className: 'vjs-icon-play vast-remaining-time-icon-play', 82 | action: (player) => player.paused() ? player.play() : player.pause(), 83 | toggleClasses: ['vjs-icon-pause', 'vjs-icon-play'], 84 | events: ['adplay', 'adpause'], 85 | initialState: (player) => player.paused() ? 'vjs-icon-play' : 'vjs-icon-pause' 86 | }, 87 | mute: { 88 | className: 'vast-remaining-time-icon-mute', 89 | action: (player) => player.muted(!player.muted()), 90 | toggleClasses: ['vjs-icon-volume-high', 'vjs-icon-volume-mute'], 91 | events: ['advolumechange'], 92 | initialState: (player) => player.muted() ? 'vjs-icon-volume-mute' : 'vjs-icon-volume-high' 93 | } 94 | }; 95 | 96 | const { className, action, toggleClasses, events, initialState } = config[type]; 97 | 98 | const button = player.addChild('button', { 99 | className: `vjs-hidden vjs-visible-text vjs-button vast-remaining-time-icon ${className}`, 100 | clickHandler: function() { 101 | action(this.player); 102 | }.bind(this), 103 | }); 104 | 105 | button.removeClass('vjs-control'); 106 | button.addClass(initialState(player)); 107 | 108 | const toggleIcon = () => { 109 | toggleClasses.forEach(cls => button.toggleClass(cls)); 110 | }; 111 | 112 | this[`remainingTime${type.charAt(0).toUpperCase() + type.slice(1)}Element`] = button.el(); 113 | 114 | events.forEach(event => player.on(event, toggleIcon)); 115 | }; 116 | 117 | setupProgressControl(); 118 | setupBlocker(); 119 | setupSkipButton(); 120 | setupRemainingTime(); 121 | setupRemainingTimeIcon('play'); 122 | setupRemainingTimeIcon('mute'); 123 | } 124 | 125 | tearDown() { 126 | const player = this.player; 127 | const originalState = this.originalState; 128 | 129 | this.duration = 0; 130 | this.skipDelay = 0; 131 | 132 | player.controls(originalState.controlsEnabled); 133 | 134 | if (originalState.seekEnabled) { 135 | player.controlBar.progressControl.enable(); 136 | } else { 137 | player.controlBar.progressControl.disable(); 138 | } 139 | 140 | this.blocker.parentElement.removeChild(this.blocker); 141 | this.skipButtonElement.parentElement.removeChild(this.skipButtonElement); 142 | 143 | if (this.options.displayRemainingTime) { 144 | this.remainingTimeElement.parentElement.removeChild(this.remainingTimeElement); 145 | } 146 | 147 | if (this.options.displayRemainingTimeIcons) { 148 | this.remainingTimePlayElement.parentElement.removeChild(this.remainingTimePlayElement); 149 | this.remainingTimeMuteElement.parentElement.removeChild(this.remainingTimeMuteElement); 150 | } 151 | 152 | player.off('adtimeupdate', this.#onTimeUpdate); 153 | player.off('adplay', this.#onAdPlay); 154 | } 155 | 156 | #onAdPlay = () => { 157 | const skipDelay = this.skipDelay; 158 | const player = this.player; 159 | if (skipDelay > 0 && (player.duration() || this.duration) >= skipDelay) { 160 | this.skipButtonElement.style.display = 'block'; 161 | 162 | if (this.options.displayRemainingTime) { 163 | this.remainingTimeElement.style.display = 'block'; 164 | } 165 | 166 | if (this.options.displayRemainingTimeIcons) { 167 | this.remainingTimePlayElement.classList.remove('vjs-hidden'); 168 | this.remainingTimeMuteElement.classList.remove('vjs-hidden'); 169 | } 170 | 171 | player.on('adtimeupdate', this.#onTimeUpdate); 172 | } 173 | player.loadingSpinner.el().style.display = 'none'; 174 | } 175 | 176 | #onTimeUpdate = () => { 177 | this.player.loadingSpinner.el().style.display = 'none'; 178 | 179 | const timeLeft = Math.ceil(this.skipDelay - this.player.currentTime()); 180 | 181 | if (this.options.displayRemainingTime) { 182 | const remainingTimeLeft = Math.ceil(this.player.remainingTime()); 183 | this.remainingTimeElement.innerHTML = this.options.messages.remainingTime.replace('{seconds}', remainingTimeLeft); 184 | } 185 | 186 | if (timeLeft > 0) { 187 | disableSkip(this.skipButtonElement); 188 | this.skipButtonElement.innerHTML = this.options.messages.skipCountdown.replace('{seconds}', timeLeft); 189 | } else { 190 | enableSkip(this.skipButtonElement); 191 | this.skipButtonElement.innerHTML = this.options.messages.skip; 192 | } 193 | } 194 | } 195 | 196 | /** 197 | * 198 | * @param {HTMLElement} skipButtonElement 199 | */ 200 | function isSkipEnabled(skipButtonElement) { 201 | return (' ' + skipButtonElement.className + ' ').indexOf(' enabled ') > -1; 202 | } 203 | 204 | /** 205 | * 206 | * @param {HTMLElement} skipButtonElement 207 | */ 208 | function disableSkip(skipButtonElement) { 209 | if (isSkipEnabled(skipButtonElement)) { 210 | skipButtonElement.className = 211 | skipButtonElement.className.replace(' enabled ', ''); 212 | } 213 | } 214 | 215 | /** 216 | * 217 | * @param {HTMLElement} skipButtonElement 218 | */ 219 | function enableSkip(skipButtonElement) { 220 | if (!isSkipEnabled(skipButtonElement)) { 221 | skipButtonElement.className += ' enabled '; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/utils.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {function} fn 4 | * @param {object|null} context 5 | * @return {function(): *} 6 | */ 7 | export function once(fn, context = null) { 8 | let result; 9 | return function () { 10 | if (fn) { 11 | result = fn.apply(context || this, arguments); 12 | fn = null; 13 | } 14 | return result; 15 | }; 16 | } 17 | 18 | export function linearFn(creative) { 19 | return creative.type === 'linear' && creative.mediaFiles.length; 20 | } 21 | 22 | export function companionFn(creative) { 23 | return creative.type === 'companion'; 24 | } 25 | 26 | export function cloneJson(obj) { 27 | return JSON.parse(JSON.stringify(obj)); 28 | } 29 | 30 | export function convertOffsetToSeconds (offsetCode, duration = null) { 31 | let result = null; 32 | if (typeof offsetCode === 'string') { 33 | if (offsetCode.includes('%')) { 34 | if (duration != null) { 35 | const percent = offsetCode.replace('%', ''); 36 | result = percent / 100 * duration; 37 | } 38 | } else if (offsetCode.includes(':')) { 39 | const [hours, minutes, seconds] = offsetCode.split(':').slice(-3); 40 | result = parseInt(hours || 0, 10) * 3600 + parseInt(minutes || 0, 10) * 60 + parseInt(seconds || 0, 10); 41 | } else { 42 | result = parseInt(offsetCode) 43 | } 44 | } 45 | 46 | if (result == null) { 47 | result = Number(offsetCode); 48 | } 49 | 50 | return isNaN(result) ? null : result; 51 | } 52 | 53 | export function isString(str) { 54 | return typeof str === 'string'; 55 | } 56 | 57 | export function isNullishOrBlankString(str) { 58 | return str == null || (isString(str) && str.trim().length === 0); 59 | } 60 | -------------------------------------------------------------------------------- /src/vast-player.css: -------------------------------------------------------------------------------- 1 | /* 2 | Original @ https://github.com/theonion/videojs-vast-plugin (commit bf6ce85fa763299739f6a7c801b5be4b90b3b363) 3 | */ 4 | 5 | .vast-skip-button { 6 | display: block; 7 | position: absolute; 8 | top: 5px; 9 | right: 0; 10 | width: auto; 11 | background-color: #000; 12 | color: #AAA; 13 | font-size: 12px; 14 | font-style: italic; 15 | line-height: 12px; 16 | padding: 10px; 17 | z-index: 2; 18 | } 19 | 20 | .vast-skip-button.enabled { 21 | cursor: pointer; 22 | color: #fff; 23 | } 24 | 25 | .vast-skip-button.enabled:hover { 26 | cursor: pointer; 27 | background: #333; 28 | } 29 | 30 | .vast-remaining-time { 31 | display: block; 32 | position: absolute; 33 | bottom: 35px; 34 | left: 75px; 35 | width: auto; 36 | color: #aaa; 37 | font-size: 12px; 38 | font-style: italic; 39 | line-height: 12px; 40 | z-index: 2; 41 | } 42 | 43 | .vast-remaining-time-icon { 44 | position: absolute; 45 | color: #aaa !important; 46 | font-size: 20px !important; 47 | z-index: 2; 48 | } 49 | 50 | .vast-remaining-time-icon:focus { 51 | text-shadow: 0 0 1em #fff; 52 | } 53 | 54 | .vast-remaining-time-icon-play { 55 | bottom: 30px; 56 | left: 8px; 57 | } 58 | 59 | .vast-remaining-time-icon-mute { 60 | bottom: 30px; 61 | left: 40px; 62 | } 63 | 64 | .vast-blocker { 65 | display: block; 66 | position: absolute; 67 | margin: 0; 68 | padding: 0; 69 | height: 100%; 70 | width: 100%; 71 | top: 0; 72 | left: 0; 73 | right: 0; 74 | bottom: 0; 75 | } 76 | 77 | .vpaid-control-bar { 78 | width: 100%; 79 | position: absolute; 80 | height: 4.0em; 81 | bottom: 0; 82 | left: 0; 83 | z-index: 100; 84 | } 85 | 86 | .vpaid-mute:before { 87 | font-size: 3.0em; 88 | content: "\f104"; 89 | } 90 | 91 | .vpaid-unmute:before { 92 | font-size: 3.0em; 93 | content: "\f107"; 94 | } 95 | 96 | .vpaid-toggle-mute-button { 97 | text-align: center; 98 | position: absolute; 99 | bottom: 0.5em; 100 | right: 0.5em; 101 | cursor: pointer; 102 | } 103 | 104 | .vpaid-icon-placeholder { 105 | font-family: VideoJS; 106 | text-shadow: 0 0 1px black, 0 0 1px black, 0 0 1px black, 0 0 1px black; 107 | font-size: 3.0em; 108 | color: #ffffff; 109 | opacity: 0.3; 110 | } 111 | 112 | .vpaid-icon-placeholder:before { 113 | content: "\f107"; 114 | } 115 | 116 | .mute .vpaid-icon-placeholder:before { 117 | content: "\f104"; 118 | } 119 | -------------------------------------------------------------------------------- /src/vast-player.mjs: -------------------------------------------------------------------------------- 1 | import 'video.js/dist/video-js.css'; 2 | import 'vast-player.css'; 3 | 4 | import videojs from 'video.js'; 5 | import 'videojs-contrib-ads'; 6 | import 'vast-plugin.mjs'; 7 | 8 | // eslint-disable-next-line no-undef 9 | window.videojs = videojs; 10 | -------------------------------------------------------------------------------- /src/vast-plugin.mjs: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import {VASTClient, VASTParser} from '@dailymotion/vast-client'; 3 | import document from 'global/document.js'; 4 | import {UI} from './ui.mjs'; 5 | import {AdLoader} from './ad-loader.mjs'; 6 | import {AdSelector} from './ad-selector.mjs'; 7 | import {VPAIDHandler} from './vpaid-handler.mjs'; 8 | import {createVASTContext} from "./event.mjs"; 9 | import {once, cloneJson, convertOffsetToSeconds} from "./utils.mjs"; 10 | 11 | const Plugin = videojs.getPlugin('plugin'); 12 | 13 | const DEFAULT_OPTIONS = Object.freeze({ 14 | seekEnabled: false, 15 | controlsEnabled: false, 16 | wrapperLimit: 10, 17 | withCredentials: true, 18 | skip: 0, 19 | displayRemainingTime: false, 20 | displayRemainingTimeIcons: false, 21 | messages: { 22 | skip: 'Skip', 23 | skipCountdown: 'Skip in {seconds}...', 24 | remainingTime: 'This ad will end in {seconds}', 25 | }, 26 | vpaid: { 27 | containerClass: 'vjs-vpaid-container', 28 | videoInstance: 'none' 29 | }, 30 | companion: { 31 | elementId: null, 32 | maxWidth: 0, 33 | maxHeight: 0 34 | }, 35 | honorSkipOffset: false, 36 | }); 37 | 38 | /** 39 | * VastPlugin 40 | */ 41 | export class VastPlugin extends Plugin { 42 | 43 | /** 44 | * Constructor 45 | * 46 | * @param {Object} player The videojs object 47 | * @param {Object} options Plugin config 48 | */ 49 | constructor(player, options) { 50 | super(player); 51 | // Could be initialized already by user 52 | if (typeof player.ads === 'function') { 53 | player.ads({debug: false, liveCuePoints: false}); 54 | } 55 | 56 | player.on('play', function() { 57 | console.log('play event triggered'); 58 | }); 59 | 60 | console.log(`videojsx-vast-plugin running`); 61 | 62 | const mergeOptionsFunction = parseInt(videojs.VERSION, 10) >= 8 ? videojs.obj.merge : videojs.mergeOptions; 63 | options = mergeOptionsFunction(DEFAULT_OPTIONS, options || {}); 64 | 65 | /** @type {VASTClient} */ 66 | const vastClient = new VASTClient(); 67 | /** @type {TrackedAd[]} */ 68 | const ads = []; 69 | /** @type {TrackedAd|null} */ 70 | let currentAd = null; 71 | /** @type {Number} */ 72 | let adCount = 0; 73 | /** @type {Number} */ 74 | let adTotal = 0; 75 | /** @type {VPAIDHandler} */ 76 | const vpaidHandler = new VPAIDHandler(player, options); 77 | /** @type {boolean} */ 78 | let timedOut = false; 79 | 80 | let schedule = options.schedule; 81 | if (schedule == null || schedule.length === 0) { 82 | schedule = [ 83 | { 84 | offset: 0, 85 | url: options.url || null, 86 | xml: options.xml || null 87 | } 88 | ] 89 | } else { 90 | schedule = cloneJson(schedule); 91 | schedule.forEach(item => delete item.offsetInSeconds); 92 | } 93 | 94 | const preRollScheduleItem = findFirstPreroll(schedule); 95 | const postRollScheduleItem = findFirstPostroll(schedule); 96 | const midRollScheduleItems = findAllMidrolls(schedule).sort((a, b) => a.offset - b.offset); 97 | 98 | const autoplay = player.autoplay(); 99 | 100 | player.on('adtimeout', () => { 101 | // failed to show an ad on time when in the "play" state 102 | timedOut = true; 103 | }); 104 | 105 | const ui = new UI(player, options); 106 | 107 | function skip () { 108 | if (currentAd?.hasVideoMedia()) { 109 | currentAd.linearAdTracker.skip(); 110 | player.trigger({ 111 | type: 'vast.skipAd', 112 | vast: createVASTContext(currentAd.linearAdTracker) 113 | }); 114 | playNextAd(); 115 | } 116 | } 117 | 118 | ui.on('skip', skip); 119 | ui.on('click', () => { 120 | currentAd.linearAdTracker.click(); 121 | }); 122 | 123 | const onTimeUpdate = (() => { 124 | let lock = false; 125 | return () => { 126 | if (lock) return; 127 | 128 | let offsetInSeconds = null; 129 | while (midRollScheduleItems.length > 0) { 130 | offsetInSeconds = midRollScheduleItems[0].offsetInSeconds; 131 | if (offsetInSeconds != null) { 132 | break; 133 | } 134 | const {offset} = midRollScheduleItems[0]; 135 | offsetInSeconds = convertOffsetToSeconds(offset, player.duration()); 136 | if (offsetInSeconds == null) { 137 | midRollScheduleItems.shift(); 138 | } else { 139 | midRollScheduleItems[0].offsetInSeconds = offsetInSeconds; 140 | } 141 | } 142 | 143 | if (offsetInSeconds != null) { 144 | if (this.player.currentTime() > offsetInSeconds) { 145 | lock = true; 146 | const scheduleItem = midRollScheduleItems.shift(); 147 | adLoader.loadAds(scheduleItem) 148 | .then(trackedAds => { 149 | if (trackedAds.length > 0) { 150 | ads.push(...trackedAds); 151 | currentAd = null; 152 | startAdBreak(); 153 | } 154 | }) 155 | .catch(err => { 156 | // eslint-disable-next-line no-console 157 | console.log(`An error occurred when loading ads for the midroll ad break: : ${err?.message}`); 158 | }) 159 | .finally(() => { 160 | lock = false; 161 | }); 162 | } 163 | } 164 | } 165 | })(); 166 | 167 | if (midRollScheduleItems.length > 0) { 168 | player.on('timeupdate', onTimeUpdate); 169 | } 170 | 171 | player.on('readyforpostroll', () => { 172 | timedOut = false; 173 | adLoader.loadAds(postRollScheduleItem) 174 | .then(trackedAds => { 175 | if (timedOut) { 176 | trackedAds.forEach(ad => { 177 | ad.linearAdTracker.error({ 178 | ERRORCODE: 301 // VAST redirect timeout reached 179 | }); 180 | }) 181 | } 182 | else if (trackedAds.length > 0) { 183 | ads.push(...trackedAds); 184 | currentAd = null; 185 | startAdBreak(); 186 | } 187 | else { 188 | player.trigger('nopostroll'); 189 | } 190 | }) 191 | .catch(err => { 192 | // eslint-disable-next-line no-console 193 | console.log(`An error occurred when loading ads for the postroll ad break: : ${err.message}`); 194 | player.trigger('nopostroll'); 195 | }) 196 | }); 197 | 198 | player.on('readyforpreroll', () => { 199 | startAdBreak(); 200 | }); 201 | 202 | const signalAdsReady = once(() => { 203 | // Can only signal 'adsready' in Preroll and BeforePreroll states (in videojs-contrib-ads). 204 | // So we need to signal even when we have no pre-rolls - because we may get mid or post rolls later. 205 | player.trigger('adsready'); 206 | }) 207 | 208 | // TODO: calculate reasonable timeout based on contrib-ads settings 209 | setTimeout(signalAdsReady, 3000); 210 | 211 | const adLoader = new AdLoader(vastClient, new VASTParser(), new AdSelector(), options); 212 | adLoader.loadAds(preRollScheduleItem) 213 | .then(trackedAds => { 214 | if (timedOut) { 215 | trackedAds.forEach(ad => { 216 | ad.linearAdTracker.error({ 217 | ERRORCODE: 301 // VAST redirect timeout reached 218 | }); 219 | }) 220 | } 221 | else if (trackedAds.length > 0) { 222 | ads.push(...trackedAds); 223 | currentAd = null; 224 | // do not start ad break here 225 | } 226 | else { 227 | player.trigger('nopreroll'); 228 | } 229 | }) 230 | .catch(err => { 231 | // eslint-disable-next-line no-console 232 | console.log(`An error occurred when loading ads for the preroll ad break: ${err.message}`); 233 | player.trigger('nopreroll'); 234 | }) 235 | .finally(() => { 236 | signalAdsReady(); 237 | if (autoplay) { 238 | player.play(); 239 | } 240 | }); 241 | 242 | /** 243 | * Create Source Objects 244 | * 245 | * @param {Array} mediaFiles Array of media files 246 | * @return {Array} Array of source objects 247 | */ 248 | const createSourceObjects = (mediaFiles) => { 249 | return mediaFiles 250 | .filter(mediaFile => mediaFile.apiFramework == null) 251 | .map(mediaFile => ({type: mediaFile.mimeType, src: mediaFile.fileURL})); 252 | } 253 | 254 | const playNextAd = () => { 255 | const nextAd = ads.shift(); 256 | 257 | // do not change ui for vpaid 258 | if (nextAd?.hasVideoMedia()) { 259 | if (!currentAd?.hasVideoMedia()) { 260 | ui.setUp(); 261 | } 262 | } else { 263 | if (currentAd?.hasVideoMedia()) { 264 | ui.tearDown(); 265 | } 266 | } 267 | 268 | if (nextAd) { 269 | currentAd = nextAd; 270 | adCount++; 271 | console.log(`Playing ad ${adCount}/${adTotal}`); 272 | 273 | if (currentAd.hasVideoMedia()) { 274 | const allMediaFiles = currentAd.linearCreative.mediaFiles; 275 | 276 | const streamingMediaFiles = allMediaFiles 277 | .filter(mediaFile => mediaFile.deliveryType === 'streaming') 278 | 279 | const nonStreamingMediaFiles = allMediaFiles 280 | .filter(mediaFile => mediaFile.deliveryType !== 'streaming'); 281 | 282 | if (nonStreamingMediaFiles.length > 0) { 283 | player.src(createSourceObjects(nonStreamingMediaFiles)); 284 | } 285 | else if (streamingMediaFiles.length > 0) { 286 | let assetDuration = currentAd.linearAdTracker.assetDuration; 287 | if (assetDuration == null || assetDuration < 1) { 288 | console.log('Streaming ads must have a duration'); 289 | playNextAd(); 290 | return; 291 | } 292 | player.src(createSourceObjects(streamingMediaFiles)); 293 | currentAd.skipAfterDuration = true; 294 | } 295 | ui.duration = currentAd.linearAdTracker.assetDuration || 0; 296 | const skipDelay = currentAd.linearAdTracker.skipDelay; 297 | ui.skipDelay = skipDelay != null && options.honorSkipOffset ? skipDelay : options.skip; 298 | } else { 299 | vpaidHandler.handle(currentAd.linearAdTracker) 300 | .then(() => { 301 | playNextAd(); 302 | }) 303 | .catch(err => { 304 | console.log(err); 305 | playNextAd(); 306 | }); 307 | } 308 | showCompanionAd(); 309 | } else { 310 | currentAd = null; 311 | adCount = 0; 312 | endAdBreak(); 313 | } 314 | } 315 | 316 | const {setUpEvents, tearDownEvents} = (() => { 317 | const adPlayFn = () => { 318 | if (currentAd.skipAfterDuration) { 319 | const ad = currentAd; 320 | setTimeout(() => { 321 | if (currentAd === ad) { 322 | skip(); 323 | } 324 | }, ad.linearAdTracker.assetDuration * 1000); 325 | } 326 | 327 | if (!currentAd.linearAdTracker.impressed && currentAd.hasVideoMedia()) { 328 | currentAd.linearAdTracker.trackImpression(); 329 | player.trigger({ 330 | type: 'vast.adStart', 331 | vast: createVASTContext(currentAd.linearAdTracker) 332 | }); 333 | } 334 | }; 335 | 336 | const timeupdateFn = () => { 337 | if (currentAd) { 338 | if (isNaN(currentAd.linearAdTracker.assetDuration)) { 339 | currentAd.linearAdTracker.assetDuration = player.duration(); 340 | } 341 | currentAd.linearAdTracker.setProgress(player.currentTime()); 342 | } 343 | }; 344 | 345 | const pauseFn = () => { 346 | if (player.remainingTime() > 0) { 347 | currentAd.linearAdTracker.setPaused(true); 348 | player.one('adplay', () => { 349 | currentAd.linearAdTracker.setPaused(false); 350 | }); 351 | } 352 | }; 353 | 354 | // args: err 355 | const adErrorFn = () => { 356 | const MEDIAFILE_PLAYBACK_ERROR = 405; 357 | currentAd.linearAdTracker.error({ERRORCODE: MEDIAFILE_PLAYBACK_ERROR}); 358 | // Do not want to show VAST related errors to the user 359 | player.error(null); 360 | playNextAd(); 361 | }; 362 | 363 | const fullScreenFn = () => { 364 | // for 'fullscreen' & 'exitfullscreen' 365 | currentAd.linearAdTracker.setFullscreen(player.isFullscreen()); 366 | }; 367 | 368 | const muteFn = (() => { 369 | let previousMuted = player.muted(); 370 | let previousVolume = player.volume(); 371 | 372 | return () => { 373 | const volumeNow = player.volume(); 374 | const mutedNow = player.muted(); 375 | 376 | if (previousMuted !== mutedNow) { 377 | currentAd.linearAdTracker.setMuted(mutedNow); 378 | previousMuted = mutedNow; 379 | } else if (previousVolume !== volumeNow) { 380 | if (previousVolume > 0 && volumeNow === 0) { 381 | currentAd.linearAdTracker.setMuted(true); 382 | } else if (previousVolume === 0 && volumeNow > 0) { 383 | currentAd.linearAdTracker.setMuted(false); 384 | } 385 | 386 | previousVolume = volumeNow; 387 | } 388 | }; 389 | })(); 390 | 391 | const adEndedFn = () => { 392 | // Ad ended, not skipped 393 | if (currentAd.hasVideoMedia()) { 394 | currentAd.linearAdTracker.complete(); 395 | player.trigger({ 396 | type: 'vast.adEnd', 397 | vast: createVASTContext(currentAd.linearAdTracker) 398 | }); 399 | playNextAd(); 400 | } 401 | }; 402 | 403 | return { 404 | setUpEvents: () => { 405 | player.on('adended', adEndedFn); 406 | player.on('adplay', adPlayFn); 407 | player.on('adtimeupdate', timeupdateFn); 408 | player.on('adpause', pauseFn); 409 | player.on('aderror', adErrorFn); 410 | player.on('advolumechange', muteFn); 411 | player.on('fullscreenchange', fullScreenFn); 412 | }, 413 | tearDownEvents: () => { 414 | player.off('adended', adEndedFn); 415 | player.off('adplay', adPlayFn); 416 | player.off('adtimeupdate', timeupdateFn); 417 | player.off('adpause', pauseFn); 418 | player.off('aderror', adErrorFn); 419 | player.off('advolumechange', muteFn); 420 | player.off('fullscreenchange', fullScreenFn); 421 | } 422 | } 423 | })(); 424 | 425 | const showCompanionAd = () => { 426 | const companionTracker = currentAd.companionTracker; 427 | const dest = document.getElementById(options.companion.elementId); 428 | 429 | if (companionTracker && companionTracker.variation && dest) { 430 | const variation = companionTracker.variation; 431 | 432 | const onClick = () => { 433 | companionTracker.click(); 434 | }; 435 | 436 | const hyperLink = document.createElement('a'); 437 | 438 | hyperLink.src = '#'; 439 | hyperLink.addEventListener('click', onClick); 440 | 441 | const image = document.createElement('img'); 442 | 443 | image.src = variation.staticResource; 444 | 445 | hyperLink.appendChild(image); 446 | 447 | dest.innerHTML = ''; 448 | dest.appendChild(hyperLink); 449 | } else if (dest) { 450 | // TODO: option to remove last companion ad when content plays? 451 | dest.innerHTML = ''; 452 | } 453 | } 454 | 455 | const startAdBreak = () => { 456 | adTotal = ads.length; 457 | console.log(`Playing ${adTotal} ads`); 458 | player.ads.startLinearAdMode(); 459 | setUpEvents(); 460 | playNextAd(); 461 | } 462 | 463 | function isPreroll(scheduleItem) { 464 | return scheduleItem.offset === 0 || scheduleItem.offset == null || scheduleItem.offset === 'pre' 465 | } 466 | 467 | function isPostroll(scheduleItem) { 468 | return scheduleItem.offset === 'post'; 469 | } 470 | 471 | function findFirstPreroll(schedule) { 472 | return schedule.find(isPreroll); 473 | } 474 | 475 | function findFirstPostroll(schedule) { 476 | return schedule.find(isPostroll); 477 | } 478 | 479 | function findAllMidrolls(schedule) { 480 | return schedule.filter(item => !isPreroll(item) && !isPostroll(item)); 481 | } 482 | 483 | const endAdBreak = () => { 484 | player.ads.endLinearAdMode(); 485 | tearDownEvents(); 486 | console.log('Playing content'); 487 | } 488 | } 489 | } 490 | 491 | videojs.registerPlugin('vast', VastPlugin); 492 | -------------------------------------------------------------------------------- /src/vpaid-handler.mjs: -------------------------------------------------------------------------------- 1 | import VPAIDHTML5Client from 'vpaid-html5-client'; 2 | import window from 'global/window.js'; 3 | import document from 'global/document.js'; 4 | import {isNullishOrBlankString, once} from './utils.mjs'; 5 | import {createVASTContext} from "./event.mjs"; 6 | 7 | const VALID_TYPES = ['application/x-javascript', 'text/javascript', 'application/javascript']; 8 | 9 | export class VPAIDHandler { 10 | #forceStopDone 11 | #cancelled 12 | #started 13 | #player 14 | #options 15 | #eventTarget 16 | #volume 17 | #muted 18 | #controlBar 19 | 20 | constructor(player, options) { 21 | this.#player = player; 22 | this.#options = options; 23 | this.#eventTarget = new videojs.EventTarget(); 24 | } 25 | 26 | handle(tracker) { 27 | this.#cancelled = false; 28 | this.#started = false 29 | this.#forceStopDone = false; 30 | 31 | this.setVolume(this.#player.volume()); 32 | 33 | return new Promise((resolve, reject) => { 34 | const options = this.#options; 35 | const player = this.#player; 36 | /** 37 | * 38 | * @type {HTMLElement|null} 39 | */ 40 | let container = null; 41 | 42 | /** 43 | * "timeout" | Error 44 | * @param {string|Error} err 45 | * @param adUnit 46 | */ 47 | const adUnitLoad = (err, adUnit) => { 48 | let videoElement; 49 | 50 | if (err) { 51 | reject(err); 52 | return; 53 | } 54 | 55 | const onAdComplete = () => { 56 | cleanUp(); 57 | resolve(); 58 | player.trigger('vpaid.AdStopped'); 59 | player.trigger({ 60 | type: 'vast.adEnd', 61 | vast: createVASTContext(tracker) 62 | }) 63 | }; 64 | 65 | adUnit.subscribe('AdStopped', onAdComplete); 66 | 67 | const forceStopAd = err => { 68 | if (adUnit && !this.#forceStopDone) { 69 | adUnit.unsubscribe('AdStopped', onAdComplete); 70 | const onAdCancel = () => { 71 | this.#forceStopDone = true; 72 | cleanUp(); 73 | reject(err); 74 | player.trigger('vpaid.AdStopped'); 75 | }; 76 | subscribeWithTimeout(adUnit, 'AdStopped', onAdCancel, onAdCancel); 77 | adUnit.stopAd(); 78 | } 79 | else { 80 | this.#forceStopDone = true; 81 | reject(err); 82 | } 83 | } 84 | 85 | this.#eventTarget.on('forceStopAd', forceStopAd); 86 | 87 | if (this.#cancelled) { 88 | forceStopAd('Received cancel signal from player'); 89 | return; 90 | } 91 | 92 | const cleanUp = () => { 93 | this.removeMuteControl(); 94 | player.controlBar.show(); 95 | 96 | player.off('playerresize', resizeAd); 97 | 98 | vpaidClient.destroy(); 99 | 100 | if (container) { 101 | container.parentElement.removeChild(container); 102 | container = null; 103 | } 104 | } 105 | 106 | const onHandShake = (error, version) => { 107 | if (error) { 108 | log.console(error); 109 | forceStopAd('Error on VPAID handshake'); 110 | return; 111 | } 112 | 113 | const creativeData = { 114 | AdParameters: creative.adParameters || '' 115 | }; 116 | 117 | /* 118 | NOTE: 'slot' and 'videoSlot' are handled by the VPAIDHTML5Client. 119 | We can provide 'videoSlot' via the VPAIDHTML5Client constructor. But not the 'slot'. 120 | The 'slot' will be created by the VPAIDHTML5Client (
). 121 | This 'slot' is not the same as the VPAID container. The VPAID container sits within 122 | video.js elements (at time of writing, before the control bar) and will house the VPAID 123 | friendly iframe (with a parent div). So if the VPAID container is: 124 |
125 | then it'll look like: 126 |
127 | */ 128 | const environmentVars = { 129 | // WARNING: do not add the 'slot' or 'videoSlot' here! Read comment above. 130 | // videoSlotCanAutoPlay: true 131 | }; 132 | 133 | subscribeWithTimeout(adUnit, 'AdLoaded', onAdLoaded, forceStopAd); 134 | 135 | const viewMode = player.isFullscreen() ? 'fullscreen' : 'normal'; 136 | 137 | adUnit.subscribe('AdError', message => { 138 | // General VPAID Error = 901 (in VAST 3 spec) 139 | tracker.error({ERRORCODE: 901}); 140 | this.#forceStopDone = true; 141 | cleanUp(); 142 | reject(`Fatal VPAID Error: ${typeof message === 'object' ? JSON.stringify(message) : message}`); 143 | player.trigger({type: 'vpaid.AdError', error: message}); 144 | }); 145 | 146 | adUnit.initAd(player.currentWidth(), player.currentHeight(), viewMode, -1, creativeData, environmentVars); 147 | } 148 | 149 | const onAdLoaded = () => { 150 | if (this.#cancelled) { 151 | forceStopAd('Received cancel signal'); 152 | return; 153 | } 154 | 155 | adUnit.subscribe('AdSkipped', () => { 156 | tracker.skip(); 157 | player.trigger('vpaid.AdSkipped'); 158 | player.trigger({ 159 | type: 'vast.adSkip', 160 | vast: createVASTContext(tracker) 161 | }) 162 | }); 163 | 164 | adUnit.subscribe('AdVolumeChange', () => { 165 | adUnit.getAdVolume((error, currentVolume) => { 166 | if (error) return; 167 | const lastVolume = this.#volume 168 | 169 | if (currentVolume === 0 && lastVolume > 0) { 170 | tracker.setMuted(true); 171 | } else if (currentVolume > 0 && lastVolume === 0) { 172 | tracker.setMuted(false); 173 | } 174 | 175 | this.setVolume(currentVolume); 176 | 177 | // keep our player in sync 178 | player.volume(currentVolume); 179 | 180 | player.trigger('vpaid.AdVolumeChange'); 181 | }); 182 | }); 183 | 184 | adUnit.subscribe('AdImpression', () => { 185 | // will also trigger createView 186 | tracker.trackImpression(); 187 | player.trigger('vpaid.AdImpression'); 188 | }); 189 | 190 | 191 | adUnit.subscribe('AdClickThru', 192 | /** 193 | * 194 | * @param {string} url 195 | * @param {string} id 196 | * @param {boolean} playerHandles 197 | */ 198 | ({url, id, playerHandles}) => { 199 | // We don't want our default for VPAID; there are rules (VPAID 2, section 2.5.4). 200 | tracker.removeAllListeners('clickthrough'); 201 | 202 | const haveUrl = !isNullishOrBlankString(url); 203 | 204 | if (playerHandles && !haveUrl) { 205 | tracker.once('clickthrough', resolvedUrl => { 206 | window.open(resolvedUrl, '_blank'); 207 | }); 208 | } 209 | 210 | // Trigger click events. Then run the registered the 'clickthrough' listeners 211 | // if the URL is resolved from the VAST. 212 | tracker.click(); 213 | 214 | // Best to open after triggering click events. 215 | if (playerHandles && haveUrl) { 216 | window.open(url, '_blank'); 217 | } 218 | 219 | player.trigger('vpaid.AdClickThru'); 220 | } 221 | ); 222 | 223 | adUnit.subscribe('AdVideoFirstQuartile', () => { 224 | tracker.track('firstQuartile'); 225 | player.trigger('vpaid.AdVideoFirstQuartile'); 226 | }); 227 | 228 | adUnit.subscribe('AdVideoMidpoint', () => { 229 | tracker.track('midpoint'); 230 | player.trigger('vpaid.AdVideoMidpoint'); 231 | }); 232 | 233 | adUnit.subscribe('AdVideoThirdQuartile', () => { 234 | tracker.track('thirdQuartile'); 235 | player.trigger('vpaid.AdVideoThirdQuartile'); 236 | }); 237 | 238 | adUnit.subscribe('AdVideoComplete', () => { 239 | tracker.track('complete'); 240 | player.trigger('vpaid.AdVideoComplete'); 241 | }); 242 | 243 | adUnit.subscribe('AdUserAcceptInvitation', () => { 244 | tracker.acceptInvitation(); 245 | player.trigger('vpaid.AdUserAcceptInvitation'); 246 | }); 247 | 248 | adUnit.subscribe('AdUserMinimize', () => { 249 | tracker.minimize(); 250 | player.trigger('vpaid.AdUserMinimize'); 251 | }); 252 | 253 | adUnit.subscribe('AdUserClose', () => { 254 | tracker.close(); 255 | player.trigger('vpaid.AdUserClose'); 256 | }); 257 | 258 | adUnit.subscribe('AdPaused', () => { 259 | tracker.setPaused(true); 260 | player.trigger('vpaid.AdPaused'); 261 | }); 262 | 263 | adUnit.subscribe('AdPlaying', () => { 264 | tracker.setPaused(false); 265 | player.trigger('vpaid.AdPlaying'); 266 | }); 267 | 268 | adUnit.getAdLinear(withTimeout((err, isLinear) => { 269 | if (this.#cancelled) { 270 | forceStopAd('Received cancel signal'); 271 | return; 272 | } 273 | 274 | if (err) { 275 | forceStopAd(err); 276 | } else if (!isLinear) { 277 | // TODO: support overlay banner 278 | forceStopAd('Non-linear not supported') 279 | } else { 280 | startLinearAd(); 281 | } 282 | }, 283 | () => { 284 | forceStopAd('Unable to get mode of operation: linear or non-linear'); 285 | })); 286 | 287 | const startLinearAd = () => { 288 | player.controlBar.hide(); 289 | 290 | if (options.vpaid.enableToggleMute) { 291 | this.addMuteControl(adUnit); 292 | } 293 | 294 | // A VPAID adunit may (incorrectly?) call AdStarted again for the first quartile event 295 | const onAdStartedOnce = once(onAdStarted); 296 | subscribeWithTimeout(adUnit, 'AdStarted', onAdStartedOnce, forceStopAd); 297 | adUnit.startAd(); 298 | } 299 | } 300 | 301 | const onAdStarted = () => { 302 | if (!this.#cancelled) { 303 | this.#started = true 304 | tracker.track('start'); 305 | player.on('playerresize', resizeAd); 306 | player.trigger('ads-ad-started'); // notify videojs-contrib-ads 307 | player.trigger({ 308 | type: 'vast.adStart', 309 | vast: createVASTContext(tracker) 310 | }); 311 | } else { 312 | forceStopAd('Received cancel signal'); 313 | } 314 | } 315 | 316 | const resizeAd = () => { 317 | adUnit.resizeAd(player.currentWidth(), player.currentHeight(), player.isFullscreen() ? 'fullscreen' : 'normal'); 318 | } 319 | 320 | // not async so no timeout is required 321 | adUnit.handshakeVersion('2.0', onHandShake); 322 | } 323 | 324 | const creative = tracker.creative; 325 | 326 | const vpaidMediaFile = creative.mediaFiles.find(mediaFile => mediaFile.apiFramework === 'VPAID' && validMime(mediaFile)); 327 | 328 | if (!vpaidMediaFile) { 329 | throw new Error('Invalid VPAID media file: only JavaScript is supported'); 330 | } 331 | 332 | container = createVPAIDContainer(options); 333 | 334 | player.el().insertBefore(container, player.controlBar.el()); 335 | 336 | const videoElement = determineVideoElement(player, options); 337 | 338 | const vpaidClient = new VPAIDHTML5Client(container, videoElement, {}); 339 | 340 | vpaidClient.loadAdUnit(vpaidMediaFile.fileURL, adUnitLoad); 341 | }); 342 | } 343 | 344 | removeMuteControl() { 345 | this.#controlBar?.remove(); 346 | this.#controlBar = null; 347 | } 348 | 349 | addMuteControl(adUnit) { 350 | const controlBarDiv = document.createElement('div'); 351 | controlBarDiv.className = 'vpaid-control-bar'; 352 | 353 | this.#controlBar = controlBarDiv; 354 | 355 | const toggleMuteButton = document.createElement('button'); 356 | toggleMuteButton.type = 'button'; 357 | toggleMuteButton.className = 'vpaid-toggle-mute-button'; 358 | 359 | const toggleMuteSpan = document.createElement('span'); 360 | toggleMuteSpan.className = 'vpaid-icon-placeholder'; 361 | 362 | toggleMuteButton.addEventListener('click', () => { 363 | this.#muted = !this.#muted; 364 | 365 | const newVolume = this.#muted ? 0 : (this.#volume || 1); 366 | 367 | adUnit.setAdVolume(newVolume, (/*error, callback*/)=>{}); 368 | 369 | // NOTE: the mute icon button will be updated via the AdVolumeChange event (triggered by the adUnit). 370 | }); 371 | 372 | toggleMuteButton.appendChild(toggleMuteSpan); 373 | controlBarDiv.appendChild(toggleMuteButton); 374 | 375 | const player = this.#player; 376 | 377 | player.el().insertBefore(controlBarDiv, player.controlBar.el()); 378 | 379 | adUnit.getAdVolume((error, currentVolume) => { 380 | if (error) return; 381 | this.setVolume(currentVolume); 382 | }); 383 | } 384 | 385 | // TODO: review. may not need. 386 | cancel() { 387 | this.#cancelled = true; 388 | if (this.#started) { 389 | this.#eventTarget.trigger('forceStopAd'); 390 | } 391 | } 392 | 393 | setVolume(v) { 394 | this.#volume = v; 395 | this.#muted = v === 0; 396 | this.updateControlBar(); 397 | } 398 | 399 | updateControlBar() { 400 | if (this.#controlBar != null) { 401 | let className = this.#controlBar.className; 402 | className = className.replaceAll('mute', '').trim(); 403 | className += this.#muted ? ' mute' : ''; 404 | this.#controlBar.className = className; 405 | } 406 | } 407 | } 408 | 409 | function determineVideoElement(player, options) { 410 | const videoInstance = options.vpaid.videoInstance; 411 | 412 | let videoElement; 413 | 414 | if (videoInstance === 'none') { 415 | videoElement = null; 416 | } else { 417 | if (videoInstance !== 'same') { 418 | console.log(`${videoInstance} is an invalid videoInstance value. Defaulting to \'same\'.`); 419 | } 420 | // Same as: player.el().querySelector('.vjs-tech'); 421 | videoElement = player.tech({kindaKnowWhatImDoing: true}).el(); 422 | if (videoElement == null) { 423 | console.log(`Unable to find the video element for VPAID.`); 424 | } 425 | } 426 | return videoElement; 427 | } 428 | 429 | function validMime(mediaFile) { 430 | return VALID_TYPES.indexOf(mediaFile.mimeType.trim()) > -1; 431 | } 432 | 433 | function createVPAIDContainer(options) { 434 | const containerClass = options.vpaid.containerClass; 435 | 436 | const vpaidContainerElement = document.createElement('div'); 437 | 438 | if (containerClass) { 439 | vpaidContainerElement.classList.add(containerClass); 440 | } 441 | 442 | return vpaidContainerElement; 443 | } 444 | 445 | /** 446 | * 447 | * @param {function} handler 448 | * @param {function()|null} timeoutFn 449 | * @return {function(): void} 450 | */ 451 | 452 | function withTimeout(handler, timeoutFn = null) { 453 | // TODO: configurable timeout 454 | const id = setTimeout(() => { 455 | handler = () => { 456 | }; 457 | if (timeoutFn) { 458 | timeoutFn(); 459 | } 460 | }, 10000); 461 | 462 | return function () { 463 | clearTimeout(id); 464 | handler.apply(null, arguments); 465 | }; 466 | } 467 | 468 | /** 469 | * @param {object} adUnit 470 | * @param {string} evtName 471 | * @param {function} handler 472 | * @param {function(Error)} timeoutFn 473 | */ 474 | function subscribeWithTimeout(adUnit, evtName, handler, timeoutFn) { 475 | const fn = withTimeout(handler, () => { 476 | if (timeoutFn) { 477 | timeoutFn(new Error(`Timeout while waiting for ${evtName} event.`)); 478 | } 479 | }); 480 | 481 | adUnit.subscribe(evtName, fn); 482 | } 483 | -------------------------------------------------------------------------------- /test/e2e/creative/image-200x600.webp: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:85650e2b2b445da458372b748c238f72df1808196f48415de21ed351cee57e3a 3 | size 18910 4 | -------------------------------------------------------------------------------- /test/e2e/creative/image-300x250.jpg: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:dc342cac64fb9d180bbccc1c9726224f1f363c5f8d113e7b1ad34b7a93b013c8 3 | size 39482 4 | -------------------------------------------------------------------------------- /test/e2e/creative/image-300x250.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:cce18313ad93b077291f86e55a59f3f4b85eaef26523b7413bfef35673d484e7 3 | size 26217 4 | -------------------------------------------------------------------------------- /test/e2e/creative/image-745x100.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:315f7f96b5e8afd5f97f2131a70462326d6c880d65e1fb22dcdb912087139c82 3 | size 42265 4 | -------------------------------------------------------------------------------- /test/e2e/creative/video-960x540-5s.mp4: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a3120f2dc7b471af46159e24cab3950b2d518cc42b9c85d0bdc85a509358e615 3 | size 1487169 4 | -------------------------------------------------------------------------------- /test/e2e/creative/video-960x540-5s.webm: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:3e766ff8ff51d026b90f576c7d3975195d5e377600537208cf86d152607c3e92 3 | size 1290559 4 | -------------------------------------------------------------------------------- /test/e2e/creative/video-960x540-6s-sound.mp4: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fe40ce5741e30b98c9bd8467ee0f1cd27b7272560239d118352cb70104312343 3 | size 1534549 4 | -------------------------------------------------------------------------------- /test/e2e/creative/video-960x540-9s-sound.mp4: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:c587f38aee8b9793dc1a81a9ce483779e917677bb304682b03a3eb4492fa49ee 3 | size 2942380 4 | -------------------------------------------------------------------------------- /test/e2e/first.test.js: -------------------------------------------------------------------------------- 1 | const portfinder = require('portfinder'); 2 | const express = require('express'); 3 | const mustacheExpress = require('mustache-express'); 4 | const cors = require('cors'); 5 | const path = require("path"); 6 | 7 | const GLOBALS = { 8 | mediaFile: 'video-960x540-5s.webm' 9 | }; 10 | 11 | const projectRoot = __dirname + '/../../' 12 | process.chdir(__dirname); 13 | 14 | async function setupPublisherServer(adserverPort) { 15 | const availablePort = await portfinder.getPortPromise(); 16 | 17 | const app = express(); 18 | 19 | // Register '.html' and '.css' extension 20 | app.engine('html', mustacheExpress()); 21 | app.engine('css', mustacheExpress()); 22 | 23 | //app.set('view engine', 'html'); 24 | app.set('views', path.resolve(".") + '/page'); 25 | 26 | app.use(express.static(projectRoot + '/dist')); 27 | 28 | app.get("/*", (req, res) => { 29 | const name = req.path.split('/').pop(); 30 | if (name.toLowerCase().endsWith("css")) { 31 | res.type('css'); 32 | } else { 33 | res.type('html'); 34 | } 35 | res.render(name, { port: adserverPort }) 36 | }); 37 | 38 | return new Promise((res) => { 39 | const publisherServer = app.listen(availablePort, () => { 40 | let port = publisherServer.address().port; 41 | console.log(`Publisher server listening on port ${port}!`); 42 | res(publisherServer); 43 | }); 44 | }); 45 | } 46 | 47 | async function setupAdvertServer() { 48 | const availablePort = await portfinder.getPortPromise(); 49 | 50 | const app = express(); 51 | 52 | app.use(cors({ 53 | origin: (origin, callback) => { 54 | callback(null, true) 55 | }, 56 | credentials: true 57 | })); 58 | 59 | 60 | // Register '.xml' extension 61 | app.engine('xml', mustacheExpress()); 62 | 63 | app.set('view engine', 'xml'); 64 | app.set('views', path.resolve('.') + '/vast'); 65 | 66 | app.get('/vast', (req, res) => { 67 | res.type('xml').render('sample01', { port: availablePort, mediaFile: GLOBALS.mediaFile }) 68 | }); 69 | 70 | app.get('/track/*', (req, res) => { 71 | res.send(''); 72 | }); 73 | 74 | app.use('/creative', express.static( path.resolve('.') + '/creative')); 75 | app.use('/page', express.static( path.resolve('.') + '/page')); 76 | 77 | return new Promise((res) => { 78 | const adServer = app.listen(availablePort, () => { 79 | let port = adServer.address().port; 80 | console.log(`Ad server listening on port ${port}!`); 81 | res(adServer); 82 | }); 83 | }); 84 | } 85 | 86 | describe('Video player', () => { 87 | let pubPort; 88 | let adPort; 89 | let pubserver; 90 | let adserver; 91 | 92 | beforeAll(async () => { 93 | adserver = await setupAdvertServer(); 94 | adPort = adserver.address().port; 95 | 96 | pubserver = await setupPublisherServer(adPort); 97 | pubPort = pubserver.address().port; 98 | }); 99 | 100 | afterAll(() => { 101 | adserver.close(); 102 | pubserver.close(); 103 | }); 104 | 105 | beforeEach(async () => { 106 | GLOBALS.mediaFile = 'video-960x540-5s.webm'; 107 | }); 108 | 109 | it('should play preroll', async () => { 110 | await page.goto(`http://localhost:${pubPort}/index.html`); 111 | 112 | await waitForVideo(); 113 | 114 | await clickVideo(); 115 | 116 | await page.waitForFunction('window.test.playedSources.length > 1'); 117 | 118 | const result = await page.evaluate(() => window.test.playedSources); 119 | 120 | expect(result.length).toEqual(2); 121 | expect(result[0]).toMatch('video-960x540-5s'); 122 | expect(result[1]).toMatch('big_buck_bunny_720p_surround'); 123 | }); 124 | 125 | it('should play content video when media file does not exist', async () => { 126 | GLOBALS.mediaFile = 'no-such-file-exists'; 127 | 128 | await page.goto(`http://localhost:${pubPort}/index.html`); 129 | 130 | await waitForVideo(); 131 | 132 | await clickVideo(); 133 | 134 | await page.waitForFunction('window.test.playedSources.length > 0'); 135 | 136 | const result = await page.evaluate(() => window.test.playedSources); 137 | 138 | expect(result.length).toEqual(1); 139 | expect(result[0]).toMatch('big_buck_bunny_720p_surround'); 140 | }); 141 | 142 | async function waitForVideo() { 143 | await page.waitForSelector('video'); 144 | await page.waitForSelector('div.vjs-poster'); 145 | } 146 | 147 | async function clickVideo() { 148 | await page.click('video'); 149 | } 150 | }); 151 | -------------------------------------------------------------------------------- /test/e2e/jest.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | transform: {}, 3 | verbose: true, 4 | testMatch: ['/**/*.js'], 5 | preset: 'jest-puppeteer', 6 | testTimeout: 10000 7 | }; 8 | -------------------------------------------------------------------------------- /test/e2e/page/demo.css: -------------------------------------------------------------------------------- 1 | /* 2 | Original @ https://github.com/theonion/videojs-vast-plugin (commit bf6ce85fa763299739f6a7c801b5be4b90b3b363) 3 | */ 4 | 5 | .vast-skip-button { 6 | display: block; 7 | position: absolute; 8 | top: 5px; 9 | right: 0; 10 | width: auto; 11 | background-color: #000; 12 | color: #AAA; 13 | font-size: 12px; 14 | font-style: italic; 15 | line-height: 12px; 16 | padding: 10px; 17 | z-index: 2; 18 | } 19 | 20 | .vast-skip-button.enabled { 21 | cursor: pointer; 22 | color: #fff; 23 | } 24 | 25 | .vast-skip-button.enabled:hover { 26 | cursor: pointer; 27 | background: #333; 28 | } 29 | 30 | .vast-blocker { 31 | display: block; 32 | position: absolute; 33 | margin: 0; 34 | padding: 0; 35 | height: 100%; 36 | width: 100%; 37 | top: 0; 38 | left: 0; 39 | right: 0; 40 | bottom: 0; 41 | } -------------------------------------------------------------------------------- /test/e2e/page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 |
17 | 18 | 19 | 32 | 33 | 34 | 35 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /test/e2e/page/landing-page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Landing Page 5 | 6 | 7 |

Landing Page

8 | 9 | 10 | -------------------------------------------------------------------------------- /test/e2e/vast/sample01.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Partner100 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 00:00:05 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /test/unit/ad-loader.test.mjs: -------------------------------------------------------------------------------- 1 | import {AdLoader} from '../../src/ad-loader.mjs'; 2 | import {VASTClient, VASTParser} from '@dailymotion/vast-client'; 3 | import {AdSelector} from "../../src/ad-selector.mjs"; 4 | import {jest} from '@jest/globals'; 5 | import window from 'global'; 6 | 7 | describe('Ad Loader', () => { 8 | const options = Object.freeze({ 9 | withCredentials: true, 10 | wrapperLimit: 2 11 | }); 12 | 13 | /** 14 | * @type {VASTClient} 15 | */ 16 | let vastClient = null; 17 | /** 18 | * @type {AdSelector} 19 | */ 20 | let adSelector = null; 21 | let vastParser = null; 22 | /** 23 | * @type {AdLoader} 24 | */ 25 | let adLoader = null; 26 | 27 | /** 28 | * @type MockedFn 29 | */ 30 | let vastClientGet = null; 31 | 32 | /** 33 | * @type MockedFn 34 | */ 35 | let vastParserParse = null; 36 | 37 | beforeEach(() => { 38 | vastClient = new VASTClient(); 39 | adSelector = new AdSelector(); 40 | vastParser = new VASTParser(); 41 | adLoader = new AdLoader(vastClient, vastParser, adSelector, options); 42 | 43 | vastParserParse = jest 44 | .spyOn(vastParser, 'parse'); 45 | 46 | vastClientGet = jest 47 | .spyOn(vastClient, 'get'); 48 | }); 49 | 50 | describe('Error handling', () => { 51 | const PARAM_NOT_SET_MSG = "xml or url must be set"; 52 | 53 | it('should error when given no params', () => { 54 | return expect(adLoader.loadAds()).rejects.toThrow(PARAM_NOT_SET_MSG); 55 | }); 56 | 57 | it('should error when given the wrong params', () => { 58 | return expect(adLoader.loadAds({location:"xyz"})).rejects.toThrow(PARAM_NOT_SET_MSG); 59 | }); 60 | 61 | it('should error when given invalid VAST XML', async () => { 62 | const PARSER_ERROR_MSG = 'bad vast'; 63 | const INVALID_PARAM_MSG = 'xml config option must be a String or XMLDocument'; 64 | 65 | vastParserParse.mockRejectedValue(new Error(PARSER_ERROR_MSG)); 66 | 67 | await expect(adLoader.loadAds({xml: ""})).rejects.toThrow(PARSER_ERROR_MSG); 68 | await expect(adLoader.loadAds({xml: " "})).rejects.toThrow(PARSER_ERROR_MSG); 69 | await expect(adLoader.loadAds({xml: ""})).rejects.toThrow(PARSER_ERROR_MSG); 70 | await expect(adLoader.loadAds({xml: document.implementation.createDocument(null, "thing")})).rejects.toThrow(PARSER_ERROR_MSG); 71 | 72 | await expect(adLoader.loadAds({xml: 1})).rejects.toThrow(INVALID_PARAM_MSG); 73 | await expect(adLoader.loadAds({xml: {}})).rejects.toThrow(INVALID_PARAM_MSG); 74 | }); 75 | 76 | it('should return no ads when vast client errors', ()=> { 77 | vastClientGet.mockRejectedValue(new Error('vast client error')); 78 | return expect(adLoader.loadAds({url: 'xyz'})).resolves.toStrictEqual([]); 79 | }); 80 | }); 81 | 82 | describe('Waterfall', () => { 83 | 84 | //@language=XML 85 | const SIMPLE_VAST = ` 86 | 87 | 88 | 89 | ad system 90 | video ad 91 | http://example.com/error 92 | http://example.com/track/impression 93 | 94 | 95 | 96 | 00:00:20 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | `; 109 | 110 | it('should use the first valid response', async () => { 111 | const responseQueue = [ 112 | () => Promise.reject(), 113 | () => Promise.reject(), 114 | // TODO: add empty VAST 115 | () => parseVast(SIMPLE_VAST), 116 | () => Promise.reject() 117 | ]; 118 | 119 | vastClientGet.mockImplementation(() => { 120 | return responseQueue.shift()(); 121 | }); 122 | 123 | await adLoader.loadAds({ 124 | url: ['http://example.com/apple', 'http://example.com/banana', 'http://example.com/carrot', 'http://example.com/dates'] 125 | }); 126 | 127 | const clientCalls = vastClientGet.mock.calls; 128 | expect(clientCalls).toHaveLength(3); 129 | expect(clientCalls[0]).toStrictEqual(['http://example.com/apple', options]); 130 | expect(clientCalls[1]).toStrictEqual(['http://example.com/banana', options]); 131 | expect(clientCalls[2]).toStrictEqual(['http://example.com/carrot', options]); 132 | }); 133 | 134 | function parseVast(xml) { 135 | let parser = new VASTParser(); 136 | const xmlDocument = (new window.DOMParser()).parseFromString(xml, 'application/xml'); 137 | return parser.parseVAST(xmlDocument); 138 | } 139 | }); 140 | }); 141 | 142 | -------------------------------------------------------------------------------- /test/unit/utils.test.mjs: -------------------------------------------------------------------------------- 1 | import * as utils from "../../src/utils.mjs"; 2 | 3 | 4 | const SECONDS_IN_HOUR = 3600; 5 | const SECONDS_IN_MINUTE = 60; 6 | 7 | describe('Utils', () => { 8 | describe('Calculate time offsets', ()=> { 9 | 10 | it ('should support offsets expressed as time codes in hh:mm:ss format', () => { 11 | expect(utils.convertOffsetToSeconds('5:')).toStrictEqual(5 * SECONDS_IN_HOUR); 12 | expect(utils.convertOffsetToSeconds('5:3')).toStrictEqual(5 * SECONDS_IN_HOUR + 3 * SECONDS_IN_MINUTE); 13 | expect(utils.convertOffsetToSeconds('5:03')).toStrictEqual(5 * SECONDS_IN_HOUR + 3 * SECONDS_IN_MINUTE); 14 | expect(utils.convertOffsetToSeconds('5:03:28')).toStrictEqual(5 * SECONDS_IN_HOUR + 3 * SECONDS_IN_MINUTE + 28); 15 | expect(utils.convertOffsetToSeconds('242:5:03:28')).toStrictEqual(5 * SECONDS_IN_HOUR + 3 * SECONDS_IN_MINUTE + 28); 16 | expect(utils.convertOffsetToSeconds('4:30')).toStrictEqual( + 4 * SECONDS_IN_HOUR + 30 * SECONDS_IN_MINUTE); 17 | expect(utils.convertOffsetToSeconds('::75')).toStrictEqual(75); 18 | expect(utils.convertOffsetToSeconds(':22:')).toStrictEqual(22 * SECONDS_IN_MINUTE); 19 | expect(utils.convertOffsetToSeconds('0:22')).toStrictEqual(22 * SECONDS_IN_MINUTE); 20 | }); 21 | 22 | it('should support offsets expressed as percentage', () => { 23 | expect(utils.convertOffsetToSeconds('10%', null)).toStrictEqual(null); 24 | expect(utils.convertOffsetToSeconds('25%', 200)).toStrictEqual(50); 25 | expect(utils.convertOffsetToSeconds('25%', '200')).toStrictEqual(50); 26 | }); 27 | 28 | it('should support offsets expressed as number in seconds', () => { 29 | expect(utils.convertOffsetToSeconds(6, null)).toStrictEqual(6); 30 | expect(utils.convertOffsetToSeconds(6, 10)).toStrictEqual(6); 31 | expect(utils.convertOffsetToSeconds('8', null)).toStrictEqual(8); 32 | expect(utils.convertOffsetToSeconds('8', 20)).toStrictEqual(8); 33 | }); 34 | 35 | it('should return null for invalid values', () => { 36 | expect(utils.convertOffsetToSeconds('abc', null)).toStrictEqual(null); 37 | }); 38 | }) 39 | }); 40 | -------------------------------------------------------------------------------- /test/unit/vast-plugin.test.mjs: -------------------------------------------------------------------------------- 1 | import videojs from 'video.js'; 2 | import 'videojs-contrib-ads'; 3 | import '../../src/vast-plugin.mjs'; 4 | 5 | window.videojs = videojs; 6 | describe('Vast Plugin', () => { 7 | it('test jsdom', () => { 8 | const player = videojs('vid'); 9 | 10 | // language=XML 11 | var xml = '\n'; 12 | 13 | var companion = { 14 | elementId: "companion", 15 | maxWidth: 300, 16 | maxHeight: 250 17 | }; 18 | 19 | player.vast({ 20 | xml: xml, 21 | //url: 'http://localhost:9999/vast.xml', 22 | skip: 3, 23 | companion: companion}) 24 | }); 25 | }) 26 | 27 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 2 | 3 | const videoPlayerConfig = { 4 | entry: {player: './src/vast-player.mjs'}, 5 | 6 | output: { 7 | path: `${__dirname}/dist`, 8 | filename: '[name].js', 9 | chunkFilename: "[name].bundle.js", 10 | }, 11 | 12 | target: 'web', 13 | 14 | module: { 15 | rules: [ 16 | { test: /\.js?$/, exclude: /node_modules/, loader: 'babel-loader' }, 17 | { test: /\.css$/i, use: ["style-loader", "css-loader"] }, 18 | ], 19 | }, 20 | 21 | resolve: { 22 | modules: ['src', 'node_modules'], 23 | extensions: ['.js', '.mjs'], 24 | }, 25 | }; 26 | 27 | const standalonePluginConfig = Object.assign({}, videoPlayerConfig, { 28 | entry: { 29 | 'videojsx.vast': ['./src/vast-plugin.mjs', './src/vast-player.css'] 30 | }, 31 | externals: { 32 | 'video.js': 'videojs' 33 | }, 34 | module: { 35 | rules: [ 36 | { test: /\.js?$/, exclude: /node_modules/, loader: 'babel-loader' }, 37 | { test: /\.css?$/, exclude: /node_modules/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }, 38 | ], 39 | }, 40 | plugins: [ 41 | new MiniCssExtractPlugin() 42 | ], 43 | 44 | }); 45 | 46 | module.exports = [videoPlayerConfig, standalonePluginConfig]; 47 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // default config for webpack cli 2 | module.exports = require("./webpack.prod.js"); 3 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const {merge} = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const path = require('node:path'); 4 | 5 | 6 | const devServerOptions = { 7 | devServer: { 8 | open: 'index.html', 9 | port: 9999, 10 | // host: '127.0.0.1', 11 | static: [ 12 | { 13 | directory: path.resolve(__dirname, "dev") 14 | }, 15 | { 16 | directory: path.resolve(__dirname, "test/e2e/creative") 17 | } 18 | ], 19 | devMiddleware: { 20 | publicPath: "/bundle/", 21 | } 22 | } 23 | }; 24 | 25 | // From https://webpack.js.org/configuration/dev-server/ 26 | // "Be aware that when exporting multiple configurations only the devServer options for the first configuration will be 27 | // taken into account and used for all the configurations in the array." 28 | common[0] = merge(common[0], devServerOptions); 29 | 30 | module.exports = common.map(c => merge(c, { 31 | mode: 'development', 32 | devtool: 'inline-source-map', 33 | })); 34 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const {merge} = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const CompressionPlugin = require('compression-webpack-plugin'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | 6 | module.exports = common.map(c => merge(c, { 7 | mode: 'production', 8 | plugins: [ 9 | new CompressionPlugin({ 10 | test: /\.js/ 11 | }), 12 | ], 13 | 14 | performance: { 15 | maxEntrypointSize: 850000, 16 | maxAssetSize: 850000 17 | }, 18 | 19 | optimization: { 20 | minimize: true, 21 | minimizer: [ 22 | new TerserPlugin({ 23 | terserOptions: { 24 | format: { 25 | comments: false, 26 | }, 27 | compress: { 28 | drop_console: true, 29 | }, 30 | }, 31 | extractComments: false, 32 | }), 33 | ], 34 | }, 35 | })); 36 | -------------------------------------------------------------------------------- /webpack.watch.js: -------------------------------------------------------------------------------- 1 | const {merge} = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = common.map(c => merge(c, { 5 | mode: 'development', 6 | devtool: 'inline-source-map', 7 | watch: true, 8 | watchOptions: { 9 | ignored: /node_modules/ 10 | } 11 | })); 12 | --------------------------------------------------------------------------------