@openplayerjs/ads 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # @openplayer/ads
1
+ # @openplayerjs/ads
2
2
 
3
- > VAST / VMAP ad plugin for [OpenPlayerJS](https://openplayerjs.com).
3
+ > VAST / VMAP / SSAI / Hybrid ad plugin for [OpenPlayerJS](https://openplayerjs.com).
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@openplayerjs/ads?color=blue&logo=npm&label=npm)](https://www.npmjs.com/package/@openplayerjs/ads)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/@openplayerjs/ads?logo=npm&label=downloads)](https://www.npmjs.com/package/@openplayerjs/ads)
@@ -10,15 +10,23 @@
10
10
 
11
11
  ---
12
12
 
13
- This package replaced the use of Google IMA SDK in OpenPlayerJS.
13
+ This package provides ad insertion for OpenPlayerJS. It supports three delivery modes selectable via `adDelivery`:
14
+
15
+ | Mode | Strategy class | Description |
16
+ | ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------ |
17
+ | `'csai'` (default) | `CsaiAdStrategy` | Client-side ad insertion — fetches VAST/VMAP, renders an ad `<video>` |
18
+ | `'ssai'` | `SsaiAdStrategy` | Server-side ad stitching — ads are baked into the HLS stream; boundaries detected via SCTE-35 TextTrack cues |
19
+ | `'hybrid'` | `HybridAdStrategy` | CSAI triggered by SCTE-35 OUT cues from the HLS engine |
20
+
21
+ ---
14
22
 
15
23
  ## Installation
16
24
 
17
25
  ```bash
18
- npm install @openplayer/ads @openplayer/core
26
+ npm install @openplayerjs/ads @openplayerjs/core
19
27
  ```
20
28
 
21
- `@dailymotion/vast-client` and `@dailymotion/vmap` are bundled automatically — you do not need to install them separately.
29
+ `@dailymotion/vast-client` and `@dailymotion/vmap` are bundled automatically — no separate install needed.
22
30
 
23
31
  ---
24
32
 
@@ -32,30 +40,292 @@ npm install @openplayer/ads @openplayer/core
32
40
  - Click-through tracking
33
41
  - Waterfall and playlist ad source modes
34
42
  - Preload-aware VMAP fetching (respects `preload="none"`)
43
+ - **SSAI** — server-side ad stitching via SCTE-35 TextTrack cues
44
+ - **Hybrid** — CSAI triggered by SCTE-35 OUT cues from the HLS engine
35
45
  - **SIMID 1.2** — Secure Interactive Media Interface Definition (interactive overlays)
36
46
  - **OMID** — Open Measurement Interface Definition (third-party viewability / verification)
37
47
 
38
48
  ---
39
49
 
40
- ## ESM usage
50
+ ## Quick start
51
+
52
+ ### CSAI — client-side ad insertion (default)
41
53
 
42
54
  ```ts
43
- import { Core } from '@openplayer/core';
44
- import { AdsPlugin } from '@openplayer/ads';
55
+ import { Core } from '@openplayerjs/core';
56
+ import { AdsPlugin } from '@openplayerjs/ads';
45
57
 
46
58
  const core = new Core(video, {
47
59
  plugins: [
48
60
  new AdsPlugin({
61
+ // adDelivery: 'csai' is the default — no need to set it explicitly
49
62
  breaks: [
50
- { at: 'preroll', url: 'https://example.com/vast-preroll.xml', once: true },
51
- { at: 60, url: 'https://example.com/vast-midroll.xml', once: true },
52
- { at: 'postroll', url: 'https://example.com/vast-postroll.xml', once: true },
63
+ { at: 'preroll', source: { type: 'VAST', src: 'https://example.com/preroll.xml' } },
64
+ { at: 30, source: { type: 'VAST', src: 'https://example.com/midroll.xml' } },
65
+ { at: 'postroll', source: { type: 'VAST', src: 'https://example.com/postroll.xml' } },
53
66
  ],
54
67
  }),
55
68
  ],
56
69
  });
57
70
  ```
58
71
 
72
+ ### VMAP (break schedule from server)
73
+
74
+ ```ts
75
+ new AdsPlugin({
76
+ sources: [{ type: 'VMAP', src: 'https://example.com/vmap.xml' }],
77
+ });
78
+ ```
79
+
80
+ ### SSAI — server-side ad stitching
81
+
82
+ SSAI mode reads SCTE-35 splice commands directly from the HLS metadata TextTrack — no VAST requests are
83
+ made. Ads are baked into the stitched stream; the plugin tracks break boundaries and fires lifecycle
84
+ events. Both ID3/DataCue (`enableID3MetadataCues`) and EXT-X-DATERANGE/VTTCue (`enableDateRangeMetadataCues`)
85
+ formats are supported automatically.
86
+
87
+ #### Minimal setup
88
+
89
+ `eventSink` is optional — `ads:break:start`, `ads:break:end`, and `ads:quartile` still fire on the EventBus.
90
+
91
+ ```ts
92
+ import { Core } from '@openplayerjs/core';
93
+ import { AdsPlugin } from '@openplayerjs/ads';
94
+
95
+ const core = new Core(video, {
96
+ plugins: [new AdsPlugin({ adDelivery: 'ssai' })],
97
+ });
98
+ ```
99
+
100
+ #### Full lifecycle tracking via `eventSink`
101
+
102
+ `eventSink` receives `impression` (break started), `quartile` (25/50/75/100%), and `complete` (break ended).
103
+
104
+ ```ts
105
+ import { Core } from '@openplayerjs/core';
106
+ import { AdsPlugin, type AdLifecycleEvent } from '@openplayerjs/ads';
107
+
108
+ const core = new Core(video, {
109
+ plugins: [
110
+ new AdsPlugin({
111
+ adDelivery: 'ssai',
112
+ ssai: {
113
+ eventSink: (event: AdLifecycleEvent) => {
114
+ switch (event.type) {
115
+ case 'impression':
116
+ sendBeacon('/ads/impression', { breakId: event.breakId, src: event.contentSrc });
117
+ break;
118
+ case 'quartile':
119
+ // event.metadata.quartile: 25 | 50 | 75 | 100
120
+ sendBeacon('/ads/quartile', { breakId: event.breakId, q: event.metadata?.quartile });
121
+ break;
122
+ case 'complete':
123
+ sendBeacon('/ads/complete', { breakId: event.breakId });
124
+ break;
125
+ case 'error':
126
+ console.error('SSAI break error', event.breakId, event.metadata);
127
+ break;
128
+ }
129
+ },
130
+ },
131
+ }),
132
+ ],
133
+ });
134
+ ```
135
+
136
+ #### Listening via EventBus
137
+
138
+ Use `core.on()` instead of (or alongside) `eventSink` — e.g. to show/hide an "Ad" badge in the UI:
139
+
140
+ ```ts
141
+ core.on('ads:break:start', ({ id }) => showAdBadge());
142
+ core.on('ads:break:end', ({ id }) => hideAdBadge());
143
+ core.on('ads:quartile', ({ breakId, quartile }) => {
144
+ if (quartile === 50) analytics.track('ssai_midpoint', { breakId });
145
+ });
146
+ ```
147
+
148
+ #### Parsing raw SCTE-35 data
149
+
150
+ Use the exported `decodeSplice` helper to inspect splice commands directly in your own TextTrack listener
151
+ (e.g. to read custom descriptors or drive logic outside of SSAI mode):
152
+
153
+ ```ts
154
+ import { decodeSplice, type SpliceCommand } from '@openplayerjs/ads';
155
+
156
+ video.textTracks.addEventListener('addtrack', (e) => {
157
+ const track = e.track;
158
+ if (track?.kind !== 'metadata') return;
159
+ track.mode = 'hidden';
160
+ track.addEventListener('cuechange', () => {
161
+ for (const cue of track.activeCues ?? []) {
162
+ const raw = cue as any;
163
+ // ID3 / DataCue path (enableID3MetadataCues)
164
+ if (raw.data instanceof ArrayBuffer) {
165
+ const cmd: SpliceCommand | null = decodeSplice(raw.data);
166
+ if (cmd?.type === 'splice_insert') {
167
+ console.log('splice_insert out=%s dur=%ss', cmd.outOfNetwork, cmd.durationSecs);
168
+ }
169
+ }
170
+ // EXT-X-DATERANGE base64 path (enableDateRangeMetadataCues)
171
+ if (typeof raw.attr?.['X-SCTE35'] === 'string') {
172
+ const cmd = decodeSplice(raw.attr['X-SCTE35']);
173
+ console.log('daterange splice:', cmd);
174
+ }
175
+ }
176
+ });
177
+ });
178
+ ```
179
+
180
+ > **Note:** No CodePen is provided for SSAI — it requires an HLS stream with embedded SCTE-35 markers
181
+ > (e.g. from AWS MediaTailor or Mux). No stable public test stream is available.
182
+
183
+ ### Hybrid — CSAI triggered by SCTE-35
184
+
185
+ Hybrid mode combines CSAI ad rendering with SCTE-35 cue detection. When the HLS engine fires a splice-out
186
+ cue, `resolveScteUrl` maps it to a VAST tag URL and the plugin runs a standard CSAI break inline.
187
+ `HybridAdStrategy` extends `CsaiAdStrategy`, so every CSAI option (`breaks`, `requestInterceptor`,
188
+ `labels`, `omid`, etc.) is available alongside the two required hybrid fields.
189
+
190
+ #### Minimal setup
191
+
192
+ ```ts
193
+ import { Core } from '@openplayerjs/core';
194
+ import { HlsMediaEngine } from '@openplayerjs/hls';
195
+ import { AdsPlugin, type ScteOutCue } from '@openplayerjs/ads';
196
+
197
+ const hlsEngine = new HlsMediaEngine();
198
+
199
+ const core = new Core(video, {
200
+ plugins: [
201
+ hlsEngine,
202
+ new AdsPlugin({
203
+ adDelivery: 'hybrid',
204
+ hybrid: {
205
+ scteSource: hlsEngine,
206
+ resolveScteUrl: ({ id, plannedDuration }: ScteOutCue) =>
207
+ `https://ads.example.com/vast?id=${id}&dur=${plannedDuration ?? 30}`,
208
+ },
209
+ }),
210
+ ],
211
+ });
212
+ ```
213
+
214
+ #### Async URL resolution — call your ad decision server
215
+
216
+ `resolveScteUrl` may return a `Promise`. Return `null` or `undefined` to skip a cue entirely.
217
+
218
+ ```ts
219
+ new AdsPlugin({
220
+ adDelivery: 'hybrid',
221
+ hybrid: {
222
+ scteSource: hlsEngine,
223
+ resolveScteUrl: async ({ id, plannedDuration }: ScteOutCue) => {
224
+ // Skip bumpers shorter than 5 s
225
+ if (plannedDuration != null && plannedDuration < 5) return null;
226
+
227
+ const params = new URLSearchParams({ breakId: id, dur: String(plannedDuration ?? 30) });
228
+ const { vastUrl } = await fetch(`/ads/decision?${params}`).then((r) => r.json());
229
+ return vastUrl ?? null; // null → skip this break
230
+ },
231
+ },
232
+ });
233
+ ```
234
+
235
+ #### Preroll + SCTE-triggered midrolls
236
+
237
+ Add static `breaks` to schedule a CSAI preroll before the stream starts, while SCTE-35 cues drive
238
+ midrolls during playback:
239
+
240
+ ```ts
241
+ new AdsPlugin({
242
+ adDelivery: 'hybrid',
243
+ hybrid: {
244
+ scteSource: hlsEngine,
245
+ resolveScteUrl: ({ id, plannedDuration }: ScteOutCue) =>
246
+ `https://ads.example.com/vast?id=${id}&dur=${plannedDuration ?? 30}`,
247
+ breaks: [{ at: 'preroll', source: { type: 'VAST', src: 'https://ads.example.com/preroll.xml' } }],
248
+ },
249
+ });
250
+ ```
251
+
252
+ #### Full configuration — waterfall sources, request interceptor, OMID, UI labels, companions
253
+
254
+ ```ts
255
+ import { Core } from '@openplayerjs/core';
256
+ import { HlsMediaEngine } from '@openplayerjs/hls';
257
+ import { AdsPlugin, type AdLifecycleEvent, type AdTagRequest, type ScteOutCue } from '@openplayerjs/ads';
258
+
259
+ const hlsEngine = new HlsMediaEngine();
260
+
261
+ const core = new Core(video, {
262
+ plugins: [
263
+ hlsEngine,
264
+ new AdsPlugin({
265
+ adDelivery: 'hybrid',
266
+ debug: true, // verbose console output
267
+
268
+ hybrid: {
269
+ // ── Required ──────────────────────────────────────────────────────────
270
+ scteSource: hlsEngine,
271
+ resolveScteUrl: async ({ id, plannedDuration }: ScteOutCue) => {
272
+ const params = new URLSearchParams({ breakId: id, dur: String(plannedDuration ?? 30) });
273
+ const { vastUrl } = await fetch(`/ads/decision?${params}`).then((r) => r.json());
274
+ return vastUrl ?? null;
275
+ },
276
+
277
+ // ── Static breaks (run alongside SCTE-triggered midrolls) ─────────────
278
+ breaks: [
279
+ {
280
+ id: 'bumper-pre', // IDs containing "bumper" are treated as bumpers
281
+ at: 'preroll',
282
+ source: { type: 'VAST', src: 'https://ads.example.com/bumper.xml' },
283
+ once: true, // play only once per page load
284
+ },
285
+ {
286
+ at: 'preroll',
287
+ // Waterfall: try primary ad server, fall back to house ad
288
+ sources: [
289
+ { type: 'VAST', src: 'https://primary-ads.example.com/preroll.xml' },
290
+ { type: 'VAST', src: 'https://house-ads.example.com/preroll.xml' },
291
+ ],
292
+ },
293
+ ],
294
+ adSourcesMode: 'waterfall', // stop at first successful source per break
295
+
296
+ // ── Network ───────────────────────────────────────────────────────────
297
+ requestInterceptor: (req: AdTagRequest) => {
298
+ const url = new URL(req.url);
299
+ url.searchParams.set('cust_params', 'env=prod&uid=abc123');
300
+ return { ...req, url: url.toString() };
301
+ },
302
+
303
+ // ── UI ────────────────────────────────────────────────────────────────
304
+ mountSelector: '#ad-container',
305
+ companionSelector: '#companion-banner',
306
+ nonLinearSelector: '#nonlinear-overlay',
307
+ labels: {
308
+ skip: 'Skip ad',
309
+ advertisement: 'Advertisement',
310
+ adEnded: 'Ad finished',
311
+ },
312
+ resumeContent: true, // resume content after every non-postroll break (default)
313
+ breakTolerance: 0.5, // seconds of tolerance for midroll cue matching
314
+
315
+ // ── Measurement ───────────────────────────────────────────────────────
316
+ omid: { accessMode: 'domain' }, // 'limited' (default) | 'domain'
317
+ eventSink: (event: AdLifecycleEvent) => {
318
+ fetch('/analytics/ads', { method: 'POST', body: JSON.stringify(event) });
319
+ },
320
+ },
321
+ }),
322
+ ],
323
+ });
324
+ ```
325
+
326
+ > **Note:** No CodePen is provided for Hybrid — it requires an HLS stream carrying SCTE-35 OUT cues.
327
+ > Use the existing [CodePen collection](https://codepen.io/collection/kkwgWj) for CSAI examples.
328
+
59
329
  ---
60
330
 
61
331
  ## UMD / CDN usage
@@ -67,7 +337,7 @@ const core = new Core(video, {
67
337
  <script>
68
338
  const player = new OpenPlayerJS('player', {
69
339
  ads: {
70
- breaks: [{ at: 'preroll', url: 'https://example.com/vast.xml', once: true }],
340
+ breaks: [{ at: 'preroll', source: { type: 'VAST', src: 'https://example.com/vast.xml' } }],
71
341
  },
72
342
  });
73
343
  player.init();
@@ -82,27 +352,75 @@ The ads bundle self-registers as `window.OpenPlayerPlugins.ads` and is discovere
82
352
 
83
353
  ### `AdsPluginConfig`
84
354
 
85
- | Option | Type | Default | Description |
86
- | --------------- | --------------------------- | ------------- | ---------------------------------------------------------------- |
87
- | `breaks` | `AdsBreakConfig[]` | `[]` | The list of ad breaks to schedule |
88
- | `adSourcesMode` | `'waterfall' \| 'playlist'` | `'waterfall'` | How multiple ad sources in a single break are handled. See below |
89
- | `debug` | `boolean` | `false` | Enable verbose ads logging |
355
+ | Option | Type | Default | Description |
356
+ | ------------ | ------------------------------ | -------- | ------------------------------------------------------------------ |
357
+ | `adDelivery` | `'csai' \| 'ssai' \| 'hybrid'` | `'csai'` | Selects the delivery strategy |
358
+ | `sources` | `AdsSource \| AdsSource[]` | | Top-level VAST/VMAP/NONLINEAR sources (shorthand for CSAI) |
359
+ | `debug` | `boolean` | `false` | Enable verbose ads logging |
360
+ | `csai` | `CsaiAdConfig` | — | CSAI-specific options (takes precedence over flat fields) |
361
+ | `ssai` | `SsaiAdConfig` | — | SSAI-specific options |
362
+ | `hybrid` | `HybridAdConfig` | — | Hybrid-specific options (`scteSource` + `resolveScteUrl` required) |
363
+
364
+ > **Backward compatibility:** All flat CSAI fields (`breaks`, `interceptPlayForPreroll`, `mountEl`, etc.) are still accepted at the top level. The `csai` sub-object takes precedence when both are present.
365
+
366
+ ### `CsaiAdConfig`
367
+
368
+ All fields are optional. When provided under `csai`, these override the flat equivalents.
369
+
370
+ | Option | Type | Default | Description |
371
+ | ------------------------- | ------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
372
+ | `breaks` | `AdsBreakConfig[]` | `[]` | Ad breaks to schedule |
373
+ | `interceptPlayForPreroll` | `boolean` | auto | Intercept the first `play()` to run a preroll. Defaults to `true` when a preroll break or VAST/VMAP source is configured |
374
+ | `autoPlayOnReady` | `boolean` | `false` | Play ads immediately when the plugin is ready, without waiting for a play gesture |
375
+ | `mountEl` | `HTMLElement` | — | Container element for the ad overlay |
376
+ | `mountSelector` | `string` | — | CSS selector for the ad overlay container (used when `mountEl` is not provided) |
377
+ | `resumeContent` | `boolean` | `true` | Resume content playback after a non-postroll break ends |
378
+ | `preferredMediaTypes` | `string[]` | `['video/mp4', 'video/webm', ...]` | Ordered list of media MIME types for selecting the best ad media file |
379
+ | `breakTolerance` | `number` | `0.25` | Seconds of tolerance when matching a midroll cue point to the current time |
380
+ | `adSourcesMode` | `'waterfall' \| 'playlist'` | `'waterfall'` | How multiple sources in a break are handled. See below |
381
+ | `omid` | `OmidConfig` | `{}` | OMID access mode (`'limited'` or `'domain'`) |
382
+ | `labels` | `{ skip?, advertisement?, adEnded? }` | — | Custom text for the skip button and ad labels |
383
+ | `requestInterceptor` | `(req: AdTagRequest) => AdTagRequest \| null \| Promise<...>` | — | Called before every ad tag fetch; return `null` to suppress the request |
384
+ | `eventSink` | `(event: AdLifecycleEvent) => void` | — | Receives structured lifecycle events (`request`, `impression`, `quartile`, `complete`, `skip`, `error`) |
385
+
386
+ ### `SsaiAdConfig`
387
+
388
+ | Option | Type | Description |
389
+ | ----------- | ----------------------------------- | ------------------------------------ |
390
+ | `eventSink` | `(event: AdLifecycleEvent) => void` | Receives SSAI break lifecycle events |
391
+
392
+ ### `HybridAdConfig`
393
+
394
+ Extends `CsaiAdConfig` with two required fields:
395
+
396
+ | Option | Type | Description |
397
+ | ---------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
398
+ | `scteSource` | `ScteSource` | Duck-typed reference to an engine that fires SCTE-35 cues (e.g. `HlsMediaEngine`). Must expose `onCue?: (cue: ScteOutCue) => void` |
399
+ | `resolveScteUrl` | `(cue: ScteOutCue) => string \| null \| undefined \| Promise<...>` | Maps a SCTE-35 OUT cue to a VAST tag URL. Return `null` or `undefined` to skip the cue |
90
400
 
91
401
  ### `AdsBreakConfig`
92
402
 
93
- Each object in the `breaks` array describes one ad break:
403
+ | Field | Type | Description |
404
+ | --------- | ----------------------------------- | -------------------------------------------------------------------------------------------------- |
405
+ | `at` | `'preroll' \| 'postroll' \| number` | When to play the break. Numbers are seconds from content start |
406
+ | `source` | `AdsSource` | Single VAST/VMAP/NONLINEAR source |
407
+ | `sources` | `AdsSource[]` | Multiple sources (used with `adSourcesMode`) |
408
+ | `id` | `string` | Optional break ID. Breaks whose `id` contains `"bumper"` (case-insensitive) are treated as bumpers |
409
+ | `once` | `boolean` | Play this break only once per page load |
410
+
411
+ ### `AdsSource`
94
412
 
95
- | Field | Type | Required | Description |
96
- | ------ | ----------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- |
97
- | `at` | `'preroll' \| 'postroll' \| number` | Yes | When to play the break. Use `'preroll'` to play before content, `'postroll'` after, or a number of seconds for a mid-roll |
98
- | `url` | `string` | Yes | The VAST or VMAP tag URL to request |
99
- | `once` | `boolean` | No | When `true`, the break plays only once per page load even if the source changes |
100
- | `id` | `string` | No | Optional unique ID. Any break whose `id` or `url` contains `"bumper"` (case-insensitive) is treated as a bumper |
413
+ ```ts
414
+ type AdsSource = {
415
+ type: 'VAST' | 'VMAP' | 'NONLINEAR';
416
+ src: string; // URL or inline XML
417
+ };
418
+ ```
101
419
 
102
420
  ### `adSourcesMode` explained
103
421
 
104
- - **`'waterfall'`** (default): A single break can have a `sources` array. The plugin tries each source in order and stops as soon as one succeeds. Use this for ad tag fallbacks.
105
- - **`'playlist'`**: Each source in a break is played as its own separate break in sequence. Use this for ad pods or sequential ad playlists.
422
+ - **`'waterfall'`** (default): A single `AdsBreakConfig` has a `sources` array. The plugin tries each source in order and stops at the first success. Use this for ad tag fallbacks.
423
+ - **`'playlist'`**: One `AdsBreakConfig` per source; each plays as a separate sequential break. Use this for pre-defined ad pods.
106
424
 
107
425
  ---
108
426
 
@@ -110,80 +428,81 @@ Each object in the `breaks` array describes one ad break:
110
428
 
111
429
  ### `AdsPlugin` methods
112
430
 
113
- These are available directly on the plugin instance (ESM):
114
-
115
- | Method | Signature | Description |
116
- | --------- | --------------------------------------- | ----------------------------------------------------------------- |
117
- | `playAds` | `playAds(url: string) => Promise<void>` | Trigger a one-off ad break from a VAST URL or raw VAST XML string |
118
- | `skip` | `skip() => void` | Skip the currently playing ad (if the skip button is active) |
119
- | `pause` | `pause() => void` | Pause the current ad |
120
- | `resume` | `resume() => void` | Resume a paused ad |
431
+ | Method | Signature | Description |
432
+ | --------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
433
+ | `setup` | `setup(ctx: PluginContext): void` | Called by the plugin registry — not for direct use |
434
+ | `playAds` | `playAds(url: string): Promise<boolean>` | Trigger a one-off ad break from a VAST URL. Returns `true` if at least one ad played |
435
+ | `playAdsFromXml` | `playAdsFromXml(xml: string): Promise<boolean>` | Trigger a one-off break from inline VAST XML |
436
+ | `requestSkip` | `requestSkip(reason?: 'button' \| 'close' \| 'api'): void` | Skip the currently playing ad |
437
+ | `getDueMidrollBreaks` | `getDueMidrollBreaks(t: number): AdsBreakConfig[]` | Return all midroll breaks at or before `t` seconds |
438
+ | `getDueMidrollBreak` | `getDueMidrollBreak(t: number): AdsBreakConfig \| undefined` | Return the earliest unplayed midroll break at or before `t` |
439
+ | `destroy` | `destroy(): void` | Release all subscriptions and DOM state |
121
440
 
122
441
  ### `installAds(Core)` and `extendAds(core, plugin)`
123
442
 
124
- For UMD / imperative usage, two optional helpers expose `core.ads`:
443
+ Two helpers expose `core.ads` and `core.playAds()` for UMD / imperative usage:
125
444
 
126
445
  ```ts
