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