@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/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