127
- import { installAds, extendAds } from '@openplayer/ads';
128
- import { Core } from '@openplayer/core';
446
+ import { installAds, extendAds } from '@openplayerjs/ads';
447
+ import { Core } from '@openplayerjs/core';
129
448
 
130
- // Prototype-level: adds Core.prototype.ads
449
+ // Adds Core.prototype.ads getter and Core.prototype.playAds() once.
131
450
  installAds(Core);
132
451
 
133
- // Instance-level: wires core.ads to the given plugin instance
134
- extendAds(core, adsPluginInstance);
452
+ // Wires a specific plugin instance to a player instance.
453
+ extendAds(core, adsPlugin);
135
454
 
136
- // core.ads is now available:
137
- core.ads.skip();
138
- core.ads.pause();
139
- core.ads.resume();
140
- core.ads.playAds('https://example.com/vast.xml');
455
+ // Now available on the instance:
456
+ core.playAds('https://example.com/vast.xml');
457
+ core.playAds('<VAST version="3.0">...</VAST>'); // XML is detected automatically
141
458
  ```
142
459
 
143
460
  ### Manual ad playback
144
461
 
145
462
  ```ts
146
- // Trigger a one-off ad break from a URL:
147
- core.ads.playAds('https://example.com/vast.xml');
463
+ // From a VAST URL:
464
+ const played = await adsPlugin.playAds('https://example.com/vast.xml');
465
+
466
+ // From inline VAST XML:
467
+ const played = await adsPlugin.playAdsFromXml('<VAST version="3.0">...</VAST>');
148
468
 
