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,420 @@
1
+ import { selectResolvedAdMediaFile } from "./media.js";
2
+ const PROGRESS_MILESTONES = [
3
+ ["firstQuartile", 0.25],
4
+ ["midpoint", 0.5],
5
+ ["thirdQuartile", 0.75],
6
+ ["complete", 1],
7
+ ];
8
+ function toError(value) {
9
+ if (value instanceof Error) {
10
+ return value;
11
+ }
12
+ return new Error(typeof value === "string" ? value : "Unknown vastlint-client playback error.");
13
+ }
14
+ function parseDurationToSeconds(value) {
15
+ const trimmed = value.trim();
16
+ if (!trimmed) {
17
+ return null;
18
+ }
19
+ const parts = trimmed.split(":");
20
+ if (parts.length !== 3) {
21
+ return null;
22
+ }
23
+ const hours = Number.parseInt(parts[0] ?? "", 10);
24
+ const minutes = Number.parseInt(parts[1] ?? "", 10);
25
+ const seconds = Number.parseFloat(parts[2] ?? "");
26
+ if (![hours, minutes, seconds].every(Number.isFinite)) {
27
+ return null;
28
+ }
29
+ return hours * 3600 + minutes * 60 + seconds;
30
+ }
31
+ function createMilestones() {
32
+ return {
33
+ start: false,
34
+ firstQuartile: false,
35
+ midpoint: false,
36
+ thirdQuartile: false,
37
+ complete: false,
38
+ };
39
+ }
40
+ function clonePlaybackMilestones(milestones) {
41
+ return { ...milestones };
42
+ }
43
+ function cloneMediaSelection(snapshot) {
44
+ return {
45
+ selected: snapshot.selected ? { ...snapshot.selected } : null,
46
+ candidates: snapshot.candidates.map((candidate) => ({
47
+ mediaFile: { ...candidate.mediaFile },
48
+ score: candidate.score,
49
+ reasons: [...candidate.reasons],
50
+ })),
51
+ };
52
+ }
53
+ function buildAdKey(resolvedAd) {
54
+ if (!resolvedAd) {
55
+ return "none";
56
+ }
57
+ return [
58
+ resolvedAd.finalUrl ?? "",
59
+ resolvedAd.adTitle,
60
+ resolvedAd.duration,
61
+ resolvedAd.adPod.adId ?? "",
62
+ String(resolvedAd.adPod.sequence ?? ""),
63
+ ...resolvedAd.mediaFiles.map((mediaFile) => mediaFile.url),
64
+ ].join("::");
65
+ }
66
+ function buildBaseSnapshot(sessionSnapshot, options) {
67
+ const resolvedAd = sessionSnapshot.resolvedAd;
68
+ return {
69
+ status: sessionSnapshot.error ? "error" : resolvedAd ? "ready" : "idle",
70
+ session: sessionSnapshot,
71
+ resolvedAd,
72
+ mediaSelection: selectResolvedAdMediaFile(resolvedAd, options.mediaSelection),
73
+ currentTimeSec: 0,
74
+ durationSec: parseDurationToSeconds(resolvedAd?.duration ?? ""),
75
+ impressionTracked: false,
76
+ muted: false,
77
+ fullscreen: false,
78
+ skipped: false,
79
+ viewability: null,
80
+ milestones: createMilestones(),
81
+ clickThroughUrl: resolvedAd?.clickThroughUrl ?? null,
82
+ error: sessionSnapshot.error?.message ?? null,
83
+ };
84
+ }
85
+ function clonePlaybackSnapshot(snapshot, session) {
86
+ const sessionSnapshot = session.getSnapshot();
87
+ return {
88
+ ...snapshot,
89
+ session: sessionSnapshot,
90
+ resolvedAd: sessionSnapshot.resolvedAd,
91
+ mediaSelection: cloneMediaSelection(snapshot.mediaSelection),
92
+ milestones: clonePlaybackMilestones(snapshot.milestones),
93
+ };
94
+ }
95
+ function normalizeStatus(currentStatus, sessionSnapshot, resolvedAd) {
96
+ if (sessionSnapshot.error) {
97
+ return "error";
98
+ }
99
+ if (!resolvedAd) {
100
+ return "idle";
101
+ }
102
+ if (currentStatus === "idle") {
103
+ return "ready";
104
+ }
105
+ return currentStatus;
106
+ }
107
+ export function createVastPlaybackController(options) {
108
+ const listeners = new Set();
109
+ let snapshot = buildBaseSnapshot(options.session.getSnapshot(), options);
110
+ const notify = () => {
111
+ const current = clonePlaybackSnapshot(snapshot, options.session);
112
+ for (const listener of listeners) {
113
+ listener(current);
114
+ }
115
+ };
116
+ const sessionUnsubscribe = options.session.subscribe((sessionSnapshot) => {
117
+ const resolvedAd = sessionSnapshot.resolvedAd;
118
+ const adChanged = buildAdKey(snapshot.resolvedAd) !== buildAdKey(resolvedAd);
119
+ const nextSnapshot = adChanged
120
+ ? {
121
+ ...buildBaseSnapshot(sessionSnapshot, options),
122
+ muted: snapshot.muted,
123
+ fullscreen: snapshot.fullscreen,
124
+ viewability: snapshot.viewability,
125
+ }
126
+ : {
127
+ ...snapshot,
128
+ session: sessionSnapshot,
129
+ resolvedAd,
130
+ mediaSelection: selectResolvedAdMediaFile(resolvedAd, options.mediaSelection),
131
+ durationSec: snapshot.durationSec ?? parseDurationToSeconds(resolvedAd?.duration ?? ""),
132
+ clickThroughUrl: resolvedAd?.clickThroughUrl ?? null,
133
+ error: sessionSnapshot.error?.message ?? snapshot.error,
134
+ status: normalizeStatus(snapshot.status, sessionSnapshot, resolvedAd),
135
+ };
136
+ snapshot = nextSnapshot;
137
+ notify();
138
+ });
139
+ const setSnapshot = (next) => {
140
+ snapshot = next;
141
+ notify();
142
+ };
143
+ const runAction = async (action) => {
144
+ try {
145
+ return await action();
146
+ }
147
+ catch (error) {
148
+ const nextError = toError(error);
149
+ setSnapshot({
150
+ ...snapshot,
151
+ status: "error",
152
+ error: nextError.message,
153
+ });
154
+ throw nextError;
155
+ }
156
+ };
157
+ const ensureReady = async () => {
158
+ if (!snapshot.resolvedAd) {
159
+ if (options.autoResolve === false) {
160
+ throw new Error("VAST playback controller requires a resolved session when autoResolve is false.");
161
+ }
162
+ await options.session.resolve();
163
+ }
164
+ if (!snapshot.resolvedAd || !snapshot.resolvedAd.resolved) {
165
+ throw new Error("No resolved inline VAST ad is available for playback.");
166
+ }
167
+ if (snapshot.status === "idle") {
168
+ setSnapshot({
169
+ ...snapshot,
170
+ status: "ready",
171
+ error: null,
172
+ });
173
+ }
174
+ return snapshot.resolvedAd;
175
+ };
176
+ const ensureStarted = async () => {
177
+ await ensureReady();
178
+ if (!snapshot.impressionTracked) {
179
+ await options.session.track("impression", { dedupe: true });
180
+ }
181
+ if (!snapshot.milestones.start) {
182
+ await options.session.track("creativeView", { dedupe: true });
183
+ await options.session.track("start", { dedupe: true });
184
+ }
185
+ setSnapshot({
186
+ ...snapshot,
187
+ status: snapshot.milestones.complete || snapshot.skipped ? "ended" : "playing",
188
+ impressionTracked: true,
189
+ milestones: {
190
+ ...snapshot.milestones,
191
+ start: true,
192
+ },
193
+ error: null,
194
+ });
195
+ };
196
+ const dispatchLifecycleEvent = async (event, optionsOverride) => {
197
+ await options.session.track(event, optionsOverride);
198
+ };
199
+ return {
200
+ async initialize() {
201
+ return runAction(async () => {
202
+ await ensureReady();
203
+ return clonePlaybackSnapshot(snapshot, options.session);
204
+ });
205
+ },
206
+ async start() {
207
+ return runAction(async () => {
208
+ await ensureStarted();
209
+ return clonePlaybackSnapshot(snapshot, options.session);
210
+ });
211
+ },
212
+ async pause() {
213
+ return runAction(async () => {
214
+ await ensureReady();
215
+ if (snapshot.status !== "playing") {
216
+ return clonePlaybackSnapshot(snapshot, options.session);
217
+ }
218
+ await dispatchLifecycleEvent("pause", { dedupe: false });
219
+ setSnapshot({
220
+ ...snapshot,
221
+ status: "paused",
222
+ error: null,
223
+ });
224
+ return clonePlaybackSnapshot(snapshot, options.session);
225
+ });
226
+ },
227
+ async resume() {
228
+ return runAction(async () => {
229
+ await ensureReady();
230
+ if (!snapshot.milestones.start) {
231
+ await ensureStarted();
232
+ return clonePlaybackSnapshot(snapshot, options.session);
233
+ }
234
+ if (snapshot.status !== "paused") {
235
+ return clonePlaybackSnapshot(snapshot, options.session);
236
+ }
237
+ await dispatchLifecycleEvent("resume", { dedupe: false });
238
+ setSnapshot({
239
+ ...snapshot,
240
+ status: "playing",
241
+ error: null,
242
+ });
243
+ return clonePlaybackSnapshot(snapshot, options.session);
244
+ });
245
+ },
246
+ async updateProgress(currentTimeSec, durationSec) {
247
+ return runAction(async () => {
248
+ await ensureReady();
249
+ if (!Number.isFinite(currentTimeSec) || currentTimeSec < 0) {
250
+ throw new Error("Playback progress time must be a finite, non-negative number.");
251
+ }
252
+ const nextDuration = durationSec ?? snapshot.durationSec ?? parseDurationToSeconds(snapshot.resolvedAd?.duration ?? "");
253
+ if (currentTimeSec > 0 && !snapshot.milestones.start) {
254
+ await ensureStarted();
255
+ }
256
+ const nextMilestones = clonePlaybackMilestones(snapshot.milestones);
257
+ if (nextDuration && nextDuration > 0) {
258
+ const progress = currentTimeSec / nextDuration;
259
+ for (const [event, threshold] of PROGRESS_MILESTONES) {
260
+ if (nextMilestones[event] || progress < threshold) {
261
+ continue;
262
+ }
263
+ await dispatchLifecycleEvent(event, { dedupe: true });
264
+ nextMilestones[event] = true;
265
+ }
266
+ }
267
+ setSnapshot({
268
+ ...snapshot,
269
+ currentTimeSec,
270
+ durationSec: nextDuration,
271
+ milestones: nextMilestones,
272
+ status: nextMilestones.complete
273
+ ? "ended"
274
+ : snapshot.status === "paused"
275
+ ? "paused"
276
+ : snapshot.milestones.start || currentTimeSec > 0
277
+ ? "playing"
278
+ : snapshot.status,
279
+ error: null,
280
+ });
281
+ return clonePlaybackSnapshot(snapshot, options.session);
282
+ });
283
+ },
284
+ async complete() {
285
+ return runAction(async () => {
286
+ await ensureStarted();
287
+ if (snapshot.milestones.complete) {
288
+ return clonePlaybackSnapshot(snapshot, options.session);
289
+ }
290
+ await dispatchLifecycleEvent("complete", { dedupe: true });
291
+ setSnapshot({
292
+ ...snapshot,
293
+ currentTimeSec: snapshot.durationSec ?? snapshot.currentTimeSec,
294
+ milestones: {
295
+ ...snapshot.milestones,
296
+ complete: true,
297
+ },
298
+ status: "ended",
299
+ error: null,
300
+ });
301
+ return clonePlaybackSnapshot(snapshot, options.session);
302
+ });
303
+ },
304
+ async setMuted(muted) {
305
+ return runAction(async () => {
306
+ await ensureReady();
307
+ if (snapshot.muted === muted) {
308
+ return clonePlaybackSnapshot(snapshot, options.session);
309
+ }
310
+ if (snapshot.milestones.start) {
311
+ await dispatchLifecycleEvent(muted ? "mute" : "unmute", { dedupe: false });
312
+ }
313
+ setSnapshot({
314
+ ...snapshot,
315
+ muted,
316
+ error: null,
317
+ });
318
+ return clonePlaybackSnapshot(snapshot, options.session);
319
+ });
320
+ },
321
+ async setFullscreen(fullscreen) {
322
+ return runAction(async () => {
323
+ await ensureReady();
324
+ if (snapshot.fullscreen === fullscreen) {
325
+ return clonePlaybackSnapshot(snapshot, options.session);
326
+ }
327
+ if (snapshot.milestones.start) {
328
+ await dispatchLifecycleEvent(fullscreen ? "fullscreen" : "exitFullscreen", { dedupe: false });
329
+ }
330
+ setSnapshot({
331
+ ...snapshot,
332
+ fullscreen,
333
+ error: null,
334
+ });
335
+ return clonePlaybackSnapshot(snapshot, options.session);
336
+ });
337
+ },
338
+ async setViewability(viewability) {
339
+ return runAction(async () => {
340
+ await ensureReady();
341
+ if (snapshot.viewability === viewability) {
342
+ return clonePlaybackSnapshot(snapshot, options.session);
343
+ }
344
+ if (snapshot.milestones.start) {
345
+ await dispatchLifecycleEvent(viewability, { dedupe: false });
346
+ }
347
+ setSnapshot({
348
+ ...snapshot,
349
+ viewability,
350
+ error: null,
351
+ });
352
+ return clonePlaybackSnapshot(snapshot, options.session);
353
+ });
354
+ },
355
+ async click(trackOptions = {}) {
356
+ return runAction(async () => {
357
+ await ensureReady();
358
+ const tracking = await options.session.track("clickTracking", {
359
+ ...trackOptions,
360
+ dedupe: false,
361
+ });
362
+ setSnapshot({
363
+ ...snapshot,
364
+ clickThroughUrl: snapshot.resolvedAd?.clickThroughUrl ?? null,
365
+ error: null,
366
+ });
367
+ return {
368
+ clickThroughUrl: snapshot.clickThroughUrl,
369
+ tracking,
370
+ snapshot: clonePlaybackSnapshot(snapshot, options.session),
371
+ };
372
+ });
373
+ },
374
+ async skip() {
375
+ return runAction(async () => {
376
+ await ensureReady();
377
+ if (snapshot.skipped || snapshot.milestones.complete) {
378
+ return clonePlaybackSnapshot(snapshot, options.session);
379
+ }
380
+ await dispatchLifecycleEvent("skip", { dedupe: true });
381
+ setSnapshot({
382
+ ...snapshot,
383
+ skipped: true,
384
+ status: "ended",
385
+ error: null,
386
+ });
387
+ return clonePlaybackSnapshot(snapshot, options.session);
388
+ });
389
+ },
390
+ async signalError(trackOptions = {}) {
391
+ return runAction(async () => {
392
+ await ensureReady();
393
+ await options.session.track("error", {
394
+ ...trackOptions,
395
+ dedupe: false,
396
+ });
397
+ setSnapshot({
398
+ ...snapshot,
399
+ status: "error",
400
+ error: "Playback error signaled.",
401
+ });
402
+ return clonePlaybackSnapshot(snapshot, options.session);
403
+ });
404
+ },
405
+ getSnapshot() {
406
+ return clonePlaybackSnapshot(snapshot, options.session);
407
+ },
408
+ subscribe(listener) {
409
+ listeners.add(listener);
410
+ listener(clonePlaybackSnapshot(snapshot, options.session));
411
+ return () => {
412
+ listeners.delete(listener);
413
+ };
414
+ },
415
+ dispose() {
416
+ sessionUnsubscribe();
417
+ listeners.clear();
418
+ },
419
+ };
420
+ }
@@ -0,0 +1,7 @@
1
+ import type { VastResolvedAd, VastResolutionSummary, VastWrapperHop } from "./types.js";
2
+ export interface VastResolvedState {
3
+ resolvedAd: VastResolvedAd | null;
4
+ resolvedAds: VastResolvedAd[];
5
+ }
6
+ export declare function buildResolvedState(wrapperChain: readonly VastWrapperHop[], resolution: VastResolutionSummary | null): VastResolvedState;
7
+ export declare function buildResolvedAd(wrapperChain: readonly VastWrapperHop[], resolution: VastResolutionSummary | null): VastResolvedAd | null;