@streamlayer/feature-advertisement 1.1.0 → 1.1.1-rc.1343

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.
Files changed (77) hide show
  1. package/classic/package.json +5 -0
  2. package/lib/advertisement.d.ts +6 -6
  3. package/lib/advertisement.js +8 -8
  4. package/lib/cjs/advertisement.d.ts +64 -0
  5. package/lib/cjs/advertisement.js +307 -0
  6. package/lib/cjs/index.d.ts +19 -0
  7. package/lib/cjs/index.js +39 -0
  8. package/lib/cjs/package.json +1 -0
  9. package/lib/cjs/units/external-ad.d.ts +58 -0
  10. package/lib/cjs/units/external-ad.js +390 -0
  11. package/lib/cjs/units/external-pause-ad.d.ts +50 -0
  12. package/lib/cjs/units/external-pause-ad.js +253 -0
  13. package/lib/cjs/units/gam.d.ts +16 -0
  14. package/lib/cjs/units/gam.js +104 -0
  15. package/lib/cjs/units/parse-vast-extensions.d.ts +17 -0
  16. package/lib/cjs/units/parse-vast-extensions.js +52 -0
  17. package/lib/cjs/units/pause-ad-card.d.ts +49 -0
  18. package/lib/cjs/units/pause-ad-card.js +136 -0
  19. package/lib/cjs/units/pause-ad.d.ts +16 -0
  20. package/lib/cjs/units/pause-ad.js +52 -0
  21. package/lib/cjs/units/ssai-ad-break.d.ts +21 -0
  22. package/lib/cjs/units/ssai-ad-break.js +11 -0
  23. package/lib/cjs/units/ssai-creative.d.ts +89 -0
  24. package/lib/cjs/units/ssai-creative.js +136 -0
  25. package/lib/cjs/units/ssai-detector.d.ts +180 -0
  26. package/lib/cjs/units/ssai-detector.js +1156 -0
  27. package/lib/cjs/units/ssai-preload.d.ts +20 -0
  28. package/lib/cjs/units/ssai-preload.js +62 -0
  29. package/lib/cjs/units/ssai-providers.d.ts +117 -0
  30. package/lib/cjs/units/ssai-providers.js +263 -0
  31. package/lib/cjs/units/vast-tracker.d.ts +45 -0
  32. package/lib/cjs/units/vast-tracker.js +64 -0
  33. package/lib/classic/_virtual/_rollupPluginBabelHelpers.js +71 -0
  34. package/lib/classic/advertisement.js +331 -0
  35. package/lib/classic/cjs/_virtual/_rollupPluginBabelHelpers.js +77 -0
  36. package/lib/classic/cjs/advertisement.js +333 -0
  37. package/lib/classic/cjs/index.js +47 -0
  38. package/lib/classic/cjs/package.json +1 -0
  39. package/lib/classic/cjs/units/external-ad.js +453 -0
  40. package/lib/classic/cjs/units/external-pause-ad.js +274 -0
  41. package/lib/classic/cjs/units/gam.js +110 -0
  42. package/lib/classic/cjs/units/parse-vast-extensions.js +56 -0
  43. package/lib/classic/cjs/units/pause-ad-card.js +134 -0
  44. package/lib/classic/cjs/units/pause-ad.js +72 -0
  45. package/lib/classic/cjs/units/ssai-ad-break.js +11 -0
  46. package/lib/classic/cjs/units/ssai-creative.js +142 -0
  47. package/lib/classic/cjs/units/ssai-detector.js +1170 -0
  48. package/lib/classic/cjs/units/ssai-preload.js +72 -0
  49. package/lib/classic/cjs/units/ssai-providers.js +299 -0
  50. package/lib/classic/cjs/units/vast-tracker.js +81 -0
  51. package/lib/classic/index.js +28 -0
  52. package/lib/classic/units/external-ad.js +447 -0
  53. package/lib/classic/units/external-pause-ad.js +271 -0
  54. package/lib/classic/units/gam.js +108 -0
  55. package/lib/classic/units/parse-vast-extensions.js +54 -0
  56. package/lib/classic/units/pause-ad-card.js +131 -0
  57. package/lib/classic/units/pause-ad.js +70 -0
  58. package/lib/classic/units/ssai-ad-break.js +9 -0
  59. package/lib/classic/units/ssai-creative.js +140 -0
  60. package/lib/classic/units/ssai-detector.js +1167 -0
  61. package/lib/classic/units/ssai-preload.js +68 -0
  62. package/lib/classic/units/ssai-providers.js +289 -0
  63. package/lib/classic/units/vast-tracker.js +77 -0
  64. package/lib/index.d.ts +13 -13
  65. package/lib/index.js +8 -8
  66. package/lib/units/external-ad.d.ts +2 -2
  67. package/lib/units/external-ad.js +2 -2
  68. package/lib/units/pause-ad.d.ts +1 -1
  69. package/lib/units/pause-ad.js +1 -1
  70. package/lib/units/ssai-ad-break.d.ts +1 -1
  71. package/lib/units/ssai-creative.d.ts +1 -1
  72. package/lib/units/ssai-creative.js +1 -1
  73. package/lib/units/ssai-detector.d.ts +1 -1
  74. package/lib/units/ssai-preload.d.ts +1 -1
  75. package/lib/units/ssai-providers.d.ts +1 -1
  76. package/lib/units/vast-tracker.d.ts +1 -1
  77. package/package.json +41 -8