149
- // Or from a raw VAST XML string:
150
- core.ads.playAds(`<VAST version="3.0">...</VAST>`);
469
+ // Skip the current ad programmatically:
470
+ adsPlugin.requestSkip('api');
151
471
  ```
152
472
 
153
473
  ---
154
474
 
155
475
  ## Events
156
476
 
157
- All ads events are prefixed with `ads:`. Listen with `core.on(eventName, handler)`.
158
-
159
- | Event | Payload | When it fires |
160
- | --------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- |
161
- | `ads:requested` | `{ url, at, id }` | An ad tag URL request has been sent to the server |
162
- | `ads:loaded` | `{ break, count }` | The VAST/VMAP response was parsed and ads are ready to play |
163
- | `ads:break:start` | `{ id, kind, at }` | An ad break is about to start; content playback pauses |
164
- | `ads:break:end` | `{ id, kind, at }` | An ad break finished; content playback resumes |
165
- | `ads:ad:start` | `{ break, index }` | An individual ad within a break started playing |
166
- | `ads:ad:end` | `{ break, index }` | An individual ad within a break finished |
167
- | `ads:impression` | `{ break, index }` | The ad impression was recorded (fires once per ad) |
168
- | `ads:quartile` | `{ break, quartile }` | Playback reached 0 %, 25 %, 50 %, 75 %, or 100 % of the ad's length. `quartile` is `0 \| 25 \| 50 \| 75 \| 100` |
169
- | `ads:timeupdate` | `{ break, currentTime, duration }` | The ad's current time updated (fires frequently, like `timeupdate`) |
170
- | `ads:duration` | `{ break, duration }` | The ad's total duration became known |
171
- | `ads:skip` | `{ break, reason }` | The current ad was skipped |
172
- | `ads:clickthrough` | `{ break, url }` | The user clicked the ad and a click-through URL was opened |
173
- | `ads:pause` | `{ break }` | The ad was paused |
174
- | `ads:resume` | `{ break }` | A paused ad was resumed |
175
- | `ads:mute` | `{ break }` | The ad was muted |
176
- | `ads:unmute` | `{ break }` | The ad was unmuted |
177
- | `ads:volumeChange` | `{ break, volume, muted }` | Volume changed during an ad |
178
- | `ads:allAdsCompleted` | `{ break }` | All scheduled ad breaks have finished playing |
179
- | `ads:error` | `{ reason, error?, url? }` | An error occurred during request, parsing, or playback |
180
-
181
- ### Example: listening to ads events
477
+ All events are prefixed with `ads:`. Listen with `ctx.events.on(...)` or via `core.on(...)`.
478
+
479
+ | Event | Payload | When it fires |
480
+ | --------------------- | ---------------------------------- | ---------------------------------------- |
481
+ | `ads:requested` | `{ url, at, id }` | An ad tag request was sent |
482
+ | `ads:loaded` | `{ break, count }` | VAST/VMAP was parsed and ads are ready |
483
+ | `ads:break:start` | `{ id, kind, at }` | An ad break is starting; content pauses |
484
+ | `ads:break:end` | `{ id, kind, at }` | An ad break finished; content resumes |
485
+ | `ads:ad:start` | `{ break, index }` | An individual ad started |
486
+ | `ads:ad:end` | `{ break, index }` | An individual ad finished |
487
+ | `ads:impression` | `{ break, index }` | Ad impression recorded (once per ad) |
488
+ | `ads:quartile` | `{ break, quartile }` | Playback reached 25 / 50 / 75 / 100 % |
489
+ | `ads:timeupdate` | `{ break, currentTime, duration }` | Ad time updated |
490
+ | `ads:duration` | `{ break, duration }` | Ad total duration became known |
491
+ | `ads:skip` | `{ break, reason }` | Ad was skipped |
492
+ | `ads:clickthrough` | `{ break, url }` | User clicked the ad |
493
+ | `ads:pause` | `{ break }` | Ad was paused |
494
+ | `ads:resume` | `{ break }` | Paused ad was resumed |
495
+ | `ads:mute` | `{ break }` | Ad was muted |
496
+ | `ads:unmute` | `{ break }` | Ad was unmuted |
497
+ | `ads:volumeChange` | `{ break, volume, muted }` | Volume changed during ad |
498
+ | `ads:allAdsCompleted` | `{ break }` | All scheduled breaks have finished |
499
+ | `ads:error` | `{ reason, error?, url? }` | Error during request, parse, or playback |
500
+
501
+ ### Listening to events
182
502
 
183
503
  ```ts
