@trillboards/ads-sdk 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +158 -0
- package/dist/index.d.mts +602 -0
- package/dist/index.d.ts +602 -0
- package/dist/index.js +1752 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1739 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react-native.d.mts +154 -0
- package/dist/react-native.d.ts +154 -0
- package/dist/react-native.js +193 -0
- package/dist/react-native.js.map +1 -0
- package/dist/react-native.mjs +190 -0
- package/dist/react-native.mjs.map +1 -0
- package/dist/react.d.mts +239 -0
- package/dist/react.d.ts +239 -0
- package/dist/react.js +1891 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +1885 -0
- package/dist/react.mjs.map +1 -0
- package/dist/server.d.mts +238 -0
- package/dist/server.d.ts +238 -0
- package/dist/server.js +209 -0
- package/dist/server.js.map +1 -0
- package/dist/server.mjs +200 -0
- package/dist/server.mjs.map +1 -0
- package/dist/trillboards-lite.global.js +2 -0
- package/dist/trillboards-lite.global.js.map +1 -0
- package/package.json +109 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1739 @@
|
|
|
1
|
+
// src/core/config.ts
|
|
2
|
+
var SDK_VERSION = "2.0.0";
|
|
3
|
+
var DEFAULT_CONFIG = {
|
|
4
|
+
API_BASE: "https://api.trillboards.com/v1/partner",
|
|
5
|
+
CDN_BASE: "https://cdn.trillboards.com",
|
|
6
|
+
CACHE_NAME: "trillboards-lite-ads",
|
|
7
|
+
CACHE_SIZE: 10,
|
|
8
|
+
REFRESH_INTERVAL: 2 * 60 * 1e3,
|
|
9
|
+
// 2 minutes
|
|
10
|
+
HEARTBEAT_INTERVAL: 60 * 1e3,
|
|
11
|
+
// 1 minute
|
|
12
|
+
DEFAULT_IMAGE_DURATION: 8e3,
|
|
13
|
+
// 8 seconds
|
|
14
|
+
DEFAULT_AD_INTERVAL: 6e4,
|
|
15
|
+
// 1 minute
|
|
16
|
+
PROGRAMMATIC_TIMEOUT_MS: 12e3,
|
|
17
|
+
// 12 seconds
|
|
18
|
+
PROGRAMMATIC_MIN_INTERVAL_MS: 5e3,
|
|
19
|
+
// 5 seconds
|
|
20
|
+
PROGRAMMATIC_RETRY_MS: 5e3,
|
|
21
|
+
// 5 seconds
|
|
22
|
+
PROGRAMMATIC_BACKOFF_MAX_MS: 5 * 60 * 1e3,
|
|
23
|
+
// 5 minutes
|
|
24
|
+
VERSION: SDK_VERSION
|
|
25
|
+
};
|
|
26
|
+
function resolveConfig(userConfig) {
|
|
27
|
+
const deviceId = userConfig.deviceId?.trim();
|
|
28
|
+
if (!deviceId) {
|
|
29
|
+
throw new Error("TrillboardsConfig: deviceId must be a non-empty string");
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
deviceId,
|
|
33
|
+
apiBase: userConfig.apiBase ?? DEFAULT_CONFIG.API_BASE,
|
|
34
|
+
cdnBase: userConfig.cdnBase ?? DEFAULT_CONFIG.CDN_BASE,
|
|
35
|
+
waterfall: userConfig.waterfall ?? "programmatic_only",
|
|
36
|
+
autoStart: userConfig.autoStart ?? true,
|
|
37
|
+
refreshInterval: Math.max(userConfig.refreshInterval ?? DEFAULT_CONFIG.REFRESH_INTERVAL, 1e4),
|
|
38
|
+
heartbeatInterval: Math.max(userConfig.heartbeatInterval ?? DEFAULT_CONFIG.HEARTBEAT_INTERVAL, 5e3),
|
|
39
|
+
defaultImageDuration: Math.max(userConfig.defaultImageDuration ?? DEFAULT_CONFIG.DEFAULT_IMAGE_DURATION, 1e3),
|
|
40
|
+
defaultAdInterval: Math.max(userConfig.defaultAdInterval ?? DEFAULT_CONFIG.DEFAULT_AD_INTERVAL, 1e3),
|
|
41
|
+
programmaticTimeout: Math.max(userConfig.programmaticTimeout ?? DEFAULT_CONFIG.PROGRAMMATIC_TIMEOUT_MS, 1e3),
|
|
42
|
+
programmaticMinInterval: Math.max(userConfig.programmaticMinInterval ?? DEFAULT_CONFIG.PROGRAMMATIC_MIN_INTERVAL_MS, 1e3),
|
|
43
|
+
programmaticRetry: Math.max(userConfig.programmaticRetry ?? DEFAULT_CONFIG.PROGRAMMATIC_RETRY_MS, 1e3),
|
|
44
|
+
programmaticBackoffMax: Math.max(userConfig.programmaticBackoffMax ?? DEFAULT_CONFIG.PROGRAMMATIC_BACKOFF_MAX_MS, 5e3),
|
|
45
|
+
cacheSize: Math.max(userConfig.cacheSize ?? DEFAULT_CONFIG.CACHE_SIZE, 1)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/core/state.ts
|
|
50
|
+
var DEFAULT_PUBLIC_STATE = {
|
|
51
|
+
initialized: false,
|
|
52
|
+
isPlaying: false,
|
|
53
|
+
isPaused: false,
|
|
54
|
+
isOffline: false,
|
|
55
|
+
currentAd: null,
|
|
56
|
+
adCount: 0,
|
|
57
|
+
programmaticPlaying: false,
|
|
58
|
+
prefetchedReady: false,
|
|
59
|
+
waterfallMode: "programmatic_only",
|
|
60
|
+
screenId: null,
|
|
61
|
+
deviceId: null
|
|
62
|
+
};
|
|
63
|
+
function createInitialState() {
|
|
64
|
+
return {
|
|
65
|
+
deviceId: null,
|
|
66
|
+
partnerId: null,
|
|
67
|
+
screenId: null,
|
|
68
|
+
ads: [],
|
|
69
|
+
currentAdIndex: 0,
|
|
70
|
+
isPlaying: false,
|
|
71
|
+
isPaused: false,
|
|
72
|
+
isOffline: false,
|
|
73
|
+
settings: {},
|
|
74
|
+
programmatic: null,
|
|
75
|
+
programmaticPlaying: false,
|
|
76
|
+
prefetchedReady: false,
|
|
77
|
+
waterfallMode: "programmatic_only",
|
|
78
|
+
autoStart: true,
|
|
79
|
+
container: null,
|
|
80
|
+
adTimer: null,
|
|
81
|
+
refreshTimer: null,
|
|
82
|
+
heartbeatTimer: null,
|
|
83
|
+
programmaticRetryTimer: null,
|
|
84
|
+
programmaticRetryActive: false,
|
|
85
|
+
programmaticRetryCount: 0,
|
|
86
|
+
programmaticLastError: null,
|
|
87
|
+
initialized: false,
|
|
88
|
+
screenOrientation: null,
|
|
89
|
+
screenDimensions: null,
|
|
90
|
+
etag: null
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function getPublicState(internal) {
|
|
94
|
+
return {
|
|
95
|
+
initialized: internal.initialized,
|
|
96
|
+
isPlaying: internal.isPlaying,
|
|
97
|
+
isPaused: internal.isPaused,
|
|
98
|
+
isOffline: internal.isOffline,
|
|
99
|
+
currentAd: internal.ads[internal.currentAdIndex] ?? null,
|
|
100
|
+
adCount: internal.ads.length,
|
|
101
|
+
programmaticPlaying: internal.programmaticPlaying,
|
|
102
|
+
prefetchedReady: internal.prefetchedReady ?? false,
|
|
103
|
+
waterfallMode: internal.waterfallMode,
|
|
104
|
+
screenId: internal.screenId,
|
|
105
|
+
deviceId: internal.deviceId
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/core/events.ts
|
|
110
|
+
var EventEmitter = class {
|
|
111
|
+
constructor() {
|
|
112
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Register a handler for the given event.
|
|
116
|
+
* The same handler reference can only be registered once per
|
|
117
|
+
* event (Set semantics).
|
|
118
|
+
*/
|
|
119
|
+
on(event, handler) {
|
|
120
|
+
if (!this.handlers.has(event)) {
|
|
121
|
+
this.handlers.set(event, /* @__PURE__ */ new Set());
|
|
122
|
+
}
|
|
123
|
+
this.handlers.get(event).add(handler);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Remove a previously registered handler.
|
|
127
|
+
* No-op if the handler was never registered.
|
|
128
|
+
*/
|
|
129
|
+
off(event, handler) {
|
|
130
|
+
this.handlers.get(event)?.delete(handler);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Emit an event, invoking every registered handler with the
|
|
134
|
+
* supplied data payload. Handlers are called synchronously in
|
|
135
|
+
* registration order. Exceptions inside handlers are caught
|
|
136
|
+
* and logged to the console so they never propagate.
|
|
137
|
+
*/
|
|
138
|
+
emit(event, data) {
|
|
139
|
+
const handlers = this.handlers.get(event);
|
|
140
|
+
if (!handlers) return;
|
|
141
|
+
for (const handler of [...handlers]) {
|
|
142
|
+
try {
|
|
143
|
+
handler(data);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error(
|
|
146
|
+
`[TrillboardsAds] Event handler error for "${String(event)}":`,
|
|
147
|
+
err
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Register a one-shot handler that automatically removes itself
|
|
154
|
+
* after the first invocation.
|
|
155
|
+
*/
|
|
156
|
+
once(event, handler) {
|
|
157
|
+
const wrapper = (data) => {
|
|
158
|
+
this.off(event, wrapper);
|
|
159
|
+
handler(data);
|
|
160
|
+
};
|
|
161
|
+
this.on(event, wrapper);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Remove all handlers for all events.
|
|
165
|
+
* Typically called during SDK teardown / destroy.
|
|
166
|
+
*/
|
|
167
|
+
removeAllListeners() {
|
|
168
|
+
this.handlers.clear();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// src/api/client.ts
|
|
173
|
+
function getPlayerTruth(container) {
|
|
174
|
+
if (typeof window === "undefined") {
|
|
175
|
+
return {
|
|
176
|
+
slotWidth: null,
|
|
177
|
+
slotHeight: null,
|
|
178
|
+
orientation: null,
|
|
179
|
+
muted: true,
|
|
180
|
+
autoplayAllowed: true,
|
|
181
|
+
userAgent: null
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
let width = window.innerWidth;
|
|
185
|
+
let height = window.innerHeight;
|
|
186
|
+
if (container) {
|
|
187
|
+
const rect = container.getBoundingClientRect();
|
|
188
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
189
|
+
width = Math.round(rect.width);
|
|
190
|
+
height = Math.round(rect.height);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
slotWidth: width,
|
|
195
|
+
slotHeight: height,
|
|
196
|
+
orientation: height > width ? "portrait" : "landscape",
|
|
197
|
+
muted: true,
|
|
198
|
+
autoplayAllowed: true,
|
|
199
|
+
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
var ApiClient = class {
|
|
203
|
+
constructor(apiBase) {
|
|
204
|
+
this.container = null;
|
|
205
|
+
this.apiBase = apiBase ?? DEFAULT_CONFIG.API_BASE;
|
|
206
|
+
}
|
|
207
|
+
/** Bind an optional container element for slot-size measurement. */
|
|
208
|
+
setContainer(container) {
|
|
209
|
+
this.container = container;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Fetch the current ad roster for a device.
|
|
213
|
+
*
|
|
214
|
+
* Supports conditional requests via `ETag` / `If-None-Match` —
|
|
215
|
+
* when the server returns 304 the caller receives the previous
|
|
216
|
+
* ad list untouched (indicated by `notModified: true`).
|
|
217
|
+
*
|
|
218
|
+
* The request is aborted after 10 seconds.
|
|
219
|
+
*/
|
|
220
|
+
async fetchAds(deviceId, etag = null) {
|
|
221
|
+
const headers = { Accept: "application/json" };
|
|
222
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
const timeoutId = setTimeout(() => controller.abort(), 1e4);
|
|
225
|
+
try {
|
|
226
|
+
const playerTruth = getPlayerTruth(this.container);
|
|
227
|
+
const params = new URLSearchParams();
|
|
228
|
+
if (playerTruth.slotWidth) params.set("slot_w", String(playerTruth.slotWidth));
|
|
229
|
+
if (playerTruth.slotHeight) params.set("slot_h", String(playerTruth.slotHeight));
|
|
230
|
+
if (playerTruth.orientation) params.set("orientation", playerTruth.orientation);
|
|
231
|
+
params.set("muted", playerTruth.muted ? "1" : "0");
|
|
232
|
+
params.set("autoplay", playerTruth.autoplayAllowed ? "1" : "0");
|
|
233
|
+
if (playerTruth.userAgent) params.set("ua", playerTruth.userAgent);
|
|
234
|
+
const query = params.toString();
|
|
235
|
+
const url = query ? `${this.apiBase}/device/${deviceId}/ads?${query}` : `${this.apiBase}/device/${deviceId}/ads`;
|
|
236
|
+
const response = await fetch(url, { headers, signal: controller.signal });
|
|
237
|
+
clearTimeout(timeoutId);
|
|
238
|
+
if (response.status === 304) {
|
|
239
|
+
return {
|
|
240
|
+
ads: [],
|
|
241
|
+
settings: {},
|
|
242
|
+
programmatic: null,
|
|
243
|
+
screenId: null,
|
|
244
|
+
screenOrientation: null,
|
|
245
|
+
screenDimensions: null,
|
|
246
|
+
etag,
|
|
247
|
+
notModified: true
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
throw new Error(`HTTP ${response.status}`);
|
|
252
|
+
}
|
|
253
|
+
const data = await response.json();
|
|
254
|
+
const newEtag = response.headers.get("ETag");
|
|
255
|
+
return {
|
|
256
|
+
ads: data.data?.ads ?? [],
|
|
257
|
+
settings: data.data?.settings ?? {},
|
|
258
|
+
programmatic: data.data?.header_bidding_settings ?? null,
|
|
259
|
+
screenId: data.data?.screen_id ?? null,
|
|
260
|
+
screenOrientation: data.data?.screen_orientation ?? null,
|
|
261
|
+
screenDimensions: data.data?.screen_dimensions ?? null,
|
|
262
|
+
etag: newEtag,
|
|
263
|
+
notModified: false
|
|
264
|
+
};
|
|
265
|
+
} catch (error) {
|
|
266
|
+
clearTimeout(timeoutId);
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Fire an impression pixel.
|
|
272
|
+
*
|
|
273
|
+
* Uses a GET request with query-string parameters so the call
|
|
274
|
+
* survives `navigator.sendBeacon`-like fallback patterns and
|
|
275
|
+
* can be retried transparently on network failure.
|
|
276
|
+
*
|
|
277
|
+
* Returns `true` if the server acknowledged the impression.
|
|
278
|
+
*/
|
|
279
|
+
async reportImpression(impression) {
|
|
280
|
+
try {
|
|
281
|
+
const params = new URLSearchParams({
|
|
282
|
+
adid: impression.adid,
|
|
283
|
+
impid: impression.impid,
|
|
284
|
+
did: impression.did,
|
|
285
|
+
aid: impression.aid,
|
|
286
|
+
duration: String(impression.duration || 0),
|
|
287
|
+
completed: impression.completed ? "true" : "false"
|
|
288
|
+
});
|
|
289
|
+
const response = await fetch(`${this.apiBase}/impression?${params}`, {
|
|
290
|
+
method: "GET",
|
|
291
|
+
signal: AbortSignal.timeout(5e3)
|
|
292
|
+
});
|
|
293
|
+
return response.ok;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Ping the heartbeat endpoint to signal this device is alive.
|
|
300
|
+
* Returns `true` on 2xx, `false` on any error.
|
|
301
|
+
*/
|
|
302
|
+
async sendHeartbeat(deviceId, screenId) {
|
|
303
|
+
try {
|
|
304
|
+
const response = await fetch(`${this.apiBase}/device/${deviceId}/heartbeat`, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: { "Content-Type": "application/json" },
|
|
307
|
+
body: JSON.stringify({
|
|
308
|
+
device_id: deviceId,
|
|
309
|
+
screen_id: screenId,
|
|
310
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
311
|
+
status: "active"
|
|
312
|
+
}),
|
|
313
|
+
signal: AbortSignal.timeout(5e3)
|
|
314
|
+
});
|
|
315
|
+
return response.ok;
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Report a programmatic lifecycle event (VAST fill, timeout,
|
|
322
|
+
* error, etc.) to the analytics backend.
|
|
323
|
+
*/
|
|
324
|
+
async reportProgrammaticEvent(event) {
|
|
325
|
+
try {
|
|
326
|
+
const response = await fetch(`${this.apiBase}/programmatic-event`, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: { "Content-Type": "application/json" },
|
|
329
|
+
body: JSON.stringify({
|
|
330
|
+
screen_id: event.screenId,
|
|
331
|
+
device_id: event.deviceId,
|
|
332
|
+
event_type: event.eventType,
|
|
333
|
+
vast_source: event.vastSource,
|
|
334
|
+
variant_name: event.variantName,
|
|
335
|
+
error_code: event.errorCode,
|
|
336
|
+
error_message: event.errorMessage,
|
|
337
|
+
duration: event.duration,
|
|
338
|
+
telemetry: event.telemetry,
|
|
339
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
340
|
+
}),
|
|
341
|
+
signal: AbortSignal.timeout(5e3)
|
|
342
|
+
});
|
|
343
|
+
return response.ok;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// src/cache/IndexedDBCache.ts
|
|
351
|
+
var IndexedDBCache = class {
|
|
352
|
+
constructor(maxCacheSize = 10) {
|
|
353
|
+
this.db = null;
|
|
354
|
+
this.DB_NAME = "TrillboardsAdsCache";
|
|
355
|
+
this.DB_VERSION = 1;
|
|
356
|
+
this.STORE_ADS = "ads";
|
|
357
|
+
this.STORE_IMPRESSIONS = "pendingImpressions";
|
|
358
|
+
this.maxCacheSize = maxCacheSize;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Open (or create) the database. Must be called once before any
|
|
362
|
+
* read/write operations — but calling it multiple times is safe.
|
|
363
|
+
*/
|
|
364
|
+
async init() {
|
|
365
|
+
if (this.db) return;
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
if (typeof indexedDB === "undefined") {
|
|
368
|
+
resolve();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
|
|
372
|
+
request.onerror = () => reject(request.error);
|
|
373
|
+
request.onsuccess = () => {
|
|
374
|
+
this.db = request.result;
|
|
375
|
+
resolve();
|
|
376
|
+
};
|
|
377
|
+
request.onupgradeneeded = (event) => {
|
|
378
|
+
const db = event.target.result;
|
|
379
|
+
if (!db.objectStoreNames.contains(this.STORE_ADS)) {
|
|
380
|
+
const adsStore = db.createObjectStore(this.STORE_ADS, { keyPath: "id" });
|
|
381
|
+
adsStore.createIndex("cached_at", "cached_at", { unique: false });
|
|
382
|
+
}
|
|
383
|
+
if (!db.objectStoreNames.contains(this.STORE_IMPRESSIONS)) {
|
|
384
|
+
db.createObjectStore(this.STORE_IMPRESSIONS, { keyPath: "impid" });
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Persist an array of ads into the cache, stamping each with the
|
|
391
|
+
* current time. After writing, any ads beyond `maxCacheSize` are
|
|
392
|
+
* evicted (oldest-first).
|
|
393
|
+
*/
|
|
394
|
+
async cacheAds(ads) {
|
|
395
|
+
if (!this.db) return;
|
|
396
|
+
const tx = this.db.transaction(this.STORE_ADS, "readwrite");
|
|
397
|
+
const store = tx.objectStore(this.STORE_ADS);
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
for (const ad of ads) {
|
|
400
|
+
store.put({ ...ad, cached_at: now });
|
|
401
|
+
}
|
|
402
|
+
await new Promise((resolve, reject) => {
|
|
403
|
+
tx.oncomplete = () => resolve();
|
|
404
|
+
tx.onerror = () => reject(tx.error);
|
|
405
|
+
});
|
|
406
|
+
await this.evictOldAds();
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Return every cached ad. The results may include the
|
|
410
|
+
* `cached_at` bookkeeping field added during `cacheAds`.
|
|
411
|
+
*/
|
|
412
|
+
async getCachedAds() {
|
|
413
|
+
if (!this.db) return [];
|
|
414
|
+
return new Promise((resolve) => {
|
|
415
|
+
const tx = this.db.transaction(this.STORE_ADS, "readonly");
|
|
416
|
+
const store = tx.objectStore(this.STORE_ADS);
|
|
417
|
+
const request = store.getAll();
|
|
418
|
+
request.onsuccess = () => resolve(request.result || []);
|
|
419
|
+
request.onerror = () => resolve([]);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Remove the oldest cached ads when the store exceeds
|
|
424
|
+
* `maxCacheSize`. Sorts by `cached_at` descending and
|
|
425
|
+
* deletes everything beyond the limit.
|
|
426
|
+
*/
|
|
427
|
+
async evictOldAds() {
|
|
428
|
+
if (!this.db) return;
|
|
429
|
+
const ads = await this.getCachedAds();
|
|
430
|
+
if (ads.length <= this.maxCacheSize) return;
|
|
431
|
+
const sorted = [...ads].sort(
|
|
432
|
+
(a, b) => (b.cached_at ?? 0) - (a.cached_at ?? 0)
|
|
433
|
+
);
|
|
434
|
+
const toRemove = sorted.slice(this.maxCacheSize);
|
|
435
|
+
const tx = this.db.transaction(this.STORE_ADS, "readwrite");
|
|
436
|
+
const store = tx.objectStore(this.STORE_ADS);
|
|
437
|
+
for (const ad of toRemove) {
|
|
438
|
+
store.delete(ad.id);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Queue an impression payload for later delivery.
|
|
443
|
+
* The object **must** contain an `impid` field (used as the
|
|
444
|
+
* primary key).
|
|
445
|
+
*/
|
|
446
|
+
async savePendingImpression(impression) {
|
|
447
|
+
if (!this.db) return;
|
|
448
|
+
const tx = this.db.transaction(this.STORE_IMPRESSIONS, "readwrite");
|
|
449
|
+
const store = tx.objectStore(this.STORE_IMPRESSIONS);
|
|
450
|
+
store.put(impression);
|
|
451
|
+
}
|
|
452
|
+
/** Retrieve all queued impressions (FIFO order not guaranteed). */
|
|
453
|
+
async getPendingImpressions() {
|
|
454
|
+
if (!this.db) return [];
|
|
455
|
+
return new Promise((resolve) => {
|
|
456
|
+
const tx = this.db.transaction(this.STORE_IMPRESSIONS, "readonly");
|
|
457
|
+
const store = tx.objectStore(this.STORE_IMPRESSIONS);
|
|
458
|
+
const request = store.getAll();
|
|
459
|
+
request.onsuccess = () => resolve(request.result || []);
|
|
460
|
+
request.onerror = () => resolve([]);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/** Remove all pending impressions (typically after a successful flush). */
|
|
464
|
+
async clearPendingImpressions() {
|
|
465
|
+
if (!this.db) return;
|
|
466
|
+
const tx = this.db.transaction(this.STORE_IMPRESSIONS, "readwrite");
|
|
467
|
+
const store = tx.objectStore(this.STORE_IMPRESSIONS);
|
|
468
|
+
store.clear();
|
|
469
|
+
}
|
|
470
|
+
/** Close the database connection and release the reference. */
|
|
471
|
+
destroy() {
|
|
472
|
+
if (this.db) {
|
|
473
|
+
this.db.close();
|
|
474
|
+
this.db = null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// src/bridge/NativeBridge.ts
|
|
480
|
+
var NativeBridge = class {
|
|
481
|
+
constructor() {
|
|
482
|
+
this.VERSION = "1.0";
|
|
483
|
+
this.registered = null;
|
|
484
|
+
this.detected = null;
|
|
485
|
+
this.sessionId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
486
|
+
this.eventFilter = null;
|
|
487
|
+
this.commandHandler = null;
|
|
488
|
+
this.deviceId = null;
|
|
489
|
+
this.screenId = null;
|
|
490
|
+
this.messageListener = null;
|
|
491
|
+
this.targetOrigin = "*";
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Attach device / screen identifiers so every outgoing payload
|
|
495
|
+
* carries them automatically.
|
|
496
|
+
*/
|
|
497
|
+
setContext(deviceId, screenId) {
|
|
498
|
+
this.deviceId = deviceId;
|
|
499
|
+
this.screenId = screenId;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Manually register a custom bridge (useful when the native
|
|
503
|
+
* shell cannot inject a global but *can* pass callbacks at
|
|
504
|
+
* construction time).
|
|
505
|
+
*
|
|
506
|
+
* Returns `true` if registration succeeded.
|
|
507
|
+
*/
|
|
508
|
+
register(config) {
|
|
509
|
+
if (typeof config.send !== "function") {
|
|
510
|
+
console.error("[TrillboardsAds] Bridge requires send function");
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
this.registered = {
|
|
514
|
+
send: config.send,
|
|
515
|
+
receive: config.receive ?? null,
|
|
516
|
+
name: config.name ?? "CustomBridge"
|
|
517
|
+
};
|
|
518
|
+
if (Array.isArray(config.events)) {
|
|
519
|
+
this.eventFilter = new Set(config.events);
|
|
520
|
+
}
|
|
521
|
+
if (this.registered.receive && this.commandHandler) {
|
|
522
|
+
this.registered.receive(this.commandHandler);
|
|
523
|
+
}
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Set the handler that will receive inbound commands from the
|
|
528
|
+
* native shell (e.g. pause, resume, skip).
|
|
529
|
+
*/
|
|
530
|
+
setCommandHandler(handler) {
|
|
531
|
+
this.commandHandler = handler;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Set the target origin for iframe postMessage bridge.
|
|
535
|
+
* Defaults to `'*'` for backwards compatibility; should be
|
|
536
|
+
* locked down in production to the parent origin.
|
|
537
|
+
*/
|
|
538
|
+
setTargetOrigin(origin) {
|
|
539
|
+
this.targetOrigin = origin;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Probe `window` for known native bridge injection points.
|
|
543
|
+
*
|
|
544
|
+
* Detection order:
|
|
545
|
+
* 1. `window.TrillboardsBridge` -- Android (primary)
|
|
546
|
+
* 2. `window.Android` -- Android (legacy)
|
|
547
|
+
* 3. `window.webkit.messageHandlers.trillboards` -- iOS / WKWebView
|
|
548
|
+
* 4. `window.ReactNativeWebView` -- React Native WebView
|
|
549
|
+
* 5. `window.Flutter` -- Flutter WebView
|
|
550
|
+
* 6. `window.__TRILL_CTV_BRIDGE__` -- Trillboards CTV agent
|
|
551
|
+
* 7. `window.electronAPI` -- Electron preload
|
|
552
|
+
* 8. `window.__TAURI__` -- Tauri IPC
|
|
553
|
+
* 9. `window.parent !== window` -- iframe / postMessage
|
|
554
|
+
*
|
|
555
|
+
* Re-detects on every call (no caching) so dynamically
|
|
556
|
+
* injected/removed bridges are picked up immediately.
|
|
557
|
+
*/
|
|
558
|
+
detect() {
|
|
559
|
+
if (typeof window === "undefined") return null;
|
|
560
|
+
let detected = null;
|
|
561
|
+
if (window.TrillboardsBridge && typeof window.TrillboardsBridge.onEvent === "function") {
|
|
562
|
+
detected = {
|
|
563
|
+
type: "android",
|
|
564
|
+
send: (p) => window.TrillboardsBridge.onEvent(p)
|
|
565
|
+
};
|
|
566
|
+
} else if (window.Android && typeof window.Android.onTrillboardsEvent === "function") {
|
|
567
|
+
detected = {
|
|
568
|
+
type: "android-alt",
|
|
569
|
+
send: (p) => window.Android.onTrillboardsEvent(p)
|
|
570
|
+
};
|
|
571
|
+
} else if (window.webkit?.messageHandlers?.trillboards?.postMessage) {
|
|
572
|
+
detected = {
|
|
573
|
+
type: "ios",
|
|
574
|
+
send: (p) => window.webkit.messageHandlers.trillboards.postMessage(JSON.parse(p))
|
|
575
|
+
};
|
|
576
|
+
} else if (window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === "function") {
|
|
577
|
+
detected = {
|
|
578
|
+
type: "react-native",
|
|
579
|
+
send: (p) => window.ReactNativeWebView.postMessage(p)
|
|
580
|
+
};
|
|
581
|
+
} else if (window.Flutter && typeof window.Flutter.postMessage === "function") {
|
|
582
|
+
detected = {
|
|
583
|
+
type: "flutter",
|
|
584
|
+
send: (p) => window.Flutter.postMessage(p)
|
|
585
|
+
};
|
|
586
|
+
} else if (window.__TRILL_CTV_BRIDGE__ && typeof window.__TRILL_CTV_BRIDGE__.postEvent === "function") {
|
|
587
|
+
detected = {
|
|
588
|
+
type: "ctv",
|
|
589
|
+
send: (p) => window.__TRILL_CTV_BRIDGE__.postEvent(p)
|
|
590
|
+
};
|
|
591
|
+
} else if (window.electronAPI && typeof window.electronAPI.trillboardsEvent === "function") {
|
|
592
|
+
detected = {
|
|
593
|
+
type: "electron",
|
|
594
|
+
send: (p) => window.electronAPI.trillboardsEvent(JSON.parse(p))
|
|
595
|
+
};
|
|
596
|
+
} else if (window.__TAURI__?.event) {
|
|
597
|
+
detected = {
|
|
598
|
+
type: "tauri",
|
|
599
|
+
send: (p) => {
|
|
600
|
+
try {
|
|
601
|
+
window.__TAURI__.event.emit("trillboards-event", JSON.parse(p));
|
|
602
|
+
} catch {
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
} else if (typeof window !== "undefined" && window.parent !== window && typeof window.parent.postMessage === "function") {
|
|
607
|
+
const origin = this.targetOrigin;
|
|
608
|
+
detected = {
|
|
609
|
+
type: "postMessage",
|
|
610
|
+
send: (p) => window.parent.postMessage(JSON.parse(p), origin)
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
this.detected = detected;
|
|
614
|
+
return this.detected;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Serialise an event + data into the canonical Trillboards
|
|
618
|
+
* bridge envelope.
|
|
619
|
+
*/
|
|
620
|
+
buildPayload(event, data) {
|
|
621
|
+
const payload = {
|
|
622
|
+
type: "trillboards",
|
|
623
|
+
version: this.VERSION,
|
|
624
|
+
event,
|
|
625
|
+
data: data ?? {},
|
|
626
|
+
timestamp: Date.now(),
|
|
627
|
+
deviceId: this.deviceId,
|
|
628
|
+
screenId: this.screenId,
|
|
629
|
+
sessionId: this.sessionId
|
|
630
|
+
};
|
|
631
|
+
return JSON.stringify(payload);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Deliver an event to the native host.
|
|
635
|
+
*
|
|
636
|
+
* Returns `true` if a bridge accepted the payload, `false` if
|
|
637
|
+
* the event was filtered out or no bridge is available.
|
|
638
|
+
*/
|
|
639
|
+
send(event, data) {
|
|
640
|
+
if (this.eventFilter && !this.eventFilter.has(event)) return false;
|
|
641
|
+
const payload = this.buildPayload(event, data);
|
|
642
|
+
if (this.registered?.send) {
|
|
643
|
+
try {
|
|
644
|
+
this.registered.send(event, JSON.parse(payload));
|
|
645
|
+
return true;
|
|
646
|
+
} catch {
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const detected = this.detect();
|
|
650
|
+
if (detected?.send) {
|
|
651
|
+
try {
|
|
652
|
+
detected.send(payload);
|
|
653
|
+
return true;
|
|
654
|
+
} catch {
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Start listening for inbound `trillboards-command` messages
|
|
661
|
+
* from the native shell (via `window.postMessage`).
|
|
662
|
+
*
|
|
663
|
+
* Call this once during SDK initialisation.
|
|
664
|
+
*/
|
|
665
|
+
initCommandListener() {
|
|
666
|
+
if (typeof window === "undefined") return;
|
|
667
|
+
if (this.messageListener) {
|
|
668
|
+
window.removeEventListener("message", this.messageListener);
|
|
669
|
+
}
|
|
670
|
+
this.messageListener = (event) => {
|
|
671
|
+
if (event.data?.type === "trillboards-command" && this.commandHandler) {
|
|
672
|
+
this.commandHandler(event.data);
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
window.addEventListener("message", this.messageListener);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Remove the message listener and release references.
|
|
679
|
+
* Call during SDK teardown to prevent memory leaks.
|
|
680
|
+
*/
|
|
681
|
+
destroy() {
|
|
682
|
+
if (typeof window !== "undefined" && this.messageListener) {
|
|
683
|
+
window.removeEventListener("message", this.messageListener);
|
|
684
|
+
this.messageListener = null;
|
|
685
|
+
}
|
|
686
|
+
this.registered = null;
|
|
687
|
+
this.detected = null;
|
|
688
|
+
this.commandHandler = null;
|
|
689
|
+
this.eventFilter = null;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
// src/player/CircuitBreaker.ts
|
|
694
|
+
var CircuitBreaker = class {
|
|
695
|
+
constructor(maxFailures = 5, openDurationMs = 3e4) {
|
|
696
|
+
this.sources = /* @__PURE__ */ new Map();
|
|
697
|
+
this.maxFailures = maxFailures;
|
|
698
|
+
this.openDurationMs = openDurationMs;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Record a successful request for a source, resetting its
|
|
702
|
+
* failure counter and clearing any open-circuit timer.
|
|
703
|
+
*/
|
|
704
|
+
recordSuccess(sourceName) {
|
|
705
|
+
if (!sourceName) return;
|
|
706
|
+
this.sources.set(sourceName, { consecutiveFailures: 0, openUntil: 0 });
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Record a failed request for a source. When consecutive
|
|
710
|
+
* failures reach `maxFailures`, the circuit opens for
|
|
711
|
+
* `openDurationMs` milliseconds.
|
|
712
|
+
*/
|
|
713
|
+
recordFailure(sourceName) {
|
|
714
|
+
if (!sourceName) return;
|
|
715
|
+
const entry = this.sources.get(sourceName) ?? { consecutiveFailures: 0, openUntil: 0 };
|
|
716
|
+
entry.consecutiveFailures += 1;
|
|
717
|
+
if (entry.consecutiveFailures >= this.maxFailures) {
|
|
718
|
+
entry.openUntil = Date.now() + this.openDurationMs;
|
|
719
|
+
}
|
|
720
|
+
this.sources.set(sourceName, entry);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Check whether a source is available for requests.
|
|
724
|
+
*
|
|
725
|
+
* Returns `true` when:
|
|
726
|
+
* - The source has never been tracked (unknown = available)
|
|
727
|
+
* - Consecutive failures are below the threshold (CLOSED)
|
|
728
|
+
* - The open-circuit timer has elapsed (HALF-OPEN — allow a probe)
|
|
729
|
+
*/
|
|
730
|
+
isAvailable(sourceName) {
|
|
731
|
+
if (!sourceName) return true;
|
|
732
|
+
const entry = this.sources.get(sourceName);
|
|
733
|
+
if (!entry) return true;
|
|
734
|
+
if (entry.consecutiveFailures < this.maxFailures) return true;
|
|
735
|
+
const now = Date.now();
|
|
736
|
+
if (now >= entry.openUntil) {
|
|
737
|
+
entry.openUntil = now + this.openDurationMs;
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Return the raw circuit breaker state for a source.
|
|
744
|
+
* Returns a zeroed-out state for unknown sources.
|
|
745
|
+
*/
|
|
746
|
+
getState(sourceName) {
|
|
747
|
+
return this.sources.get(sourceName) ?? { consecutiveFailures: 0, openUntil: 0 };
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Clear all tracked source state.
|
|
751
|
+
*/
|
|
752
|
+
reset() {
|
|
753
|
+
this.sources.clear();
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// src/player/Telemetry.ts
|
|
758
|
+
var Telemetry = class {
|
|
759
|
+
constructor() {
|
|
760
|
+
this.totalRequests = 0;
|
|
761
|
+
this.fills = 0;
|
|
762
|
+
this.noFills = 0;
|
|
763
|
+
this.timeouts = 0;
|
|
764
|
+
this.errors = 0;
|
|
765
|
+
}
|
|
766
|
+
/** Count a new ad request. */
|
|
767
|
+
recordRequest() {
|
|
768
|
+
this.totalRequests++;
|
|
769
|
+
}
|
|
770
|
+
/** Count a successful fill (ad loaded and ready to play). */
|
|
771
|
+
recordFill() {
|
|
772
|
+
this.fills++;
|
|
773
|
+
}
|
|
774
|
+
/** Count a request that returned no ad (no fill). */
|
|
775
|
+
recordNoFill() {
|
|
776
|
+
this.noFills++;
|
|
777
|
+
}
|
|
778
|
+
/** Count a request that timed out before a response arrived. */
|
|
779
|
+
recordTimeout() {
|
|
780
|
+
this.timeouts++;
|
|
781
|
+
}
|
|
782
|
+
/** Count a request that failed with an error (not a timeout). */
|
|
783
|
+
recordError() {
|
|
784
|
+
this.errors++;
|
|
785
|
+
}
|
|
786
|
+
/** Ratio of fills to total requests (0-1). */
|
|
787
|
+
getFillRate() {
|
|
788
|
+
return this.totalRequests > 0 ? this.fills / this.totalRequests : 0;
|
|
789
|
+
}
|
|
790
|
+
/** Ratio of no-fills to total requests (0-1). */
|
|
791
|
+
getNoFillRate() {
|
|
792
|
+
return this.totalRequests > 0 ? this.noFills / this.totalRequests : 0;
|
|
793
|
+
}
|
|
794
|
+
/** Ratio of timeouts to total requests (0-1). */
|
|
795
|
+
getTimeoutRate() {
|
|
796
|
+
return this.totalRequests > 0 ? this.timeouts / this.totalRequests : 0;
|
|
797
|
+
}
|
|
798
|
+
/** Ratio of errors to total requests (0-1). */
|
|
799
|
+
getErrorRate() {
|
|
800
|
+
return this.totalRequests > 0 ? this.errors / this.totalRequests : 0;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Return a snapshot object suitable for serialization.
|
|
804
|
+
* Rates are rounded to four decimal places.
|
|
805
|
+
*/
|
|
806
|
+
getSnapshot() {
|
|
807
|
+
return {
|
|
808
|
+
fill_rate: Math.round(this.getFillRate() * 1e4) / 1e4,
|
|
809
|
+
no_fill_rate: Math.round(this.getNoFillRate() * 1e4) / 1e4,
|
|
810
|
+
timeout_rate: Math.round(this.getTimeoutRate() * 1e4) / 1e4,
|
|
811
|
+
error_rate: Math.round(this.getErrorRate() * 1e4) / 1e4
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
/** Reset all counters to zero. */
|
|
815
|
+
reset() {
|
|
816
|
+
this.totalRequests = 0;
|
|
817
|
+
this.fills = 0;
|
|
818
|
+
this.noFills = 0;
|
|
819
|
+
this.timeouts = 0;
|
|
820
|
+
this.errors = 0;
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
// src/player/WaterfallEngine.ts
|
|
825
|
+
var WaterfallEngine = class {
|
|
826
|
+
constructor(circuitBreaker) {
|
|
827
|
+
this.circuitBreaker = circuitBreaker;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Get the next available VAST source from the waterfall.
|
|
831
|
+
* Sources are sorted by priority (lower = higher priority),
|
|
832
|
+
* with a random tiebreaker for equal priorities.
|
|
833
|
+
* Circuit-broken sources are skipped.
|
|
834
|
+
*
|
|
835
|
+
* If all sources are circuit-broken, returns `null` to let
|
|
836
|
+
* the caller handle retry scheduling rather than bypassing
|
|
837
|
+
* the circuit breaker.
|
|
838
|
+
*/
|
|
839
|
+
getNextSource(sources) {
|
|
840
|
+
if (!sources || sources.length === 0) return null;
|
|
841
|
+
const sorted = [...sources].filter((s) => s.enabled).sort((a, b) => a.priority - b.priority || Math.random() - 0.5);
|
|
842
|
+
for (const source of sorted) {
|
|
843
|
+
if (this.circuitBreaker.isAvailable(source.name)) {
|
|
844
|
+
return { source, vastUrl: source.vast_url };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Get all available sources in priority order (for parallel bidding).
|
|
851
|
+
* Only returns sources that are not circuit-broken.
|
|
852
|
+
* Equal-priority sources are randomized for load distribution.
|
|
853
|
+
*/
|
|
854
|
+
getAvailableSources(sources) {
|
|
855
|
+
if (!sources || sources.length === 0) return [];
|
|
856
|
+
return sources.filter((s) => s.enabled && this.circuitBreaker.isAvailable(s.name)).sort((a, b) => a.priority - b.priority || Math.random() - 0.5).map((source) => ({ source, vastUrl: source.vast_url }));
|
|
857
|
+
}
|
|
858
|
+
/** Record a successful ad fill for a source. */
|
|
859
|
+
recordSuccess(sourceName) {
|
|
860
|
+
this.circuitBreaker.recordSuccess(sourceName);
|
|
861
|
+
}
|
|
862
|
+
/** Record a failed request for a source. */
|
|
863
|
+
recordFailure(sourceName) {
|
|
864
|
+
this.circuitBreaker.recordFailure(sourceName);
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
// src/player/ProgrammaticPlayer.ts
|
|
869
|
+
var ProgrammaticPlayer = class {
|
|
870
|
+
constructor(events, timeoutMs = 12e3) {
|
|
871
|
+
// ── Public state ──────────────────────────────────────────
|
|
872
|
+
this.vastTagUrl = null;
|
|
873
|
+
this.variantName = null;
|
|
874
|
+
this.screenId = null;
|
|
875
|
+
this.adContainer = null;
|
|
876
|
+
this.contentVideo = null;
|
|
877
|
+
// ── Internal state ────────────────────────────────────────
|
|
878
|
+
this.ima = {};
|
|
879
|
+
this.currentSourceName = null;
|
|
880
|
+
this.isPlaying = false;
|
|
881
|
+
this.isPrefetching = false;
|
|
882
|
+
this.prefetchedReady = false;
|
|
883
|
+
this.prefetchTimer = null;
|
|
884
|
+
this.adStartTime = 0;
|
|
885
|
+
// ── Waterfall sources from server ─────────────────────────
|
|
886
|
+
this.sources = [];
|
|
887
|
+
this.circuitBreaker = new CircuitBreaker(5, 3e4);
|
|
888
|
+
this.telemetry = new Telemetry();
|
|
889
|
+
this.waterfallEngine = new WaterfallEngine(this.circuitBreaker);
|
|
890
|
+
this.events = events;
|
|
891
|
+
this.timeoutMs = timeoutMs;
|
|
892
|
+
}
|
|
893
|
+
/** Replace the current set of demand sources. */
|
|
894
|
+
setSources(sources) {
|
|
895
|
+
this.sources = sources;
|
|
896
|
+
}
|
|
897
|
+
/** Return aggregate fill/timeout/error rates. */
|
|
898
|
+
getTelemetrySnapshot() {
|
|
899
|
+
return this.telemetry.getSnapshot();
|
|
900
|
+
}
|
|
901
|
+
/** Whether a programmatic ad is currently playing. */
|
|
902
|
+
getIsPlaying() {
|
|
903
|
+
return this.isPlaying;
|
|
904
|
+
}
|
|
905
|
+
/** Whether a prefetched ad is ready for instant playback. */
|
|
906
|
+
hasPrefetchedAd() {
|
|
907
|
+
return this.prefetchedReady;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Create the ad container and content video element needed
|
|
911
|
+
* by IMA SDK. Appended as an overlay to `parentElement`.
|
|
912
|
+
*/
|
|
913
|
+
createAdContainer(parentElement) {
|
|
914
|
+
if (this.adContainer) return;
|
|
915
|
+
this.adContainer = document.createElement("div");
|
|
916
|
+
this.adContainer.id = "trillboards-ad-container";
|
|
917
|
+
this.adContainer.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;z-index:10000;display:none;";
|
|
918
|
+
this.contentVideo = document.createElement("video");
|
|
919
|
+
this.contentVideo.id = "trillboards-content-video";
|
|
920
|
+
this.contentVideo.style.cssText = "width:100%;height:100%;";
|
|
921
|
+
this.contentVideo.setAttribute("playsinline", "");
|
|
922
|
+
this.contentVideo.muted = true;
|
|
923
|
+
this.adContainer.appendChild(this.contentVideo);
|
|
924
|
+
parentElement.appendChild(this.adContainer);
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Request and play a VAST ad using Google IMA SDK.
|
|
928
|
+
*
|
|
929
|
+
* Picks the next available source from the waterfall (or
|
|
930
|
+
* falls back to `vastTagUrl`), adds a cache-busting correlator,
|
|
931
|
+
* and delegates to IMA for ad loading + playback.
|
|
932
|
+
*/
|
|
933
|
+
async play(onComplete, onError) {
|
|
934
|
+
if (this.isPlaying) return;
|
|
935
|
+
if (!this.adContainer || !this.contentVideo) {
|
|
936
|
+
onError("Ad container not initialized");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
let vastUrl = this.vastTagUrl;
|
|
940
|
+
let sourceName = "default";
|
|
941
|
+
if (this.sources.length > 0) {
|
|
942
|
+
const result = this.waterfallEngine.getNextSource(this.sources);
|
|
943
|
+
if (result) {
|
|
944
|
+
vastUrl = result.vastUrl;
|
|
945
|
+
sourceName = result.source.name;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (!vastUrl) {
|
|
949
|
+
onError("No VAST URL available");
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
this.currentSourceName = sourceName;
|
|
953
|
+
this.telemetry.recordRequest();
|
|
954
|
+
const correlator = Date.now();
|
|
955
|
+
const finalUrl = vastUrl.includes("correlator=") ? vastUrl.replace(/correlator=[^&]*/, `correlator=${correlator}`) : `${vastUrl}${vastUrl.includes("?") ? "&" : "?"}correlator=${correlator}`;
|
|
956
|
+
if (typeof google === "undefined" || !google?.ima) {
|
|
957
|
+
onError("Google IMA SDK not loaded");
|
|
958
|
+
this.telemetry.recordError();
|
|
959
|
+
this.waterfallEngine.recordFailure(sourceName);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
this.isPlaying = true;
|
|
963
|
+
this.adContainer.style.display = "block";
|
|
964
|
+
try {
|
|
965
|
+
await this.requestAdsViaIMA(finalUrl, onComplete, onError);
|
|
966
|
+
} catch (err) {
|
|
967
|
+
this.isPlaying = false;
|
|
968
|
+
this.adContainer.style.display = "none";
|
|
969
|
+
this.telemetry.recordError();
|
|
970
|
+
this.waterfallEngine.recordFailure(sourceName);
|
|
971
|
+
onError(err instanceof Error ? err.message : String(err));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// ── IMA request / playback lifecycle ──────────────────────
|
|
975
|
+
async requestAdsViaIMA(vastUrl, onComplete, onError) {
|
|
976
|
+
return new Promise((resolve) => {
|
|
977
|
+
let settled = false;
|
|
978
|
+
const guardedComplete = () => {
|
|
979
|
+
if (settled) return;
|
|
980
|
+
settled = true;
|
|
981
|
+
onComplete();
|
|
982
|
+
resolve();
|
|
983
|
+
};
|
|
984
|
+
const guardedError = (msg) => {
|
|
985
|
+
if (settled) return;
|
|
986
|
+
settled = true;
|
|
987
|
+
onError(msg);
|
|
988
|
+
resolve();
|
|
989
|
+
};
|
|
990
|
+
this.destroyIMA();
|
|
991
|
+
const adDisplayContainer = new google.ima.AdDisplayContainer(
|
|
992
|
+
this.adContainer,
|
|
993
|
+
this.contentVideo
|
|
994
|
+
);
|
|
995
|
+
adDisplayContainer.initialize();
|
|
996
|
+
const adsLoader = new google.ima.AdsLoader(adDisplayContainer);
|
|
997
|
+
this.ima.adDisplayContainer = adDisplayContainer;
|
|
998
|
+
this.ima.adsLoader = adsLoader;
|
|
999
|
+
const timeout = setTimeout(() => {
|
|
1000
|
+
this.telemetry.recordTimeout();
|
|
1001
|
+
this.waterfallEngine.recordFailure(this.currentSourceName);
|
|
1002
|
+
this.stop({ silent: true });
|
|
1003
|
+
this.events.emit("programmatic_timeout", {
|
|
1004
|
+
source: this.currentSourceName || "unknown"
|
|
1005
|
+
});
|
|
1006
|
+
guardedError("VAST request timeout");
|
|
1007
|
+
}, this.timeoutMs);
|
|
1008
|
+
adsLoader.addEventListener(
|
|
1009
|
+
google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,
|
|
1010
|
+
(adsManagerEvent) => {
|
|
1011
|
+
clearTimeout(timeout);
|
|
1012
|
+
this.telemetry.recordFill();
|
|
1013
|
+
this.waterfallEngine.recordSuccess(this.currentSourceName);
|
|
1014
|
+
const adsManager = adsManagerEvent.getAdsManager(this.contentVideo);
|
|
1015
|
+
this.ima.adsManager = adsManager;
|
|
1016
|
+
this.adStartTime = Date.now();
|
|
1017
|
+
adsManager.addEventListener(
|
|
1018
|
+
google.ima.AdEvent.Type.COMPLETE,
|
|
1019
|
+
() => {
|
|
1020
|
+
const duration = Math.round(
|
|
1021
|
+
(Date.now() - this.adStartTime) / 1e3
|
|
1022
|
+
);
|
|
1023
|
+
this.events.emit("programmatic_ended", {
|
|
1024
|
+
source: this.currentSourceName || "unknown",
|
|
1025
|
+
variant: this.variantName,
|
|
1026
|
+
duration
|
|
1027
|
+
});
|
|
1028
|
+
this.stop({ silent: true });
|
|
1029
|
+
guardedComplete();
|
|
1030
|
+
}
|
|
1031
|
+
);
|
|
1032
|
+
adsManager.addEventListener(
|
|
1033
|
+
google.ima.AdEvent.Type.STARTED,
|
|
1034
|
+
() => {
|
|
1035
|
+
this.events.emit("programmatic_started", {
|
|
1036
|
+
source: this.currentSourceName || "unknown",
|
|
1037
|
+
variant: this.variantName
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1041
|
+
adsManager.addEventListener(
|
|
1042
|
+
google.ima.AdErrorEvent.Type.AD_ERROR,
|
|
1043
|
+
(adErrorEvent) => {
|
|
1044
|
+
const error = adErrorEvent.getError();
|
|
1045
|
+
this.events.emit("programmatic_error", {
|
|
1046
|
+
error: error?.getMessage?.() || "Ad playback error",
|
|
1047
|
+
code: error?.getErrorCode?.()
|
|
1048
|
+
});
|
|
1049
|
+
this.stop({ silent: true });
|
|
1050
|
+
guardedError(error?.getMessage?.() || "Ad error");
|
|
1051
|
+
}
|
|
1052
|
+
);
|
|
1053
|
+
try {
|
|
1054
|
+
const width = this.adContainer.offsetWidth || window.innerWidth;
|
|
1055
|
+
const height = this.adContainer.offsetHeight || window.innerHeight;
|
|
1056
|
+
adsManager.init(width, height, google.ima.ViewMode.NORMAL);
|
|
1057
|
+
adsManager.start();
|
|
1058
|
+
} catch {
|
|
1059
|
+
this.stop({ silent: true });
|
|
1060
|
+
guardedError("Failed to start ad");
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
);
|
|
1064
|
+
adsLoader.addEventListener(
|
|
1065
|
+
google.ima.AdErrorEvent.Type.AD_ERROR,
|
|
1066
|
+
(adErrorEvent) => {
|
|
1067
|
+
clearTimeout(timeout);
|
|
1068
|
+
const error = adErrorEvent.getError();
|
|
1069
|
+
const errorCode = error?.getErrorCode?.();
|
|
1070
|
+
const errorMsg = error?.getMessage?.() || "Ad load error";
|
|
1071
|
+
if (errorCode === 303) {
|
|
1072
|
+
this.waterfallEngine.recordFailure(this.currentSourceName);
|
|
1073
|
+
this.events.emit("programmatic_no_fill", {
|
|
1074
|
+
source: this.currentSourceName || "unknown"
|
|
1075
|
+
});
|
|
1076
|
+
} else {
|
|
1077
|
+
this.telemetry.recordError();
|
|
1078
|
+
this.waterfallEngine.recordFailure(this.currentSourceName);
|
|
1079
|
+
this.events.emit("programmatic_error", {
|
|
1080
|
+
error: errorMsg,
|
|
1081
|
+
code: errorCode
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
this.stop({ silent: true });
|
|
1085
|
+
guardedError(errorMsg);
|
|
1086
|
+
}
|
|
1087
|
+
);
|
|
1088
|
+
const adsRequest = new google.ima.AdsRequest();
|
|
1089
|
+
adsRequest.adTagUrl = vastUrl;
|
|
1090
|
+
adsRequest.linearAdSlotWidth = this.adContainer.offsetWidth || window.innerWidth;
|
|
1091
|
+
adsRequest.linearAdSlotHeight = this.adContainer.offsetHeight || window.innerHeight;
|
|
1092
|
+
this.ima.adsRequest = adsRequest;
|
|
1093
|
+
adsLoader.requestAds(adsRequest);
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
// ── Prefetch ──────────────────────────────────────────────
|
|
1097
|
+
/**
|
|
1098
|
+
* Prefetch the next ad for instant playback.
|
|
1099
|
+
* Currently marks prefetch as ready; actual IMA preloading
|
|
1100
|
+
* is a future enhancement.
|
|
1101
|
+
*/
|
|
1102
|
+
async prefetch() {
|
|
1103
|
+
if (this.isPrefetching || this.prefetchedReady) return false;
|
|
1104
|
+
if (!this.vastTagUrl && this.sources.length === 0) return false;
|
|
1105
|
+
this.isPrefetching = true;
|
|
1106
|
+
this.prefetchedReady = true;
|
|
1107
|
+
this.isPrefetching = false;
|
|
1108
|
+
this.events.emit("ad_ready", {
|
|
1109
|
+
type: "programmatic",
|
|
1110
|
+
source: this.currentSourceName || void 0
|
|
1111
|
+
});
|
|
1112
|
+
return true;
|
|
1113
|
+
}
|
|
1114
|
+
/** Destroy any prefetched ad state. */
|
|
1115
|
+
destroyPrefetched() {
|
|
1116
|
+
this.prefetchedReady = false;
|
|
1117
|
+
this.isPrefetching = false;
|
|
1118
|
+
if (this.prefetchTimer) {
|
|
1119
|
+
clearTimeout(this.prefetchTimer);
|
|
1120
|
+
this.prefetchTimer = null;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
// ── Stop / destroy ────────────────────────────────────────
|
|
1124
|
+
/** Stop current ad playback and hide the container. */
|
|
1125
|
+
stop(_options) {
|
|
1126
|
+
this.isPlaying = false;
|
|
1127
|
+
this.destroyIMA();
|
|
1128
|
+
if (this.adContainer) {
|
|
1129
|
+
this.adContainer.style.display = "none";
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
destroyIMA() {
|
|
1133
|
+
if (this.ima.adsManager) {
|
|
1134
|
+
try {
|
|
1135
|
+
this.ima.adsManager.removeEventListener(
|
|
1136
|
+
google.ima.AdEvent.Type.COMPLETE
|
|
1137
|
+
);
|
|
1138
|
+
this.ima.adsManager.removeEventListener(
|
|
1139
|
+
google.ima.AdEvent.Type.STARTED
|
|
1140
|
+
);
|
|
1141
|
+
this.ima.adsManager.removeEventListener(
|
|
1142
|
+
google.ima.AdErrorEvent.Type.AD_ERROR
|
|
1143
|
+
);
|
|
1144
|
+
} catch {
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
this.ima.adsManager.destroy();
|
|
1148
|
+
} catch {
|
|
1149
|
+
}
|
|
1150
|
+
this.ima.adsManager = null;
|
|
1151
|
+
}
|
|
1152
|
+
if (this.ima.adsLoader) {
|
|
1153
|
+
try {
|
|
1154
|
+
this.ima.adsLoader.destroy();
|
|
1155
|
+
} catch {
|
|
1156
|
+
}
|
|
1157
|
+
this.ima.adsLoader = null;
|
|
1158
|
+
}
|
|
1159
|
+
this.ima.adDisplayContainer = null;
|
|
1160
|
+
this.ima.adsRequest = null;
|
|
1161
|
+
}
|
|
1162
|
+
/** Tear down everything — DOM, timers, circuit breaker state. */
|
|
1163
|
+
destroy() {
|
|
1164
|
+
this.stop({ silent: true });
|
|
1165
|
+
this.destroyPrefetched();
|
|
1166
|
+
if (this.adContainer) {
|
|
1167
|
+
this.adContainer.remove();
|
|
1168
|
+
this.adContainer = null;
|
|
1169
|
+
this.contentVideo = null;
|
|
1170
|
+
}
|
|
1171
|
+
this.circuitBreaker.reset();
|
|
1172
|
+
this.telemetry.reset();
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
// src/player/Player.ts
|
|
1177
|
+
var MAX_AD_DURATION_SECONDS = 300;
|
|
1178
|
+
var Player = class {
|
|
1179
|
+
/**
|
|
1180
|
+
* @param events Shared event emitter for SDK-wide events.
|
|
1181
|
+
* @param defaultImageDuration Fallback display time for images in ms.
|
|
1182
|
+
*/
|
|
1183
|
+
constructor(events, defaultImageDuration = 8e3) {
|
|
1184
|
+
this.container = null;
|
|
1185
|
+
this.currentAd = null;
|
|
1186
|
+
this.adTimer = null;
|
|
1187
|
+
this.videoElement = null;
|
|
1188
|
+
this.imageElement = null;
|
|
1189
|
+
this.videoEndedHandler = null;
|
|
1190
|
+
this.videoErrorHandler = null;
|
|
1191
|
+
this.events = events;
|
|
1192
|
+
this.defaultImageDuration = defaultImageDuration;
|
|
1193
|
+
}
|
|
1194
|
+
/** Set the DOM element that ads will be rendered into. */
|
|
1195
|
+
setContainer(container) {
|
|
1196
|
+
this.container = container;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Play a direct ad. Stops any currently playing ad first.
|
|
1200
|
+
* Calls `onComplete` when playback finishes (natural end or
|
|
1201
|
+
* error fallback).
|
|
1202
|
+
*
|
|
1203
|
+
* @param ad The ad to play.
|
|
1204
|
+
* @param onComplete Called when the ad finishes or errors.
|
|
1205
|
+
* @param index Position in the playlist (emitted in ad_started).
|
|
1206
|
+
*/
|
|
1207
|
+
play(ad, onComplete, index = 0) {
|
|
1208
|
+
if (!this.container) return;
|
|
1209
|
+
this.stop();
|
|
1210
|
+
this.currentAd = ad;
|
|
1211
|
+
this.events.emit("ad_started", { id: ad.id, type: ad.type, index });
|
|
1212
|
+
if (ad.type === "video") {
|
|
1213
|
+
this.playVideo(ad, onComplete);
|
|
1214
|
+
} else {
|
|
1215
|
+
this.playImage(ad, onComplete);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// ── Video playback ────────────────────────────────────────
|
|
1219
|
+
playVideo(ad, onComplete) {
|
|
1220
|
+
this.videoElement = document.createElement("video");
|
|
1221
|
+
this.videoElement.src = ad.media_url;
|
|
1222
|
+
this.videoElement.style.cssText = "width:100%;height:100%;object-fit:contain;";
|
|
1223
|
+
this.videoElement.setAttribute("playsinline", "");
|
|
1224
|
+
this.videoElement.muted = true;
|
|
1225
|
+
this.videoElement.autoplay = true;
|
|
1226
|
+
this.videoEndedHandler = () => {
|
|
1227
|
+
const duration = Math.min(
|
|
1228
|
+
Math.round(this.videoElement?.duration ?? ad.duration),
|
|
1229
|
+
MAX_AD_DURATION_SECONDS
|
|
1230
|
+
);
|
|
1231
|
+
this.events.emit("ad_ended", {
|
|
1232
|
+
id: ad.id,
|
|
1233
|
+
type: ad.type,
|
|
1234
|
+
duration
|
|
1235
|
+
});
|
|
1236
|
+
this.stop();
|
|
1237
|
+
onComplete();
|
|
1238
|
+
};
|
|
1239
|
+
this.videoErrorHandler = () => {
|
|
1240
|
+
this.events.emit("ad_error", { error: "Video playback error" });
|
|
1241
|
+
this.stop();
|
|
1242
|
+
onComplete();
|
|
1243
|
+
};
|
|
1244
|
+
this.videoElement.addEventListener("ended", this.videoEndedHandler);
|
|
1245
|
+
this.videoElement.addEventListener("error", this.videoErrorHandler);
|
|
1246
|
+
while (this.container.firstChild) {
|
|
1247
|
+
this.container.removeChild(this.container.firstChild);
|
|
1248
|
+
}
|
|
1249
|
+
this.container.appendChild(this.videoElement);
|
|
1250
|
+
}
|
|
1251
|
+
// ── Image playback ────────────────────────────────────────
|
|
1252
|
+
playImage(ad, onComplete) {
|
|
1253
|
+
this.imageElement = document.createElement("img");
|
|
1254
|
+
this.imageElement.src = ad.media_url;
|
|
1255
|
+
this.imageElement.style.cssText = "width:100%;height:100%;object-fit:contain;";
|
|
1256
|
+
this.imageElement.alt = ad.title ?? "Advertisement";
|
|
1257
|
+
while (this.container.firstChild) {
|
|
1258
|
+
this.container.removeChild(this.container.firstChild);
|
|
1259
|
+
}
|
|
1260
|
+
this.container.appendChild(this.imageElement);
|
|
1261
|
+
const durationSec = Math.min(
|
|
1262
|
+
ad.duration || this.defaultImageDuration / 1e3,
|
|
1263
|
+
MAX_AD_DURATION_SECONDS
|
|
1264
|
+
);
|
|
1265
|
+
const durationMs = durationSec * 1e3;
|
|
1266
|
+
this.adTimer = setTimeout(() => {
|
|
1267
|
+
this.events.emit("ad_ended", {
|
|
1268
|
+
id: ad.id,
|
|
1269
|
+
type: ad.type,
|
|
1270
|
+
duration: Math.round(durationSec)
|
|
1271
|
+
});
|
|
1272
|
+
this.stop();
|
|
1273
|
+
onComplete();
|
|
1274
|
+
}, durationMs);
|
|
1275
|
+
}
|
|
1276
|
+
// ── Stop / destroy ────────────────────────────────────────
|
|
1277
|
+
/** Stop any currently playing ad and clean up DOM nodes. */
|
|
1278
|
+
stop() {
|
|
1279
|
+
if (this.adTimer) {
|
|
1280
|
+
clearTimeout(this.adTimer);
|
|
1281
|
+
this.adTimer = null;
|
|
1282
|
+
}
|
|
1283
|
+
if (this.videoElement) {
|
|
1284
|
+
if (this.videoEndedHandler) {
|
|
1285
|
+
this.videoElement.removeEventListener("ended", this.videoEndedHandler);
|
|
1286
|
+
this.videoEndedHandler = null;
|
|
1287
|
+
}
|
|
1288
|
+
if (this.videoErrorHandler) {
|
|
1289
|
+
this.videoElement.removeEventListener("error", this.videoErrorHandler);
|
|
1290
|
+
this.videoErrorHandler = null;
|
|
1291
|
+
}
|
|
1292
|
+
this.videoElement.pause();
|
|
1293
|
+
this.videoElement.removeAttribute("src");
|
|
1294
|
+
this.videoElement.load();
|
|
1295
|
+
this.videoElement.remove();
|
|
1296
|
+
this.videoElement = null;
|
|
1297
|
+
}
|
|
1298
|
+
if (this.imageElement) {
|
|
1299
|
+
this.imageElement.remove();
|
|
1300
|
+
this.imageElement = null;
|
|
1301
|
+
}
|
|
1302
|
+
this.currentAd = null;
|
|
1303
|
+
}
|
|
1304
|
+
/** Tear down the player, releasing the container reference. */
|
|
1305
|
+
destroy() {
|
|
1306
|
+
this.stop();
|
|
1307
|
+
this.container = null;
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
// src/core/TrillboardsAds.ts
|
|
1312
|
+
function normalizeWaterfallMode(value) {
|
|
1313
|
+
if (!value) return null;
|
|
1314
|
+
const normalized = String(value).toLowerCase();
|
|
1315
|
+
if (["programmatic_only", "programmatic-only", "programmatic"].includes(normalized)) {
|
|
1316
|
+
return "programmatic_only";
|
|
1317
|
+
}
|
|
1318
|
+
if (["programmatic_then_direct", "programmatic-direct", "programmatic+direct", "hybrid"].includes(normalized)) {
|
|
1319
|
+
return "programmatic_then_direct";
|
|
1320
|
+
}
|
|
1321
|
+
if (["direct_only", "direct-only", "direct"].includes(normalized)) {
|
|
1322
|
+
return "direct_only";
|
|
1323
|
+
}
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
var _TrillboardsAds = class _TrillboardsAds {
|
|
1327
|
+
constructor(config) {
|
|
1328
|
+
/** Stored references for window event listeners (cleanup in destroy). */
|
|
1329
|
+
this.onlineHandler = null;
|
|
1330
|
+
this.offlineHandler = null;
|
|
1331
|
+
this.visibilityHandler = null;
|
|
1332
|
+
this.config = resolveConfig(config);
|
|
1333
|
+
this.state = createInitialState();
|
|
1334
|
+
this.events = new EventEmitter();
|
|
1335
|
+
this.api = new ApiClient(this.config.apiBase);
|
|
1336
|
+
this.cache = new IndexedDBCache(this.config.cacheSize);
|
|
1337
|
+
this.bridge = new NativeBridge();
|
|
1338
|
+
this.programmaticPlayer = new ProgrammaticPlayer(this.events, this.config.programmaticTimeout);
|
|
1339
|
+
this.directPlayer = new Player(this.events, this.config.defaultImageDuration);
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Create and initialize a new TrillboardsAds instance.
|
|
1343
|
+
* If a previous instance exists, it is destroyed first
|
|
1344
|
+
* (singleton guard).
|
|
1345
|
+
*/
|
|
1346
|
+
static async create(config) {
|
|
1347
|
+
if (_TrillboardsAds._instance) {
|
|
1348
|
+
_TrillboardsAds._instance.destroy();
|
|
1349
|
+
_TrillboardsAds._instance = null;
|
|
1350
|
+
}
|
|
1351
|
+
const instance = new _TrillboardsAds(config);
|
|
1352
|
+
await instance.init(config);
|
|
1353
|
+
_TrillboardsAds._instance = instance;
|
|
1354
|
+
return instance;
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Initialize the SDK (private — called by `create()` only).
|
|
1358
|
+
*/
|
|
1359
|
+
async init(config) {
|
|
1360
|
+
if (this.state.initialized) return;
|
|
1361
|
+
this.config = resolveConfig(config);
|
|
1362
|
+
this.state.deviceId = this.config.deviceId;
|
|
1363
|
+
this.state.waterfallMode = this.config.waterfall;
|
|
1364
|
+
this.state.autoStart = this.config.autoStart;
|
|
1365
|
+
try {
|
|
1366
|
+
await this.cache.init();
|
|
1367
|
+
} catch {
|
|
1368
|
+
}
|
|
1369
|
+
this.bridge.setContext(this.state.deviceId, this.state.screenId);
|
|
1370
|
+
this.bridge.setCommandHandler((command) => this.handleBridgeCommand(command));
|
|
1371
|
+
this.bridge.initCommandListener();
|
|
1372
|
+
this.bridge.detect();
|
|
1373
|
+
const bridgeEvents = [
|
|
1374
|
+
"initialized",
|
|
1375
|
+
"ad_started",
|
|
1376
|
+
"ad_ended",
|
|
1377
|
+
"ad_ready",
|
|
1378
|
+
"ad_error",
|
|
1379
|
+
"programmatic_started",
|
|
1380
|
+
"programmatic_ended",
|
|
1381
|
+
"programmatic_error",
|
|
1382
|
+
"programmatic_no_fill",
|
|
1383
|
+
"state_changed"
|
|
1384
|
+
];
|
|
1385
|
+
for (const eventName of bridgeEvents) {
|
|
1386
|
+
this.events.on(eventName, (data) => {
|
|
1387
|
+
this.bridge.send(eventName, data);
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
this.createContainer();
|
|
1391
|
+
if (typeof window !== "undefined") {
|
|
1392
|
+
this.onlineHandler = () => {
|
|
1393
|
+
if (this.state.isOffline) {
|
|
1394
|
+
this.state.isOffline = false;
|
|
1395
|
+
this.events.emit("online", {});
|
|
1396
|
+
}
|
|
1397
|
+
this.refreshAds().catch(() => {
|
|
1398
|
+
});
|
|
1399
|
+
};
|
|
1400
|
+
this.offlineHandler = () => {
|
|
1401
|
+
this.state.isOffline = true;
|
|
1402
|
+
this.events.emit("offline", {});
|
|
1403
|
+
this.emitStateChanged();
|
|
1404
|
+
};
|
|
1405
|
+
window.addEventListener("online", this.onlineHandler);
|
|
1406
|
+
window.addEventListener("offline", this.offlineHandler);
|
|
1407
|
+
this.visibilityHandler = () => {
|
|
1408
|
+
const visible = !document.hidden;
|
|
1409
|
+
this.events.emit("visibility_changed", { visible });
|
|
1410
|
+
if (visible) {
|
|
1411
|
+
this.refreshAds().catch(() => {
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
1416
|
+
}
|
|
1417
|
+
await this.refreshAds();
|
|
1418
|
+
this.startRefreshTimer();
|
|
1419
|
+
this.startHeartbeatTimer();
|
|
1420
|
+
this.state.initialized = true;
|
|
1421
|
+
this.events.emit("initialized", { deviceId: this.config.deviceId });
|
|
1422
|
+
this.emitStateChanged();
|
|
1423
|
+
if (this.state.autoStart) {
|
|
1424
|
+
this.prefetchNextAd().catch(() => {
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Destroy the SDK instance and clean up all resources.
|
|
1430
|
+
*/
|
|
1431
|
+
destroy() {
|
|
1432
|
+
if (this.state.refreshTimer) clearInterval(this.state.refreshTimer);
|
|
1433
|
+
if (this.state.heartbeatTimer) clearInterval(this.state.heartbeatTimer);
|
|
1434
|
+
if (this.state.adTimer) clearTimeout(this.state.adTimer);
|
|
1435
|
+
if (this.state.programmaticRetryTimer) clearTimeout(this.state.programmaticRetryTimer);
|
|
1436
|
+
this.programmaticPlayer.destroy();
|
|
1437
|
+
this.directPlayer.destroy();
|
|
1438
|
+
this.bridge.destroy();
|
|
1439
|
+
this.cache.destroy();
|
|
1440
|
+
if (typeof window !== "undefined") {
|
|
1441
|
+
if (this.onlineHandler) {
|
|
1442
|
+
window.removeEventListener("online", this.onlineHandler);
|
|
1443
|
+
this.onlineHandler = null;
|
|
1444
|
+
}
|
|
1445
|
+
if (this.offlineHandler) {
|
|
1446
|
+
window.removeEventListener("offline", this.offlineHandler);
|
|
1447
|
+
this.offlineHandler = null;
|
|
1448
|
+
}
|
|
1449
|
+
if (this.visibilityHandler) {
|
|
1450
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
1451
|
+
this.visibilityHandler = null;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
if (this.state.container) {
|
|
1455
|
+
this.state.container.remove();
|
|
1456
|
+
}
|
|
1457
|
+
this.events.removeAllListeners();
|
|
1458
|
+
this.state = createInitialState();
|
|
1459
|
+
if (_TrillboardsAds._instance === this) {
|
|
1460
|
+
_TrillboardsAds._instance = null;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Show the ad container and start playback.
|
|
1465
|
+
*/
|
|
1466
|
+
show() {
|
|
1467
|
+
if (this.state.container) {
|
|
1468
|
+
this.state.container.style.display = "block";
|
|
1469
|
+
}
|
|
1470
|
+
this.state.isPlaying = true;
|
|
1471
|
+
this.state.isPaused = false;
|
|
1472
|
+
this.playNextAd();
|
|
1473
|
+
this.emitStateChanged();
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Hide the ad container and pause playback.
|
|
1477
|
+
*/
|
|
1478
|
+
hide() {
|
|
1479
|
+
if (this.state.container) {
|
|
1480
|
+
this.state.container.style.display = "none";
|
|
1481
|
+
}
|
|
1482
|
+
this.state.isPlaying = false;
|
|
1483
|
+
this.state.isPaused = true;
|
|
1484
|
+
this.programmaticPlayer.stop();
|
|
1485
|
+
this.directPlayer.stop();
|
|
1486
|
+
this.emitStateChanged();
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Skip the current ad.
|
|
1490
|
+
*/
|
|
1491
|
+
skipAd() {
|
|
1492
|
+
this.programmaticPlayer.stop();
|
|
1493
|
+
this.directPlayer.stop();
|
|
1494
|
+
this.playNextAd();
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Force refresh — re-fetches VAST URLs from server with null etag.
|
|
1498
|
+
*/
|
|
1499
|
+
async refresh() {
|
|
1500
|
+
this.programmaticPlayer.destroyPrefetched();
|
|
1501
|
+
this.resetProgrammaticBackoff();
|
|
1502
|
+
this.state.etag = null;
|
|
1503
|
+
await this.refreshAds();
|
|
1504
|
+
this.prefetchNextAd();
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Prefetch the next ad for instant playback.
|
|
1508
|
+
*/
|
|
1509
|
+
async prefetchNextAd() {
|
|
1510
|
+
const mode = this.state.waterfallMode;
|
|
1511
|
+
if (mode === "programmatic_only" || mode === "programmatic_then_direct") {
|
|
1512
|
+
const prog = this.state.programmatic;
|
|
1513
|
+
if (prog?.vast_tag_url || prog?.sources && prog.sources.length > 0) {
|
|
1514
|
+
await this.programmaticPlayer.prefetch();
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* Get current public state.
|
|
1520
|
+
*/
|
|
1521
|
+
getState() {
|
|
1522
|
+
return getPublicState(this.state);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Get current ad list.
|
|
1526
|
+
*/
|
|
1527
|
+
getAds() {
|
|
1528
|
+
return [...this.state.ads];
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Subscribe to an event.
|
|
1532
|
+
*/
|
|
1533
|
+
on(event, handler) {
|
|
1534
|
+
this.events.on(event, handler);
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Unsubscribe from an event.
|
|
1538
|
+
*/
|
|
1539
|
+
off(event, handler) {
|
|
1540
|
+
this.events.off(event, handler);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Register a custom native bridge.
|
|
1544
|
+
*/
|
|
1545
|
+
registerBridge(config) {
|
|
1546
|
+
return this.bridge.register(config);
|
|
1547
|
+
}
|
|
1548
|
+
// ========================
|
|
1549
|
+
// Private methods
|
|
1550
|
+
// ========================
|
|
1551
|
+
createContainer() {
|
|
1552
|
+
if (typeof document === "undefined") return;
|
|
1553
|
+
const container = document.createElement("div");
|
|
1554
|
+
container.id = "trillboards-ads-container";
|
|
1555
|
+
container.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;z-index:99999;display:none;background:#000;";
|
|
1556
|
+
document.body.appendChild(container);
|
|
1557
|
+
this.state.container = container;
|
|
1558
|
+
this.api.setContainer(container);
|
|
1559
|
+
this.directPlayer.setContainer(container);
|
|
1560
|
+
this.programmaticPlayer.createAdContainer(container);
|
|
1561
|
+
}
|
|
1562
|
+
async refreshAds() {
|
|
1563
|
+
try {
|
|
1564
|
+
const result = await this.api.fetchAds(this.config.deviceId, this.state.etag);
|
|
1565
|
+
if (result.notModified) return;
|
|
1566
|
+
if (this.state.isOffline) {
|
|
1567
|
+
this.state.isOffline = false;
|
|
1568
|
+
this.events.emit("online", {});
|
|
1569
|
+
}
|
|
1570
|
+
this.state.ads = result.ads;
|
|
1571
|
+
this.state.settings = result.settings;
|
|
1572
|
+
this.state.etag = result.etag;
|
|
1573
|
+
this.state.screenId = result.screenId ?? this.state.screenId;
|
|
1574
|
+
this.state.programmatic = result.programmatic;
|
|
1575
|
+
this.state.screenOrientation = result.screenOrientation ?? this.state.screenOrientation;
|
|
1576
|
+
this.state.screenDimensions = result.screenDimensions ?? this.state.screenDimensions;
|
|
1577
|
+
this.bridge.setContext(this.state.deviceId, this.state.screenId);
|
|
1578
|
+
this.programmaticPlayer.screenId = this.state.screenId;
|
|
1579
|
+
this.programmaticPlayer.vastTagUrl = this.state.programmatic?.vast_tag_url ?? null;
|
|
1580
|
+
this.programmaticPlayer.variantName = this.state.programmatic?.variant_name ?? null;
|
|
1581
|
+
if (this.state.programmatic?.sources) {
|
|
1582
|
+
this.programmaticPlayer.setSources(this.state.programmatic.sources);
|
|
1583
|
+
}
|
|
1584
|
+
if (this.state.ads.length > 0) {
|
|
1585
|
+
await this.cache.cacheAds(this.state.ads);
|
|
1586
|
+
}
|
|
1587
|
+
this.events.emit("ads_refreshed", { count: this.state.ads.length });
|
|
1588
|
+
} catch {
|
|
1589
|
+
this.state.isOffline = true;
|
|
1590
|
+
const cachedAds = await this.cache.getCachedAds();
|
|
1591
|
+
if (cachedAds.length > 0) {
|
|
1592
|
+
this.state.ads = cachedAds;
|
|
1593
|
+
this.events.emit("ads_loaded_from_cache", { count: cachedAds.length });
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
startRefreshTimer() {
|
|
1598
|
+
if (this.state.refreshTimer) clearInterval(this.state.refreshTimer);
|
|
1599
|
+
this.state.refreshTimer = setInterval(() => {
|
|
1600
|
+
this.refreshAds().catch(() => {
|
|
1601
|
+
});
|
|
1602
|
+
}, this.config.refreshInterval);
|
|
1603
|
+
}
|
|
1604
|
+
startHeartbeatTimer() {
|
|
1605
|
+
if (this.state.heartbeatTimer) clearInterval(this.state.heartbeatTimer);
|
|
1606
|
+
this.state.heartbeatTimer = setInterval(() => {
|
|
1607
|
+
this.api.sendHeartbeat(this.config.deviceId, this.state.screenId);
|
|
1608
|
+
}, this.config.heartbeatInterval);
|
|
1609
|
+
}
|
|
1610
|
+
playNextAd() {
|
|
1611
|
+
const mode = this.state.waterfallMode;
|
|
1612
|
+
if (mode === "programmatic_only" || mode === "programmatic_then_direct") {
|
|
1613
|
+
this.playProgrammatic();
|
|
1614
|
+
} else if (mode === "direct_only") {
|
|
1615
|
+
this.playDirect();
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
playProgrammatic() {
|
|
1619
|
+
if (this.programmaticPlayer.getIsPlaying()) return;
|
|
1620
|
+
this.state.programmaticPlaying = true;
|
|
1621
|
+
this.emitStateChanged();
|
|
1622
|
+
this.programmaticPlayer.play(
|
|
1623
|
+
() => {
|
|
1624
|
+
this.state.programmaticPlaying = false;
|
|
1625
|
+
this.emitStateChanged();
|
|
1626
|
+
this.scheduleProgrammaticRetry();
|
|
1627
|
+
},
|
|
1628
|
+
(error) => {
|
|
1629
|
+
this.state.programmaticPlaying = false;
|
|
1630
|
+
this.state.programmaticLastError = error;
|
|
1631
|
+
this.state.programmaticRetryCount++;
|
|
1632
|
+
this.emitStateChanged();
|
|
1633
|
+
if (this.state.waterfallMode === "programmatic_then_direct" && this.state.ads.length > 0) {
|
|
1634
|
+
this.playDirect();
|
|
1635
|
+
} else {
|
|
1636
|
+
this.scheduleProgrammaticRetry();
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
playDirect() {
|
|
1642
|
+
if (this.state.ads.length === 0) return;
|
|
1643
|
+
const index = this.state.currentAdIndex % this.state.ads.length;
|
|
1644
|
+
const ad = this.state.ads[index];
|
|
1645
|
+
if (!ad) return;
|
|
1646
|
+
this.directPlayer.play(
|
|
1647
|
+
ad,
|
|
1648
|
+
() => {
|
|
1649
|
+
this.state.currentAdIndex = (index + 1) % this.state.ads.length;
|
|
1650
|
+
this.api.reportImpression({
|
|
1651
|
+
adid: ad.id,
|
|
1652
|
+
impid: ad.impression_id,
|
|
1653
|
+
did: this.config.deviceId,
|
|
1654
|
+
aid: ad.allocation_id ?? "",
|
|
1655
|
+
duration: ad.duration,
|
|
1656
|
+
completed: true
|
|
1657
|
+
});
|
|
1658
|
+
this.emitStateChanged();
|
|
1659
|
+
this.scheduleNextDirect();
|
|
1660
|
+
},
|
|
1661
|
+
index
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
scheduleNextDirect() {
|
|
1665
|
+
if (this.state.adTimer) clearTimeout(this.state.adTimer);
|
|
1666
|
+
this.state.adTimer = setTimeout(() => {
|
|
1667
|
+
this.playNextAd();
|
|
1668
|
+
}, this.config.defaultAdInterval);
|
|
1669
|
+
}
|
|
1670
|
+
scheduleProgrammaticRetry() {
|
|
1671
|
+
if (this.state.programmaticRetryTimer) clearTimeout(this.state.programmaticRetryTimer);
|
|
1672
|
+
const retryCount = this.state.programmaticRetryCount;
|
|
1673
|
+
const baseDelay = this.config.programmaticRetry;
|
|
1674
|
+
const maxDelay = this.config.programmaticBackoffMax;
|
|
1675
|
+
const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
|
|
1676
|
+
const jitter = delay * 0.2 * Math.random();
|
|
1677
|
+
this.state.programmaticRetryTimer = setTimeout(() => {
|
|
1678
|
+
this.prefetchNextAd().catch(() => {
|
|
1679
|
+
}).then(() => {
|
|
1680
|
+
this.playNextAd();
|
|
1681
|
+
});
|
|
1682
|
+
}, delay + jitter);
|
|
1683
|
+
}
|
|
1684
|
+
resetProgrammaticBackoff() {
|
|
1685
|
+
this.state.programmaticRetryCount = 0;
|
|
1686
|
+
this.state.programmaticLastError = null;
|
|
1687
|
+
if (this.state.programmaticRetryTimer) {
|
|
1688
|
+
clearTimeout(this.state.programmaticRetryTimer);
|
|
1689
|
+
this.state.programmaticRetryTimer = null;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
emitStateChanged() {
|
|
1693
|
+
this.events.emit("state_changed", this.getState());
|
|
1694
|
+
}
|
|
1695
|
+
handleBridgeCommand(command) {
|
|
1696
|
+
let cmd;
|
|
1697
|
+
try {
|
|
1698
|
+
cmd = typeof command === "string" ? JSON.parse(command) : command;
|
|
1699
|
+
} catch {
|
|
1700
|
+
console.warn("[TrillboardsAds] Failed to parse bridge command:", command);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
const action = cmd.action || cmd.command;
|
|
1704
|
+
const params = cmd.params || cmd.data || {};
|
|
1705
|
+
switch (action) {
|
|
1706
|
+
case "show":
|
|
1707
|
+
this.show();
|
|
1708
|
+
break;
|
|
1709
|
+
case "hide":
|
|
1710
|
+
this.hide();
|
|
1711
|
+
break;
|
|
1712
|
+
case "skip":
|
|
1713
|
+
this.skipAd();
|
|
1714
|
+
break;
|
|
1715
|
+
case "refresh":
|
|
1716
|
+
this.refresh();
|
|
1717
|
+
break;
|
|
1718
|
+
case "getState":
|
|
1719
|
+
this.bridge.send("state_changed", this.getState());
|
|
1720
|
+
break;
|
|
1721
|
+
case "configure":
|
|
1722
|
+
if (params.waterfall) {
|
|
1723
|
+
const normalized = normalizeWaterfallMode(params.waterfall);
|
|
1724
|
+
if (normalized) this.state.waterfallMode = normalized;
|
|
1725
|
+
}
|
|
1726
|
+
if (typeof params.volume === "number") {
|
|
1727
|
+
this.state.settings.sound_enabled = params.volume > 0;
|
|
1728
|
+
}
|
|
1729
|
+
break;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
/** Singleton reference — `create()` destroys any previous instance. */
|
|
1734
|
+
_TrillboardsAds._instance = null;
|
|
1735
|
+
var TrillboardsAds = _TrillboardsAds;
|
|
1736
|
+
|
|
1737
|
+
export { ApiClient, CircuitBreaker, DEFAULT_CONFIG, DEFAULT_PUBLIC_STATE, EventEmitter, SDK_VERSION, Telemetry, TrillboardsAds, WaterfallEngine, createInitialState, getPublicState, resolveConfig };
|
|
1738
|
+
//# sourceMappingURL=index.mjs.map
|
|
1739
|
+
//# sourceMappingURL=index.mjs.map
|