@streamlayer/feature-advertisement 0.4.3 → 1.0.0

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