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