@streamlayer/feature-advertisement 1.0.0 → 1.1.1-rc.1342

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