├── .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 |
67 |
68 | Your browser does not support video.
69 |
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 |
12 |
13 | Your browser does not support video.
14 |
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 |
12 |
13 | Your browser does not support video.
14 |
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 |
24 |
25 | Your browser does not support video.
26 |
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 |
24 |
25 | Your browser does not support video.
26 |
27 |
28 |
29 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/dev/schedule.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Schedule Example
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Your browser does not support video.
14 |
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 |
24 |
25 | Your browser does not support video.
26 |
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 |
Learn More
56 |
57 |
Skip Ad
58 |
59 |
60 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/dev/vpaid.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Player Example
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Your browser does not support video.
14 |
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 |
12 |
13 | Your browser does not support video.
14 |
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 |
13 |
14 | Your browser does not support video.
15 |
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 = 'AdSystem \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 |
--------------------------------------------------------------------------------