stormcloud-video-player 0.1.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.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/lib/index.cjs +1748 -0
- package/lib/index.cjs.map +1 -0
- package/lib/index.d.cts +157 -0
- package/lib/index.d.ts +157 -0
- package/lib/index.js +1706 -0
- package/lib/index.js.map +1 -0
- package/package.json +38 -0
- package/src/index.ts +20 -0
- package/src/player/StormcloudVideoPlayer.ts +1124 -0
- package/src/sdk/ima.ts +369 -0
- package/src/types.ts +124 -0
- package/src/ui/StormcloudVideoPlayer.tsx +258 -0
- package/src/utils/tracking.ts +251 -0
- package/tsconfig.json +49 -0
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
import Hls from "hls.js";
|
|
2
|
+
import type {
|
|
3
|
+
StormcloudVideoPlayerConfig,
|
|
4
|
+
Scte35Marker,
|
|
5
|
+
Id3TagInfo,
|
|
6
|
+
ImaController,
|
|
7
|
+
AdBreak,
|
|
8
|
+
AdSchedule,
|
|
9
|
+
StormcloudApiResponse,
|
|
10
|
+
} from "../types";
|
|
11
|
+
import { createImaController } from "../sdk/ima";
|
|
12
|
+
import { sendInitialTracking, sendHeartbeat } from "../utils/tracking";
|
|
13
|
+
|
|
14
|
+
export class StormcloudVideoPlayer {
|
|
15
|
+
private readonly video: HTMLVideoElement;
|
|
16
|
+
private readonly config: StormcloudVideoPlayerConfig;
|
|
17
|
+
private hls?: Hls;
|
|
18
|
+
private ima: ImaController;
|
|
19
|
+
private attached = false;
|
|
20
|
+
private adSchedule: AdSchedule | undefined;
|
|
21
|
+
private inAdBreak = false;
|
|
22
|
+
private currentAdBreakStartWallClockMs: number | undefined;
|
|
23
|
+
private expectedAdBreakDurationMs: number | undefined;
|
|
24
|
+
private adStopTimerId: number | undefined;
|
|
25
|
+
private adStartTimerId: number | undefined;
|
|
26
|
+
private adFailsafeTimerId: number | undefined;
|
|
27
|
+
private ptsDriftEmaMs = 0;
|
|
28
|
+
private adPodQueue: string[] = [];
|
|
29
|
+
private apiVastTagUrl: string | undefined;
|
|
30
|
+
private vastConfig:
|
|
31
|
+
| StormcloudApiResponse["response"]["options"]["vast"]
|
|
32
|
+
| undefined;
|
|
33
|
+
private lastHeartbeatTime: number = 0;
|
|
34
|
+
private heartbeatInterval: number | undefined;
|
|
35
|
+
private currentAdIndex: number = 0;
|
|
36
|
+
private totalAdsInBreak: number = 0;
|
|
37
|
+
private showAds: boolean = false;
|
|
38
|
+
|
|
39
|
+
constructor(config: StormcloudVideoPlayerConfig) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.video = config.videoElement;
|
|
42
|
+
this.adSchedule = config.adSchedule;
|
|
43
|
+
this.ima = createImaController(this.video);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async load(): Promise<void> {
|
|
47
|
+
if (!this.attached) {
|
|
48
|
+
this.attach();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await this.fetchAdConfiguration();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (this.config.debugAdTiming) {
|
|
55
|
+
console.warn(
|
|
56
|
+
"[StormcloudVideoPlayer] Failed to fetch ad configuration:",
|
|
57
|
+
error
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.initializeTracking();
|
|
63
|
+
|
|
64
|
+
if (this.shouldUseNativeHls()) {
|
|
65
|
+
this.video.src = this.config.src;
|
|
66
|
+
if (this.config.autoplay) {
|
|
67
|
+
await this.video.play().catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.hls = new Hls({
|
|
73
|
+
enableWorker: true,
|
|
74
|
+
backBufferLength: 30,
|
|
75
|
+
liveDurationInfinity: true,
|
|
76
|
+
lowLatencyMode: !!this.config.lowLatencyMode,
|
|
77
|
+
maxLiveSyncPlaybackRate: this.config.lowLatencyMode ? 1.5 : 1.0,
|
|
78
|
+
...(this.config.lowLatencyMode ? { liveSyncDuration: 2 } : {}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this.hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
82
|
+
this.hls?.loadSource(this.config.src);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
|
86
|
+
if (this.config.autoplay) {
|
|
87
|
+
await this.video.play().catch(() => {});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
this.hls.on(Hls.Events.FRAG_PARSING_METADATA, (_evt, data: any) => {
|
|
92
|
+
const id3Tags: Id3TagInfo[] = (data?.samples || []).map((s: any) => ({
|
|
93
|
+
key: "ID3",
|
|
94
|
+
value: s?.data,
|
|
95
|
+
ptsSeconds: s?.pts,
|
|
96
|
+
}));
|
|
97
|
+
id3Tags.forEach((tag) => this.onId3Tag(tag));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.hls.on(Hls.Events.FRAG_CHANGED, (_evt, data: any) => {
|
|
101
|
+
const frag = data?.frag;
|
|
102
|
+
const tagList: any[] | undefined = frag?.tagList;
|
|
103
|
+
if (!Array.isArray(tagList)) return;
|
|
104
|
+
|
|
105
|
+
for (const entry of tagList) {
|
|
106
|
+
let tag = "";
|
|
107
|
+
let value = "";
|
|
108
|
+
if (Array.isArray(entry)) {
|
|
109
|
+
tag = String(entry[0] ?? "");
|
|
110
|
+
value = String(entry[1] ?? "");
|
|
111
|
+
} else if (typeof entry === "string") {
|
|
112
|
+
const idx = entry.indexOf(":");
|
|
113
|
+
if (idx >= 0) {
|
|
114
|
+
tag = entry.substring(0, idx);
|
|
115
|
+
value = entry.substring(idx + 1);
|
|
116
|
+
} else {
|
|
117
|
+
tag = entry;
|
|
118
|
+
value = "";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!tag) continue;
|
|
123
|
+
if (tag.includes("EXT-X-CUE-OUT")) {
|
|
124
|
+
const durationSeconds = this.parseCueOutDuration(value);
|
|
125
|
+
const marker: Scte35Marker = {
|
|
126
|
+
type: "start",
|
|
127
|
+
...(durationSeconds !== undefined ? { durationSeconds } : {}),
|
|
128
|
+
raw: { tag, value },
|
|
129
|
+
} as Scte35Marker;
|
|
130
|
+
this.onScte35Marker(marker);
|
|
131
|
+
} else if (tag.includes("EXT-X-CUE-OUT-CONT")) {
|
|
132
|
+
const prog = this.parseCueOutCont(value);
|
|
133
|
+
const marker: Scte35Marker = {
|
|
134
|
+
type: "progress",
|
|
135
|
+
...(prog?.duration !== undefined
|
|
136
|
+
? { durationSeconds: prog.duration }
|
|
137
|
+
: {}),
|
|
138
|
+
...(prog?.elapsed !== undefined
|
|
139
|
+
? { ptsSeconds: prog.elapsed }
|
|
140
|
+
: {}),
|
|
141
|
+
raw: { tag, value },
|
|
142
|
+
} as Scte35Marker;
|
|
143
|
+
this.onScte35Marker(marker);
|
|
144
|
+
} else if (tag.includes("EXT-X-CUE-IN")) {
|
|
145
|
+
this.onScte35Marker({ type: "end", raw: { tag, value } });
|
|
146
|
+
} else if (tag.includes("EXT-X-DATERANGE")) {
|
|
147
|
+
const attrs = this.parseAttributeList(value);
|
|
148
|
+
const hasScteOut =
|
|
149
|
+
"SCTE35-OUT" in attrs || attrs["SCTE35-OUT"] !== undefined;
|
|
150
|
+
const hasScteIn =
|
|
151
|
+
"SCTE35-IN" in attrs || attrs["SCTE35-IN"] !== undefined;
|
|
152
|
+
const klass = String(attrs["CLASS"] ?? "");
|
|
153
|
+
const duration = this.toNumber(attrs["DURATION"]);
|
|
154
|
+
|
|
155
|
+
if (hasScteOut || /com\.apple\.hls\.cue/i.test(klass)) {
|
|
156
|
+
const marker: Scte35Marker = {
|
|
157
|
+
type: "start",
|
|
158
|
+
...(duration !== undefined ? { durationSeconds: duration } : {}),
|
|
159
|
+
raw: { tag, value, attrs },
|
|
160
|
+
} as Scte35Marker;
|
|
161
|
+
this.onScte35Marker(marker);
|
|
162
|
+
}
|
|
163
|
+
if (hasScteIn) {
|
|
164
|
+
this.onScte35Marker({ type: "end", raw: { tag, value, attrs } });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
this.hls.on(Hls.Events.ERROR, (_evt, data) => {
|
|
171
|
+
if (data?.fatal) {
|
|
172
|
+
switch (data.type) {
|
|
173
|
+
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
174
|
+
this.hls?.startLoad();
|
|
175
|
+
break;
|
|
176
|
+
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
177
|
+
this.hls?.recoverMediaError();
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
this.destroy();
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
this.hls.attachMedia(this.video);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private attach(): void {
|
|
190
|
+
if (this.attached) return;
|
|
191
|
+
this.attached = true;
|
|
192
|
+
this.video.autoplay = !!this.config.autoplay;
|
|
193
|
+
this.video.muted = !!this.config.muted;
|
|
194
|
+
|
|
195
|
+
this.ima.initialize();
|
|
196
|
+
this.ima.on("all_ads_completed", () => {
|
|
197
|
+
if (!this.inAdBreak) return;
|
|
198
|
+
const remaining = this.getRemainingAdMs();
|
|
199
|
+
if (remaining > 500 && this.adPodQueue.length > 0) {
|
|
200
|
+
const next = this.adPodQueue.shift()!;
|
|
201
|
+
this.currentAdIndex++;
|
|
202
|
+
this.playSingleAd(next).catch(() => {});
|
|
203
|
+
} else {
|
|
204
|
+
this.currentAdIndex = 0;
|
|
205
|
+
this.totalAdsInBreak = 0;
|
|
206
|
+
this.showAds = false;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
this.ima.on("ad_error", () => {
|
|
210
|
+
if (this.config.debugAdTiming) {
|
|
211
|
+
console.log("[StormcloudVideoPlayer] IMA ad_error event received");
|
|
212
|
+
}
|
|
213
|
+
if (!this.inAdBreak) return;
|
|
214
|
+
const remaining = this.getRemainingAdMs();
|
|
215
|
+
if (remaining > 500 && this.adPodQueue.length > 0) {
|
|
216
|
+
const next = this.adPodQueue.shift()!;
|
|
217
|
+
this.currentAdIndex++;
|
|
218
|
+
this.playSingleAd(next).catch(() => {});
|
|
219
|
+
} else {
|
|
220
|
+
this.handleAdFailure();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
this.ima.on("content_pause", () => {
|
|
224
|
+
if (this.config.debugAdTiming) {
|
|
225
|
+
console.log("[StormcloudVideoPlayer] IMA content_pause event received");
|
|
226
|
+
}
|
|
227
|
+
this.clearAdFailsafeTimer();
|
|
228
|
+
});
|
|
229
|
+
this.ima.on("content_resume", () => {
|
|
230
|
+
if (this.config.debugAdTiming) {
|
|
231
|
+
console.log(
|
|
232
|
+
"[StormcloudVideoPlayer] IMA content_resume event received"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
this.clearAdFailsafeTimer();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
this.video.addEventListener("timeupdate", () => {
|
|
239
|
+
this.onTimeUpdate(this.video.currentTime);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private shouldUseNativeHls(): boolean {
|
|
244
|
+
const canNative = this.video.canPlayType("application/vnd.apple.mpegURL");
|
|
245
|
+
return !!(this.config.allowNativeHls && canNative);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private onId3Tag(tag: Id3TagInfo): void {
|
|
249
|
+
if (typeof tag.ptsSeconds === "number") {
|
|
250
|
+
this.updatePtsDrift(tag.ptsSeconds);
|
|
251
|
+
}
|
|
252
|
+
const marker = this.parseScte35FromId3(tag);
|
|
253
|
+
if (marker) {
|
|
254
|
+
this.onScte35Marker(marker);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private parseScte35FromId3(tag: Id3TagInfo): Scte35Marker | undefined {
|
|
259
|
+
const text = this.decodeId3ValueToText(tag.value);
|
|
260
|
+
if (!text) return undefined;
|
|
261
|
+
|
|
262
|
+
const cueOutMatch =
|
|
263
|
+
text.match(/EXT-X-CUE-OUT(?::([^\r\n]*))?/i) ||
|
|
264
|
+
text.match(/CUE-OUT(?::([^\r\n]*))?/i);
|
|
265
|
+
if (cueOutMatch) {
|
|
266
|
+
const arg = (cueOutMatch[1] ?? "").trim();
|
|
267
|
+
const dur = this.parseCueOutDuration(arg);
|
|
268
|
+
const marker: Scte35Marker = {
|
|
269
|
+
type: "start",
|
|
270
|
+
...(tag.ptsSeconds !== undefined ? { ptsSeconds: tag.ptsSeconds } : {}),
|
|
271
|
+
...(dur !== undefined ? { durationSeconds: dur } : {}),
|
|
272
|
+
raw: { id3: text },
|
|
273
|
+
} as Scte35Marker;
|
|
274
|
+
return marker;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const cueOutContMatch = text.match(/EXT-X-CUE-OUT-CONT:([^\r\n]*)/i);
|
|
278
|
+
if (cueOutContMatch) {
|
|
279
|
+
const arg = (cueOutContMatch[1] ?? "").trim();
|
|
280
|
+
const cont = this.parseCueOutCont(arg);
|
|
281
|
+
const marker: Scte35Marker = {
|
|
282
|
+
type: "progress",
|
|
283
|
+
...(tag.ptsSeconds !== undefined ? { ptsSeconds: tag.ptsSeconds } : {}),
|
|
284
|
+
...(cont?.duration !== undefined
|
|
285
|
+
? { durationSeconds: cont.duration }
|
|
286
|
+
: {}),
|
|
287
|
+
raw: { id3: text },
|
|
288
|
+
} as Scte35Marker;
|
|
289
|
+
return marker;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const cueInMatch = text.match(/EXT-X-CUE-IN\b/i) || text.match(/CUE-IN\b/i);
|
|
293
|
+
if (cueInMatch) {
|
|
294
|
+
const marker: Scte35Marker = {
|
|
295
|
+
type: "end",
|
|
296
|
+
...(tag.ptsSeconds !== undefined ? { ptsSeconds: tag.ptsSeconds } : {}),
|
|
297
|
+
raw: { id3: text },
|
|
298
|
+
} as Scte35Marker;
|
|
299
|
+
return marker;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const daterangeMatch = text.match(/EXT-X-DATERANGE:([^\r\n]*)/i);
|
|
303
|
+
if (daterangeMatch) {
|
|
304
|
+
const attrs = this.parseAttributeList(daterangeMatch[1] ?? "");
|
|
305
|
+
const hasScteOut =
|
|
306
|
+
"SCTE35-OUT" in attrs || attrs["SCTE35-OUT"] !== undefined;
|
|
307
|
+
const hasScteIn =
|
|
308
|
+
"SCTE35-IN" in attrs || attrs["SCTE35-IN"] !== undefined;
|
|
309
|
+
const klass = String(attrs["CLASS"] ?? "");
|
|
310
|
+
const duration = this.toNumber(attrs["DURATION"]);
|
|
311
|
+
if (hasScteOut || /com\.apple\.hls\.cue/i.test(klass)) {
|
|
312
|
+
const marker: Scte35Marker = {
|
|
313
|
+
type: "start",
|
|
314
|
+
...(tag.ptsSeconds !== undefined
|
|
315
|
+
? { ptsSeconds: tag.ptsSeconds }
|
|
316
|
+
: {}),
|
|
317
|
+
...(duration !== undefined ? { durationSeconds: duration } : {}),
|
|
318
|
+
raw: { id3: text, attrs },
|
|
319
|
+
} as Scte35Marker;
|
|
320
|
+
return marker;
|
|
321
|
+
}
|
|
322
|
+
if (hasScteIn) {
|
|
323
|
+
const marker: Scte35Marker = {
|
|
324
|
+
type: "end",
|
|
325
|
+
...(tag.ptsSeconds !== undefined
|
|
326
|
+
? { ptsSeconds: tag.ptsSeconds }
|
|
327
|
+
: {}),
|
|
328
|
+
raw: { id3: text, attrs },
|
|
329
|
+
} as Scte35Marker;
|
|
330
|
+
return marker;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (/SCTE35-OUT/i.test(text)) {
|
|
335
|
+
const marker: Scte35Marker = {
|
|
336
|
+
type: "start",
|
|
337
|
+
...(tag.ptsSeconds !== undefined ? { ptsSeconds: tag.ptsSeconds } : {}),
|
|
338
|
+
raw: { id3: text },
|
|
339
|
+
} as Scte35Marker;
|
|
340
|
+
return marker;
|
|
341
|
+
}
|
|
342
|
+
if (/SCTE35-IN/i.test(text)) {
|
|
343
|
+
const marker: Scte35Marker = {
|
|
344
|
+
type: "end",
|
|
345
|
+
...(tag.ptsSeconds !== undefined ? { ptsSeconds: tag.ptsSeconds } : {}),
|
|
346
|
+
raw: { id3: text },
|
|
347
|
+
} as Scte35Marker;
|
|
348
|
+
return marker;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (tag.value instanceof Uint8Array) {
|
|
352
|
+
const bin = this.parseScte35Binary(tag.value);
|
|
353
|
+
if (bin) return bin;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private decodeId3ValueToText(value: string | Uint8Array): string | undefined {
|
|
360
|
+
try {
|
|
361
|
+
if (typeof value === "string") return value;
|
|
362
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
363
|
+
const text = decoder.decode(value);
|
|
364
|
+
if (text && /[\x20-\x7E]/.test(text)) return text;
|
|
365
|
+
let out = "";
|
|
366
|
+
for (let i = 0; i < value.length; i++)
|
|
367
|
+
out += String.fromCharCode(value[i]!);
|
|
368
|
+
return out;
|
|
369
|
+
} catch {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private onScte35Marker(marker: Scte35Marker): void {
|
|
375
|
+
if (this.config.debugAdTiming) {
|
|
376
|
+
console.log("[StormcloudVideoPlayer] SCTE-35 marker detected:", {
|
|
377
|
+
type: marker.type,
|
|
378
|
+
ptsSeconds: marker.ptsSeconds,
|
|
379
|
+
durationSeconds: marker.durationSeconds,
|
|
380
|
+
currentTime: this.video.currentTime,
|
|
381
|
+
raw: marker.raw,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (marker.type === "start") {
|
|
386
|
+
this.inAdBreak = true;
|
|
387
|
+
const durationMs =
|
|
388
|
+
marker.durationSeconds != null
|
|
389
|
+
? marker.durationSeconds * 1000
|
|
390
|
+
: undefined;
|
|
391
|
+
this.expectedAdBreakDurationMs = durationMs;
|
|
392
|
+
this.currentAdBreakStartWallClockMs = Date.now();
|
|
393
|
+
|
|
394
|
+
const policy = this.adSchedule?.lateJoinPolicy ?? "play_remaining";
|
|
395
|
+
if (policy !== "skip_to_content") {
|
|
396
|
+
const isManifestMarker = this.isManifestBasedMarker(marker);
|
|
397
|
+
const forceImmediate = this.config.immediateManifestAds ?? true; // Default to true for better UX
|
|
398
|
+
|
|
399
|
+
if (this.config.debugAdTiming) {
|
|
400
|
+
console.log("[StormcloudVideoPlayer] Ad start decision:", {
|
|
401
|
+
isManifestMarker,
|
|
402
|
+
forceImmediate,
|
|
403
|
+
hasPts: typeof marker.ptsSeconds === "number",
|
|
404
|
+
policy,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (isManifestMarker && forceImmediate) {
|
|
409
|
+
if (this.config.debugAdTiming) {
|
|
410
|
+
console.log(
|
|
411
|
+
"[StormcloudVideoPlayer] Starting ad immediately (manifest-based)"
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
this.clearAdStartTimer();
|
|
415
|
+
this.handleAdStart(marker);
|
|
416
|
+
} else if (typeof marker.ptsSeconds === "number") {
|
|
417
|
+
const tol = this.config.driftToleranceMs ?? 1000;
|
|
418
|
+
const nowMs = this.video.currentTime * 1000;
|
|
419
|
+
const estCurrentPtsMs = nowMs - this.ptsDriftEmaMs;
|
|
420
|
+
const deltaMs = Math.floor(
|
|
421
|
+
marker.ptsSeconds * 1000 - estCurrentPtsMs
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
if (this.config.debugAdTiming) {
|
|
425
|
+
console.log(
|
|
426
|
+
"[StormcloudVideoPlayer] PTS-based timing calculation:",
|
|
427
|
+
{
|
|
428
|
+
nowMs,
|
|
429
|
+
estCurrentPtsMs,
|
|
430
|
+
markerPtsMs: marker.ptsSeconds * 1000,
|
|
431
|
+
deltaMs,
|
|
432
|
+
tolerance: tol,
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (deltaMs > tol) {
|
|
438
|
+
if (this.config.debugAdTiming) {
|
|
439
|
+
console.log(
|
|
440
|
+
`[StormcloudVideoPlayer] Scheduling ad start in ${deltaMs}ms`
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
this.scheduleAdStartIn(deltaMs);
|
|
444
|
+
} else {
|
|
445
|
+
if (this.config.debugAdTiming) {
|
|
446
|
+
console.log(
|
|
447
|
+
"[StormcloudVideoPlayer] Starting ad immediately (within tolerance)"
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
this.clearAdStartTimer();
|
|
451
|
+
this.handleAdStart(marker);
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
if (this.config.debugAdTiming) {
|
|
455
|
+
console.log(
|
|
456
|
+
"[StormcloudVideoPlayer] Starting ad immediately (fallback)"
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
this.clearAdStartTimer();
|
|
460
|
+
this.handleAdStart(marker);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (this.expectedAdBreakDurationMs != null) {
|
|
464
|
+
this.scheduleAdStopCountdown(this.expectedAdBreakDurationMs);
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (marker.type === "progress" && this.inAdBreak) {
|
|
469
|
+
if (marker.durationSeconds != null) {
|
|
470
|
+
this.expectedAdBreakDurationMs = marker.durationSeconds * 1000;
|
|
471
|
+
}
|
|
472
|
+
if (
|
|
473
|
+
this.expectedAdBreakDurationMs != null &&
|
|
474
|
+
this.currentAdBreakStartWallClockMs != null
|
|
475
|
+
) {
|
|
476
|
+
const elapsedMs = Date.now() - this.currentAdBreakStartWallClockMs;
|
|
477
|
+
const remainingMs = Math.max(
|
|
478
|
+
0,
|
|
479
|
+
this.expectedAdBreakDurationMs - elapsedMs
|
|
480
|
+
);
|
|
481
|
+
this.scheduleAdStopCountdown(remainingMs);
|
|
482
|
+
}
|
|
483
|
+
if (!this.ima.isAdPlaying()) {
|
|
484
|
+
const policy = this.adSchedule?.lateJoinPolicy ?? "play_remaining";
|
|
485
|
+
const scheduled = this.findCurrentOrNextBreak(
|
|
486
|
+
this.video.currentTime * 1000
|
|
487
|
+
);
|
|
488
|
+
const tags =
|
|
489
|
+
this.selectVastTagsForBreak(scheduled) ||
|
|
490
|
+
(this.apiVastTagUrl ? [this.apiVastTagUrl] : undefined);
|
|
491
|
+
if (policy === "play_remaining" && tags && tags.length > 0) {
|
|
492
|
+
const first = tags[0] as string;
|
|
493
|
+
const rest = tags.slice(1);
|
|
494
|
+
this.adPodQueue = rest;
|
|
495
|
+
this.playSingleAd(first).catch(() => {});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (marker.type === "end") {
|
|
501
|
+
this.inAdBreak = false;
|
|
502
|
+
this.expectedAdBreakDurationMs = undefined;
|
|
503
|
+
this.currentAdBreakStartWallClockMs = undefined;
|
|
504
|
+
this.clearAdStartTimer();
|
|
505
|
+
this.clearAdStopTimer();
|
|
506
|
+
if (this.ima.isAdPlaying()) {
|
|
507
|
+
this.ima.stop().catch(() => {});
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private parseCueOutDuration(value: string): number | undefined {
|
|
514
|
+
const num = parseFloat(value.trim());
|
|
515
|
+
if (!Number.isNaN(num)) return num;
|
|
516
|
+
const match =
|
|
517
|
+
value.match(/(?:^|[,\s])DURATION\s*=\s*([0-9.]+)/i) ||
|
|
518
|
+
value.match(/Duration\s*=\s*([0-9.]+)/i);
|
|
519
|
+
if (match && match[1] != null) {
|
|
520
|
+
const dStr = match[1];
|
|
521
|
+
const d = parseFloat(dStr);
|
|
522
|
+
return Number.isNaN(d) ? undefined : d;
|
|
523
|
+
}
|
|
524
|
+
return undefined;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private parseCueOutCont(
|
|
528
|
+
value: string
|
|
529
|
+
): { elapsed?: number; duration?: number } | undefined {
|
|
530
|
+
const elapsedMatch = value.match(/Elapsed\s*=\s*([0-9.]+)/i);
|
|
531
|
+
const durationMatch = value.match(/Duration\s*=\s*([0-9.]+)/i);
|
|
532
|
+
const res: { elapsed?: number; duration?: number } = {};
|
|
533
|
+
if (elapsedMatch && elapsedMatch[1] != null) {
|
|
534
|
+
const e = parseFloat(elapsedMatch[1]);
|
|
535
|
+
if (!Number.isNaN(e)) res.elapsed = e;
|
|
536
|
+
}
|
|
537
|
+
if (durationMatch && durationMatch[1] != null) {
|
|
538
|
+
const d = parseFloat(durationMatch[1]);
|
|
539
|
+
if (!Number.isNaN(d)) res.duration = d;
|
|
540
|
+
}
|
|
541
|
+
if ("elapsed" in res || "duration" in res) return res;
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private parseAttributeList(value: string): Record<string, string> {
|
|
546
|
+
const attrs: Record<string, string> = {};
|
|
547
|
+
const regex = /([A-Z0-9-]+)=(("[^"]*")|([^",]*))(?:,|$)/gi;
|
|
548
|
+
let match: RegExpExecArray | null;
|
|
549
|
+
while ((match = regex.exec(value)) !== null) {
|
|
550
|
+
const key: string = (match[1] ?? "") as string;
|
|
551
|
+
let rawVal: string = (match[3] ?? match[4] ?? "") as string;
|
|
552
|
+
if (rawVal.startsWith('"') && rawVal.endsWith('"')) {
|
|
553
|
+
rawVal = rawVal.slice(1, -1);
|
|
554
|
+
}
|
|
555
|
+
if (key) {
|
|
556
|
+
attrs[key] = rawVal;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return attrs;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private toNumber(val: unknown): number | undefined {
|
|
563
|
+
if (val == null) return undefined;
|
|
564
|
+
const n = typeof val === "string" ? parseFloat(val) : Number(val);
|
|
565
|
+
return Number.isNaN(n) ? undefined : n;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private isManifestBasedMarker(marker: Scte35Marker): boolean {
|
|
569
|
+
const raw = marker.raw as any;
|
|
570
|
+
if (!raw) return false;
|
|
571
|
+
|
|
572
|
+
if (raw.tag) {
|
|
573
|
+
const tag = String(raw.tag);
|
|
574
|
+
return (
|
|
575
|
+
tag.includes("EXT-X-CUE-OUT") ||
|
|
576
|
+
tag.includes("EXT-X-CUE-IN") ||
|
|
577
|
+
tag.includes("EXT-X-DATERANGE")
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (raw.id3) return false;
|
|
582
|
+
|
|
583
|
+
if (raw.splice_command_type) return false;
|
|
584
|
+
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private parseScte35Binary(data: Uint8Array): Scte35Marker | undefined {
|
|
589
|
+
class BitReader {
|
|
590
|
+
private bytePos = 0;
|
|
591
|
+
private bitPos = 0; // 0..7
|
|
592
|
+
constructor(private readonly buf: Uint8Array) {}
|
|
593
|
+
readBits(numBits: number): number {
|
|
594
|
+
let result = 0;
|
|
595
|
+
while (numBits > 0) {
|
|
596
|
+
if (this.bytePos >= this.buf.length) return result;
|
|
597
|
+
const remainingInByte = 8 - this.bitPos;
|
|
598
|
+
const toRead = Math.min(numBits, remainingInByte);
|
|
599
|
+
const currentByte = this.buf[this.bytePos]!;
|
|
600
|
+
const shift = remainingInByte - toRead;
|
|
601
|
+
const mask = ((1 << toRead) - 1) & 0xff;
|
|
602
|
+
const bits = (currentByte >> shift) & mask;
|
|
603
|
+
result = (result << toRead) | bits;
|
|
604
|
+
this.bitPos += toRead;
|
|
605
|
+
if (this.bitPos >= 8) {
|
|
606
|
+
this.bitPos = 0;
|
|
607
|
+
this.bytePos += 1;
|
|
608
|
+
}
|
|
609
|
+
numBits -= toRead;
|
|
610
|
+
}
|
|
611
|
+
return result >>> 0;
|
|
612
|
+
}
|
|
613
|
+
skipBits(n: number): void {
|
|
614
|
+
this.readBits(n);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const r = new BitReader(data);
|
|
619
|
+
const tableId = r.readBits(8);
|
|
620
|
+
if (tableId !== 0xfc) return undefined;
|
|
621
|
+
r.readBits(1);
|
|
622
|
+
r.readBits(1);
|
|
623
|
+
r.readBits(2);
|
|
624
|
+
const sectionLength = r.readBits(12);
|
|
625
|
+
r.readBits(8);
|
|
626
|
+
r.readBits(1);
|
|
627
|
+
r.readBits(6);
|
|
628
|
+
const ptsAdjHigh = r.readBits(1);
|
|
629
|
+
const ptsAdjLow = r.readBits(32);
|
|
630
|
+
void ptsAdjHigh;
|
|
631
|
+
void ptsAdjLow;
|
|
632
|
+
r.readBits(8);
|
|
633
|
+
r.readBits(12);
|
|
634
|
+
const spliceCommandLength = r.readBits(12);
|
|
635
|
+
const spliceCommandType = r.readBits(8);
|
|
636
|
+
if (spliceCommandType !== 5) {
|
|
637
|
+
return undefined;
|
|
638
|
+
}
|
|
639
|
+
r.readBits(32);
|
|
640
|
+
const cancel = r.readBits(1) === 1;
|
|
641
|
+
r.readBits(7);
|
|
642
|
+
if (cancel) return undefined;
|
|
643
|
+
const outOfNetwork = r.readBits(1) === 1;
|
|
644
|
+
const programSpliceFlag = r.readBits(1) === 1;
|
|
645
|
+
const durationFlag = r.readBits(1) === 1;
|
|
646
|
+
const spliceImmediateFlag = r.readBits(1) === 1;
|
|
647
|
+
r.readBits(4);
|
|
648
|
+
if (programSpliceFlag && !spliceImmediateFlag) {
|
|
649
|
+
const timeSpecifiedFlag = r.readBits(1) === 1;
|
|
650
|
+
if (timeSpecifiedFlag) {
|
|
651
|
+
r.readBits(6);
|
|
652
|
+
r.readBits(33);
|
|
653
|
+
} else {
|
|
654
|
+
r.readBits(7);
|
|
655
|
+
}
|
|
656
|
+
} else if (!programSpliceFlag) {
|
|
657
|
+
const componentCount = r.readBits(8);
|
|
658
|
+
for (let i = 0; i < componentCount; i++) {
|
|
659
|
+
r.readBits(8);
|
|
660
|
+
if (!spliceImmediateFlag) {
|
|
661
|
+
const timeSpecifiedFlag = r.readBits(1) === 1;
|
|
662
|
+
if (timeSpecifiedFlag) {
|
|
663
|
+
r.readBits(6);
|
|
664
|
+
r.readBits(33);
|
|
665
|
+
} else {
|
|
666
|
+
r.readBits(7);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
let durationSeconds: number | undefined = undefined;
|
|
672
|
+
if (durationFlag) {
|
|
673
|
+
r.readBits(6);
|
|
674
|
+
r.readBits(1);
|
|
675
|
+
const high = r.readBits(1);
|
|
676
|
+
const low = r.readBits(32);
|
|
677
|
+
const durationTicks = high * 0x100000000 + low;
|
|
678
|
+
durationSeconds = durationTicks / 90000;
|
|
679
|
+
}
|
|
680
|
+
r.readBits(16);
|
|
681
|
+
r.readBits(8);
|
|
682
|
+
r.readBits(8);
|
|
683
|
+
|
|
684
|
+
if (outOfNetwork) {
|
|
685
|
+
const marker: Scte35Marker = {
|
|
686
|
+
type: "start",
|
|
687
|
+
...(durationSeconds !== undefined ? { durationSeconds } : {}),
|
|
688
|
+
raw: { splice_command_type: 5 },
|
|
689
|
+
} as Scte35Marker;
|
|
690
|
+
return marker;
|
|
691
|
+
}
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private initializeTracking(): void {
|
|
696
|
+
sendInitialTracking().catch((error) => {
|
|
697
|
+
if (this.config.debugAdTiming) {
|
|
698
|
+
console.warn(
|
|
699
|
+
"[StormcloudVideoPlayer] Failed to send initial tracking:",
|
|
700
|
+
error
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
this.heartbeatInterval = window.setInterval(() => {
|
|
706
|
+
this.sendHeartbeatIfNeeded();
|
|
707
|
+
}, 1000);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private sendHeartbeatIfNeeded(): void {
|
|
711
|
+
const now = Date.now();
|
|
712
|
+
if (!this.lastHeartbeatTime || now - this.lastHeartbeatTime > 10000) {
|
|
713
|
+
this.lastHeartbeatTime = now;
|
|
714
|
+
sendHeartbeat().catch((error) => {
|
|
715
|
+
if (this.config.debugAdTiming) {
|
|
716
|
+
console.warn(
|
|
717
|
+
"[StormcloudVideoPlayer] Failed to send heartbeat:",
|
|
718
|
+
error
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private async fetchAdConfiguration(): Promise<void> {
|
|
726
|
+
const apiUrl =
|
|
727
|
+
"https://stormcloud.co/api-stormcloud-dev/stormcloud/ads/web";
|
|
728
|
+
|
|
729
|
+
if (this.config.debugAdTiming) {
|
|
730
|
+
console.log(
|
|
731
|
+
"[StormcloudVideoPlayer] Fetching ad configuration from:",
|
|
732
|
+
apiUrl
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const response = await fetch(apiUrl);
|
|
737
|
+
if (!response.ok) {
|
|
738
|
+
throw new Error(`Failed to fetch ad configuration: ${response.status}`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const data: StormcloudApiResponse = await response.json();
|
|
742
|
+
|
|
743
|
+
const imaPayload = data.response?.ima?.["publisherdesk.ima"]?.payload;
|
|
744
|
+
if (imaPayload) {
|
|
745
|
+
this.apiVastTagUrl = decodeURIComponent(imaPayload);
|
|
746
|
+
if (this.config.debugAdTiming) {
|
|
747
|
+
console.log(
|
|
748
|
+
"[StormcloudVideoPlayer] Extracted VAST tag URL:",
|
|
749
|
+
this.apiVastTagUrl
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
this.vastConfig = data.response?.options?.vast;
|
|
755
|
+
|
|
756
|
+
if (this.config.debugAdTiming) {
|
|
757
|
+
console.log("[StormcloudVideoPlayer] Ad configuration loaded:", {
|
|
758
|
+
vastTagUrl: this.apiVastTagUrl,
|
|
759
|
+
vastConfig: this.vastConfig,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
setAdSchedule(schedule: AdSchedule | undefined): void {
|
|
765
|
+
this.adSchedule = schedule;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
getCurrentAdIndex(): number {
|
|
769
|
+
return this.currentAdIndex;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
getTotalAdsInBreak(): number {
|
|
773
|
+
return this.totalAdsInBreak;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
isAdPlaying(): boolean {
|
|
777
|
+
return this.inAdBreak && this.ima.isAdPlaying();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
isShowingAds(): boolean {
|
|
781
|
+
return this.showAds;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
getStreamType(): "hls" | "other" {
|
|
785
|
+
const url = this.config.src.toLowerCase();
|
|
786
|
+
if (
|
|
787
|
+
url.includes(".m3u8") ||
|
|
788
|
+
url.includes("/hls/") ||
|
|
789
|
+
url.includes("application/vnd.apple.mpegurl")
|
|
790
|
+
) {
|
|
791
|
+
return "hls";
|
|
792
|
+
}
|
|
793
|
+
return "other";
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
shouldShowNativeControls(): boolean {
|
|
797
|
+
return this.getStreamType() !== "hls";
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async loadAdScheduleFromUrl(url: string): Promise<void> {
|
|
801
|
+
const res = await fetch(url);
|
|
802
|
+
if (!res.ok) throw new Error(`Failed to fetch ad schedule: ${res.status}`);
|
|
803
|
+
const data = await res.json();
|
|
804
|
+
this.adSchedule = data as AdSchedule;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async loadDefaultVastFromAiry(
|
|
808
|
+
airyApiUrl: string,
|
|
809
|
+
params?: Record<string, string>
|
|
810
|
+
): Promise<void> {
|
|
811
|
+
const usp = new URLSearchParams(params || {});
|
|
812
|
+
const url = `${airyApiUrl}?${usp.toString()}`;
|
|
813
|
+
const res = await fetch(url);
|
|
814
|
+
if (!res.ok) throw new Error(`Failed to fetch Airy ads: ${res.status}`);
|
|
815
|
+
const data = await res.json();
|
|
816
|
+
const tag = data?.adTagUrl || data?.vastTagUrl || data?.tagUrl;
|
|
817
|
+
if (typeof tag === "string" && tag.length > 0) {
|
|
818
|
+
this.apiVastTagUrl = tag;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private async handleAdStart(_marker: Scte35Marker): Promise<void> {
|
|
823
|
+
const scheduled = this.findCurrentOrNextBreak(
|
|
824
|
+
this.video.currentTime * 1000
|
|
825
|
+
);
|
|
826
|
+
const tags = this.selectVastTagsForBreak(scheduled);
|
|
827
|
+
|
|
828
|
+
let vastTagUrl: string | undefined;
|
|
829
|
+
let adsNumber = 1;
|
|
830
|
+
|
|
831
|
+
if (this.apiVastTagUrl) {
|
|
832
|
+
vastTagUrl = this.apiVastTagUrl;
|
|
833
|
+
|
|
834
|
+
if (this.vastConfig) {
|
|
835
|
+
const isHls =
|
|
836
|
+
this.config.src.includes(".m3u8") || this.config.src.includes("hls");
|
|
837
|
+
if (isHls && this.vastConfig.cue_tones?.number_ads) {
|
|
838
|
+
adsNumber = this.vastConfig.cue_tones.number_ads;
|
|
839
|
+
} else if (!isHls && this.vastConfig.timer_vod?.number_ads) {
|
|
840
|
+
adsNumber = this.vastConfig.timer_vod.number_ads;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
this.adPodQueue = new Array(adsNumber - 1).fill(vastTagUrl);
|
|
845
|
+
this.currentAdIndex = 0;
|
|
846
|
+
this.totalAdsInBreak = adsNumber;
|
|
847
|
+
|
|
848
|
+
if (this.config.debugAdTiming) {
|
|
849
|
+
console.log(
|
|
850
|
+
`[StormcloudVideoPlayer] Using API VAST tag with ${adsNumber} ads:`,
|
|
851
|
+
vastTagUrl
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
} else if (tags && tags.length > 0) {
|
|
855
|
+
vastTagUrl = tags[0] as string;
|
|
856
|
+
const rest = tags.slice(1);
|
|
857
|
+
this.adPodQueue = rest;
|
|
858
|
+
this.currentAdIndex = 0;
|
|
859
|
+
this.totalAdsInBreak = tags.length;
|
|
860
|
+
|
|
861
|
+
if (this.config.debugAdTiming) {
|
|
862
|
+
console.log(
|
|
863
|
+
"[StormcloudVideoPlayer] Using scheduled VAST tag:",
|
|
864
|
+
vastTagUrl
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
} else {
|
|
868
|
+
if (this.config.debugAdTiming) {
|
|
869
|
+
console.log("[StormcloudVideoPlayer] No VAST tag available for ad");
|
|
870
|
+
}
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (vastTagUrl) {
|
|
875
|
+
this.showAds = true;
|
|
876
|
+
this.currentAdIndex++;
|
|
877
|
+
await this.playSingleAd(vastTagUrl);
|
|
878
|
+
}
|
|
879
|
+
if (
|
|
880
|
+
this.expectedAdBreakDurationMs == null &&
|
|
881
|
+
scheduled?.durationMs != null
|
|
882
|
+
) {
|
|
883
|
+
this.expectedAdBreakDurationMs = scheduled.durationMs;
|
|
884
|
+
this.currentAdBreakStartWallClockMs =
|
|
885
|
+
this.currentAdBreakStartWallClockMs ?? Date.now();
|
|
886
|
+
this.scheduleAdStopCountdown(this.expectedAdBreakDurationMs);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private findCurrentOrNextBreak(nowMs: number): AdBreak | undefined {
|
|
891
|
+
const schedule = this.adSchedule?.breaks || [];
|
|
892
|
+
let candidate: AdBreak | undefined;
|
|
893
|
+
for (const b of schedule) {
|
|
894
|
+
const tol = this.config.driftToleranceMs ?? 1000;
|
|
895
|
+
if (
|
|
896
|
+
b.startTimeMs <= nowMs + tol &&
|
|
897
|
+
(candidate == null || b.startTimeMs > (candidate.startTimeMs || 0))
|
|
898
|
+
) {
|
|
899
|
+
candidate = b;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return candidate;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
private onTimeUpdate(currentTimeSec: number): void {
|
|
906
|
+
if (!this.adSchedule || this.ima.isAdPlaying()) return;
|
|
907
|
+
const nowMs = currentTimeSec * 1000;
|
|
908
|
+
const breakToPlay = this.findBreakForTime(nowMs);
|
|
909
|
+
if (breakToPlay) {
|
|
910
|
+
this.handleMidAdJoin(breakToPlay, nowMs);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private async handleMidAdJoin(
|
|
915
|
+
adBreak: AdBreak,
|
|
916
|
+
nowMs: number
|
|
917
|
+
): Promise<void> {
|
|
918
|
+
const durationMs = adBreak.durationMs ?? 0;
|
|
919
|
+
const endMs = adBreak.startTimeMs + durationMs;
|
|
920
|
+
if (durationMs > 0 && nowMs > adBreak.startTimeMs && nowMs < endMs) {
|
|
921
|
+
const remainingMs = endMs - nowMs;
|
|
922
|
+
const policy = this.adSchedule?.lateJoinPolicy ?? "play_remaining";
|
|
923
|
+
if (policy === "skip_to_content") return;
|
|
924
|
+
const tags =
|
|
925
|
+
this.selectVastTagsForBreak(adBreak) ||
|
|
926
|
+
(this.apiVastTagUrl ? [this.apiVastTagUrl] : undefined);
|
|
927
|
+
if (policy === "play_remaining" && tags && tags.length > 0) {
|
|
928
|
+
const first = tags[0] as string;
|
|
929
|
+
const rest = tags.slice(1);
|
|
930
|
+
this.adPodQueue = rest;
|
|
931
|
+
await this.playSingleAd(first);
|
|
932
|
+
this.inAdBreak = true;
|
|
933
|
+
this.expectedAdBreakDurationMs = remainingMs;
|
|
934
|
+
this.currentAdBreakStartWallClockMs = Date.now();
|
|
935
|
+
this.scheduleAdStopCountdown(remainingMs);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private scheduleAdStopCountdown(remainingMs: number): void {
|
|
941
|
+
this.clearAdStopTimer();
|
|
942
|
+
const ms = Math.max(0, Math.floor(remainingMs));
|
|
943
|
+
if (ms === 0) {
|
|
944
|
+
this.ensureAdStoppedByTimer();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
this.adStopTimerId = window.setTimeout(() => {
|
|
948
|
+
this.ensureAdStoppedByTimer();
|
|
949
|
+
}, ms) as unknown as number;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private clearAdStopTimer(): void {
|
|
953
|
+
if (this.adStopTimerId != null) {
|
|
954
|
+
clearTimeout(this.adStopTimerId);
|
|
955
|
+
this.adStopTimerId = undefined;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
private ensureAdStoppedByTimer(): void {
|
|
960
|
+
if (!this.inAdBreak) return;
|
|
961
|
+
this.inAdBreak = false;
|
|
962
|
+
this.expectedAdBreakDurationMs = undefined;
|
|
963
|
+
this.currentAdBreakStartWallClockMs = undefined;
|
|
964
|
+
this.adStopTimerId = undefined;
|
|
965
|
+
if (this.ima.isAdPlaying()) {
|
|
966
|
+
this.ima.stop().catch(() => {});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
private scheduleAdStartIn(delayMs: number): void {
|
|
971
|
+
this.clearAdStartTimer();
|
|
972
|
+
const ms = Math.max(0, Math.floor(delayMs));
|
|
973
|
+
if (ms === 0) {
|
|
974
|
+
this.handleAdStart({ type: "start" } as Scte35Marker).catch(() => {});
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
this.adStartTimerId = window.setTimeout(() => {
|
|
978
|
+
this.handleAdStart({ type: "start" } as Scte35Marker).catch(() => {});
|
|
979
|
+
}, ms) as unknown as number;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
private clearAdStartTimer(): void {
|
|
983
|
+
if (this.adStartTimerId != null) {
|
|
984
|
+
clearTimeout(this.adStartTimerId);
|
|
985
|
+
this.adStartTimerId = undefined;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
private updatePtsDrift(ptsSecondsSample: number): void {
|
|
990
|
+
const sampleMs = (this.video.currentTime - ptsSecondsSample) * 1000;
|
|
991
|
+
if (!Number.isFinite(sampleMs) || Math.abs(sampleMs) > 60000) return;
|
|
992
|
+
const alpha = 0.1;
|
|
993
|
+
this.ptsDriftEmaMs = this.ptsDriftEmaMs * (1 - alpha) + sampleMs * alpha;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
private async playSingleAd(vastTagUrl: string): Promise<void> {
|
|
997
|
+
if (this.config.debugAdTiming) {
|
|
998
|
+
console.log("[StormcloudVideoPlayer] Attempting to play ad:", vastTagUrl);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
this.startAdFailsafeTimer();
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
await this.ima.requestAds(vastTagUrl);
|
|
1005
|
+
await this.ima.play();
|
|
1006
|
+
|
|
1007
|
+
if (this.config.debugAdTiming) {
|
|
1008
|
+
console.log("[StormcloudVideoPlayer] Ad playback started successfully");
|
|
1009
|
+
}
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
if (this.config.debugAdTiming) {
|
|
1012
|
+
console.error("[StormcloudVideoPlayer] Ad playback failed:", error);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
this.handleAdFailure();
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
private handleAdFailure(): void {
|
|
1020
|
+
if (this.config.debugAdTiming) {
|
|
1021
|
+
console.log(
|
|
1022
|
+
"[StormcloudVideoPlayer] Handling ad failure - resuming content"
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
this.inAdBreak = false;
|
|
1027
|
+
this.expectedAdBreakDurationMs = undefined;
|
|
1028
|
+
this.currentAdBreakStartWallClockMs = undefined;
|
|
1029
|
+
this.clearAdStartTimer();
|
|
1030
|
+
this.clearAdStopTimer();
|
|
1031
|
+
this.clearAdFailsafeTimer();
|
|
1032
|
+
this.adPodQueue = [];
|
|
1033
|
+
this.showAds = false;
|
|
1034
|
+
this.currentAdIndex = 0;
|
|
1035
|
+
this.totalAdsInBreak = 0;
|
|
1036
|
+
|
|
1037
|
+
if (this.video.paused) {
|
|
1038
|
+
this.video.play().catch(() => {
|
|
1039
|
+
if (this.config.debugAdTiming) {
|
|
1040
|
+
console.error(
|
|
1041
|
+
"[StormcloudVideoPlayer] Failed to resume video after ad failure"
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
private startAdFailsafeTimer(): void {
|
|
1049
|
+
this.clearAdFailsafeTimer();
|
|
1050
|
+
|
|
1051
|
+
const failsafeMs = this.config.adFailsafeTimeoutMs ?? 10000;
|
|
1052
|
+
|
|
1053
|
+
if (this.config.debugAdTiming) {
|
|
1054
|
+
console.log(
|
|
1055
|
+
`[StormcloudVideoPlayer] Starting failsafe timer (${failsafeMs}ms)`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
this.adFailsafeTimerId = window.setTimeout(() => {
|
|
1060
|
+
if (this.video.paused) {
|
|
1061
|
+
if (this.config.debugAdTiming) {
|
|
1062
|
+
console.warn(
|
|
1063
|
+
"[StormcloudVideoPlayer] Failsafe timer triggered - forcing video resume"
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
this.handleAdFailure();
|
|
1067
|
+
}
|
|
1068
|
+
}, failsafeMs) as unknown as number;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private clearAdFailsafeTimer(): void {
|
|
1072
|
+
if (this.adFailsafeTimerId != null) {
|
|
1073
|
+
clearTimeout(this.adFailsafeTimerId);
|
|
1074
|
+
this.adFailsafeTimerId = undefined;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private selectVastTagsForBreak(b?: AdBreak): string[] | undefined {
|
|
1079
|
+
if (!b || !b.vastTagUrl) return undefined;
|
|
1080
|
+
if (b.vastTagUrl.includes(",")) {
|
|
1081
|
+
return b.vastTagUrl
|
|
1082
|
+
.split(",")
|
|
1083
|
+
.map((s) => s.trim())
|
|
1084
|
+
.filter((s) => s.length > 0);
|
|
1085
|
+
}
|
|
1086
|
+
return [b.vastTagUrl];
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
private getRemainingAdMs(): number {
|
|
1090
|
+
if (
|
|
1091
|
+
this.expectedAdBreakDurationMs == null ||
|
|
1092
|
+
this.currentAdBreakStartWallClockMs == null
|
|
1093
|
+
)
|
|
1094
|
+
return 0;
|
|
1095
|
+
const elapsed = Date.now() - this.currentAdBreakStartWallClockMs;
|
|
1096
|
+
return Math.max(0, this.expectedAdBreakDurationMs - elapsed);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private findBreakForTime(nowMs: number): AdBreak | undefined {
|
|
1100
|
+
const schedule = this.adSchedule?.breaks || [];
|
|
1101
|
+
for (const b of schedule) {
|
|
1102
|
+
const end = (b.startTimeMs || 0) + (b.durationMs || 0);
|
|
1103
|
+
if (
|
|
1104
|
+
nowMs >= (b.startTimeMs || 0) &&
|
|
1105
|
+
(b.durationMs ? nowMs < end : true)
|
|
1106
|
+
) {
|
|
1107
|
+
return b;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return undefined;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
destroy(): void {
|
|
1114
|
+
this.clearAdStartTimer();
|
|
1115
|
+
this.clearAdStopTimer();
|
|
1116
|
+
this.clearAdFailsafeTimer();
|
|
1117
|
+
if (this.heartbeatInterval) {
|
|
1118
|
+
clearInterval(this.heartbeatInterval);
|
|
1119
|
+
this.heartbeatInterval = undefined;
|
|
1120
|
+
}
|
|
1121
|
+
this.hls?.destroy();
|
|
1122
|
+
this.ima?.destroy();
|
|
1123
|
+
}
|
|
1124
|
+
}
|