184
- core.on('ads:break:start', ({ id, kind, at }) => {
185
- console.log(`Ad break "${kind}" starting at ${at}s`);
186
- // Hide any custom overlays or pause your UI here
504
+ core.on('ads:break:start', ({ kind, at }) => {
505
+ console.log(`Ad break "${kind}" at ${at}s starting`);
187
506
  });
188
507
 
189
508
  core.on('ads:break:end', () => {
@@ -191,38 +510,112 @@ core.on('ads:break:end', () => {
191
510
  });
192
511
 
193
512
  core.on('ads:quartile', ({ quartile }) => {
194
- if (quartile === 50) console.log('User reached the midpoint of the ad');
513
+ if (quartile === 50) console.log('Reached ad midpoint');
195
514
  });
196
515
 
197
516
  core.on('ads:error', ({ reason, error }) => {
198
- console.warn('Ad failed:', reason, error);
199
- // Content playback resumes automatically on error
517
+ console.warn('Ad error:', reason, error);
518
+ // Content playback resumes automatically
519
+ });
520
+ ```
521
+
522
+ ### `AdLifecycleEvent` (structured sink)
523
+
524
+ For server-side or analytics use, provide `eventSink` in the config:
525
+
526
+ ```ts
527
+ new AdsPlugin({
528
+ csai: {
529
+ eventSink: (event) => {
530
+ // event.type: 'request' | 'impression' | 'quartile' | 'complete' | 'skip' | 'error'
531
+ // event.adId, event.breakId, event.contentSrc, event.elapsedSec, event.metadata
532
+ fetch('/analytics', { method: 'POST', body: JSON.stringify(event) });
533
+ },
534
+ },
200
535
  });
201
536
  ```
202
537
 
203
538
  ---
204
539
 
205
- ## SIMID 1.2Interactive Ad Creatives
540
+ ## Ad source modes waterfall vs playlist
206
541
 
207
- [SIMID (Secure Interactive Media Interface Definition)](https://iabtechlab.com/standards/simid/) is an IAB standard that allows VAST ad creatives to render interactive overlays in an iframe alongside the video.
542
+ ### Waterfall (default)
208
543
 
209
- ### How it works
544
+ Try sources in order, stop at the first success:
210
545
 
211
- When a VAST response includes an `<InteractiveCreativeFile apiFramework="SIMID">` element, the plugin automatically:
546
+ ```ts
547
+ new AdsPlugin({
548
+ adSourcesMode: 'waterfall',
549
+ breaks: [
550
+ {
551
+ at: 'preroll',
552
+ sources: [
553
+ { type: 'VAST', src: 'https://primary-ad-server.com/vast.xml' },
554
+ { type: 'VAST', src: 'https://backup-ad-server.com/vast.xml' },
555
+ { type: 'VAST', src: 'https://house-ad.com/vast.xml' },
556
+ ],
557
+ },
558
+ ],
559
+ });
560
+ ```
212
561
 
213
- 1. Mounts the creative URL in a sandboxed `<iframe>` over the ad video.
214
- 2. Runs the SIMID 1.2 handshake:
215
- - Responds to the creative's `createSession` with a `resolve` + `SIMID:Player:init`.
216
- - Replies to `SIMID:Creative:getMediaState` with the current media state.
217
- - Sends `SIMID:Player:startCreative` when the creative signals it is ready.
218
- 3. Keeps the creative in sync with ad playback (progress, pause, resume, volume, skip, stop).
219
- 4. Handles creative-initiated actions: skip, stop, pause, play, click-through, fullscreen, tracking events.
562
+ ### Playlist
563
+
564
+ Play each source as its own sequential break:
565
+
566
+ ```ts
567
+ new AdsPlugin({
568
+ adSourcesMode: 'playlist',
569
+ breaks: [
570
+ { at: 'preroll', source: { type: 'VAST', src: 'https://ads.example.com/ad1.xml' } },
571
+ { at: 'preroll', source: { type: 'VAST', src: 'https://ads.example.com/ad2.xml' } },
572
+ ],
573
+ });
574
+ ```
575
+
576
+ ---
577
+
578
+ ## `requestInterceptor` — modify or suppress ad tag requests
579
+
580
+ ```ts
581
+ new AdsPlugin({
582
+ csai: {
583
+ requestInterceptor: async (req) => {
584
+ // Add targeting parameters to every ad request.
585
+ const url = new URL(req.url);
586
+ url.searchParams.set('cust_params', 'section=sports&user_id=abc123');
587
+ return { ...req, url: url.toString() };
588
+ },
589
+ },
590
+ });
591
+
592
+ // Return null to suppress a specific request:
593
+ new AdsPlugin({
594
+ csai: {
595
+ requestInterceptor: (req) => {
596
+ if (req.adType === 'vmap') return null; // Skip VMAP fetches
597
+ return req;
598
+ },
599
+ },
600
+ });
601
+ ```
602
+
603
+ ---
604
+
605
+ ## SIMID 1.2 — Interactive Ad Creatives
606
+
607
+ [SIMID](https://iabtechlab.com/standards/simid/) allows VAST creatives to render interactive overlays in an iframe alongside the ad video.
608
+
609
+ When a VAST response includes `<InteractiveCreativeFile apiFramework="SIMID">`, the plugin automatically:
220
610
 
221
- No extra configuration is needed detection and lifecycle management happen automatically.
611
+ 1. Mounts the creative URL in a sandboxed `<iframe>` over the ad video.
612
+ 2. Completes the SIMID 1.2 handshake (`createSession` → `SIMID:Player:init` → `SIMID:Player:startCreative`).
613
+ 3. Keeps the creative in sync with playback (progress, pause, resume, volume, skip, stop).
614
+ 4. Handles creative-initiated actions: skip, stop, click-through, fullscreen, tracking events.
222
615
 
223
- ### SIMID sandbox
616
+ No configuration is required — SIMID is detected and managed automatically.
224
617
 
225
- The iframe is sandboxed with `allow-scripts allow-same-origin allow-forms allow-popups`. The creative's origin is preserved via `referrerpolicy="no-referrer-when-downgrade"`. The iframe is sized to cover the player container.
618
+ The iframe is sandboxed with `allow-scripts allow-same-origin allow-forms allow-popups`.
226
619
 
227
620
  ### Testing SIMID ads
228
621
 
@@ -231,8 +624,7 @@ new AdsPlugin({
231
624
  sources: [
232
625
  {
233
626
  type: 'VAST',
234
- // Google IMA SIMID test tag (replace correlator for each test):
235
- src: `https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/simid&description_url=https%3A%2F%2Fdevelopers.google.com%2Finteractive-media-ads&sz=640x480&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&correlator=${Date.now()}`,
627
+ src: `https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/simid&sz=640x480&gdfp_req=1&output=vast&unviewed_position_start=1&env=vp&correlator=${Date.now()}`,
236
628
  },
237
629
  ],
238
630
  });
@@ -242,70 +634,69 @@ new AdsPlugin({
242
634
 
243
635
  ## OMID — Open Measurement
244
636
 
245
- [OMID (Open Measurement Interface Definition)](https://iabtechlab.com/standards/open-measurement-sdk/) enables third-party viewability and verification measurement inside a VAST ad break.
637
+ [OMID](https://iabtechlab.com/standards/open-measurement-sdk/) enables third-party viewability and verification measurement.
246
638
 
247
- ### Requirements
639
+ ### Setup
248
640
 
249
- The OMID Session Client SDK (`omweb-v1.js`) must be loaded on the page before ads play:
641
+ Load the IAB OMID Session Client SDK before the player:
250
642
 
251
643
  ```html
252
- <!-- Load the IAB OMID Session Client SDK before the player -->
253
644
  <script src="https://iab-mm-omid.com/omweb-v1.js"></script>
254
645
  ```
255
646
 
256
- The plugin detects `window.OmidSessionClient` at runtime. If the SDK is absent OMID silently no-ops — ad playback is never blocked.
647
+ The plugin detects `window.OmidSessionClient` at runtime. If absent, OMID silently no-ops.
257
648
 
258
649
  ### How it works
259
650
 
260
- When the VAST response contains `<AdVerifications>` entries, the plugin:
261
-
262
- 1. Injects each `<JavaScriptResource>` verification script as a `<script>` tag into the page.
263
- 2. Instantiates an `OmidSession` with the ad video element and all verification resources.
264
- 3. Fires the required OMID lifecycle events:
265
- - `impression()` on ad impression
266
- - `loaded()` with skip/autoplay/position metadata
267
- - `start(duration, volume)` when the ad begins playing
268
- - `firstQuartile()`, `midpoint()`, `thirdQuartile()`, `complete()` at the correct percentages
269
- - `pause()` / `resume()` on pause/play
270
- - `skipped()` when the ad is skipped
271
- - `volumeChange(volume)` on volume changes
272
- - `playerStateChange('fullscreen')` on fullscreen toggle
273
- 4. Calls `destroy()` when the break ends.
274
-
275
- No extra configuration is needed — OMID runs automatically when the SDK and `<AdVerifications>` are both present.
651
+ When the VAST response contains `<AdVerifications>`, the plugin injects the verification scripts and fires the required OMID lifecycle events automatically (`impression`, `loaded`, `start`, `firstQuartile`, `midpoint`, `thirdQuartile`, `complete`, `pause`, `resume`, `skipped`, `volumeChange`, `playerStateChange`).
276
652
 
277
653
  ### Access mode
278
654
 
279
- By default, verification scripts run in `limited` access mode (no cross-origin DOM access). You can override this in the plugin config:
280
-
281
655
  ```ts
282
656
  new AdsPlugin({
283
- omid: { accessMode: 'domain' }, // 'limited' (default) | 'domain'
657
+ csai: {
658
+ omid: { accessMode: 'domain' }, // 'limited' (default) | 'domain'
659
+ },
284
660
  sources: [{ type: 'VAST', src: '...' }],
285
661
  });
286
662
  ```
287
663
 
288
- ### Testing OMID ads
664
+ ---
665
+
666
+ ## Architecture
667
+
668
+ `AdsPlugin` is a thin dispatcher. On `setup()` it selects the appropriate strategy:
669
+
670
+ ```
671
+ AdsPlugin.setup()
672
+ ├── adDelivery === 'ssai' → new SsaiAdStrategy()
673
+ ├── adDelivery === 'hybrid' → new HybridAdStrategy() (extends CsaiAdStrategy)
674
+ └── default ('csai') → new CsaiAdStrategy()
675
+ ```
676
+
677
+ Each strategy implements `AdSessionStrategy`:
289
678
 
290
679
  ```ts
291
- new AdsPlugin({
292
- sources: [
293
- {
294
- type: 'VAST',
295
- // Google IMA OMID test tag (replace correlator for each test):
296
- src: `https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/omid_ad_samples&env=vp&gdfp_req=1&output=vast&sz=640x480&description_url=http%3A%2F%2Ftest_site.com%2Fhomepage&vpmute=0&vpa=0&vad_format=linear&url=http%3A%2F%2Ftest_site.com&vpos=preroll&unviewed_position_start=1&correlator=${Date.now()}`,
297
- },
298
- ],
299
- });
680
+ type AdSessionStrategy = {
681
+ readonly mode: AdDeliveryMode;
682
+ init(ctx: PluginContext, config: AdsPluginConfig): void;
683
+ destroy(): void;
684
+ playAds?(url: string): Promise<boolean>;
685
+ getDueMidrollBreaks?(t: number): AdsBreakConfig[];
686
+ getDueMidrollBreak?(t: number): AdsBreakConfig | undefined;
687
+ requestSkip?(reason?: 'button' | 'close' | 'api'): void;
688
+ };
300
689
  ```
301
690
 
691
+ All public methods on `AdsPlugin` delegate to the active strategy, so you can swap delivery modes without changing call sites.
692
+
302
693
  ---
303
694
 
304
695
  ## Dependencies
305
696
 
306
697
  | Package | Type | Required version |
307
698
  | -------------------------- | ------- | ---------------- |
308
- | `@openplayer/core` | peer | `>=3.0.0` |
699
+ | `@openplayerjs/core` | peer | `>=3.0.0` |
309
700
  | `@dailymotion/vast-client` | bundled | `>=6.0.0` |
310
701
  | `@dailymotion/vmap` | bundled | `>=3.0.0` |
311
702
 
@@ -313,37 +704,18 @@ new AdsPlugin({
313
704
 
314
705
  ## Compatibility with iframe engines (YouTube, Vimeo, etc.)
315
706
 
316
- `AdsPlugin` is designed for native `<video>`/`<audio>` content and currently has several integration gaps when used alongside iframe-based engines such as `@openplayerjs/youtube`. These are **known areas of concern** — ads + YouTube is not a supported combination yet:
317
-
318
- ### 1. `media.duration` read from the native element
319
-
320
- `getDueMidrollBreaks()` and related percentage-based midroll logic read `this.ctx.core.media.duration` directly from the native `<video>` element. When a YouTube (or other iframe) engine is active, that element is hidden and has nothing loaded — `media.duration` will be `NaN`, so percentage-based midrolls will never trigger.
321
-
322
- **Fix needed:** replace `this.ctx.core.media.duration` with `this.ctx.core.duration` (which is synced from the active surface via `bindSurfaceSync()`).
323
-
324
- ### 2. `media.currentTime` read from the native element
325
-
326
- `shouldInterceptPreroll()` checks `media?.currentTime` to skip a preroll if playback has already advanced past 0.25 s. For iframe engines, `media.currentTime` is always `0` (the native element is unloaded), so this guard never fires correctly.
327
-
328
- **Fix needed:** replace `media?.currentTime` with `this.ctx.core.currentTime`.
329
-
330
- ### 3. DOM `timeupdate` listener on the native element
331
-
332
- `bindBreakScheduler()` listens to the native `'timeupdate'` DOM event on `core.media` to fire midroll checks. Iframe engines never trigger this event on the native element — they emit `timeupdate` through the player EventBus instead.
333
-
334
- **Fix needed:** replace the native DOM listener with an EventBus listener (`ctx.events.on('timeupdate', ...)`) so it fires for all engine types.
335
-
336
- ### 4. Native `'play'` capture listener in preroll interceptors
337
-
338
- `bindPrerollInterceptors()` attaches a capture-phase `'play'` listener to `core.media` to intercept prerolls before the browser starts rendering. For iframe engines the native `<video>` never emits `'play'` (it is hidden and unloaded), so the preroll interceptor is never triggered.
707
+ `AdsPlugin` is designed for native `<video>`/`<audio>` content. Several gaps exist when used alongside iframe-based engines — ads + YouTube is not a supported combination yet:
339
708
 
340
- **Fix needed:** for iframe engines, hook the preroll interceptor to `cmd:play` on the EventBus instead of the native DOM event.
709
+ - **`media.duration`**: midroll cue-point logic reads the native `<video>` duration directly, which is `NaN` for iframe engines.
710
+ - **`media.currentTime`**: preroll guards check native `currentTime`, which is always `0` for iframe engines.
711
+ - **`timeupdate` listener**: `bindBreakScheduler()` listens on the native DOM element, which never fires for iframe engines.
712
+ - **`play` capture listener**: `bindPrerollInterceptors()` uses a capture-phase listener on the native element that is never triggered for iframe engines.
341
713
 
342
714
  ---
343
715
 
344
716
  ## Code samples
345
717
 
346
- A wide collection of ready-to-run examples covering preroll, midroll, and postroll VAST/VMAP setups, waterfall sources, and non-linear ads is available as a living cookbook in the CodePen collection below.
718
+ Ready-to-run examples covering preroll, midroll, postroll, waterfall sources, VMAP, SSAI, and non-linear ads are available in the CodePen collection:
347
719
 
348
720
  CodePen Collection: [https://codepen.io/collection/kkwgWj](https://codepen.io/collection/kkwgWj)
349
721