vastlint-client 0.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,550 @@
1
+ import { selectResolvedAdMediaFile } from "./media.js";
2
+ import { expandTrackingUrl } from "./tracking.js";
3
+ const PROGRESS_MILESTONES = [
4
+ ["firstQuartile", 0.25],
5
+ ["midpoint", 0.5],
6
+ ["thirdQuartile", 0.75],
7
+ ["complete", 1],
8
+ ];
9
+ function createTimestamp() {
10
+ return new Date().toISOString();
11
+ }
12
+ function toError(value) {
13
+ if (value instanceof Error) {
14
+ return value;
15
+ }
16
+ return new Error(typeof value === "string" ? value : "Unknown vastlint-client playback queue error.");
17
+ }
18
+ function parseDurationToSeconds(value) {
19
+ const trimmed = value.trim();
20
+ if (!trimmed) {
21
+ return null;
22
+ }
23
+ const parts = trimmed.split(":");
24
+ if (parts.length !== 3) {
25
+ return null;
26
+ }
27
+ const hours = Number.parseInt(parts[0] ?? "", 10);
28
+ const minutes = Number.parseInt(parts[1] ?? "", 10);
29
+ const seconds = Number.parseFloat(parts[2] ?? "");
30
+ if (![hours, minutes, seconds].every(Number.isFinite)) {
31
+ return null;
32
+ }
33
+ return hours * 3600 + minutes * 60 + seconds;
34
+ }
35
+ function createMilestones() {
36
+ return {
37
+ start: false,
38
+ firstQuartile: false,
39
+ midpoint: false,
40
+ thirdQuartile: false,
41
+ complete: false,
42
+ };
43
+ }
44
+ function clonePlaybackMilestones(milestones) {
45
+ return { ...milestones };
46
+ }
47
+ function cloneMediaSelection(item) {
48
+ return {
49
+ selected: item.selected ? { ...item.selected } : null,
50
+ candidates: item.candidates.map((candidate) => ({
51
+ mediaFile: { ...candidate.mediaFile },
52
+ score: candidate.score,
53
+ reasons: [...candidate.reasons],
54
+ })),
55
+ };
56
+ }
57
+ function buildAdKey(resolvedAd) {
58
+ return [
59
+ resolvedAd.finalUrl ?? "",
60
+ resolvedAd.adTitle,
61
+ resolvedAd.duration,
62
+ resolvedAd.adPod.adId ?? "",
63
+ String(resolvedAd.adPod.sequence ?? ""),
64
+ ...resolvedAd.mediaFiles.map((mediaFile) => mediaFile.url),
65
+ ].join("::");
66
+ }
67
+ function buildQueueItem(resolvedAd, adIndex, options) {
68
+ return {
69
+ adIndex,
70
+ resolvedAd,
71
+ mediaSelection: selectResolvedAdMediaFile(resolvedAd, options.mediaSelection),
72
+ currentTimeSec: 0,
73
+ durationSec: parseDurationToSeconds(resolvedAd.duration),
74
+ impressionTracked: false,
75
+ skipped: false,
76
+ milestones: createMilestones(),
77
+ status: resolvedAd.resolved ? "ready" : "idle",
78
+ clickThroughUrl: resolvedAd.clickThroughUrl ?? null,
79
+ error: null,
80
+ };
81
+ }
82
+ function syncQueueItem(existing, resolvedAd, adIndex, options) {
83
+ if (!existing) {
84
+ return buildQueueItem(resolvedAd, adIndex, options);
85
+ }
86
+ return {
87
+ ...existing,
88
+ adIndex,
89
+ resolvedAd,
90
+ mediaSelection: selectResolvedAdMediaFile(resolvedAd, options.mediaSelection),
91
+ durationSec: existing.durationSec ?? parseDurationToSeconds(resolvedAd.duration),
92
+ clickThroughUrl: resolvedAd.clickThroughUrl ?? null,
93
+ };
94
+ }
95
+ function cloneQueueItem(item) {
96
+ return {
97
+ ...item,
98
+ mediaSelection: cloneMediaSelection(item.mediaSelection),
99
+ milestones: clonePlaybackMilestones(item.milestones),
100
+ };
101
+ }
102
+ function buildQueueItems(resolvedAds, options, previousItems = []) {
103
+ const previousByKey = new Map(previousItems.map((item) => [buildAdKey(item.resolvedAd), item]));
104
+ return resolvedAds.map((resolvedAd, adIndex) => syncQueueItem(previousByKey.get(buildAdKey(resolvedAd)), resolvedAd, adIndex, options));
105
+ }
106
+ function normalizeQueueSnapshot(next) {
107
+ const currentAdIndex = next.currentAdIndex !== null && next.currentAdIndex >= 0 && next.currentAdIndex < next.items.length
108
+ ? next.currentAdIndex
109
+ : next.items.length > 0
110
+ ? 0
111
+ : null;
112
+ const currentItem = currentAdIndex !== null ? next.items[currentAdIndex] ?? null : null;
113
+ const status = next.session.error
114
+ ? "error"
115
+ : currentItem?.status ?? (next.items.length > 0 ? "ready" : "idle");
116
+ return {
117
+ ...next,
118
+ resolvedAds: [...next.session.resolvedAds],
119
+ currentAdIndex,
120
+ currentItem,
121
+ hasNext: currentAdIndex !== null ? currentAdIndex + 1 < next.items.length : false,
122
+ status,
123
+ error: next.session.error?.message ?? currentItem?.error ?? null,
124
+ };
125
+ }
126
+ function cloneQueueSnapshot(snapshot, session) {
127
+ const sessionSnapshot = session.getSnapshot();
128
+ const currentAdIndex = snapshot.currentAdIndex !== null && snapshot.currentAdIndex < snapshot.items.length
129
+ ? snapshot.currentAdIndex
130
+ : snapshot.items.length > 0
131
+ ? 0
132
+ : null;
133
+ return normalizeQueueSnapshot({
134
+ ...snapshot,
135
+ session: sessionSnapshot,
136
+ resolvedAds: [...sessionSnapshot.resolvedAds],
137
+ items: snapshot.items.map((item) => cloneQueueItem(item)),
138
+ currentAdIndex,
139
+ currentItem: currentAdIndex !== null ? cloneQueueItem(snapshot.items[currentAdIndex] ?? snapshot.currentItem ?? snapshot.items[0]) : null,
140
+ });
141
+ }
142
+ function selectTrackingUrls(resolvedAd, event) {
143
+ if (event === "impression") {
144
+ return [...resolvedAd.impressionUrls];
145
+ }
146
+ if (event === "error") {
147
+ return [...resolvedAd.errorUrls];
148
+ }
149
+ if (event === "clickTracking") {
150
+ return [...resolvedAd.clickTrackingUrls];
151
+ }
152
+ return [...(resolvedAd.trackingEvents[event] ?? [])];
153
+ }
154
+ export function createVastPlaybackQueueController(options) {
155
+ const listeners = new Set();
156
+ const dispatchedTrackingKeys = new Set();
157
+ let snapshot = normalizeQueueSnapshot({
158
+ status: "idle",
159
+ session: options.session.getSnapshot(),
160
+ resolvedAds: options.session.getSnapshot().resolvedAds,
161
+ items: buildQueueItems(options.session.getSnapshot().resolvedAds, options),
162
+ currentAdIndex: options.session.getSnapshot().resolvedAds.length > 0 ? 0 : null,
163
+ currentItem: null,
164
+ hasNext: false,
165
+ muted: false,
166
+ fullscreen: false,
167
+ viewability: null,
168
+ error: options.session.getSnapshot().error?.message ?? null,
169
+ });
170
+ const notify = () => {
171
+ const current = cloneQueueSnapshot(snapshot, options.session);
172
+ for (const listener of listeners) {
173
+ listener(current);
174
+ }
175
+ };
176
+ const setSnapshot = (next) => {
177
+ snapshot = normalizeQueueSnapshot(next);
178
+ notify();
179
+ };
180
+ const sessionUnsubscribe = options.session.subscribe((sessionSnapshot) => {
181
+ const currentKey = snapshot.currentItem ? buildAdKey(snapshot.currentItem.resolvedAd) : null;
182
+ const items = buildQueueItems(sessionSnapshot.resolvedAds, options, snapshot.items);
183
+ const nextIndex = currentKey
184
+ ? items.findIndex((item) => buildAdKey(item.resolvedAd) === currentKey)
185
+ : snapshot.currentAdIndex ?? (items.length > 0 ? 0 : null);
186
+ setSnapshot({
187
+ ...snapshot,
188
+ session: sessionSnapshot,
189
+ resolvedAds: sessionSnapshot.resolvedAds,
190
+ items,
191
+ currentAdIndex: typeof nextIndex === "number" && nextIndex >= 0 ? nextIndex : items.length > 0 ? 0 : null,
192
+ currentItem: null,
193
+ });
194
+ });
195
+ const updateCurrentItem = (mutator) => {
196
+ if (snapshot.currentAdIndex === null) {
197
+ throw new Error("No active VAST ad is available in the playback queue.");
198
+ }
199
+ const currentItem = snapshot.items[snapshot.currentAdIndex];
200
+ if (!currentItem) {
201
+ throw new Error("The current VAST playback queue item is unavailable.");
202
+ }
203
+ const nextItem = mutator(currentItem);
204
+ const items = snapshot.items.map((item, index) => (index === snapshot.currentAdIndex ? nextItem : item));
205
+ setSnapshot({
206
+ ...snapshot,
207
+ items,
208
+ currentItem: nextItem,
209
+ });
210
+ return nextItem;
211
+ };
212
+ const runAction = async (action) => {
213
+ try {
214
+ return await action();
215
+ }
216
+ catch (error) {
217
+ const nextError = toError(error);
218
+ if (snapshot.currentAdIndex !== null) {
219
+ updateCurrentItem((item) => ({
220
+ ...item,
221
+ status: "error",
222
+ error: nextError.message,
223
+ }));
224
+ }
225
+ else {
226
+ setSnapshot({
227
+ ...snapshot,
228
+ error: nextError.message,
229
+ });
230
+ }
231
+ throw nextError;
232
+ }
233
+ };
234
+ const ensureQueueReady = async () => {
235
+ if (snapshot.items.length === 0) {
236
+ if (options.autoResolve === false) {
237
+ throw new Error("VAST playback queue requires a resolved session when autoResolve is false.");
238
+ }
239
+ await options.session.resolve();
240
+ }
241
+ if (snapshot.currentAdIndex === null || !snapshot.currentItem) {
242
+ throw new Error("No resolved VAST ad is available in the playback queue.");
243
+ }
244
+ if (!snapshot.currentItem.resolvedAd.resolved) {
245
+ throw new Error("The current VAST queue item does not resolve to an inline playable ad.");
246
+ }
247
+ return snapshot.currentItem;
248
+ };
249
+ const dispatchCurrentEvent = async (event, trackOptions = {}, defaultDedupe = true) => {
250
+ const currentItem = await ensureQueueReady();
251
+ const urls = selectTrackingUrls(currentItem.resolvedAd, event);
252
+ const dedupe = trackOptions.dedupe ?? defaultDedupe;
253
+ const fetchImpl = options.fetch ?? globalThis.fetch;
254
+ if (typeof fetchImpl !== "function") {
255
+ throw new Error("No fetch implementation is available for VAST playback queue tracking dispatch.");
256
+ }
257
+ const filteredUrls = dedupe
258
+ ? urls.filter((url) => !dispatchedTrackingKeys.has(`${buildAdKey(currentItem.resolvedAd)}:${event}:${url}`))
259
+ : urls;
260
+ const results = await Promise.all(filteredUrls.map(async (url) => {
261
+ const resolvedUrl = expandTrackingUrl(url, trackOptions.macros);
262
+ try {
263
+ const response = await fetchImpl(resolvedUrl, { method: "GET" });
264
+ return {
265
+ event,
266
+ url,
267
+ resolvedUrl,
268
+ hopIndex: currentItem.resolvedAd.finalHopIndex ?? 0,
269
+ sourceUrl: currentItem.resolvedAd.finalUrl,
270
+ offset: null,
271
+ ok: response.ok,
272
+ status: response.status,
273
+ dispatchedAt: createTimestamp(),
274
+ error: null,
275
+ };
276
+ }
277
+ catch (dispatchError) {
278
+ return {
279
+ event,
280
+ url,
281
+ resolvedUrl,
282
+ hopIndex: currentItem.resolvedAd.finalHopIndex ?? 0,
283
+ sourceUrl: currentItem.resolvedAd.finalUrl,
284
+ offset: null,
285
+ ok: false,
286
+ status: null,
287
+ dispatchedAt: createTimestamp(),
288
+ error: toError(dispatchError).message,
289
+ };
290
+ }
291
+ }));
292
+ for (const url of filteredUrls) {
293
+ dispatchedTrackingKeys.add(`${buildAdKey(currentItem.resolvedAd)}:${event}:${url}`);
294
+ }
295
+ return results;
296
+ };
297
+ const ensureStarted = async () => {
298
+ const currentItem = await ensureQueueReady();
299
+ if (!currentItem.impressionTracked) {
300
+ await dispatchCurrentEvent("impression", { dedupe: true });
301
+ }
302
+ if (!currentItem.milestones.start) {
303
+ await dispatchCurrentEvent("creativeView", { dedupe: true });
304
+ await dispatchCurrentEvent("start", { dedupe: true });
305
+ }
306
+ updateCurrentItem((item) => ({
307
+ ...item,
308
+ impressionTracked: true,
309
+ milestones: {
310
+ ...item.milestones,
311
+ start: true,
312
+ },
313
+ status: item.milestones.complete || item.skipped ? "ended" : "playing",
314
+ error: null,
315
+ }));
316
+ };
317
+ return {
318
+ async initialize() {
319
+ return runAction(async () => {
320
+ await ensureQueueReady();
321
+ return cloneQueueSnapshot(snapshot, options.session);
322
+ });
323
+ },
324
+ async start() {
325
+ return runAction(async () => {
326
+ await ensureStarted();
327
+ return cloneQueueSnapshot(snapshot, options.session);
328
+ });
329
+ },
330
+ async pause() {
331
+ return runAction(async () => {
332
+ const currentItem = await ensureQueueReady();
333
+ if (currentItem.status !== "playing") {
334
+ return cloneQueueSnapshot(snapshot, options.session);
335
+ }
336
+ await dispatchCurrentEvent("pause", { dedupe: false }, false);
337
+ updateCurrentItem((item) => ({
338
+ ...item,
339
+ status: "paused",
340
+ error: null,
341
+ }));
342
+ return cloneQueueSnapshot(snapshot, options.session);
343
+ });
344
+ },
345
+ async resume() {
346
+ return runAction(async () => {
347
+ const currentItem = await ensureQueueReady();
348
+ if (!currentItem.milestones.start) {
349
+ await ensureStarted();
350
+ return cloneQueueSnapshot(snapshot, options.session);
351
+ }
352
+ if (currentItem.status !== "paused") {
353
+ return cloneQueueSnapshot(snapshot, options.session);
354
+ }
355
+ await dispatchCurrentEvent("resume", { dedupe: false }, false);
356
+ updateCurrentItem((item) => ({
357
+ ...item,
358
+ status: "playing",
359
+ error: null,
360
+ }));
361
+ return cloneQueueSnapshot(snapshot, options.session);
362
+ });
363
+ },
364
+ async updateProgress(currentTimeSec, durationSec) {
365
+ return runAction(async () => {
366
+ const currentItem = await ensureQueueReady();
367
+ if (!Number.isFinite(currentTimeSec) || currentTimeSec < 0) {
368
+ throw new Error("Playback progress time must be a finite, non-negative number.");
369
+ }
370
+ if (currentTimeSec > 0 && !currentItem.milestones.start) {
371
+ await ensureStarted();
372
+ }
373
+ const activeItem = snapshot.currentItem;
374
+ if (!activeItem) {
375
+ throw new Error("The current VAST playback queue item is unavailable.");
376
+ }
377
+ const nextDuration = durationSec ?? activeItem.durationSec ?? parseDurationToSeconds(activeItem.resolvedAd.duration);
378
+ const nextMilestones = clonePlaybackMilestones(activeItem.milestones);
379
+ if (nextDuration && nextDuration > 0) {
380
+ const progress = currentTimeSec / nextDuration;
381
+ for (const [event, threshold] of PROGRESS_MILESTONES) {
382
+ if (nextMilestones[event] || progress < threshold) {
383
+ continue;
384
+ }
385
+ await dispatchCurrentEvent(event, { dedupe: true });
386
+ nextMilestones[event] = true;
387
+ }
388
+ }
389
+ updateCurrentItem((item) => ({
390
+ ...item,
391
+ currentTimeSec,
392
+ durationSec: nextDuration,
393
+ milestones: nextMilestones,
394
+ status: nextMilestones.complete
395
+ ? "ended"
396
+ : item.status === "paused"
397
+ ? "paused"
398
+ : item.milestones.start || currentTimeSec > 0
399
+ ? "playing"
400
+ : item.status,
401
+ error: null,
402
+ }));
403
+ return cloneQueueSnapshot(snapshot, options.session);
404
+ });
405
+ },
406
+ async completeCurrent() {
407
+ return runAction(async () => {
408
+ await ensureStarted();
409
+ const currentItem = snapshot.currentItem;
410
+ if (!currentItem || currentItem.milestones.complete) {
411
+ return cloneQueueSnapshot(snapshot, options.session);
412
+ }
413
+ await dispatchCurrentEvent("complete", { dedupe: true });
414
+ updateCurrentItem((item) => ({
415
+ ...item,
416
+ currentTimeSec: item.durationSec ?? item.currentTimeSec,
417
+ milestones: {
418
+ ...item.milestones,
419
+ complete: true,
420
+ },
421
+ status: "ended",
422
+ error: null,
423
+ }));
424
+ return cloneQueueSnapshot(snapshot, options.session);
425
+ });
426
+ },
427
+ async next() {
428
+ return runAction(async () => {
429
+ await ensureQueueReady();
430
+ if (snapshot.currentAdIndex === null || snapshot.currentAdIndex + 1 >= snapshot.items.length) {
431
+ return cloneQueueSnapshot(snapshot, options.session);
432
+ }
433
+ setSnapshot({
434
+ ...snapshot,
435
+ currentAdIndex: snapshot.currentAdIndex + 1,
436
+ currentItem: null,
437
+ error: null,
438
+ });
439
+ return cloneQueueSnapshot(snapshot, options.session);
440
+ });
441
+ },
442
+ async setMuted(muted) {
443
+ return runAction(async () => {
444
+ await ensureQueueReady();
445
+ if (snapshot.muted === muted) {
446
+ return cloneQueueSnapshot(snapshot, options.session);
447
+ }
448
+ if (snapshot.currentItem?.milestones.start) {
449
+ await dispatchCurrentEvent(muted ? "mute" : "unmute", { dedupe: false }, false);
450
+ }
451
+ setSnapshot({
452
+ ...snapshot,
453
+ muted,
454
+ });
455
+ return cloneQueueSnapshot(snapshot, options.session);
456
+ });
457
+ },
458
+ async setFullscreen(fullscreen) {
459
+ return runAction(async () => {
460
+ await ensureQueueReady();
461
+ if (snapshot.fullscreen === fullscreen) {
462
+ return cloneQueueSnapshot(snapshot, options.session);
463
+ }
464
+ if (snapshot.currentItem?.milestones.start) {
465
+ await dispatchCurrentEvent(fullscreen ? "fullscreen" : "exitFullscreen", { dedupe: false }, false);
466
+ }
467
+ setSnapshot({
468
+ ...snapshot,
469
+ fullscreen,
470
+ });
471
+ return cloneQueueSnapshot(snapshot, options.session);
472
+ });
473
+ },
474
+ async setViewability(viewability) {
475
+ return runAction(async () => {
476
+ await ensureQueueReady();
477
+ if (snapshot.viewability === viewability) {
478
+ return cloneQueueSnapshot(snapshot, options.session);
479
+ }
480
+ if (snapshot.currentItem?.milestones.start) {
481
+ await dispatchCurrentEvent(viewability, { dedupe: false }, false);
482
+ }
483
+ setSnapshot({
484
+ ...snapshot,
485
+ viewability,
486
+ });
487
+ return cloneQueueSnapshot(snapshot, options.session);
488
+ });
489
+ },
490
+ async click(trackOptions = {}) {
491
+ return runAction(async () => {
492
+ const currentItem = await ensureQueueReady();
493
+ const tracking = await dispatchCurrentEvent("clickTracking", {
494
+ ...trackOptions,
495
+ dedupe: false,
496
+ }, false);
497
+ return {
498
+ clickThroughUrl: currentItem.clickThroughUrl,
499
+ tracking,
500
+ snapshot: cloneQueueSnapshot(snapshot, options.session),
501
+ };
502
+ });
503
+ },
504
+ async skip() {
505
+ return runAction(async () => {
506
+ const currentItem = await ensureQueueReady();
507
+ if (currentItem.skipped || currentItem.milestones.complete) {
508
+ return cloneQueueSnapshot(snapshot, options.session);
509
+ }
510
+ await dispatchCurrentEvent("skip", { dedupe: true });
511
+ updateCurrentItem((item) => ({
512
+ ...item,
513
+ skipped: true,
514
+ status: "ended",
515
+ error: null,
516
+ }));
517
+ return cloneQueueSnapshot(snapshot, options.session);
518
+ });
519
+ },
520
+ async signalError(trackOptions = {}) {
521
+ return runAction(async () => {
522
+ await ensureQueueReady();
523
+ await dispatchCurrentEvent("error", {
524
+ ...trackOptions,
525
+ dedupe: false,
526
+ }, false);
527
+ updateCurrentItem((item) => ({
528
+ ...item,
529
+ status: "error",
530
+ error: "Playback error signaled.",
531
+ }));
532
+ return cloneQueueSnapshot(snapshot, options.session);
533
+ });
534
+ },
535
+ getSnapshot() {
536
+ return cloneQueueSnapshot(snapshot, options.session);
537
+ },
538
+ subscribe(listener) {
539
+ listeners.add(listener);
540
+ listener(cloneQueueSnapshot(snapshot, options.session));
541
+ return () => {
542
+ listeners.delete(listener);
543
+ };
544
+ },
545
+ dispose() {
546
+ sessionUnsubscribe();
547
+ listeners.clear();
548
+ },
549
+ };
550
+ }
@@ -0,0 +1,2 @@
1
+ import type { VastPlaybackController, VastPlaybackControllerOptions } from "./types.js";
2
+ export declare function createVastPlaybackController(options: VastPlaybackControllerOptions): VastPlaybackController;