@@ -0,0 +1,1170 @@
1
+ 'use strict';
2
+
3
+ var _rollupPluginBabelHelpers = require('../_virtual/_rollupPluginBabelHelpers.js');
4
+ require('core-js/modules/es.array.iterator.js');
5
+ require('core-js/modules/es.array.push.js');
6
+ require('core-js/modules/es.array.sort.js');
7
+ require('core-js/modules/es.object.values.js');
8
+ require('core-js/modules/es.promise.js');
9
+ require('core-js/modules/es.regexp.exec.js');
10
+ require('core-js/modules/es.string.split.js');
11
+ require('core-js/modules/es.string.trim.js');
12
+ require('core-js/modules/esnext.iterator.constructor.js');
13
+ require('core-js/modules/esnext.iterator.filter.js');
14
+ require('core-js/modules/esnext.iterator.find.js');
15
+ require('core-js/modules/esnext.iterator.map.js');
16
+ require('core-js/modules/esnext.iterator.some.js');
17
+ require('core-js/modules/esnext.set.difference.v2.js');
18
+ require('core-js/modules/esnext.set.intersection.v2.js');
19
+ require('core-js/modules/esnext.set.is-disjoint-from.v2.js');
20
+ require('core-js/modules/esnext.set.is-subset-of.v2.js');
21
+ require('core-js/modules/esnext.set.is-superset-of.v2.js');
22
+ require('core-js/modules/esnext.set.symmetric-difference.v2.js');
23
+ require('core-js/modules/esnext.set.union.v2.js');
24
+ require('core-js/modules/web.dom-collections.iterator.js');
25
+ var classic = require('@streamlayer/sdk-web-logger/classic');
26
+
27
+ const log = classic.createLogger('ssai:detector');
28
+ const RANGE_EXPIRY_MS = 10 * 60 * 1000;
29
+ const ACTIVE_POLL_MS = 250;
30
+ const MANIFEST_POLL_MS = 3000;
31
+ /**
32
+ * Supplemental manifest poll cadence on the hls.js path.
33
+ *
34
+ * hls.js re-reads a live playlist about once per target duration, and a DAI
35
+ * playout publishes an ad's tracking markers only seconds AFTER that ad started
36
+ * at the live edge (measured on the live playout: ~5s), while a default-config
37
+ * viewer trails the edge by ~3 target durations. The leftover headroom is close
38
+ * to zero — the bar for the second ad of a pod rose late simply because the
39
+ * manifest carrying its markers had not been re-read yet. Polling the active
40
+ * level's playlist at 1s shaves that reload latency. DATERANGE merging only;
41
+ * the program clock still comes from `playingDate`.
42
+ */
43
+ const LIVE_EDGE_POLL_MS = 1000;
44
+ /**
45
+ * The end edge's ambiguity window. Both edges normally fire on the poll tick —
46
+ * the end is decided from the schedule, not a timer (see `updateActiveAd`): a
47
+ * break with no further window opening within this span is really over and
48
+ * closes immediately. The timer covers only what the schedule cannot settle on
49
+ * the spot: the next ad of a pod opens within this window (hold the bar through
50
+ * the gap — entering it cancels the timer), or the program clock is momentarily
51
+ * unreadable.
52
+ */
53
+ const END_DEBOUNCE_MS = 1000;
54
+ /**
55
+ * A window shorter than this is noise, not an ad — no real creative runs under
56
+ * 2s (bumpers are 6s), while a misread marker or degenerate fold easily does.
57
+ * This is the data-domain replacement for the start debounce that used to keep
58
+ * short windows from flashing: filtering them out entirely costs no latency on
59
+ * real ads.
60
+ */
61
+ const MIN_AD_WINDOW_MS = 2000;
62
+ const DEBUG_EVENTS_MAX = 50;
63
+ const DROPPED_KEYS_MAX = 100;
64
+ /**
65
+ * How far before the active ad window's end `onAdEnding` fires. Sized to fit
66
+ * the overlay's closing fade (200ms) plus one poll tick, so the bar finishes
67
+ * dissolving roughly AT the boundary — and the next ad of a pod then enters
68
+ * with its own animation instead of the content swapping inside an open
69
+ * overlay.
70
+ */
71
+ const AD_ENDING_LEAD_MS = 500;
72
+ /** Lifecycle beacons, keyed by the fraction of the ad window that triggers them. */
73
+ const PROGRESS_THRESHOLDS = [['start', 0], ['firstQuartile', 0.25], ['midpoint', 0.5], ['thirdQuartile', 0.75]];
74
+ // hls.js event names — kept as string constants to avoid importing hls.js.
75
+ const HLS_EVENT_LEVEL_UPDATED = 'hlsLevelUpdated';
76
+ const HLS_EVENT_FRAG_CHANGED = 'hlsFragChanged';
77
+ const readAttr = (attrs, key) => {
78
+ if (!attrs) {
79
+ return undefined;
80
+ }
81
+ if (typeof attrs.get === 'function') {
82
+ return attrs.get(key);
83
+ }
84
+ return attrs[key];
85
+ };
86
+ const toTimeMs = value => {
87
+ if (value instanceof Date) {
88
+ return value.getTime();
89
+ }
90
+ if (typeof value === 'number') {
91
+ return value > 1000000000000 ? value : value * 1000;
92
+ }
93
+ if (typeof value === 'string' && value.length > 0) {
94
+ const parsed = Date.parse(value);
95
+ return Number.isFinite(parsed) ? parsed : NaN;
96
+ }
97
+ return NaN;
98
+ };
99
+ const normalizeRange = range => {
100
+ if (!range.creativeId || !Number.isFinite(range.startMs) || !Number.isFinite(range.durationSec)) {
101
+ return null;
102
+ }
103
+ const id = range.id || `${range.creativeId}-${range.startMs}`;
104
+ return {
105
+ id,
106
+ creativeId: range.creativeId,
107
+ startMs: range.startMs,
108
+ durationSec: range.durationSec,
109
+ endMs: range.startMs + range.durationSec * 1000
110
+ };
111
+ };
112
+ // -- DATERANGE dialects ------------------------------------------------------
113
+ //
114
+ // Two shapes of ad signalling are supported, told apart by the tags themselves
115
+ // rather than by configuration — a stream may even carry both.
116
+ //
117
+ // 1. CREATIVE ranges (StreamLayer): ONE DATERANGE per creative whose own
118
+ // START-DATE/DURATION *is* the ad window, identified by `X-CREATIVE-ID`
119
+ // (or `X-AD-ID`).
120
+ //
121
+ // 2. TRACKING markers (Synamedia/Quortex DAI): a burst of DATERANGEs per ad,
122
+ // each `DURATION=0.250`, carrying `X-AD-ID` + `X-AD-EVENT-TYPE`
123
+ // (impression / start / quartiles / complete) and the beacon in `X-AD-URL`,
124
+ // plus one `CLASS="com.synamedia.dai.adbreak"` range spanning the break.
125
+ //
126
+ // A marker's own 250ms duration is NOT the ad window — taking it literally
127
+ // would put every break below MIN_AD_WINDOW_MS, and the bar would never show
128
+ // at all. The ad's real window is its `start` marker through its `complete`
129
+ // marker, which is what `foldTrackingMarkers` reconstructs (measured against
130
+ // the live stream: start 10:38:04.628 → complete 10:38:19.610 = 14.982s,
131
+ // exactly the sibling adbreak range's DURATION).
132
+ const SYNAMEDIA_ADBREAK_CLASS = 'com.synamedia.dai.adbreak';
133
+ /**
134
+ * Classify one DATERANGE. Order matters: a tracking marker also carries
135
+ * `X-AD-ID`, so the event-type test has to come before the creative test or
136
+ * every marker would be mistaken for a 250ms ad.
137
+ */
138
+ const classifyDateRange = fields => {
139
+ const {
140
+ id,
141
+ creativeId,
142
+ adId,
143
+ eventType,
144
+ className,
145
+ adUrl,
146
+ startMs,
147
+ durationSec
148
+ } = fields;
149
+ if (!Number.isFinite(startMs)) {
150
+ return {
151
+ kind: 'invalid',
152
+ key: id || `${creativeId || adId || className || 'unknown'}-no-start`,
153
+ detail: `no usable START-DATE (id=${id || 'n/a'}, creative=${creativeId || adId || 'n/a'})`
154
+ };
155
+ }
156
+ if (eventType && adId) {
157
+ return {
158
+ kind: 'marker',
159
+ marker: {
160
+ id: id || `${adId}-${eventType}-${startMs}`,
161
+ adId,
162
+ event: eventType,
163
+ timeMs: startMs,
164
+ url: adUrl
165
+ }
166
+ };
167
+ }
168
+ if (className === SYNAMEDIA_ADBREAK_CLASS && Number.isFinite(durationSec)) {
169
+ return {
170
+ kind: 'adbreak',
171
+ window: {
172
+ id: id || `adbreak-${startMs}`,
173
+ startMs,
174
+ endMs: startMs + durationSec * 1000
175
+ }
176
+ };
177
+ }
178
+ // `X-AD-ID` is accepted alongside `X-CREATIVE-ID` so a stream that marks the
179
+ // ad directly (id + real duration, no tracking burst) still works.
180
+ const resolvedCreativeId = creativeId || adId;
181
+ const range = normalizeRange({
182
+ id,
183
+ creativeId: resolvedCreativeId,
184
+ startMs,
185
+ durationSec
186
+ });
187
+ if (!range) {
188
+ return {
189
+ kind: 'invalid',
190
+ key: id || `no-creative-id-${startMs}`,
191
+ detail: `no usable creative id or duration (id=${id || 'n/a'}, start=${debugClock(startMs)})`
192
+ };
193
+ }
194
+ return {
195
+ kind: 'creative',
196
+ range
197
+ };
198
+ };
199
+ // A playout rotates a small ad pool, so the SAME creative re-airs minutes apart
200
+ // and its markers — up to RANGE_EXPIRY_MS of them — share one `X-AD-ID`.
201
+ // Markers of one ad id therefore must be clustered into AIRINGS before folding;
202
+ // a global min→max fold spans the CONTENT between two airings (observed live: a
203
+ // 15s creative folded into a 305s window, so the bar opened 282s before its ad
204
+ // video, all four quartile beacons fired in one tick, and the error grew with
205
+ // watch time as older markers stretched the window further back).
206
+ //
207
+ // An airing boundary is decided by the playout's own break signalling first —
208
+ // two markers inside DIFFERENT `com.synamedia.dai.adbreak` windows are different
209
+ // airings — and by dwell gap when either marker sits outside every known break
210
+ // window: within one airing, consecutive markers are at most a quartile apart
211
+ // (ad duration / 4), so a gap above this threshold cannot be the same airing.
212
+ const MARKER_AIRING_GAP_MS = 60 * 1000;
213
+ // A `complete` marker regularly lands exactly ON its break's end boundary (and
214
+ // encoders jitter a little past it), so window membership is tested with slack.
215
+ const MARKER_BREAK_TOLERANCE_MS = 1000;
216
+ /**
217
+ * `X-AD-EVENT-TYPE` values that map onto lifecycle beacons, lowercased. The
218
+ * marker's own timestamp is the ad server's declared time for that beacon, so it
219
+ * beats deriving the quartile from fractions of the (possibly provisional) window.
220
+ */
221
+ const PROGRESS_MARKER_EVENTS = {
222
+ start: 'start',
223
+ firstquartile: 'firstQuartile',
224
+ midpoint: 'midpoint',
225
+ thirdquartile: 'thirdQuartile',
226
+ complete: 'complete'
227
+ };
228
+ /**
229
+ * Which break window does a marker belong to?
230
+ *
231
+ * The live playout emits ONE `adbreak` range PER AD, back-to-back — so two
232
+ * windows share a boundary instant exactly, and time alone cannot decide it:
233
+ * ad N's `complete` and ad N+1's `impression`/`start` sit on the SAME
234
+ * millisecond, one belonging left and one right. (Observed live: assigning
235
+ * ad N+1's start markers to ad N's window split them off into a separate
236
+ * degenerate airing, so the visible window began at the FIRST QUARTILE and the
237
+ * second ad's bar rose ~a quarter of the ad late.) The event's meaning breaks
238
+ * the tie — a closing marker sits on its break's END, an opening one on its
239
+ * START. Everything else prefers strict containment, with the ±tolerance pass
240
+ * as the fallback for encoder jitter past a boundary.
241
+ */
242
+ const findEnclosingBreak = (breaks, timeMs, event) => {
243
+ const kind = event ? event.toLowerCase() : '';
244
+ if (kind === 'complete') {
245
+ const closing = breaks.find(window => Math.abs(window.endMs - timeMs) <= MARKER_BREAK_TOLERANCE_MS);
246
+ if (closing) {
247
+ return closing;
248
+ }
249
+ }
250
+ if (kind === 'impression' || kind === 'start') {
251
+ const opening = breaks.find(window => Math.abs(window.startMs - timeMs) <= MARKER_BREAK_TOLERANCE_MS);
252
+ if (opening) {
253
+ return opening;
254
+ }
255
+ }
256
+ return breaks.find(window => timeMs >= window.startMs && timeMs < window.endMs) || breaks.find(window => timeMs >= window.startMs - MARKER_BREAK_TOLERANCE_MS && timeMs <= window.endMs + MARKER_BREAK_TOLERANCE_MS);
257
+ };
258
+ /**
259
+ * Reconstruct one ad window per AIRING of an `X-AD-ID` from its tracking
260
+ * markers: earliest marker (impression/start) to latest (complete).
261
+ *
262
+ * `breaks` separates re-airings of the same creative (see above) and is the
263
+ * fallback end: until the `complete` marker shows up, an airing whose markers
264
+ * all share one timestamp would fold to a zero-length window and never display;
265
+ * when an enclosing adbreak range covers it, its end is used instead, and the
266
+ * window tightens on the refresh that brings `complete` in.
267
+ *
268
+ * Exported for unit tests only — production code reaches it through the
269
+ * detector.
270
+ */
271
+ const foldTrackingMarkers = (markers, breaks) => {
272
+ const byAdId = new Map();
273
+ for (const marker of markers) {
274
+ const group = byAdId.get(marker.adId);
275
+ if (group) {
276
+ group.push(marker);
277
+ } else {
278
+ byAdId.set(marker.adId, [marker]);
279
+ }
280
+ }
281
+ const ranges = [];
282
+ for (const [adId, group] of byAdId.entries()) {
283
+ group.sort((left, right) => left.timeMs - right.timeMs);
284
+ // Split the sorted markers into airings.
285
+ const airings = [];
286
+ let current = [];
287
+ let currentBreak;
288
+ for (const marker of group) {
289
+ const markerBreak = findEnclosingBreak(breaks, marker.timeMs, marker.event);
290
+ if (current.length > 0) {
291
+ const previous = current[current.length - 1];
292
+ const startsNewAiring = markerBreak && currentBreak ? markerBreak !== currentBreak : marker.timeMs - previous.timeMs > MARKER_AIRING_GAP_MS;
293
+ if (startsNewAiring) {
294
+ airings.push(current);
295
+ current = [];
296
+ currentBreak = undefined;
297
+ }
298
+ }
299
+ current.push(marker);
300
+ if (markerBreak) {
301
+ currentBreak = markerBreak;
302
+ }
303
+ }
304
+ if (current.length > 0) {
305
+ airings.push(current);
306
+ }
307
+ for (const airing of airings) {
308
+ const startMs = airing[0].timeMs;
309
+ let endMs = airing[airing.length - 1].timeMs;
310
+ if (endMs <= startMs) {
311
+ // An airing's first marker is its impression/start, so resolve the
312
+ // enclosing break with opening semantics — on a shared boundary the
313
+ // window STARTING here is the one this airing belongs to.
314
+ const enclosing = findEnclosingBreak(breaks, startMs, 'start');
315
+ endMs = enclosing ? enclosing.endMs : startMs;
316
+ }
317
+ if (endMs <= startMs) {
318
+ continue;
319
+ }
320
+ // The airing is sorted, so the first marker of an event is its earliest —
321
+ // and the only one there should be.
322
+ const progressAtMs = {};
323
+ for (const airingMarker of airing) {
324
+ const progressEvent = PROGRESS_MARKER_EVENTS[airingMarker.event.toLowerCase()];
325
+ if (progressEvent && progressAtMs[progressEvent] === undefined) {
326
+ progressAtMs[progressEvent] = airingMarker.timeMs;
327
+ }
328
+ }
329
+ ranges.push(_rollupPluginBabelHelpers.objectSpread2({
330
+ id: `ssai-ad:${adId}:${startMs}`,
331
+ creativeId: adId,
332
+ startMs,
333
+ durationSec: (endMs - startMs) / 1000,
334
+ endMs
335
+ }, Object.keys(progressAtMs).length > 0 ? {
336
+ progressAtMs
337
+ } : {}));
338
+ }
339
+ }
340
+ return ranges;
341
+ };
342
+ const parseHlsDateRange = raw => {
343
+ if (!raw || typeof raw !== 'object') {
344
+ return null;
345
+ }
346
+ const attr = raw.attr || raw.attrs || raw.attributes;
347
+ return classifyDateRange({
348
+ id: raw.id || readAttr(attr, 'ID'),
349
+ creativeId: readAttr(attr, 'X-CREATIVE-ID') || raw['X-CREATIVE-ID'],
350
+ adId: readAttr(attr, 'X-AD-ID') || raw['X-AD-ID'],
351
+ eventType: readAttr(attr, 'X-AD-EVENT-TYPE'),
352
+ className: readAttr(attr, 'CLASS'),
353
+ adUrl: readAttr(attr, 'X-AD-URL'),
354
+ startMs: toTimeMs(raw.startDate || raw.startDateTime || readAttr(attr, 'START-DATE')),
355
+ durationSec: Number(raw.duration || readAttr(attr, 'DURATION'))
356
+ });
357
+ };
358
+ const parseAttributeList = input => {
359
+ const attrs = {};
360
+ let index = 0;
361
+ while (index < input.length) {
362
+ while (input[index] === ',' || input[index] === ' ') {
363
+ index += 1;
364
+ }
365
+ const keyStart = index;
366
+ while (index < input.length && input[index] !== '=') {
367
+ index += 1;
368
+ }
369
+ const key = input.slice(keyStart, index).trim();
370
+ index += 1;
371
+ let value;
372
+ if (input[index] === '"') {
373
+ index += 1;
374
+ const valueStart = index;
375
+ while (index < input.length && input[index] !== '"') {
376
+ index += 1;
377
+ }
378
+ value = input.slice(valueStart, index);
379
+ index += 1;
380
+ } else {
381
+ const valueStart = index;
382
+ while (index < input.length && input[index] !== ',') {
383
+ index += 1;
384
+ }
385
+ value = input.slice(valueStart, index).trim();
386
+ }
387
+ if (key) {
388
+ attrs[key] = value;
389
+ }
390
+ if (input[index] === ',') {
391
+ index += 1;
392
+ }
393
+ }
394
+ return attrs;
395
+ };
396
+ const parseManifestDateRanges = manifest => {
397
+ return manifest.split(/\r?\n/).filter(line => line.startsWith('#EXT-X-DATERANGE:')).map(line => {
398
+ const attr = parseAttributeList(line.slice('#EXT-X-DATERANGE:'.length));
399
+ return classifyDateRange({
400
+ id: attr.ID,
401
+ creativeId: attr['X-CREATIVE-ID'],
402
+ adId: attr['X-AD-ID'],
403
+ eventType: attr['X-AD-EVENT-TYPE'],
404
+ className: attr.CLASS,
405
+ adUrl: attr['X-AD-URL'],
406
+ startMs: toTimeMs(attr['START-DATE']),
407
+ durationSec: Number(attr.DURATION)
408
+ });
409
+ }).filter(parsed => Boolean(parsed));
410
+ };
411
+ // Parse media-playlist segments and assign each a program time (ms).
412
+ //
413
+ // EXT-X-PROGRAM-DATE-TIME anchors absolute time to the *next* segment; we then
414
+ // accumulate EXTINF durations forward until the next explicit PDT re-anchors.
415
+ // For a live sliding window, only some refreshes carry a fresh PDT (typically
416
+ // the first segment), so we must accept a PDT that appears mid-playlist and
417
+ // re-anchor from there rather than latching the first value of the file.
418
+ const parseManifestSegments = manifest => {
419
+ const segments = [];
420
+ let nextDurationSec = NaN;
421
+ let currentProgramDateTimeMs = NaN;
422
+ for (const rawLine of manifest.split(/\r?\n/)) {
423
+ const line = rawLine.trim();
424
+ if (!line) {
425
+ continue;
426
+ }
427
+ if (line.startsWith('#EXTINF:')) {
428
+ nextDurationSec = Number(line.slice('#EXTINF:'.length).split(',')[0]);
429
+ continue;
430
+ }
431
+ if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) {
432
+ // Explicit anchor — re-base accumulation from here (handles refreshes whose
433
+ // first segment carries the only PDT, and mid-playlist re-anchors).
434
+ currentProgramDateTimeMs = toTimeMs(line.slice('#EXT-X-PROGRAM-DATE-TIME:'.length));
435
+ continue;
436
+ }
437
+ if (line.startsWith('#')) {
438
+ continue;
439
+ }
440
+ const durationSec = Number.isFinite(nextDurationSec) ? nextDurationSec : 0;
441
+ segments.push({
442
+ url: line,
443
+ durationSec,
444
+ programDateTimeMs: currentProgramDateTimeMs
445
+ });
446
+ currentProgramDateTimeMs = Number.isFinite(currentProgramDateTimeMs) ? currentProgramDateTimeMs + durationSec * 1000 : NaN;
447
+ nextDurationSec = NaN;
448
+ }
449
+ return segments;
450
+ };
451
+ const mediaRangeEnd = ranges => {
452
+ if (!ranges || ranges.length === 0) {
453
+ return NaN;
454
+ }
455
+ return ranges.end(ranges.length - 1);
456
+ };
457
+ const findLast = (items, predicate) => {
458
+ for (let index = items.length - 1; index >= 0; index -= 1) {
459
+ if (predicate(items[index])) {
460
+ return items[index];
461
+ }
462
+ }
463
+ return undefined;
464
+ };
465
+ /** "HH:MM:SS.mmm" (UTC) for debug-event details; program time and START-DATE share the UTC timeline. */
466
+ const debugClock = ms => Number.isFinite(ms) ? new Date(ms).toISOString().slice(11, 23) : 'n/a';
467
+ const createSsaiDetector = options => {
468
+ var _player$media;
469
+ const {
470
+ player,
471
+ videoRef,
472
+ manifestUrl,
473
+ onAdDiscovered,
474
+ onBreakStart,
475
+ onCreativeChange,
476
+ onAdEnding,
477
+ onBreakEnd,
478
+ onProgress
479
+ } = options;
480
+ const video = (_player$media = player === null || player === void 0 ? void 0 : player.media) !== null && _player$media !== void 0 ? _player$media : videoRef;
481
+ if (!player && !video) {
482
+ log.info('ssai detector: neither player nor videoRef provided, no-op');
483
+ return {
484
+ destroy: () => undefined,
485
+ dismissActiveBreak: () => undefined,
486
+ getDebugSnapshot: () => ({
487
+ wallMs: Date.now(),
488
+ programMs: NaN,
489
+ clockSource: 'wall-clock',
490
+ attachMode: 'none',
491
+ playingDateMs: NaN,
492
+ pdtOriginProgramMs: NaN,
493
+ programTimeOriginMs: NaN,
494
+ hls: null,
495
+ video: null,
496
+ ranges: [],
497
+ markerCount: 0,
498
+ adBreakCount: 0,
499
+ breakActive: false,
500
+ breakSuppressed: false,
501
+ activeRangeId: null,
502
+ activeCreativeId: null,
503
+ progress: null,
504
+ events: []
505
+ })
506
+ };
507
+ }
508
+ // Creative ranges arrive ready to use; tracking markers have to be kept raw and
509
+ // re-folded on every refresh, since a later manifest is what carries the
510
+ // `complete` marker that closes an ad's window.
511
+ const rangesById = new Map();
512
+ const markersById = new Map();
513
+ const adBreaksById = new Map();
514
+ let programTimeOriginMs = NaN;
515
+ // active state
516
+ let activeRangeId = '';
517
+ let activeCreativeId = '';
518
+ // End-edge debounce only; a break START fires the instant the playhead enters
519
+ // a window (short/bogus windows are filtered by MIN_AD_WINDOW_MS instead).
520
+ let breakEndTimer = null;
521
+ let breakActive = false;
522
+ // User dismissed the current break: stay hidden (no creative-change re-show)
523
+ // until this break ends. A NEW break after this one shows again.
524
+ let breakSuppressed = false;
525
+ // Which range already got its one `onAdEnding` announcement.
526
+ let endingNotifiedRangeId = '';
527
+ // Lifecycle-beacon state for the ad currently being watched. Tracked per ad
528
+ // (not per break) so each ad of a pod reports its own quartiles.
529
+ let progressRange = null;
530
+ let progressFired = {};
531
+ let destroyed = false;
532
+ // Debug-only state. Recording is side-effect free for detection: nothing
533
+ // below ever reads it back.
534
+ const debugEvents = [];
535
+ const seenMarkerAdIds = new Set();
536
+ // Malformed tags persist in the manifest across refreshes; log each once.
537
+ const seenDroppedKeys = new Set();
538
+ const attachMode = player ? 'hls' : videoRef && manifestUrl ? 'manifest-poll' : 'none';
539
+ const recordDebugEvent = (type, detail, programMs) => {
540
+ debugEvents.push({
541
+ wallMs: Date.now(),
542
+ programMs: programMs !== undefined ? programMs : currentProgramTimeMs(),
543
+ type,
544
+ detail
545
+ });
546
+ if (debugEvents.length > DEBUG_EVENTS_MAX) {
547
+ debugEvents.splice(0, debugEvents.length - DEBUG_EVENTS_MAX);
548
+ }
549
+ };
550
+ const getVideo = () => {
551
+ var _ref, _player$media2;
552
+ return (_ref = (_player$media2 = player === null || player === void 0 ? void 0 : player.media) !== null && _player$media2 !== void 0 ? _player$media2 : video) !== null && _ref !== void 0 ? _ref : null;
553
+ };
554
+ // Memoized view over the three marker maps. `updateActiveAd` polls 4×/s and
555
+ // re-folding markers + sorting on every tick is measurable churn on low-end
556
+ // TV CPUs; the underlying sets only actually change on a manifest refresh
557
+ // (every few seconds), so the fold is rebuilt exactly then — `mergeDateRanges`
558
+ // invalidates when (and only when) an add, a content change or an expiry
559
+ // really altered them.
560
+ let rangesCache = null;
561
+ const invalidateRangesCache = () => {
562
+ rangesCache = null;
563
+ };
564
+ const getRangesCache = () => {
565
+ if (!rangesCache) {
566
+ const folded = foldTrackingMarkers(Array.from(markersById.values()), Array.from(adBreaksById.values()));
567
+ const sorted = Array.from(rangesById.values()).concat(folded).sort((left, right) => left.startMs - right.startMs);
568
+ rangesCache = {
569
+ folded,
570
+ sorted
571
+ };
572
+ }
573
+ return rangesCache;
574
+ };
575
+ /**
576
+ * A creative id is new to us — hand it to the prefetch hook. Guarded so a
577
+ * throwing handler can never abort the manifest merge it interrupts.
578
+ */
579
+ const notifyAdDiscovered = creativeId => {
580
+ if (!onAdDiscovered) {
581
+ return;
582
+ }
583
+ try {
584
+ onAdDiscovered(creativeId);
585
+ } catch (error) {
586
+ log.warn(error, 'ssai onAdDiscovered handler threw');
587
+ }
588
+ };
589
+ const mergeDateRanges = parsed => {
590
+ const nowMs = Date.now();
591
+ const expiredBefore = nowMs - RANGE_EXPIRY_MS;
592
+ let changed = false;
593
+ for (const entry of parsed) {
594
+ if (entry.kind === 'invalid') {
595
+ if (!seenDroppedKeys.has(entry.key)) {
596
+ if (seenDroppedKeys.size >= DROPPED_KEYS_MAX) {
597
+ seenDroppedKeys.clear();
598
+ }
599
+ seenDroppedKeys.add(entry.key);
600
+ recordDebugEvent('range-dropped', entry.detail);
601
+ log.warn({
602
+ detail: entry.detail
603
+ }, 'ssai DATERANGE dropped');
604
+ }
605
+ continue;
606
+ }
607
+ if (entry.kind === 'creative') {
608
+ const existing = rangesById.get(entry.range.id);
609
+ if (!existing) {
610
+ changed = true;
611
+ recordDebugEvent('range-discovered', `${entry.range.creativeId} ${debugClock(entry.range.startMs)}+${entry.range.durationSec}s`);
612
+ notifyAdDiscovered(entry.range.creativeId);
613
+ } else if (existing.startMs !== entry.range.startMs || existing.endMs !== entry.range.endMs) {
614
+ changed = true;
615
+ }
616
+ rangesById.set(entry.range.id, entry.range);
617
+ } else if (entry.kind === 'marker') {
618
+ if (!seenMarkerAdIds.has(entry.marker.adId)) {
619
+ seenMarkerAdIds.add(entry.marker.adId);
620
+ recordDebugEvent('marker-ad-discovered', `${entry.marker.adId} ${entry.marker.event}@${debugClock(entry.marker.timeMs)}`);
621
+ notifyAdDiscovered(entry.marker.adId);
622
+ }
623
+ const existing = markersById.get(entry.marker.id);
624
+ if (!existing || existing.timeMs !== entry.marker.timeMs) {
625
+ changed = true;
626
+ }
627
+ markersById.set(entry.marker.id, entry.marker);
628
+ } else {
629
+ const existing = adBreaksById.get(entry.window.id);
630
+ if (!existing) {
631
+ changed = true;
632
+ recordDebugEvent('adbreak-discovered', `${debugClock(entry.window.startMs)}-${debugClock(entry.window.endMs)}`);
633
+ } else if (existing.startMs !== entry.window.startMs || existing.endMs !== entry.window.endMs) {
634
+ changed = true;
635
+ }
636
+ adBreaksById.set(entry.window.id, entry.window);
637
+ }
638
+ }
639
+ for (const [id, range] of rangesById.entries()) {
640
+ if (range.endMs < expiredBefore) {
641
+ rangesById.delete(id);
642
+ changed = true;
643
+ }
644
+ }
645
+ for (const [id, marker] of markersById.entries()) {
646
+ if (marker.timeMs < expiredBefore) {
647
+ markersById.delete(id);
648
+ changed = true;
649
+ }
650
+ }
651
+ // Keep the discovery set in step with expiry so an ad id that legitimately
652
+ // recurs later (and a stream running for hours) re-logs instead of leaking.
653
+ if (seenMarkerAdIds.size > 0) {
654
+ const liveAdIds = new Set();
655
+ for (const marker of markersById.values()) {
656
+ liveAdIds.add(marker.adId);
657
+ }
658
+ for (const adId of seenMarkerAdIds) {
659
+ if (!liveAdIds.has(adId)) {
660
+ seenMarkerAdIds.delete(adId);
661
+ }
662
+ }
663
+ }
664
+ for (const [id, window] of adBreaksById.entries()) {
665
+ if (window.endMs < expiredBefore) {
666
+ adBreaksById.delete(id);
667
+ changed = true;
668
+ }
669
+ }
670
+ if (changed) {
671
+ invalidateRangesCache();
672
+ }
673
+ };
674
+ const findActiveRange = programTimeMs => {
675
+ // A non-finite program time means we have no usable origin yet (e.g. native
676
+ // HLS before the first PDT-bearing manifest). Skip rather than risk matching
677
+ // a range against a bogus time.
678
+ if (!Number.isFinite(programTimeMs)) {
679
+ return null;
680
+ }
681
+ // The LATEST-starting window containing the playhead, not the earliest:
682
+ // windows overlap while a marker airing's end is still provisional (its
683
+ // fallback end spans the whole adbreak), and inside the overlap the ad
684
+ // actually on screen is the one that started last. Picking the earliest
685
+ // kept the previous ad's creative on the bar until the `complete` marker
686
+ // arrived to tighten its window — the next ad of a pod showed late.
687
+ return findLast(getRangesCache().sorted, range => range.endMs - range.startMs >= MIN_AD_WINDOW_MS && programTimeMs >= range.startMs && programTimeMs < range.endMs) || null;
688
+ };
689
+ /**
690
+ * Does another qualifying ad window open within `withinMs` after
691
+ * `programTimeMs`? The data-domain end edge on a live schedule: between two
692
+ * ads of a pod the next window is already in the manifest, so the bar is held
693
+ * through the gap — while a break that is really over has nothing upcoming
694
+ * and closes on the spot.
695
+ */
696
+ const nextRangeStartsWithin = (programTimeMs, withinMs) => getRangesCache().sorted.some(range => range.endMs - range.startMs >= MIN_AD_WINDOW_MS && range.startMs > programTimeMs && range.startMs - programTimeMs <= withinMs);
697
+ /**
698
+ * Program time of the frame the viewer is looking at — NOT the wall clock.
699
+ *
700
+ * The distinction is the whole ballgame for sync. `START-DATE` on a DATERANGE is
701
+ * program time, while a live viewer's playhead sits seconds behind the live edge
702
+ * (hls.js starts ~3 target durations back, plus whatever the CDN adds). Answering
703
+ * with `Date.now()` therefore fires every edge EARLY by exactly that latency: the
704
+ * overlay would appear a few seconds before the ad video it belongs to — which is
705
+ * precisely the drift this ordering exists to prevent.
706
+ *
707
+ * Preference order: hls.js' own `playingDate`, then our PDT-anchored origin, then
708
+ * nothing. "Nothing" (NaN) parks the detector until it can answer honestly, which
709
+ * is the right call — a late bar is a blemish, an early one is wrong.
710
+ */
711
+ const currentProgramTimeMs = () => {
712
+ const playingDateMs = player !== null && player !== void 0 && player.playingDate ? player.playingDate.getTime() : NaN;
713
+ if (Number.isFinite(playingDateMs)) {
714
+ return playingDateMs;
715
+ }
716
+ const el = getVideo();
717
+ if (Number.isFinite(programTimeOriginMs) && el && Number.isFinite(el.currentTime)) {
718
+ return programTimeOriginMs + el.currentTime * 1000;
719
+ }
720
+ // There IS a media element, we just cannot map it yet (no PDT seen). Wait for
721
+ // one rather than guessing with the wall clock and showing the bar early.
722
+ if (el) {
723
+ return NaN;
724
+ }
725
+ // No media element at all — nothing better than the wall clock exists, and a
726
+ // player-less integration still deserves break detection.
727
+ return Date.now();
728
+ };
729
+ const updateTimeMapperFromFragment = (fragment, targetDuration) => {
730
+ if (!fragment) {
731
+ return;
732
+ }
733
+ const start = Number(fragment.start);
734
+ if (!Number.isFinite(start)) {
735
+ return;
736
+ }
737
+ const pdtMs = toTimeMs(fragment.programDateTime || fragment.rawProgramDateTime || fragment.pdt);
738
+ if (Number.isFinite(pdtMs)) {
739
+ programTimeOriginMs = pdtMs - start * 1000;
740
+ return;
741
+ }
742
+ const sequence = Number(fragment.sn);
743
+ const segmentSec = Number(targetDuration || fragment.duration || 10);
744
+ if (Number.isFinite(sequence) && Number.isFinite(segmentSec) && segmentSec > 0) {
745
+ programTimeOriginMs = sequence * segmentSec * 1000 - start * 1000;
746
+ }
747
+ };
748
+ const updateTimeMapperFromDetails = details => {
749
+ const el = getVideo();
750
+ const fragments = details && (details.fragments || details.frags);
751
+ if (!el || !Array.isArray(fragments) || fragments.length === 0) {
752
+ return;
753
+ }
754
+ const currentFragment = fragments.find(fragment => {
755
+ const start = Number(fragment.start);
756
+ const duration = Number(fragment.duration);
757
+ return Number.isFinite(start) && Number.isFinite(duration) && el.currentTime >= start && el.currentTime < start + duration;
758
+ });
759
+ if (currentFragment) {
760
+ updateTimeMapperFromFragment(currentFragment, details === null || details === void 0 ? void 0 : details.targetduration);
761
+ }
762
+ };
763
+ const ingestHlsDateRanges = details => {
764
+ if (!details || !details.dateRanges) {
765
+ return;
766
+ }
767
+ const rawRanges = details.dateRanges instanceof Map ? Array.from(details.dateRanges.values()) : Array.isArray(details.dateRanges) ? details.dateRanges : Object.values(details.dateRanges);
768
+ mergeDateRanges(rawRanges.map(parseHlsDateRange).filter(parsed => Boolean(parsed)));
769
+ };
770
+ // -- native-HLS manifest polling --
771
+ const nativeBufferedTimeAnchorSec = el => {
772
+ if (!el.buffered || el.buffered.length === 0) {
773
+ return NaN;
774
+ }
775
+ const currentTime = el.currentTime;
776
+ if (Number.isFinite(currentTime)) {
777
+ for (let index = 0; index < el.buffered.length; index += 1) {
778
+ if (currentTime >= el.buffered.start(index) && currentTime <= el.buffered.end(index)) {
779
+ return el.buffered.end(index);
780
+ }
781
+ }
782
+ }
783
+ return mediaRangeEnd(el.buffered);
784
+ };
785
+ // Native HLS (no hls.js): derive `programTimeOriginMs` so that
786
+ // programTime(ms) = programTimeOriginMs + video.currentTime * 1000
787
+ //
788
+ // We re-derive the origin on EVERY manifest poll. For a live sliding window the
789
+ // PDT->media-time relationship drifts as old segments fall off the front, so a
790
+ // once-latched origin (or one guarded by a tiny anchor delta) goes stale. By
791
+ // re-anchoring each poll from the freshest segment that actually carries a PDT
792
+ // we always track the current window.
793
+ const updateNativeTimeMapperFromManifest = manifest => {
794
+ const el = getVideo();
795
+ if (!el) {
796
+ return;
797
+ }
798
+ const timeAnchorSec = nativeBufferedTimeAnchorSec(el);
799
+ if (!Number.isFinite(timeAnchorSec)) {
800
+ return;
801
+ }
802
+ const segments = parseManifestSegments(manifest);
803
+ // Use the latest segment that has a resolved program time. parseManifestSegments
804
+ // already forward-fills PDT across segments, so a refresh whose first segment
805
+ // is the only one carrying #EXT-X-PROGRAM-DATE-TIME still yields finite times.
806
+ const lastSegment = findLast(segments, segment => Number.isFinite(segment.programDateTimeMs) && segment.durationSec > 0);
807
+ if (!lastSegment) {
808
+ // No segment had a usable PDT this refresh — leave the previous origin in
809
+ // place rather than latching a NaN/stale value.
810
+ return;
811
+ }
812
+ const segmentEndMs = lastSegment.programDateTimeMs + lastSegment.durationSec * 1000;
813
+ programTimeOriginMs = segmentEndMs - timeAnchorSec * 1000;
814
+ };
815
+ const pollManifest = () => {
816
+ if (destroyed || !manifestUrl) {
817
+ return;
818
+ }
819
+ fetch(manifestUrl, {
820
+ cache: 'no-store'
821
+ }).then(response => response.text()).then(manifest => {
822
+ if (destroyed) {
823
+ return;
824
+ }
825
+ mergeDateRanges(parseManifestDateRanges(manifest));
826
+ updateNativeTimeMapperFromManifest(manifest);
827
+ }).catch(error => {
828
+ log.warn(error, 'ssai manifest poll failed');
829
+ });
830
+ };
831
+ // -- live-edge supplement (hls path) --
832
+ /**
833
+ * The playlist the player is actually playing — that is where the DATERANGEs
834
+ * live (a multivariant "master" URL carries none). Mirrors the demo/dashboard
835
+ * level-picking order; falls back to the host-supplied `manifestUrl` for
836
+ * single-playlist streams whose one URL IS the media playlist.
837
+ */
838
+ const currentLevelUrl = () => {
839
+ const levels = player === null || player === void 0 ? void 0 : player.levels;
840
+ if (Array.isArray(levels) && levels.length > 0) {
841
+ var _levels$index, _level$uri;
842
+ const currentLevel = Number(player === null || player === void 0 ? void 0 : player.currentLevel);
843
+ const loadLevel = Number(player === null || player === void 0 ? void 0 : player.loadLevel);
844
+ const index = currentLevel >= 0 ? currentLevel : loadLevel >= 0 ? loadLevel : 0;
845
+ const level = (_levels$index = levels[index]) !== null && _levels$index !== void 0 ? _levels$index : levels[0];
846
+ const raw = (_level$uri = level === null || level === void 0 ? void 0 : level.uri) !== null && _level$uri !== void 0 ? _level$uri : level === null || level === void 0 ? void 0 : level.url;
847
+ const resolved = Array.isArray(raw) ? raw[0] : raw;
848
+ if (typeof resolved === 'string' && resolved.length > 0) {
849
+ return resolved;
850
+ }
851
+ }
852
+ return manifestUrl;
853
+ };
854
+ // Guards overlapping fetches when the manifest host answers slower than the
855
+ // poll cadence — the next tick simply skips.
856
+ let liveEdgePollInflight = false;
857
+ const pollLiveEdge = () => {
858
+ if (destroyed || liveEdgePollInflight) {
859
+ return;
860
+ }
861
+ const url = currentLevelUrl();
862
+ if (!url) {
863
+ return;
864
+ }
865
+ liveEdgePollInflight = true;
866
+ // CORS is already granted here: hls.js fetches this same playlist via XHR.
867
+ fetch(url, {
868
+ cache: 'no-store'
869
+ }).then(response => response.text()).then(manifest => {
870
+ liveEdgePollInflight = false;
871
+ if (!destroyed) {
872
+ mergeDateRanges(parseManifestDateRanges(manifest));
873
+ }
874
+ }).catch(error => {
875
+ liveEdgePollInflight = false;
876
+ log.debug(error, 'ssai live-edge poll failed');
877
+ });
878
+ };
879
+ // -- active-ad resolution --
880
+ const clearBreakEndTimer = () => {
881
+ if (breakEndTimer !== null) {
882
+ clearTimeout(breakEndTimer);
883
+ breakEndTimer = null;
884
+ }
885
+ };
886
+ const emitProgress = (range, event) => {
887
+ if (!onProgress || progressFired[event]) {
888
+ return;
889
+ }
890
+ progressFired[event] = true;
891
+ recordDebugEvent(`progress:${event}`, range.creativeId);
892
+ try {
893
+ onProgress(range, event);
894
+ } catch (error) {
895
+ log.warn(error, 'ssai onProgress handler threw');
896
+ }
897
+ };
898
+ /**
899
+ * Close out the ad we were tracking. `complete` is only honest once playback
900
+ * actually reached the end of the ad window — leaving it early (a seek out of
901
+ * the break, a stream switch, `destroy`) must NOT report a completed view.
902
+ */
903
+ const finishProgress = programTimeMs => {
904
+ const range = progressRange;
905
+ if (!range) {
906
+ return;
907
+ }
908
+ if (Number.isFinite(programTimeMs) && programTimeMs >= range.endMs) {
909
+ emitProgress(range, 'complete');
910
+ }
911
+ progressRange = null;
912
+ progressFired = {};
913
+ };
914
+ /** Fire whichever milestones the playhead has passed inside the active ad. */
915
+ const updateProgress = (range, programTimeMs) => {
916
+ if (!onProgress) {
917
+ return;
918
+ }
919
+ if (!progressRange || progressRange.id !== range.id) {
920
+ // A new ad (first of the break, or the next one in a pod): the previous
921
+ // one is over, and by definition the playhead has left it.
922
+ finishProgress(programTimeMs);
923
+ progressRange = range;
924
+ progressFired = {};
925
+ }
926
+ const spanMs = range.endMs - range.startMs;
927
+ if (spanMs <= 0) {
928
+ return;
929
+ }
930
+ for (const [event, threshold] of PROGRESS_THRESHOLDS) {
931
+ var _range$progressAtMs$e, _range$progressAtMs;
932
+ // The stream's own marker for this event is the ad server's declared time
933
+ // and stays honest while the window's end is still provisional (before the
934
+ // `complete` marker has been published); fractions of the window are the
935
+ // fallback for the creative-DATERANGE dialect.
936
+ const eventAtMs = (_range$progressAtMs$e = (_range$progressAtMs = range.progressAtMs) === null || _range$progressAtMs === void 0 ? void 0 : _range$progressAtMs[event]) !== null && _range$progressAtMs$e !== void 0 ? _range$progressAtMs$e : range.startMs + spanMs * threshold;
937
+ if (programTimeMs >= eventAtMs) {
938
+ emitProgress(range, event);
939
+ }
940
+ }
941
+ };
942
+ const emitBreakEnd = () => {
943
+ if (!breakActive) {
944
+ return;
945
+ }
946
+ breakActive = false;
947
+ breakSuppressed = false;
948
+ activeRangeId = '';
949
+ activeCreativeId = '';
950
+ endingNotifiedRangeId = '';
951
+ recordDebugEvent('break-end');
952
+ try {
953
+ onBreakEnd();
954
+ } catch (error) {
955
+ log.warn(error, 'ssai onBreakEnd handler threw');
956
+ }
957
+ };
958
+ const updateActiveAd = () => {
959
+ if (destroyed) {
960
+ return;
961
+ }
962
+ const programTimeMs = currentProgramTimeMs();
963
+ const range = findActiveRange(programTimeMs);
964
+ if (!range) {
965
+ // Playback has left the ad — settle its lifecycle beacons before anything
966
+ // else. Never deferred: `complete` reports what playback did, and delaying
967
+ // it by a second would only risk losing it if the page goes away in
968
+ // between.
969
+ finishProgress(programTimeMs);
970
+ if (!breakActive) {
971
+ return;
972
+ }
973
+ // The end edge is decided from the schedule, on this very tick: with a
974
+ // trustworthy clock and no further window opening within END_DEBOUNCE_MS,
975
+ // the break is really over — a bar that outlives its ad by a debounce
976
+ // reads as lag, exactly like a bar that trails its start did.
977
+ if (Number.isFinite(programTimeMs) && !nextRangeStartsWithin(programTimeMs, END_DEBOUNCE_MS)) {
978
+ clearBreakEndTimer();
979
+ emitBreakEnd();
980
+ return;
981
+ }
982
+ // Ambiguous: the next ad of a pod starts inside the window (hold the bar
983
+ // through the gap — entering that ad cancels the timer and becomes a
984
+ // creative change), or the clock is momentarily unreadable. The timer is
985
+ // the fallback if the playhead never actually arrives there.
986
+ if (breakEndTimer === null) {
987
+ breakEndTimer = setTimeout(() => {
988
+ breakEndTimer = null;
989
+ emitBreakEnd();
990
+ }, END_DEBOUNCE_MS);
991
+ }
992
+ return;
993
+ }
994
+ // We have an active range — cancel any pending end.
995
+ clearBreakEndTimer();
996
+ // Beacons are deliberately updated BEFORE the visibility logic below: the ad
997
+ // is stitched into the stream, so it plays — and must be reported —
998
+ // regardless of whether the overlay is showing or was dismissed.
999
+ updateProgress(range, programTimeMs);
1000
+ if (!breakActive) {
1001
+ // Fire on the same tick the playhead enters the window. There is no start
1002
+ // debounce: every millisecond here is the bar lagging an ad that is
1003
+ // already on screen, and the flash the old debounce guarded against is
1004
+ // prevented in the data instead (MIN_AD_WINDOW_MS in findActiveRange).
1005
+ breakActive = true;
1006
+ activeRangeId = range.id;
1007
+ activeCreativeId = range.creativeId;
1008
+ // `at+X.Xs` = how far into the ad window the bar actually fired — the
1009
+ // number to compare with what the screen shows when sync is in doubt.
1010
+ recordDebugEvent('break-start', `${range.creativeId} at+${((programTimeMs - range.startMs) / 1000).toFixed(1)}s`, programTimeMs);
1011
+ try {
1012
+ onBreakStart(range);
1013
+ } catch (error) {
1014
+ log.warn(error, 'ssai onBreakStart handler threw');
1015
+ }
1016
+ return;
1017
+ }
1018
+ // Break already active — detect creative change within a multi-ad pod.
1019
+ if (activeRangeId !== range.id || activeCreativeId !== range.creativeId) {
1020
+ activeRangeId = range.id;
1021
+ activeCreativeId = range.creativeId;
1022
+ recordDebugEvent(breakSuppressed ? 'creative-change-suppressed' : 'creative-change', range.creativeId, programTimeMs);
1023
+ // User dismissed this break: track the new creative internally but don't
1024
+ // re-show the bar. Suppression lifts when the break ends (emitBreakEnd).
1025
+ if (breakSuppressed) {
1026
+ return;
1027
+ }
1028
+ try {
1029
+ onCreativeChange(range);
1030
+ } catch (error) {
1031
+ log.warn(error, 'ssai onCreativeChange handler threw');
1032
+ }
1033
+ }
1034
+ // Announce the approaching end once per airing, so the UI can play its
1035
+ // closing animation BEFORE the boundary instead of after it.
1036
+ if (onAdEnding && endingNotifiedRangeId !== range.id && range.endMs - programTimeMs <= AD_ENDING_LEAD_MS) {
1037
+ endingNotifiedRangeId = range.id;
1038
+ recordDebugEvent('ad-ending', range.creativeId, programTimeMs);
1039
+ try {
1040
+ onAdEnding(range);
1041
+ } catch (error) {
1042
+ log.warn(error, 'ssai onAdEnding handler threw');
1043
+ }
1044
+ }
1045
+ };
1046
+ // -- wiring --
1047
+ const handleLevelUpdated = (...args) => {
1048
+ var _args$;
1049
+ const data = (_args$ = args[1]) !== null && _args$ !== void 0 ? _args$ : args[0];
1050
+ ingestHlsDateRanges(data === null || data === void 0 ? void 0 : data.details);
1051
+ updateTimeMapperFromDetails(data === null || data === void 0 ? void 0 : data.details);
1052
+ };
1053
+ const handleFragChanged = (...args) => {
1054
+ var _args$2;
1055
+ const data = (_args$2 = args[1]) !== null && _args$2 !== void 0 ? _args$2 : args[0];
1056
+ updateTimeMapperFromFragment(data === null || data === void 0 ? void 0 : data.frag);
1057
+ };
1058
+ let manifestInterval = null;
1059
+ let liveEdgeInterval = null;
1060
+ if (player) {
1061
+ try {
1062
+ player.on(HLS_EVENT_LEVEL_UPDATED, handleLevelUpdated);
1063
+ player.on(HLS_EVENT_FRAG_CHANGED, handleFragChanged);
1064
+ } catch (error) {
1065
+ log.warn(error, 'ssai detector: failed to attach hls.js listeners');
1066
+ }
1067
+ liveEdgeInterval = setInterval(pollLiveEdge, LIVE_EDGE_POLL_MS);
1068
+ } else if (videoRef && manifestUrl) {
1069
+ pollManifest();
1070
+ manifestInterval = setInterval(pollManifest, MANIFEST_POLL_MS);
1071
+ } else if (videoRef && !manifestUrl) {
1072
+ log.info('ssai detector: videoRef provided without manifestUrl, native polling disabled');
1073
+ }
1074
+ const activeInterval = setInterval(updateActiveAd, ACTIVE_POLL_MS);
1075
+ // hls.js diagnostics are getters over live controllers — read them guarded so
1076
+ // an exotic host object can never take the snapshot down.
1077
+ const readOptionalNumber = read => {
1078
+ try {
1079
+ const value = read();
1080
+ return typeof value === 'number' && Number.isFinite(value) ? value : NaN;
1081
+ } catch (_unused) {
1082
+ return NaN;
1083
+ }
1084
+ };
1085
+ const getDebugSnapshot = () => {
1086
+ const el = getVideo();
1087
+ const playingDateMs = player !== null && player !== void 0 && player.playingDate ? player.playingDate.getTime() : NaN;
1088
+ const pdtOriginProgramMs = Number.isFinite(programTimeOriginMs) && el && Number.isFinite(el.currentTime) ? programTimeOriginMs + el.currentTime * 1000 : NaN;
1089
+ // Mirrors currentProgramTimeMs()'s preference order, spelled out per source.
1090
+ const clockSource = Number.isFinite(playingDateMs) ? 'playingDate' : Number.isFinite(pdtOriginProgramMs) ? 'pdt-origin' : el ? 'waiting-for-pdt' : 'wall-clock';
1091
+ const ranges = Array.from(rangesById.values()).map(range => _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, range), {}, {
1092
+ source: 'daterange'
1093
+ })).concat(getRangesCache().folded.map(range => _rollupPluginBabelHelpers.objectSpread2(_rollupPluginBabelHelpers.objectSpread2({}, range), {}, {
1094
+ source: 'markers'
1095
+ }))).sort((left, right) => left.startMs - right.startMs);
1096
+ return {
1097
+ wallMs: Date.now(),
1098
+ programMs: currentProgramTimeMs(),
1099
+ clockSource,
1100
+ attachMode,
1101
+ playingDateMs,
1102
+ pdtOriginProgramMs,
1103
+ programTimeOriginMs,
1104
+ hls: player ? {
1105
+ latencySec: readOptionalNumber(() => player.latency),
1106
+ driftSec: readOptionalNumber(() => player.drift),
1107
+ liveSyncPositionSec: readOptionalNumber(() => player.liveSyncPosition),
1108
+ targetLatencySec: readOptionalNumber(() => player.targetLatency)
1109
+ } : null,
1110
+ video: el ? {
1111
+ currentTimeSec: el.currentTime,
1112
+ bufferedEndSec: mediaRangeEnd(el.buffered),
1113
+ seekableEndSec: mediaRangeEnd(el.seekable),
1114
+ paused: el.paused,
1115
+ playbackRate: el.playbackRate,
1116
+ readyState: el.readyState
1117
+ } : null,
1118
+ ranges,
1119
+ markerCount: markersById.size,
1120
+ adBreakCount: adBreaksById.size,
1121
+ breakActive,
1122
+ breakSuppressed,
1123
+ activeRangeId: breakActive ? activeRangeId : null,
1124
+ activeCreativeId: breakActive ? activeCreativeId : null,
1125
+ progress: progressRange ? {
1126
+ rangeId: progressRange.id,
1127
+ creativeId: progressRange.creativeId,
1128
+ fired: Object.keys(progressFired)
1129
+ } : null,
1130
+ events: debugEvents.slice()
1131
+ };
1132
+ };
1133
+ return {
1134
+ getDebugSnapshot,
1135
+ dismissActiveBreak: () => {
1136
+ if (breakActive) {
1137
+ breakSuppressed = true;
1138
+ recordDebugEvent('dismissed', activeCreativeId);
1139
+ }
1140
+ },
1141
+ destroy: () => {
1142
+ if (destroyed) {
1143
+ return;
1144
+ }
1145
+ destroyed = true;
1146
+ clearInterval(activeInterval);
1147
+ if (manifestInterval !== null) {
1148
+ clearInterval(manifestInterval);
1149
+ }
1150
+ if (liveEdgeInterval !== null) {
1151
+ clearInterval(liveEdgeInterval);
1152
+ }
1153
+ clearBreakEndTimer();
1154
+ if (player) {
1155
+ try {
1156
+ player.off(HLS_EVENT_LEVEL_UPDATED, handleLevelUpdated);
1157
+ player.off(HLS_EVENT_FRAG_CHANGED, handleFragChanged);
1158
+ } catch (error) {
1159
+ log.warn(error, 'ssai detector: failed to detach hls.js listeners (host may be destroyed)');
1160
+ }
1161
+ }
1162
+ rangesById.clear();
1163
+ markersById.clear();
1164
+ adBreaksById.clear();
1165
+ }
1166
+ };
1167
+ };
1168
+
1169
+ exports.createSsaiDetector = createSsaiDetector;
1170
+ exports.foldTrackingMarkers = foldTrackingMarkers;