@qbix/q 1.0.5

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +678 -0
  3. package/dist/Metrics.js +2873 -0
  4. package/dist/Metrics.min.js +95 -0
  5. package/dist/Q.js +15677 -0
  6. package/dist/Q.min.js +385 -0
  7. package/dist/Q.minimal.js +11594 -0
  8. package/dist/Q.minimal.min.js +286 -0
  9. package/dist/handlebars-v4.0.10.min.js +29 -0
  10. package/dist/handlebars.minimal.min.js +1 -0
  11. package/dist/img/hints/rotate-left.gif +0 -0
  12. package/dist/img/hints/swipe-down.gif +0 -0
  13. package/dist/img/hints/swipe-up.gif +0 -0
  14. package/dist/img/hints/tap.gif +0 -0
  15. package/dist/img/throbbers/loading.gif +0 -0
  16. package/dist/jquery.minimal.min.js +18 -0
  17. package/dist/methods/Q/Audio/load.js +29 -0
  18. package/dist/methods/Q/Audio/loadVoices.js +36 -0
  19. package/dist/methods/Q/Audio/play.js +47 -0
  20. package/dist/methods/Q/Audio/speak.js +149 -0
  21. package/dist/methods/Q/Crypto/delegate.js +186 -0
  22. package/dist/methods/Q/Crypto/internalKeypair.js +170 -0
  23. package/dist/methods/Q/Crypto/sign.js +200 -0
  24. package/dist/methods/Q/Crypto/verify.js +212 -0
  25. package/dist/methods/Q/Crypto/verifyDelegated.js +214 -0
  26. package/dist/methods/Q/Data/Bloom/_internal.js +163 -0
  27. package/dist/methods/Q/Data/Bloom/create.js +23 -0
  28. package/dist/methods/Q/Data/Bloom/fromBase64.js +21 -0
  29. package/dist/methods/Q/Data/Bloom/fromBytes.js +15 -0
  30. package/dist/methods/Q/Data/Bloom/fromElements.js +33 -0
  31. package/dist/methods/Q/Data/Merkle/_internal.js +68 -0
  32. package/dist/methods/Q/Data/Merkle/build.js +29 -0
  33. package/dist/methods/Q/Data/Merkle/proof.js +50 -0
  34. package/dist/methods/Q/Data/Merkle/verify.js +32 -0
  35. package/dist/methods/Q/Data/Prolly/_internal.js +190 -0
  36. package/dist/methods/Q/Data/Prolly/build.js +28 -0
  37. package/dist/methods/Q/Data/Prolly/delete.js +28 -0
  38. package/dist/methods/Q/Data/Prolly/diff.js +70 -0
  39. package/dist/methods/Q/Data/Prolly/get.js +38 -0
  40. package/dist/methods/Q/Data/Prolly/set.js +32 -0
  41. package/dist/methods/Q/Data/compress.js +45 -0
  42. package/dist/methods/Q/Data/decompress.js +35 -0
  43. package/dist/methods/Q/Data/decrypt.js +55 -0
  44. package/dist/methods/Q/Data/derive.js +76 -0
  45. package/dist/methods/Q/Data/digest.js +29 -0
  46. package/dist/methods/Q/Data/encrypt.js +61 -0
  47. package/dist/methods/Q/Data/generateKey.js +40 -0
  48. package/dist/methods/Q/Data/hkdf.js +44 -0
  49. package/dist/methods/Q/Data/importKey.js +34 -0
  50. package/dist/methods/Q/Data/sign.js +42 -0
  51. package/dist/methods/Q/Data/verify.js +45 -0
  52. package/dist/methods/Q/Onboarding/handle.js +50 -0
  53. package/dist/methods/Q/Onboarding/start.js +165 -0
  54. package/dist/methods/Q/Onboarding/stop.js +21 -0
  55. package/dist/methods/Q/Sandbox/run.js +392 -0
  56. package/dist/methods/Q/Tool/define/component.js +218 -0
  57. package/dist/methods/Q/globalMemoryWalk.js +82 -0
  58. package/dist/methods/Q/leaves.js +34 -0
  59. package/dist/methods/Q/registerWebComponent.js +0 -0
  60. package/dist/methods/Q/sanitize.js +142 -0
  61. package/dist/test.html +6 -0
  62. package/dist/tools/Q/lazyload.js +433 -0
  63. package/package.json +26 -0
@@ -0,0 +1,2873 @@
1
+ /**
2
+ * Metrics plugin's front end code
3
+ *
4
+ * @module Metrics
5
+ * @class Metrics
6
+ */
7
+ "use strict";
8
+
9
+ // ── Core Metrics object (works with or without Q) ──
10
+ (function (root) {
11
+
12
+ var Metrics = root.Metrics || {};
13
+ root.Metrics = Metrics;
14
+
15
+ // ── Session Management ──
16
+
17
+ Metrics._sessionKey = 'metrics_sid';
18
+ Metrics._visitorKey = 'metrics_vid';
19
+ Metrics._sid = null;
20
+ Metrics._vid = null;
21
+
22
+ Metrics.getSessionId = function () {
23
+ if (Metrics._sid) return Metrics._sid;
24
+ try {
25
+ var sid = sessionStorage.getItem(Metrics._sessionKey);
26
+ if (!sid) {
27
+ sid = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
28
+ sessionStorage.setItem(Metrics._sessionKey, sid);
29
+ }
30
+ Metrics._sid = sid;
31
+ } catch (e) {
32
+ Metrics._sid = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
33
+ }
34
+ return Metrics._sid;
35
+ };
36
+
37
+ /**
38
+ * Get or create a persistent visitor ID that survives across sessions.
39
+ * Tries localStorage first (persists until cleared).
40
+ * Falls back to sessionStorage (ITP-safe, but per-tab only).
41
+ * Either way, ITP won't block it — both are first-party storage
42
+ * when the script runs on the page's own domain.
43
+ * When loaded cross-origin (e.g. from invites.to CDN onto
44
+ * summerfest.com), localStorage may be partitioned by Safari's
45
+ * ITP — meaning the same visitor gets different vids on different
46
+ * sites. This is fine: the inv_token from the redirect query
47
+ * string is what links visits across domains, not the vid.
48
+ * @method getVisitorId
49
+ * @return {String}
50
+ */
51
+ Metrics.getVisitorId = function () {
52
+ if (Metrics._vid) return Metrics._vid;
53
+ var vid = null;
54
+ // Try localStorage first (persistent)
55
+ try {
56
+ vid = localStorage.getItem(Metrics._visitorKey);
57
+ if (!vid) {
58
+ vid = Math.random().toString(36).slice(2) + Date.now().toString(36)
59
+ + Math.random().toString(36).slice(2);
60
+ localStorage.setItem(Metrics._visitorKey, vid);
61
+ }
62
+ Metrics._vid = vid;
63
+ return vid;
64
+ } catch (e) { /* localStorage blocked or full */ }
65
+ // Fall back to sessionStorage
66
+ try {
67
+ vid = sessionStorage.getItem(Metrics._visitorKey);
68
+ if (!vid) {
69
+ vid = Math.random().toString(36).slice(2) + Date.now().toString(36)
70
+ + Math.random().toString(36).slice(2);
71
+ sessionStorage.setItem(Metrics._visitorKey, vid);
72
+ }
73
+ Metrics._vid = vid;
74
+ return vid;
75
+ } catch (e) { /* sessionStorage blocked */ }
76
+ // Last resort: in-memory only (won't survive page reload)
77
+ vid = Math.random().toString(36).slice(2) + Date.now().toString(36)
78
+ + Math.random().toString(36).slice(2);
79
+ Metrics._vid = vid;
80
+ return vid;
81
+ };
82
+
83
+ // ── Transport (standalone — overridden by Q integration below) ──
84
+
85
+ Metrics._endpoint = null;
86
+ Metrics._page = null;
87
+ Metrics._extra = null;
88
+ Metrics._unloaded = false;
89
+ Metrics._startTime = Date.now();
90
+
91
+ /**
92
+ * Send a telemetry event. When Q framework is loaded, this is
93
+ * enhanced to also POST via Q.req(). Standalone mode uses
94
+ * sendBeacon / fetch to the configured endpoint.
95
+ * @param {String} label — event label
96
+ * @param {Object} [data] — optional extra data
97
+ */
98
+ Metrics.send = function (label, data) {
99
+ if (!Metrics._endpoint || Metrics._unloaded) return;
100
+
101
+ var payload = {
102
+ session: Metrics.getSessionId(),
103
+ visitor: Metrics.getVisitorId(),
104
+ page: Metrics._page || document.title,
105
+ label: label,
106
+ t: Date.now()
107
+ };
108
+ if (Metrics._extra) payload.extra = Metrics._extra;
109
+ if (data) payload.data = data;
110
+
111
+ var body = JSON.stringify(payload);
112
+ try {
113
+ if (navigator.sendBeacon) {
114
+ navigator.sendBeacon(Metrics._endpoint, new Blob([body], { type: 'text/plain' }));
115
+ } else {
116
+ fetch(Metrics._endpoint, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'text/plain' },
119
+ keepalive: true,
120
+ body: body
121
+ });
122
+ }
123
+ } catch (e) { /* silent */ }
124
+ };
125
+
126
+ // ── Visibility Detection ──
127
+ // Uses Q.onVisibilityChange when available, otherwise vendor-prefixed
128
+ // visibilitychange + mobile lifecycle events (Cordova/Capacitor)
129
+
130
+ Metrics._visible = true;
131
+ Metrics._visibilityCallbacks = [];
132
+ Metrics._visibilityBound = false;
133
+
134
+ /**
135
+ * Whether the page is currently visible
136
+ * @returns {Boolean}
137
+ */
138
+ Metrics.isVisible = function () {
139
+ return Metrics._visible;
140
+ };
141
+
142
+ /**
143
+ * Register a callback for visibility changes
144
+ * @param {Function} fn(isVisible) — called when visibility changes
145
+ * @param {String} [key] — optional key for deduplication
146
+ */
147
+ Metrics.onVisibilityChange = function (fn, key) {
148
+ if (key) {
149
+ // Replace existing callback with same key
150
+ for (var i = 0; i < Metrics._visibilityCallbacks.length; i++) {
151
+ if (Metrics._visibilityCallbacks[i].key === key) {
152
+ Metrics._visibilityCallbacks[i].fn = fn;
153
+ return;
154
+ }
155
+ }
156
+ }
157
+ Metrics._visibilityCallbacks.push({ fn: fn, key: key || null });
158
+ };
159
+
160
+ function _fireVisibility(isVisible) {
161
+ if (isVisible === Metrics._visible) return; // deduplicate
162
+ Metrics._visible = isVisible;
163
+ for (var i = 0; i < Metrics._visibilityCallbacks.length; i++) {
164
+ try { Metrics._visibilityCallbacks[i].fn(isVisible); } catch (e) {}
165
+ }
166
+ }
167
+
168
+ function _bindVisibility() {
169
+ if (Metrics._visibilityBound) return;
170
+ Metrics._visibilityBound = true;
171
+
172
+ // Detect vendor-prefixed visibility API
173
+ var visibilityChange = null;
174
+ var prefixes = ['', 'moz', 'ms', 'webkit', 'o'];
175
+ for (var i = 0; i < prefixes.length; i++) {
176
+ var k = prefixes[i];
177
+ var hidden = k ? k + 'Hidden' : 'hidden';
178
+ if (hidden in document) {
179
+ visibilityChange = k ? k + 'visibilitychange' : 'visibilitychange';
180
+ break;
181
+ }
182
+ }
183
+
184
+ function handleVisEvent(event) {
185
+ var isHidden;
186
+ if (event.type === 'pause' || event.type === 'resign') {
187
+ isHidden = true;
188
+ } else if (event.type === 'resume' || event.type === 'active') {
189
+ isHidden = false;
190
+ } else {
191
+ isHidden = document.visibilityState === 'hidden';
192
+ }
193
+ _fireVisibility(!isHidden);
194
+ }
195
+
196
+ if (visibilityChange) {
197
+ document.addEventListener(visibilityChange, handleVisEvent, false);
198
+ }
199
+ // Mobile lifecycle (Cordova / Capacitor)
200
+ document.addEventListener('pause', handleVisEvent, false);
201
+ document.addEventListener('resume', handleVisEvent, false);
202
+ document.addEventListener('resign', handleVisEvent, false);
203
+ document.addEventListener('active', handleVisEvent, false);
204
+ }
205
+
206
+ // ── Unload / bfcache ──
207
+
208
+ Metrics._unloadBound = false;
209
+
210
+ function _bindUnload() {
211
+ if (Metrics._unloadBound) return;
212
+ Metrics._unloadBound = true;
213
+
214
+ // Visibility-based exit (most reliable)
215
+ Metrics.onVisibilityChange(function (isVisible) {
216
+ if (!isVisible) {
217
+ _sendUnload();
218
+ } else {
219
+ Metrics._unloaded = false; // returned to page
220
+ }
221
+ }, 'Metrics.unload');
222
+
223
+ // pagehide fallback
224
+ window.addEventListener('pagehide', function () {
225
+ _sendUnload();
226
+ });
227
+
228
+ // bfcache restore
229
+ window.addEventListener('pageshow', function (e) {
230
+ if (e.persisted) {
231
+ Metrics._unloaded = false;
232
+ }
233
+ });
234
+ }
235
+
236
+ function _sendUnload() {
237
+ if (Metrics._unloaded) return;
238
+ Metrics._unloaded = true;
239
+ var elapsed = Math.round((Date.now() - Metrics._startTime) / 1000);
240
+ Metrics.send('unload:' + elapsed + 's');
241
+ }
242
+
243
+ /**
244
+ * Initialize standalone page tracking (no Q framework needed)
245
+ * @param {Object} options
246
+ * @param {String} options.endpoint — POST URL for beacons
247
+ * @param {String} [options.page] — page identifier
248
+ * @param {String} [options.sessionKey] — sessionStorage key
249
+ * @param {String} [options.sessionId] — override session ID
250
+ * @param {Object} [options.extra] — extra data with every event
251
+ * @param {Boolean} [options.trackUnload=true] — send unload beacon
252
+ */
253
+ Metrics.init = function (options) {
254
+ options = options || {};
255
+ if (options.endpoint) Metrics._endpoint = options.endpoint;
256
+ if (options.page) Metrics._page = options.page;
257
+ if (options.sessionKey) Metrics._sessionKey = options.sessionKey;
258
+ if (options.sessionId) Metrics._sid = options.sessionId;
259
+ if (options.extra) Metrics._extra = options.extra;
260
+
261
+ _bindVisibility();
262
+ if (options.trackUnload !== false) {
263
+ _bindUnload();
264
+ }
265
+
266
+ Metrics.send('loaded');
267
+ return Metrics;
268
+ };
269
+
270
+ })(typeof window !== 'undefined' ? window : this);
271
+
272
+
273
+ // ── Q Framework Integration (only runs if Q exists) ──
274
+ if (typeof Q !== 'undefined') {
275
+ (function (Q) {
276
+
277
+ // Bridge: make Q.Metrics point to the global Metrics
278
+ Q.Metrics = Q.plugins.Metrics = window.Metrics;
279
+ var Metrics = window.Metrics;
280
+
281
+ Metrics.setState = function (state, extra) {
282
+ var url = Q.info.url;
283
+ Metrics.setState.pending[url] = Q.setTimeout(function () {
284
+ if (Metrics.setState.pending[url]) {
285
+ clearTimeout(Metrics.setState.pending[url]);
286
+ delete Metrics.setState.pending[url];
287
+ }
288
+ Q.req('Metrics/update', [], null, {
289
+ method: 'POST',
290
+ fields: {
291
+ navigatorUrl: location.href,
292
+ url: Q.info.url,
293
+ state: state,
294
+ extra: JSON.stringify(extra)
295
+ },
296
+ keepalive: true
297
+ });
298
+ }, 5000);
299
+ };
300
+ Metrics.setState.pending = {};
301
+
302
+ var dc = Q.extend.dontCopy;
303
+ dc["Q.Users.User"] = true;
304
+
305
+ Q.text.Metrics = {};
306
+
307
+ function ensureVisitInHash(visitId) {
308
+ var current = location.hash || '#';
309
+ var updated = current.queryField('v', visitId);
310
+ if (updated !== current) {
311
+ history.replaceState(
312
+ history.state,
313
+ document.title,
314
+ updated
315
+ );
316
+ }
317
+ }
318
+
319
+ Q.onReady.add(function () {
320
+ // If Q.onVisibilityChange exists, bridge it to Metrics visibility
321
+ if (Q.onVisibilityChange && Q.onVisibilityChange.set) {
322
+ Q.onVisibilityChange.set(function (shown) {
323
+ // Sync Q's visibility detection into Metrics
324
+ if (Metrics._visible !== shown) {
325
+ Metrics._visible = shown;
326
+ for (var i = 0; i < Metrics._visibilityCallbacks.length; i++) {
327
+ try { Metrics._visibilityCallbacks[i].fn(shown); } catch (e) {}
328
+ }
329
+ }
330
+ }, 'Metrics');
331
+ }
332
+
333
+ // Initialize NavigationTracker if configured
334
+ var stConfig = Q.getObject('Metrics.navigationTracker', Q.plugins)
335
+ || Q.getObject('Metrics.navigationTracker', Q);
336
+ if (stConfig && Metrics.NavigationTracker) {
337
+ stConfig.page = stConfig.page || Q.info.url || document.title;
338
+ Metrics.NavigationTracker.init(stConfig);
339
+ }
340
+
341
+ // Initialize MediaTracker if configured
342
+ var mtConfig = Q.getObject('Metrics.mediaTracker', Q.plugins)
343
+ || Q.getObject('Metrics.mediaTracker', Q);
344
+ if (mtConfig && Metrics.MediaTracker) {
345
+ Metrics.MediaTracker.init(mtConfig);
346
+ }
347
+
348
+ // ── Auto-wire into Q tools for navigation tracking ──
349
+ var NT = Metrics.NavigationTracker;
350
+ if (NT) {
351
+ // Q/tabs — track tab switches (debounced to avoid rapid fire)
352
+ var _tabDebounce = null;
353
+ Q.Tool.onActivate('Q/tabs').set(function () {
354
+ var tabsTool = this;
355
+ tabsTool.state.onCurrent.set(function (tab, tabName) {
356
+ clearTimeout(_tabDebounce);
357
+ var _name = tabName;
358
+ _tabDebounce = setTimeout(function () {
359
+ if (_name && NT.state.initialized) {
360
+ NT.opened('tab:' + _name);
361
+ }
362
+ }, 300);
363
+ }, 'Metrics.NavigationTracker');
364
+ }, 'Metrics.NavigationTracker');
365
+
366
+ // Q/columns — track column open/close
367
+ Q.Tool.onActivate('Q/columns').set(function () {
368
+ var columnsTool = this;
369
+ columnsTool.state.onActivate.set(function (div, options, index) {
370
+ if (!NT.state.initialized) return;
371
+ var name = (div && div.getAttribute('data-name')) || ('column-' + index);
372
+ NT.opened('column:' + name);
373
+ }, 'Metrics.NavigationTracker');
374
+ columnsTool.state.onClose.set(function (index, div) {
375
+ if (!NT.state.initialized) return;
376
+ var name = (div && div.getAttribute('data-name')) || ('column-' + index);
377
+ NT.closed('column:' + name);
378
+ }, 'Metrics.NavigationTracker');
379
+ }, 'Metrics.NavigationTracker');
380
+
381
+ // Q/expandable — track expand/collapse
382
+ Q.Tool.onActivate('Q/expandable').set(function () {
383
+ var expTool = this;
384
+ var expId = expTool.element.id
385
+ || expTool.element.getAttribute('data-name')
386
+ || expTool.id;
387
+ expTool.state.onExpand.set(function () {
388
+ if (!NT.state.initialized) return;
389
+ NT.opened('expandable:' + expId);
390
+ }, 'Metrics.NavigationTracker');
391
+ expTool.state.onCollapse.set(function () {
392
+ if (!NT.state.initialized) return;
393
+ NT.closed('expandable:' + expId);
394
+ }, 'Metrics.NavigationTracker');
395
+ }, 'Metrics.NavigationTracker');
396
+
397
+ // Q.Contextual — track contextual menu show/hide/item selection
398
+ if (Q.Contextual) {
399
+ Q.Contextual.onShow.set(function (contextual) {
400
+ if (!NT.state.initialized) return;
401
+ var $ctx = $(contextual);
402
+ var $trigger = $ctx.data('Q/contextual trigger');
403
+ var ctxId = ($trigger && $trigger.attr('data-name'))
404
+ || ($trigger && $trigger.attr('id'))
405
+ || 'contextual-' + Q.Contextual.current;
406
+ NT.opened('contextual:' + ctxId);
407
+
408
+ // Track links/items scrolling into view inside the contextual
409
+ var listing = contextual.querySelector
410
+ ? contextual.querySelector('.Q_listing_wrapper, .Q_listing')
411
+ : null;
412
+ if (listing) {
413
+ NT.observeNavContainer(listing);
414
+ }
415
+ }, 'Metrics.NavigationTracker');
416
+
417
+ Q.Contextual.onHide.set(function (contextual) {
418
+ if (!NT.state.initialized) return;
419
+ if (NT.state.activeSection
420
+ && NT.state.activeSection.indexOf('contextual:') === 0) {
421
+ NT.closed(NT.state.activeSection);
422
+ }
423
+ }, 'Metrics.NavigationTracker');
424
+
425
+ // Intercept contextual item selection
426
+ var _origItemHandler = Q.Contextual.itemSelectHandler;
427
+ if (_origItemHandler) {
428
+ Q.Contextual.itemSelectHandler = function (element, event) {
429
+ if (NT.state.initialized) {
430
+ var action = element.getAttribute('data-action')
431
+ || element.getAttribute('data-name')
432
+ || (element.textContent || '').trim().slice(0, 40);
433
+ Metrics.send('contextual-item:' + action);
434
+ }
435
+ return _origItemHandler.apply(this, arguments);
436
+ };
437
+ }
438
+ }
439
+ }
440
+
441
+ // Visit chaining — look for a parent visitId in the hash
442
+ var parentVisitId = location.hash.queryField('v');
443
+ if (!parentVisitId) {
444
+ return;
445
+ }
446
+ ensureVisitInHash(parentVisitId);
447
+
448
+ Q.req('Metrics/landed', {
449
+ method: 'POST',
450
+ fields: { trackerId: 'visitId:' + parentVisitId }
451
+ }, function (err, res) {
452
+ if (err) {
453
+ if (window.console) {
454
+ console.error('Metrics landed request failed', err);
455
+ }
456
+ return;
457
+ }
458
+ if (res && res.slots && res.slots.visitId) {
459
+ ensureVisitInHash(res.slots.visitId);
460
+ }
461
+ });
462
+ }, 'Metrics');
463
+
464
+ // Error telemetry
465
+ (function () {
466
+ function sendErrorTelemetry(errorInfo) {
467
+ var payload = JSON.stringify({ error: errorInfo });
468
+ Q.req('Metrics/update', [], null, {
469
+ method: 'POST',
470
+ fields: {
471
+ navigatorUrl: location.href,
472
+ url: Q.info && Q.info.url,
473
+ state: 'error',
474
+ extra: payload
475
+ },
476
+ keepalive: true
477
+ });
478
+ }
479
+
480
+ function formatErrorPayload(message, stack, details) {
481
+ var payload = {
482
+ message: message || '',
483
+ stack: stack || '',
484
+ url: location.href,
485
+ userAgent: navigator.userAgent,
486
+ timestamp: Date.now(),
487
+ performanceNow: performance.now()
488
+ };
489
+ if (details) {
490
+ payload.details = details;
491
+ }
492
+ return payload;
493
+ }
494
+
495
+ function handleError(reason, isRejection) {
496
+ var message = '';
497
+ var stack = '';
498
+ var details;
499
+
500
+ if (reason instanceof Error) {
501
+ message = reason.message;
502
+ stack = reason.stack;
503
+ } else if (typeof reason === 'string') {
504
+ message = reason;
505
+ } else if (reason && typeof reason === 'object') {
506
+ try {
507
+ details = JSON.stringify(reason);
508
+ } catch (e) {
509
+ details = '[unserializable reason]';
510
+ }
511
+ }
512
+
513
+ var errorInfo = formatErrorPayload(message, stack, details);
514
+ sendErrorTelemetry(errorInfo);
515
+ console.warn(isRejection ? 'Unhandled rejection:' : 'Unhandled error:', reason);
516
+
517
+ if (message && /indexedDB/i.test(message)) {
518
+ console.warn('[Recovery] Error suggests IndexedDB corruption. Triggering recovery...');
519
+ }
520
+ }
521
+
522
+ window.addEventListener('unhandledrejection', function (event) {
523
+ handleError(event.reason, true);
524
+ });
525
+
526
+ window.addEventListener('error', function (event) {
527
+ handleError(event.error || event.message, false);
528
+ });
529
+ })();
530
+
531
+ })(Q);
532
+ }
533
+
534
+ /**
535
+ * Metrics.ScrollTracker — Section-aware scroll telemetry
536
+ *
537
+ * Tracks which sections a user reads, how far they scroll,
538
+ * and what they click. Delegates transport, session, visibility,
539
+ * and unload handling to the core Metrics object.
540
+ *
541
+ * Usage (standalone):
542
+ * Metrics.init({ endpoint: '/telemetry.php', page: 'My Page' });
543
+ * Metrics.ScrollTracker.init({
544
+ * sections: 'h2[id], h3[id]',
545
+ * debounce: 1000
546
+ * });
547
+ *
548
+ * Usage (with Q framework):
549
+ * // Auto-initializes from config if endpoint is set
550
+ *
551
+ * @module Metrics
552
+ * @class Metrics.ScrollTracker
553
+ */
554
+ "use strict";
555
+ (function (root) {
556
+
557
+ var Metrics = root.Metrics;
558
+ if (!Metrics) {
559
+ console.warn('Metrics.ScrollTracker: Metrics core not loaded');
560
+ return;
561
+ }
562
+
563
+ var defaults = {
564
+ // CSS selector for sections to track
565
+ sections: 'h2[id], h3[id], section[id], [data-section]',
566
+
567
+ // Minimum pixel height for auto-detected containers
568
+ minSectionHeight: 100,
569
+
570
+ // Milliseconds to wait after scroll stops before firing
571
+ debounce: 1000,
572
+
573
+ // Milliseconds to wait on page load (ignores scroll restoration)
574
+ initDelay: 800,
575
+
576
+ // Milliseconds to suppress tracking after anchor click
577
+ anchorCooldown: 1500,
578
+
579
+ // Scroll depth milestones (percentage)
580
+ depthMilestones: [25, 50, 75, 100],
581
+
582
+ // Max px above viewport top to consider a section "current"
583
+ sectionLookback: 300,
584
+
585
+ // Scroll considered settled if moved less than this (px)
586
+ settleTolerance: 2,
587
+
588
+ // Recheck interval when not settled (ms)
589
+ recheckInterval: 500,
590
+
591
+ // Track link/anchor clicks
592
+ trackClicks: true,
593
+
594
+ // Visual TOC highlighting selector (real-time, not debounced)
595
+ tocSelector: null,
596
+ tocActiveClass: 'active',
597
+ tocSectionSelector: 'h2[id]'
598
+ };
599
+
600
+ // ── State ──
601
+ var state = {
602
+ initialized: false,
603
+ options: null,
604
+ sections: [],
605
+ tocSections: [],
606
+ seen: {},
607
+ depthHit: {},
608
+ scrollTimer: null,
609
+ anchorCooling: false,
610
+ _prevY: -1
611
+ };
612
+
613
+ // ── Section Discovery ──
614
+
615
+ function discoverSections(selector) {
616
+ var elements = document.querySelectorAll(selector);
617
+ var result = [];
618
+ var ordinal = 0;
619
+ var minH = (state.options && state.options.minSectionHeight) || 0;
620
+
621
+ for (var i = 0; i < elements.length; i++) {
622
+ var el = elements[i];
623
+ if (el.offsetHeight < minH) continue;
624
+
625
+ if (!el.id) {
626
+ var text = (el.textContent || '').trim().slice(0, 60);
627
+ var slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
628
+ el.id = slug || ('section-' + ordinal);
629
+ }
630
+
631
+ result.push({
632
+ el: el,
633
+ id: el.id,
634
+ tag: el.tagName.toLowerCase(),
635
+ ordinal: ordinal++,
636
+ snippet: (el.textContent || '').trim().slice(0, 80)
637
+ });
638
+ }
639
+ return result;
640
+ }
641
+
642
+ // ── Find Current Section ──
643
+
644
+ function findCurrentSection() {
645
+ var scrollY = window.scrollY || window.pageYOffset;
646
+ var opts = state.options;
647
+ var best = null;
648
+ var bestDist = Infinity;
649
+
650
+ for (var i = 0; i < state.sections.length; i++) {
651
+ var sec = state.sections[i];
652
+ var top = sec.el.offsetTop;
653
+ if (top <= scrollY + opts.sectionLookback) {
654
+ var dist = Math.abs(top - scrollY - 180);
655
+ if (dist < bestDist) {
656
+ bestDist = dist;
657
+ best = sec;
658
+ }
659
+ }
660
+ }
661
+ return best;
662
+ }
663
+
664
+ // ── Scroll Settle Detection ──
665
+
666
+ function onScrollSettle() {
667
+ var opts = state.options;
668
+ var curY = window.scrollY || window.pageYOffset;
669
+
670
+ if (Math.abs(curY - state._prevY) > opts.settleTolerance) {
671
+ state._prevY = curY;
672
+ state.scrollTimer = setTimeout(onScrollSettle, opts.recheckInterval);
673
+ return;
674
+ }
675
+
676
+ // Settled — report section
677
+ var current = findCurrentSection();
678
+ if (current && !state.seen[current.id]) {
679
+ state.seen[current.id] = true;
680
+ Metrics.send('section:' + current.id, {
681
+ tag: current.tag,
682
+ ordinal: current.ordinal,
683
+ snippet: current.snippet
684
+ });
685
+ }
686
+
687
+ // Report depth
688
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
689
+ if (docH > 0) {
690
+ var pct = Math.round((curY / docH) * 100);
691
+ for (var j = 0; j < opts.depthMilestones.length; j++) {
692
+ var m = opts.depthMilestones[j];
693
+ if (pct >= m && !state.depthHit[m]) {
694
+ state.depthHit[m] = true;
695
+ Metrics.send('depth:' + m + '%');
696
+ }
697
+ }
698
+ }
699
+ }
700
+
701
+ function onScroll() {
702
+ if (state.anchorCooling) return;
703
+ clearTimeout(state.scrollTimer);
704
+ state._prevY = window.scrollY || window.pageYOffset;
705
+ state.scrollTimer = setTimeout(onScrollSettle, state.options.debounce);
706
+ }
707
+
708
+ // ── TOC Highlighting (real-time) ──
709
+
710
+ function updateTocHighlight() {
711
+ var opts = state.options;
712
+ if (!opts.tocSelector) return;
713
+
714
+ var scrollY = window.scrollY || window.pageYOffset;
715
+ var currentId = '';
716
+
717
+ for (var i = 0; i < state.tocSections.length; i++) {
718
+ if (scrollY >= state.tocSections[i].el.offsetTop - 160) {
719
+ currentId = state.tocSections[i].id;
720
+ }
721
+ }
722
+
723
+ var links = document.querySelectorAll(opts.tocSelector);
724
+ for (var j = 0; j < links.length; j++) {
725
+ links[j].classList.remove(opts.tocActiveClass);
726
+ if (links[j].getAttribute('href') === '#' + currentId) {
727
+ links[j].classList.add(opts.tocActiveClass);
728
+ }
729
+ }
730
+ }
731
+
732
+ // ── Click Tracking ──
733
+
734
+ function onDocumentClick(e) {
735
+ var a = e.target.closest('a[href]');
736
+ if (!a) return;
737
+
738
+ var href = a.getAttribute('href') || '';
739
+
740
+ if (href.charAt(0) === '#') {
741
+ // Anchor click — cooldown to suppress scroll tracking
742
+ state.anchorCooling = true;
743
+ setTimeout(function () { state.anchorCooling = false; },
744
+ state.options.anchorCooldown);
745
+ Metrics.send('anchor:' + href.slice(1));
746
+ return;
747
+ }
748
+
749
+ // External link
750
+ var label = a.dataset.track || 'link:' + href;
751
+ Metrics.send(label);
752
+ }
753
+
754
+ // ── Pre-mark Initial State ──
755
+
756
+ function premarkInitialState() {
757
+ var scrollY = window.scrollY || window.pageYOffset;
758
+
759
+ for (var i = 0; i < state.sections.length; i++) {
760
+ var rect = state.sections[i].el.getBoundingClientRect();
761
+ if (rect.top >= -100 && rect.top < window.innerHeight) {
762
+ state.seen[state.sections[i].id] = true;
763
+ }
764
+ }
765
+
766
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
767
+ if (docH > 0) {
768
+ var pct = Math.round((scrollY / docH) * 100);
769
+ for (var j = 0; j < state.options.depthMilestones.length; j++) {
770
+ var m = state.options.depthMilestones[j];
771
+ if (pct >= m) state.depthHit[m] = true;
772
+ }
773
+ }
774
+ }
775
+
776
+ // ── Public API ──
777
+
778
+ Metrics.ScrollTracker = {
779
+
780
+ init: function (options) {
781
+ if (state.initialized) {
782
+ console.warn('Metrics.ScrollTracker already initialized');
783
+ return this;
784
+ }
785
+
786
+ var opts = {};
787
+ var k;
788
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
789
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
790
+ state.options = opts;
791
+ state.initialized = true;
792
+
793
+ // If Metrics core hasn't been initialized yet with an endpoint,
794
+ // initialize it now from our options
795
+ if (!Metrics._endpoint && options && options.endpoint) {
796
+ Metrics.init({
797
+ endpoint: options.endpoint,
798
+ page: options.page,
799
+ sessionKey: options.sessionKey,
800
+ sessionId: options.sessionId,
801
+ extra: options.extra,
802
+ trackUnload: options.trackUnload
803
+ });
804
+ }
805
+
806
+ // Delayed init — let scroll restoration settle
807
+ setTimeout(function () {
808
+ state.sections = discoverSections(opts.sections);
809
+
810
+ if (opts.tocSelector && opts.tocSectionSelector) {
811
+ state.tocSections = discoverSections(opts.tocSectionSelector);
812
+ }
813
+
814
+ premarkInitialState();
815
+
816
+ window.addEventListener('scroll', onScroll, { passive: true });
817
+
818
+ if (opts.tocSelector) {
819
+ window.addEventListener('scroll', updateTocHighlight, { passive: true });
820
+ updateTocHighlight();
821
+ }
822
+ }, opts.initDelay);
823
+
824
+ if (opts.trackClicks) {
825
+ document.addEventListener('click', onDocumentClick);
826
+ }
827
+
828
+ return this;
829
+ },
830
+
831
+ send: function (label, data) { Metrics.send(label, data); },
832
+ markSeen: function (id) { state.seen[id] = true; },
833
+ getSessionId: function () { return Metrics.getSessionId(); },
834
+ getSections: function () {
835
+ return state.sections.map(function (s) {
836
+ return { id: s.id, tag: s.tag, ordinal: s.ordinal, snippet: s.snippet };
837
+ });
838
+ },
839
+ getSeen: function () {
840
+ var copy = {};
841
+ for (var k in state.seen) copy[k] = true;
842
+ return copy;
843
+ },
844
+ reset: function () {
845
+ state.seen = {};
846
+ state.depthHit = {};
847
+ state.anchorCooling = false;
848
+ clearTimeout(state.scrollTimer);
849
+ state.sections = discoverSections(state.options.sections);
850
+ premarkInitialState();
851
+ },
852
+ destroy: function () {
853
+ window.removeEventListener('scroll', onScroll);
854
+ window.removeEventListener('scroll', updateTocHighlight);
855
+ document.removeEventListener('click', onDocumentClick);
856
+ clearTimeout(state.scrollTimer);
857
+ state.initialized = false;
858
+ },
859
+
860
+ defaults: defaults,
861
+ state: state
862
+ };
863
+
864
+ })(typeof window !== 'undefined' ? window : this);
865
+
866
+ /**
867
+ * Metrics.ScrollTracker — Section-aware scroll telemetry
868
+ *
869
+ * Tracks which sections a user reads, how far they scroll,
870
+ * and what they click. Delegates transport, session, visibility,
871
+ * and unload handling to the core Metrics object.
872
+ *
873
+ * Usage (standalone):
874
+ * Metrics.init({ endpoint: '/telemetry.php', page: 'My Page' });
875
+ * Metrics.ScrollTracker.init({
876
+ * sections: 'h2[id], h3[id]',
877
+ * debounce: 1000
878
+ * });
879
+ *
880
+ * Usage (with Q framework):
881
+ * // Auto-initializes from config if endpoint is set
882
+ *
883
+ * @module Metrics
884
+ * @class Metrics.ScrollTracker
885
+ */
886
+ "use strict";
887
+ (function (root) {
888
+
889
+ var Metrics = root.Metrics;
890
+ if (!Metrics) {
891
+ console.warn('Metrics.ScrollTracker: Metrics core not loaded');
892
+ return;
893
+ }
894
+
895
+ var defaults = {
896
+ // CSS selector for sections to track
897
+ sections: 'h2[id], h3[id], section[id], [data-section]',
898
+
899
+ // Minimum pixel height for auto-detected containers
900
+ minSectionHeight: 100,
901
+
902
+ // Milliseconds to wait after scroll stops before firing
903
+ debounce: 1000,
904
+
905
+ // Milliseconds to wait on page load (ignores scroll restoration)
906
+ initDelay: 800,
907
+
908
+ // Milliseconds to suppress tracking after anchor click
909
+ anchorCooldown: 1500,
910
+
911
+ // Scroll depth milestones (percentage)
912
+ depthMilestones: [25, 50, 75, 100],
913
+
914
+ // Max px above viewport top to consider a section "current"
915
+ sectionLookback: 300,
916
+
917
+ // Scroll considered settled if moved less than this (px)
918
+ settleTolerance: 2,
919
+
920
+ // Recheck interval when not settled (ms)
921
+ recheckInterval: 500,
922
+
923
+ // Track link/anchor clicks
924
+ trackClicks: true,
925
+
926
+ // Visual TOC highlighting selector (real-time, not debounced)
927
+ tocSelector: null,
928
+ tocActiveClass: 'active',
929
+ tocSectionSelector: 'h2[id]'
930
+ };
931
+
932
+ // ── State ──
933
+ var state = {
934
+ initialized: false,
935
+ options: null,
936
+ sections: [],
937
+ tocSections: [],
938
+ seen: {},
939
+ depthHit: {},
940
+ scrollTimer: null,
941
+ anchorCooling: false,
942
+ _prevY: -1
943
+ };
944
+
945
+ // ── Section Discovery ──
946
+
947
+ function discoverSections(selector) {
948
+ var elements = document.querySelectorAll(selector);
949
+ var result = [];
950
+ var ordinal = 0;
951
+ var minH = (state.options && state.options.minSectionHeight) || 0;
952
+
953
+ for (var i = 0; i < elements.length; i++) {
954
+ var el = elements[i];
955
+ if (el.offsetHeight < minH) continue;
956
+
957
+ if (!el.id) {
958
+ var text = (el.textContent || '').trim().slice(0, 60);
959
+ var slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
960
+ el.id = slug || ('section-' + ordinal);
961
+ }
962
+
963
+ result.push({
964
+ el: el,
965
+ id: el.id,
966
+ tag: el.tagName.toLowerCase(),
967
+ ordinal: ordinal++,
968
+ snippet: (el.textContent || '').trim().slice(0, 80)
969
+ });
970
+ }
971
+ return result;
972
+ }
973
+
974
+ // ── Find Current Section ──
975
+
976
+ function findCurrentSection() {
977
+ var scrollY = window.scrollY || window.pageYOffset;
978
+ var opts = state.options;
979
+ var best = null;
980
+ var bestDist = Infinity;
981
+
982
+ for (var i = 0; i < state.sections.length; i++) {
983
+ var sec = state.sections[i];
984
+ var top = sec.el.offsetTop;
985
+ if (top <= scrollY + opts.sectionLookback) {
986
+ var dist = Math.abs(top - scrollY - 180);
987
+ if (dist < bestDist) {
988
+ bestDist = dist;
989
+ best = sec;
990
+ }
991
+ }
992
+ }
993
+ return best;
994
+ }
995
+
996
+ // ── Scroll Settle Detection ──
997
+
998
+ function onScrollSettle() {
999
+ var opts = state.options;
1000
+ var curY = window.scrollY || window.pageYOffset;
1001
+
1002
+ if (Math.abs(curY - state._prevY) > opts.settleTolerance) {
1003
+ state._prevY = curY;
1004
+ state.scrollTimer = setTimeout(onScrollSettle, opts.recheckInterval);
1005
+ return;
1006
+ }
1007
+
1008
+ // Settled — report section
1009
+ var current = findCurrentSection();
1010
+ if (current && !state.seen[current.id]) {
1011
+ state.seen[current.id] = true;
1012
+ Metrics.send('section:' + current.id, {
1013
+ tag: current.tag,
1014
+ ordinal: current.ordinal,
1015
+ snippet: current.snippet
1016
+ });
1017
+ }
1018
+
1019
+ // Report depth
1020
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
1021
+ if (docH > 0) {
1022
+ var pct = Math.round((curY / docH) * 100);
1023
+ for (var j = 0; j < opts.depthMilestones.length; j++) {
1024
+ var m = opts.depthMilestones[j];
1025
+ if (pct >= m && !state.depthHit[m]) {
1026
+ state.depthHit[m] = true;
1027
+ Metrics.send('depth:' + m + '%');
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ function onScroll() {
1034
+ if (state.anchorCooling) return;
1035
+ clearTimeout(state.scrollTimer);
1036
+ state._prevY = window.scrollY || window.pageYOffset;
1037
+ state.scrollTimer = setTimeout(onScrollSettle, state.options.debounce);
1038
+ }
1039
+
1040
+ // ── TOC Highlighting (real-time) ──
1041
+
1042
+ function updateTocHighlight() {
1043
+ var opts = state.options;
1044
+ if (!opts.tocSelector) return;
1045
+
1046
+ var scrollY = window.scrollY || window.pageYOffset;
1047
+ var currentId = '';
1048
+
1049
+ for (var i = 0; i < state.tocSections.length; i++) {
1050
+ if (scrollY >= state.tocSections[i].el.offsetTop - 160) {
1051
+ currentId = state.tocSections[i].id;
1052
+ }
1053
+ }
1054
+
1055
+ var links = document.querySelectorAll(opts.tocSelector);
1056
+ for (var j = 0; j < links.length; j++) {
1057
+ links[j].classList.remove(opts.tocActiveClass);
1058
+ if (links[j].getAttribute('href') === '#' + currentId) {
1059
+ links[j].classList.add(opts.tocActiveClass);
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ // ── Click Tracking ──
1065
+
1066
+ function onDocumentClick(e) {
1067
+ var a = e.target.closest('a[href]');
1068
+ if (!a) return;
1069
+
1070
+ var href = a.getAttribute('href') || '';
1071
+
1072
+ if (href.charAt(0) === '#') {
1073
+ // Anchor click — cooldown to suppress scroll tracking
1074
+ state.anchorCooling = true;
1075
+ setTimeout(function () { state.anchorCooling = false; },
1076
+ state.options.anchorCooldown);
1077
+ Metrics.send('anchor:' + href.slice(1));
1078
+ return;
1079
+ }
1080
+
1081
+ // External link
1082
+ var label = a.dataset.track || 'link:' + href;
1083
+ Metrics.send(label);
1084
+ }
1085
+
1086
+ // ── Pre-mark Initial State ──
1087
+
1088
+ function premarkInitialState() {
1089
+ var scrollY = window.scrollY || window.pageYOffset;
1090
+
1091
+ for (var i = 0; i < state.sections.length; i++) {
1092
+ var rect = state.sections[i].el.getBoundingClientRect();
1093
+ if (rect.top >= -100 && rect.top < window.innerHeight) {
1094
+ state.seen[state.sections[i].id] = true;
1095
+ }
1096
+ }
1097
+
1098
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
1099
+ if (docH > 0) {
1100
+ var pct = Math.round((scrollY / docH) * 100);
1101
+ for (var j = 0; j < state.options.depthMilestones.length; j++) {
1102
+ var m = state.options.depthMilestones[j];
1103
+ if (pct >= m) state.depthHit[m] = true;
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ // ── Public API ──
1109
+
1110
+ Metrics.ScrollTracker = {
1111
+
1112
+ init: function (options) {
1113
+ if (state.initialized) {
1114
+ console.warn('Metrics.ScrollTracker already initialized');
1115
+ return this;
1116
+ }
1117
+
1118
+ var opts = {};
1119
+ var k;
1120
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
1121
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
1122
+ state.options = opts;
1123
+ state.initialized = true;
1124
+
1125
+ // If Metrics core hasn't been initialized yet with an endpoint,
1126
+ // initialize it now from our options
1127
+ if (!Metrics._endpoint && options && options.endpoint) {
1128
+ Metrics.init({
1129
+ endpoint: options.endpoint,
1130
+ page: options.page,
1131
+ sessionKey: options.sessionKey,
1132
+ sessionId: options.sessionId,
1133
+ extra: options.extra,
1134
+ trackUnload: options.trackUnload
1135
+ });
1136
+ }
1137
+
1138
+ // Delayed init — let scroll restoration settle
1139
+ setTimeout(function () {
1140
+ state.sections = discoverSections(opts.sections);
1141
+
1142
+ if (opts.tocSelector && opts.tocSectionSelector) {
1143
+ state.tocSections = discoverSections(opts.tocSectionSelector);
1144
+ }
1145
+
1146
+ premarkInitialState();
1147
+
1148
+ window.addEventListener('scroll', onScroll, { passive: true });
1149
+
1150
+ if (opts.tocSelector) {
1151
+ window.addEventListener('scroll', updateTocHighlight, { passive: true });
1152
+ updateTocHighlight();
1153
+ }
1154
+ }, opts.initDelay);
1155
+
1156
+ if (opts.trackClicks) {
1157
+ document.addEventListener('click', onDocumentClick);
1158
+ }
1159
+
1160
+ return this;
1161
+ },
1162
+
1163
+ send: function (label, data) { Metrics.send(label, data); },
1164
+ markSeen: function (id) { state.seen[id] = true; },
1165
+ getSessionId: function () { return Metrics.getSessionId(); },
1166
+ getSections: function () {
1167
+ return state.sections.map(function (s) {
1168
+ return { id: s.id, tag: s.tag, ordinal: s.ordinal, snippet: s.snippet };
1169
+ });
1170
+ },
1171
+ getSeen: function () {
1172
+ var copy = {};
1173
+ for (var k in state.seen) copy[k] = true;
1174
+ return copy;
1175
+ },
1176
+ reset: function () {
1177
+ state.seen = {};
1178
+ state.depthHit = {};
1179
+ state.anchorCooling = false;
1180
+ clearTimeout(state.scrollTimer);
1181
+ state.sections = discoverSections(state.options.sections);
1182
+ premarkInitialState();
1183
+ },
1184
+ destroy: function () {
1185
+ window.removeEventListener('scroll', onScroll);
1186
+ window.removeEventListener('scroll', updateTocHighlight);
1187
+ document.removeEventListener('click', onDocumentClick);
1188
+ clearTimeout(state.scrollTimer);
1189
+ state.initialized = false;
1190
+ },
1191
+
1192
+ defaults: defaults,
1193
+ state: state
1194
+ };
1195
+
1196
+ })(typeof window !== 'undefined' ? window : this);
1197
+
1198
+ /**
1199
+ * Metrics.NavigationTracker — Track how users navigate and explore content
1200
+ *
1201
+ * Tracks scroll-based sections, dynamic panels (tabs, columns, expandables,
1202
+ * contextual menus), and links that scroll into view in navigation containers.
1203
+ * Delegates transport, session, visibility, and unload to Metrics core.
1204
+ *
1205
+ * Event prefixes:
1206
+ * section:id — scroll-based section reached viewport
1207
+ * depth:N% — scroll depth milestone
1208
+ * anchor:id — clicked an anchor link
1209
+ * link:url — clicked an external link
1210
+ * tab:name — tab switched to
1211
+ * column:name — column opened/closed
1212
+ * expandable:id — expandable opened/closed
1213
+ * contextual:id — contextual menu shown/hidden
1214
+ * contextual-item:action — item selected from contextual menu
1215
+ * nav-link:id — link scrolled into view in a nav container
1216
+ * opened:prefix:id — first time viewing a dynamic section
1217
+ * switched:prefix:id — returned to previously viewed section
1218
+ * dwell:prefix:id — time spent in a dynamic section
1219
+ *
1220
+ * @module Metrics
1221
+ * @class Metrics.NavigationTracker
1222
+ */
1223
+ "use strict";
1224
+ (function (root) {
1225
+
1226
+ var Metrics = root.Metrics;
1227
+ if (!Metrics) {
1228
+ console.warn('Metrics.NavigationTracker: Metrics core not loaded');
1229
+ return;
1230
+ }
1231
+
1232
+ var defaults = {
1233
+ // ── Scroll-based section tracking ──
1234
+ sections: 'h2[id], h3[id], section[id], [data-section]',
1235
+ minSectionHeight: 100,
1236
+ debounce: 1000,
1237
+ initDelay: 800,
1238
+ anchorCooldown: 1500,
1239
+ depthMilestones: [25, 50, 75, 100],
1240
+ sectionLookback: 300,
1241
+ settleTolerance: 2,
1242
+ recheckInterval: 500,
1243
+
1244
+ // ── Dynamic section tracking ──
1245
+ observeDom: false,
1246
+ observeRoot: null,
1247
+ observeSelector: '[data-section], [data-track-section]',
1248
+ trackDwell: true,
1249
+
1250
+ // ── Navigation container link tracking ──
1251
+ // CSS selector for scrollable nav containers whose links should be
1252
+ // tracked as they scroll into view
1253
+ navContainers: null, // e.g. '.Q_listing_wrapper, .sidebar-nav'
1254
+
1255
+ // ── Contextual menu tracking ──
1256
+ trackContextuals: true,
1257
+
1258
+ // ── Click & TOC ──
1259
+ trackClicks: true,
1260
+ tocSelector: null,
1261
+ tocActiveClass: 'active',
1262
+ tocSectionSelector: 'h2[id]'
1263
+ };
1264
+
1265
+ // ── State ──
1266
+ var state = {
1267
+ initialized: false,
1268
+ options: null,
1269
+ sections: [],
1270
+ tocSections: [],
1271
+ seen: {},
1272
+ depthHit: {},
1273
+ scrollTimer: null,
1274
+ anchorCooling: false,
1275
+ _prevY: -1,
1276
+ // Dynamic sections
1277
+ dynamicSections: {},
1278
+ activeSection: null,
1279
+ activeSince: null,
1280
+ // DOM observer
1281
+ _mutationObserver: null,
1282
+ // Nav container observers
1283
+ _navObservers: [],
1284
+ _navLinksSeen: {}
1285
+ };
1286
+
1287
+ // ── Utilities ──
1288
+
1289
+ function slugify(text) {
1290
+ return text.toLowerCase()
1291
+ .replace(/[^a-z0-9]+/g, '-')
1292
+ .replace(/^-|-$/g, '')
1293
+ .slice(0, 60);
1294
+ }
1295
+
1296
+ function snippet(el) {
1297
+ return (el.textContent || '').trim().slice(0, 80);
1298
+ }
1299
+
1300
+ function now() { return Date.now(); }
1301
+
1302
+ // ── Section Discovery ──
1303
+
1304
+ function discoverSections(selector) {
1305
+ var elements = document.querySelectorAll(selector);
1306
+ var result = [];
1307
+ var ordinal = 0;
1308
+ var minH = (state.options && state.options.minSectionHeight) || 0;
1309
+ for (var i = 0; i < elements.length; i++) {
1310
+ var el = elements[i];
1311
+ if (el.offsetHeight < minH) continue;
1312
+ if (!el.id) {
1313
+ el.id = slugify(el.textContent || '') || ('section-' + ordinal);
1314
+ }
1315
+ result.push({
1316
+ el: el, id: el.id,
1317
+ tag: el.tagName.toLowerCase(),
1318
+ ordinal: ordinal++,
1319
+ snippet: snippet(el)
1320
+ });
1321
+ }
1322
+ return result;
1323
+ }
1324
+
1325
+ // ── Find Current Section ──
1326
+
1327
+ function findCurrentSection() {
1328
+ var scrollY = window.scrollY || window.pageYOffset;
1329
+ var opts = state.options;
1330
+ var best = null, bestDist = Infinity;
1331
+ for (var i = 0; i < state.sections.length; i++) {
1332
+ var sec = state.sections[i];
1333
+ var top = sec.el.offsetTop;
1334
+ if (top <= scrollY + opts.sectionLookback) {
1335
+ var dist = Math.abs(top - scrollY - 180);
1336
+ if (dist < bestDist) { bestDist = dist; best = sec; }
1337
+ }
1338
+ }
1339
+ return best;
1340
+ }
1341
+
1342
+ // ── Scroll Settle ──
1343
+
1344
+ function onScrollSettle() {
1345
+ var opts = state.options;
1346
+ var curY = window.scrollY || window.pageYOffset;
1347
+ if (Math.abs(curY - state._prevY) > opts.settleTolerance) {
1348
+ state._prevY = curY;
1349
+ state.scrollTimer = setTimeout(onScrollSettle, opts.recheckInterval);
1350
+ return;
1351
+ }
1352
+ var current = findCurrentSection();
1353
+ if (current && !state.seen[current.id]) {
1354
+ state.seen[current.id] = true;
1355
+ Metrics.send('section:' + current.id, {
1356
+ tag: current.tag, ordinal: current.ordinal, snippet: current.snippet
1357
+ });
1358
+ }
1359
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
1360
+ if (docH > 0) {
1361
+ var pct = Math.round((curY / docH) * 100);
1362
+ for (var j = 0; j < opts.depthMilestones.length; j++) {
1363
+ var m = opts.depthMilestones[j];
1364
+ if (pct >= m && !state.depthHit[m]) {
1365
+ state.depthHit[m] = true;
1366
+ Metrics.send('depth:' + m + '%');
1367
+ }
1368
+ }
1369
+ }
1370
+ }
1371
+
1372
+ function onScroll() {
1373
+ if (state.anchorCooling) return;
1374
+ clearTimeout(state.scrollTimer);
1375
+ state._prevY = window.scrollY || window.pageYOffset;
1376
+ state.scrollTimer = setTimeout(onScrollSettle, state.options.debounce);
1377
+ }
1378
+
1379
+ // ── TOC Highlighting ──
1380
+
1381
+ function updateTocHighlight() {
1382
+ var opts = state.options;
1383
+ if (!opts.tocSelector) return;
1384
+ var scrollY = window.scrollY || window.pageYOffset;
1385
+ var currentId = '';
1386
+ for (var i = 0; i < state.tocSections.length; i++) {
1387
+ if (scrollY >= state.tocSections[i].el.offsetTop - 160) {
1388
+ currentId = state.tocSections[i].id;
1389
+ }
1390
+ }
1391
+ var links = document.querySelectorAll(opts.tocSelector);
1392
+ for (var j = 0; j < links.length; j++) {
1393
+ links[j].classList.remove(opts.tocActiveClass);
1394
+ if (links[j].getAttribute('href') === '#' + currentId) {
1395
+ links[j].classList.add(opts.tocActiveClass);
1396
+ }
1397
+ }
1398
+ }
1399
+
1400
+ // ── Click Tracking ──
1401
+
1402
+ function onDocumentClick(e) {
1403
+ var a = e.target.closest('a[href]');
1404
+ if (!a) return;
1405
+ var href = a.getAttribute('href') || '';
1406
+ if (href.charAt(0) === '#') {
1407
+ state.anchorCooling = true;
1408
+ setTimeout(function () { state.anchorCooling = false; },
1409
+ state.options.anchorCooldown);
1410
+ Metrics.send('anchor:' + href.slice(1));
1411
+ return;
1412
+ }
1413
+ var label = a.dataset.track || 'link:' + href;
1414
+ Metrics.send(label);
1415
+ }
1416
+
1417
+ // ── Dynamic Section Tracking ──
1418
+
1419
+ function _closeCurrent() {
1420
+ if (!state.activeSection || !state.options.trackDwell) return;
1421
+ var elapsed = Math.round((now() - state.activeSince) / 1000);
1422
+ if (elapsed > 0) {
1423
+ var sec = state.dynamicSections[state.activeSection];
1424
+ Metrics.send('dwell:' + state.activeSection, {
1425
+ seconds: elapsed,
1426
+ name: sec ? sec.name : state.activeSection
1427
+ });
1428
+ }
1429
+ state.activeSection = null;
1430
+ state.activeSince = null;
1431
+ }
1432
+
1433
+ function _observeElement(el, opts) {
1434
+ var id = opts.id || el.id || el.getAttribute('data-section')
1435
+ || el.getAttribute('data-track-section')
1436
+ || slugify(el.textContent || '') || ('dyn-' + Object.keys(state.dynamicSections).length);
1437
+ if (!el.id) el.id = id;
1438
+ state.dynamicSections[id] = {
1439
+ el: el, id: id,
1440
+ name: opts.name || el.getAttribute('data-section-name') || el.getAttribute('title') || id,
1441
+ snippet: snippet(el), observedAt: now()
1442
+ };
1443
+ if (window.IntersectionObserver && opts.autoTrack !== false) {
1444
+ var io = new IntersectionObserver(function (entries) {
1445
+ entries.forEach(function (entry) {
1446
+ if (entry.isIntersecting && entry.intersectionRatio > 0.3) {
1447
+ NT.opened(id);
1448
+ } else if (!entry.isIntersecting && state.activeSection === id) {
1449
+ NT.closed(id);
1450
+ }
1451
+ });
1452
+ }, { threshold: [0, 0.3] });
1453
+ io.observe(el);
1454
+ state.dynamicSections[id]._io = io;
1455
+ }
1456
+ return id;
1457
+ }
1458
+
1459
+ // ── Nav Container Link Tracking ──
1460
+ // Watches scrollable navigation containers and fires 'nav-link:' events
1461
+ // when links scroll into view (like contextual menu items, sidebar nav, etc.)
1462
+
1463
+ function _setupNavContainerTracking() {
1464
+ var selector = state.options.navContainers;
1465
+ if (!selector || !window.IntersectionObserver) return;
1466
+
1467
+ var containers = document.querySelectorAll(selector);
1468
+ for (var c = 0; c < containers.length; c++) {
1469
+ _observeNavContainer(containers[c]);
1470
+ }
1471
+ }
1472
+
1473
+ function _observeNavContainer(container) {
1474
+ var links = container.querySelectorAll('a[href], li[data-action], li[data-name]');
1475
+ if (!links.length) return;
1476
+
1477
+ var io = new IntersectionObserver(function (entries) {
1478
+ entries.forEach(function (entry) {
1479
+ if (!entry.isIntersecting) return;
1480
+ var el = entry.target;
1481
+ var linkId = el.getAttribute('data-name')
1482
+ || el.getAttribute('data-action')
1483
+ || el.getAttribute('href')
1484
+ || el.textContent.trim().slice(0, 40);
1485
+ var key = 'nav-link:' + linkId;
1486
+ if (!state._navLinksSeen[key]) {
1487
+ state._navLinksSeen[key] = true;
1488
+ Metrics.send(key, { text: el.textContent.trim().slice(0, 60) });
1489
+ }
1490
+ });
1491
+ }, {
1492
+ root: container,
1493
+ threshold: 0.5
1494
+ });
1495
+
1496
+ for (var i = 0; i < links.length; i++) {
1497
+ io.observe(links[i]);
1498
+ }
1499
+ state._navObservers.push(io);
1500
+ }
1501
+
1502
+ // ── DOM Mutation Observer ──
1503
+
1504
+ function _startMutationObserver() {
1505
+ if (!window.MutationObserver || !state.options.observeDom) return;
1506
+ var root = state.options.observeRoot || document.body;
1507
+ var selector = state.options.observeSelector;
1508
+ state._mutationObserver = new MutationObserver(function (mutations) {
1509
+ mutations.forEach(function (mutation) {
1510
+ mutation.addedNodes.forEach(function (node) {
1511
+ if (node.nodeType !== 1) return;
1512
+ if (node.matches && node.matches(selector)) {
1513
+ _observeElement(node, {});
1514
+ }
1515
+ if (node.querySelectorAll) {
1516
+ var matches = node.querySelectorAll(selector);
1517
+ for (var i = 0; i < matches.length; i++) {
1518
+ _observeElement(matches[i], {});
1519
+ }
1520
+ }
1521
+ });
1522
+ });
1523
+ });
1524
+ state._mutationObserver.observe(root, { childList: true, subtree: true });
1525
+ }
1526
+
1527
+ // ── Pre-mark Initial State ──
1528
+
1529
+ function premarkInitialState() {
1530
+ var scrollY = window.scrollY || window.pageYOffset;
1531
+ for (var i = 0; i < state.sections.length; i++) {
1532
+ var rect = state.sections[i].el.getBoundingClientRect();
1533
+ if (rect.top >= -100 && rect.top < window.innerHeight) {
1534
+ state.seen[state.sections[i].id] = true;
1535
+ }
1536
+ }
1537
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
1538
+ if (docH > 0) {
1539
+ var pct = Math.round((scrollY / docH) * 100);
1540
+ for (var j = 0; j < state.options.depthMilestones.length; j++) {
1541
+ var m = state.options.depthMilestones[j];
1542
+ if (pct >= m) state.depthHit[m] = true;
1543
+ }
1544
+ }
1545
+ }
1546
+
1547
+ // ── Public API ──
1548
+
1549
+ var NT = {
1550
+
1551
+ init: function (options) {
1552
+ if (state.initialized) {
1553
+ console.warn('Metrics.NavigationTracker already initialized');
1554
+ return this;
1555
+ }
1556
+ var opts = {};
1557
+ var k;
1558
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
1559
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
1560
+ state.options = opts;
1561
+ state.initialized = true;
1562
+
1563
+ if (!Metrics._endpoint && options && options.endpoint) {
1564
+ Metrics.init({
1565
+ endpoint: options.endpoint, page: options.page,
1566
+ sessionKey: options.sessionKey, sessionId: options.sessionId,
1567
+ extra: options.extra, trackUnload: options.trackUnload
1568
+ });
1569
+ }
1570
+
1571
+ // Delayed init
1572
+ setTimeout(function () {
1573
+ state.sections = discoverSections(opts.sections);
1574
+ if (opts.tocSelector && opts.tocSectionSelector) {
1575
+ state.tocSections = discoverSections(opts.tocSectionSelector);
1576
+ }
1577
+ premarkInitialState();
1578
+ window.addEventListener('scroll', onScroll, { passive: true });
1579
+ if (opts.tocSelector) {
1580
+ window.addEventListener('scroll', updateTocHighlight, { passive: true });
1581
+ updateTocHighlight();
1582
+ }
1583
+ if (opts.observeDom) _startMutationObserver();
1584
+ if (opts.navContainers) _setupNavContainerTracking();
1585
+ }, opts.initDelay);
1586
+
1587
+ if (opts.trackClicks) {
1588
+ document.addEventListener('click', onDocumentClick);
1589
+ }
1590
+
1591
+ if (opts.trackDwell) {
1592
+ Metrics.onVisibilityChange(function (visible) {
1593
+ if (!visible) _closeCurrent();
1594
+ }, 'NavigationTracker.dwell');
1595
+ }
1596
+
1597
+ return this;
1598
+ },
1599
+
1600
+ /**
1601
+ * Observe a dynamic element as a trackable section
1602
+ */
1603
+ observe: function (el, opts) {
1604
+ return _observeElement(el, opts || {});
1605
+ },
1606
+
1607
+ /**
1608
+ * Signal a section/panel/tab/contextual was opened or switched to
1609
+ */
1610
+ opened: function (id) {
1611
+ if (state.activeSection === id) return;
1612
+ _closeCurrent();
1613
+ state.activeSection = id;
1614
+ state.activeSince = now();
1615
+ if (!state.seen[id]) {
1616
+ state.seen[id] = true;
1617
+ var sec = state.dynamicSections[id];
1618
+ Metrics.send('opened:' + id, {
1619
+ name: sec ? sec.name : id,
1620
+ snippet: sec ? sec.snippet : ''
1621
+ });
1622
+ } else {
1623
+ Metrics.send('switched:' + id, {
1624
+ name: (state.dynamicSections[id] || {}).name || id
1625
+ });
1626
+ }
1627
+ },
1628
+
1629
+ /**
1630
+ * Signal a section/panel/tab/contextual was closed
1631
+ */
1632
+ closed: function (id) {
1633
+ if (state.activeSection !== id) return;
1634
+ _closeCurrent();
1635
+ },
1636
+
1637
+ /**
1638
+ * Track a navigation container — observe its links scrolling into view
1639
+ */
1640
+ observeNavContainer: function (container) {
1641
+ _observeNavContainer(container);
1642
+ },
1643
+
1644
+ send: function (label, data) { Metrics.send(label, data); },
1645
+ markSeen: function (id) { state.seen[id] = true; },
1646
+ getSessionId: function () { return Metrics.getSessionId(); },
1647
+ getSections: function () {
1648
+ return state.sections.map(function (s) {
1649
+ return { id: s.id, tag: s.tag, ordinal: s.ordinal, snippet: s.snippet };
1650
+ });
1651
+ },
1652
+ getDynamicSections: function () {
1653
+ var result = {};
1654
+ for (var id in state.dynamicSections) {
1655
+ var s = state.dynamicSections[id];
1656
+ result[id] = { id: s.id, name: s.name, snippet: s.snippet };
1657
+ }
1658
+ return result;
1659
+ },
1660
+ getSeen: function () {
1661
+ var copy = {};
1662
+ for (var k in state.seen) copy[k] = true;
1663
+ return copy;
1664
+ },
1665
+ getActive: function () {
1666
+ if (!state.activeSection) return null;
1667
+ return {
1668
+ id: state.activeSection,
1669
+ since: state.activeSince,
1670
+ elapsed: Math.round((now() - state.activeSince) / 1000)
1671
+ };
1672
+ },
1673
+ reset: function () {
1674
+ _closeCurrent();
1675
+ state.seen = {};
1676
+ state.depthHit = {};
1677
+ state.anchorCooling = false;
1678
+ state._navLinksSeen = {};
1679
+ clearTimeout(state.scrollTimer);
1680
+ for (var id in state.dynamicSections) {
1681
+ if (state.dynamicSections[id]._io) state.dynamicSections[id]._io.disconnect();
1682
+ }
1683
+ state.dynamicSections = {};
1684
+ state.activeSection = null;
1685
+ state.activeSince = null;
1686
+ state.sections = discoverSections(state.options.sections);
1687
+ premarkInitialState();
1688
+ },
1689
+ destroy: function () {
1690
+ _closeCurrent();
1691
+ window.removeEventListener('scroll', onScroll);
1692
+ window.removeEventListener('scroll', updateTocHighlight);
1693
+ document.removeEventListener('click', onDocumentClick);
1694
+ clearTimeout(state.scrollTimer);
1695
+ if (state._mutationObserver) {
1696
+ state._mutationObserver.disconnect();
1697
+ state._mutationObserver = null;
1698
+ }
1699
+ for (var id in state.dynamicSections) {
1700
+ if (state.dynamicSections[id]._io) state.dynamicSections[id]._io.disconnect();
1701
+ }
1702
+ for (var n = 0; n < state._navObservers.length; n++) {
1703
+ state._navObservers[n].disconnect();
1704
+ }
1705
+ state._navObservers = [];
1706
+ state.initialized = false;
1707
+ },
1708
+
1709
+ defaults: defaults,
1710
+ state: state
1711
+ };
1712
+
1713
+ Metrics.NavigationTracker = NT;
1714
+ // Backward compat aliases
1715
+ Metrics.SectionTracker = NT;
1716
+ Metrics.ScrollTracker = NT;
1717
+
1718
+ })(typeof window !== 'undefined' ? window : this);
1719
+
1720
+ /**
1721
+ * Metrics.MediaTracker — Track video/audio engagement
1722
+ *
1723
+ * Auto-discovers native <video>/<audio> elements and YouTube/Vimeo
1724
+ * iframes. Hooks into play/pause/seek/ended events and sends
1725
+ * periodic checkpoints during playback.
1726
+ *
1727
+ * Events:
1728
+ * media-play:id — playback started
1729
+ * media-pause:id — playback paused
1730
+ * media-checkpoint:id — periodic position update during playback
1731
+ * media-ended:id — reached the end
1732
+ * media-seeked:id — user jumped to a position
1733
+ *
1734
+ * Usage:
1735
+ * Metrics.init({ endpoint: '/telemetry.php' });
1736
+ * Metrics.MediaTracker.init({
1737
+ * checkpointInterval: 10, // seconds
1738
+ * reloadIframes: false // don't reload to inject API params
1739
+ * });
1740
+ *
1741
+ * @module Metrics
1742
+ * @class Metrics.MediaTracker
1743
+ */
1744
+ "use strict";
1745
+ (function (root) {
1746
+
1747
+ var Metrics = root.Metrics;
1748
+ if (!Metrics) {
1749
+ console.warn('Metrics.MediaTracker: Metrics core not loaded');
1750
+ return;
1751
+ }
1752
+
1753
+ var defaults = {
1754
+ // Seconds between checkpoint events during playback
1755
+ checkpointInterval: 10,
1756
+
1757
+ // Auto-discover media elements on init
1758
+ autoDiscover: true,
1759
+
1760
+ // CSS selector for native media elements
1761
+ mediaSelector: 'video, audio',
1762
+
1763
+ // Whether to reload YouTube/Vimeo iframes to inject API params
1764
+ // Default false: logs a warning instead of disrupting playback
1765
+ reloadIframes: false,
1766
+
1767
+ // Observe DOM for dynamically added media
1768
+ observeDom: true,
1769
+
1770
+ // Root element to watch for mutations
1771
+ observeRoot: null,
1772
+
1773
+ // Debounce checkpoint sends (ms) — prevents burst on rapid seeks
1774
+ checkpointDebounce: 1000
1775
+ };
1776
+
1777
+ // ── State ──
1778
+ var state = {
1779
+ initialized: false,
1780
+ options: null,
1781
+ tracked: {}, // id → tracker object
1782
+ _counter: 0, // for generating IDs
1783
+ _mutationObserver: null,
1784
+ _ytApiLoaded: false,
1785
+ _ytApiLoading: false,
1786
+ _ytPendingPlayers: [], // iframes waiting for API
1787
+ _vimeoApiLoaded: false,
1788
+ _vimeoApiLoading: false
1789
+ };
1790
+
1791
+ // ── Utilities ──
1792
+
1793
+ function genId(el) {
1794
+ if (el.id) return el.id;
1795
+ var src = el.src || el.currentSrc || '';
1796
+ if (src) {
1797
+ // Extract meaningful part of URL
1798
+ var match = src.match(/(?:youtu\.be\/|youtube\.com\/embed\/|vimeo\.com\/video\/|vimeo\.com\/)([^?&#]+)/);
1799
+ if (match) return match[1];
1800
+ // Use filename
1801
+ var parts = src.split('/').pop().split('?')[0];
1802
+ if (parts && parts.length < 60) return parts;
1803
+ }
1804
+ return 'media-' + (state._counter++);
1805
+ }
1806
+
1807
+ function now() { return Date.now(); }
1808
+
1809
+ // ── Watched Seconds Tracker ──
1810
+ // Tracks unique seconds viewed (handles seeking/rewatching)
1811
+
1812
+ function WatchedTracker() {
1813
+ this.ranges = []; // [{start, end}] sorted, non-overlapping
1814
+ }
1815
+
1816
+ WatchedTracker.prototype.add = function (from, to) {
1817
+ if (to <= from) return;
1818
+ var newRange = { start: Math.floor(from), end: Math.ceil(to) };
1819
+ var merged = [];
1820
+ var inserted = false;
1821
+ for (var i = 0; i < this.ranges.length; i++) {
1822
+ var r = this.ranges[i];
1823
+ if (r.end < newRange.start) {
1824
+ merged.push(r);
1825
+ } else if (r.start > newRange.end) {
1826
+ if (!inserted) { merged.push(newRange); inserted = true; }
1827
+ merged.push(r);
1828
+ } else {
1829
+ newRange.start = Math.min(newRange.start, r.start);
1830
+ newRange.end = Math.max(newRange.end, r.end);
1831
+ }
1832
+ }
1833
+ if (!inserted) merged.push(newRange);
1834
+ this.ranges = merged;
1835
+ };
1836
+
1837
+ WatchedTracker.prototype.total = function () {
1838
+ var t = 0;
1839
+ for (var i = 0; i < this.ranges.length; i++) {
1840
+ t += this.ranges[i].end - this.ranges[i].start;
1841
+ }
1842
+ return t;
1843
+ };
1844
+
1845
+ // ── Core Tracker Object (per media element) ──
1846
+
1847
+ function createTracker(id, type, el, duration) {
1848
+ return {
1849
+ id: id,
1850
+ type: type, // 'native', 'youtube', 'vimeo'
1851
+ el: el,
1852
+ duration: duration || 0,
1853
+ playing: false,
1854
+ lastPosition: 0,
1855
+ lastCheckpointAt: 0, // timestamp of last checkpoint send
1856
+ watched: new WatchedTracker(),
1857
+ checkpointTimer: null,
1858
+ _lastTimeUpdate: 0 // position at last timeupdate
1859
+ };
1860
+ }
1861
+
1862
+ function sendEvent(tracker, event, extra) {
1863
+ var data = {
1864
+ type: tracker.type,
1865
+ position: Math.round(tracker.lastPosition),
1866
+ duration: Math.round(tracker.duration),
1867
+ watched: tracker.watched.total()
1868
+ };
1869
+ if (extra) {
1870
+ for (var k in extra) data[k] = extra[k];
1871
+ }
1872
+ Metrics.send(event + ':' + tracker.id, data);
1873
+ }
1874
+
1875
+ // ── Checkpoint Timer ──
1876
+
1877
+ function startCheckpoints(tracker) {
1878
+ stopCheckpoints(tracker);
1879
+ var interval = state.options.checkpointInterval * 1000;
1880
+ tracker.checkpointTimer = setInterval(function () {
1881
+ if (tracker.playing) {
1882
+ sendEvent(tracker, 'media-checkpoint');
1883
+ }
1884
+ }, interval);
1885
+ }
1886
+
1887
+ function stopCheckpoints(tracker) {
1888
+ if (tracker.checkpointTimer) {
1889
+ clearInterval(tracker.checkpointTimer);
1890
+ tracker.checkpointTimer = null;
1891
+ }
1892
+ }
1893
+
1894
+ // ── Native <video> / <audio> ──
1895
+
1896
+ function trackNative(el) {
1897
+ var id = genId(el);
1898
+ if (state.tracked[id]) return; // already tracking
1899
+
1900
+ var tracker = createTracker(id, 'native', el, el.duration || 0);
1901
+ state.tracked[id] = tracker;
1902
+
1903
+ el.addEventListener('loadedmetadata', function () {
1904
+ tracker.duration = el.duration || 0;
1905
+ });
1906
+
1907
+ el.addEventListener('play', function () {
1908
+ tracker.playing = true;
1909
+ tracker.lastPosition = el.currentTime;
1910
+ tracker._lastTimeUpdate = el.currentTime;
1911
+ sendEvent(tracker, 'media-play');
1912
+ startCheckpoints(tracker);
1913
+ });
1914
+
1915
+ el.addEventListener('pause', function () {
1916
+ if (!tracker.playing) return;
1917
+ tracker.playing = false;
1918
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
1919
+ tracker.lastPosition = el.currentTime;
1920
+ stopCheckpoints(tracker);
1921
+ sendEvent(tracker, 'media-pause');
1922
+ });
1923
+
1924
+ el.addEventListener('ended', function () {
1925
+ tracker.playing = false;
1926
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
1927
+ tracker.lastPosition = el.currentTime;
1928
+ stopCheckpoints(tracker);
1929
+ sendEvent(tracker, 'media-ended');
1930
+ });
1931
+
1932
+ el.addEventListener('seeked', function () {
1933
+ var from = tracker.lastPosition;
1934
+ tracker.lastPosition = el.currentTime;
1935
+ tracker._lastTimeUpdate = el.currentTime;
1936
+ sendEvent(tracker, 'media-seeked', {
1937
+ from: Math.round(from),
1938
+ to: Math.round(el.currentTime)
1939
+ });
1940
+ });
1941
+
1942
+ el.addEventListener('timeupdate', function () {
1943
+ // Track watched range
1944
+ if (tracker.playing && el.currentTime > tracker._lastTimeUpdate) {
1945
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
1946
+ }
1947
+ tracker._lastTimeUpdate = el.currentTime;
1948
+ tracker.lastPosition = el.currentTime;
1949
+ });
1950
+ }
1951
+
1952
+ // ── YouTube Iframe ──
1953
+
1954
+ function loadYouTubeAPI(callback) {
1955
+ if (state._ytApiLoaded) { callback(); return; }
1956
+ if (state._ytApiLoading) {
1957
+ state._ytPendingPlayers.push(callback);
1958
+ return;
1959
+ }
1960
+ state._ytApiLoading = true;
1961
+
1962
+ var prev = root.onYouTubeIframeAPIReady;
1963
+ root.onYouTubeIframeAPIReady = function () {
1964
+ state._ytApiLoaded = true;
1965
+ state._ytApiLoading = false;
1966
+ if (prev) prev();
1967
+ callback();
1968
+ for (var i = 0; i < state._ytPendingPlayers.length; i++) {
1969
+ state._ytPendingPlayers[i]();
1970
+ }
1971
+ state._ytPendingPlayers = [];
1972
+ };
1973
+
1974
+ var script = document.createElement('script');
1975
+ script.src = 'https://www.youtube.com/iframe_api';
1976
+ document.head.appendChild(script);
1977
+ }
1978
+
1979
+ function trackYouTube(iframe) {
1980
+ var src = iframe.src || '';
1981
+ var id = genId(iframe);
1982
+ if (state.tracked[id]) return;
1983
+
1984
+ // Check for enablejsapi=1
1985
+ if (src.indexOf('enablejsapi') === -1) {
1986
+ if (state.options.reloadIframes) {
1987
+ var separator = src.indexOf('?') === -1 ? '?' : '&';
1988
+ iframe.src = src + separator + 'enablejsapi=1&origin=' + encodeURIComponent(location.origin);
1989
+ // iframe will reload, we'll catch it again via mutation observer or re-scan
1990
+ } else {
1991
+ console.warn('Metrics.MediaTracker: YouTube iframe missing enablejsapi=1, '
1992
+ + 'set reloadIframes:true to auto-fix. iframe:', iframe);
1993
+ return;
1994
+ }
1995
+ }
1996
+
1997
+ // Ensure iframe has an id for the YT API
1998
+ if (!iframe.id) iframe.id = 'yt-' + id;
1999
+
2000
+ loadYouTubeAPI(function () {
2001
+ if (state.tracked[id]) return;
2002
+ var tracker = createTracker(id, 'youtube', iframe, 0);
2003
+ state.tracked[id] = tracker;
2004
+
2005
+ var player = new YT.Player(iframe.id, {
2006
+ events: {
2007
+ onReady: function (e) {
2008
+ tracker.duration = player.getDuration() || 0;
2009
+ },
2010
+ onStateChange: function (e) {
2011
+ var pos = player.getCurrentTime() || 0;
2012
+ tracker.lastPosition = pos;
2013
+
2014
+ switch (e.data) {
2015
+ case YT.PlayerState.PLAYING:
2016
+ tracker.playing = true;
2017
+ tracker._lastTimeUpdate = pos;
2018
+ tracker.duration = player.getDuration() || tracker.duration;
2019
+ sendEvent(tracker, 'media-play');
2020
+ startCheckpoints(tracker);
2021
+ // Poll position since YT has no timeupdate
2022
+ tracker._pollTimer = setInterval(function () {
2023
+ var p = player.getCurrentTime() || 0;
2024
+ if (tracker.playing && p > tracker._lastTimeUpdate) {
2025
+ tracker.watched.add(tracker._lastTimeUpdate, p);
2026
+ }
2027
+ tracker._lastTimeUpdate = p;
2028
+ tracker.lastPosition = p;
2029
+ }, 1000);
2030
+ break;
2031
+
2032
+ case YT.PlayerState.PAUSED:
2033
+ if (!tracker.playing) break;
2034
+ tracker.playing = false;
2035
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2036
+ stopCheckpoints(tracker);
2037
+ clearInterval(tracker._pollTimer);
2038
+ sendEvent(tracker, 'media-pause');
2039
+ break;
2040
+
2041
+ case YT.PlayerState.ENDED:
2042
+ tracker.playing = false;
2043
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2044
+ stopCheckpoints(tracker);
2045
+ clearInterval(tracker._pollTimer);
2046
+ sendEvent(tracker, 'media-ended');
2047
+ break;
2048
+ }
2049
+ }
2050
+ }
2051
+ });
2052
+ tracker._player = player;
2053
+ });
2054
+ }
2055
+
2056
+ // ── Vimeo Iframe ──
2057
+
2058
+ function loadVimeoAPI(callback) {
2059
+ if (state._vimeoApiLoaded) { callback(); return; }
2060
+ if (state._vimeoApiLoading) {
2061
+ setTimeout(function () { loadVimeoAPI(callback); }, 200);
2062
+ return;
2063
+ }
2064
+ state._vimeoApiLoading = true;
2065
+
2066
+ var script = document.createElement('script');
2067
+ script.src = 'https://player.vimeo.com/api/player.js';
2068
+ script.onload = function () {
2069
+ state._vimeoApiLoaded = true;
2070
+ state._vimeoApiLoading = false;
2071
+ callback();
2072
+ };
2073
+ document.head.appendChild(script);
2074
+ }
2075
+
2076
+ function trackVimeo(iframe) {
2077
+ var src = iframe.src || '';
2078
+ var id = genId(iframe);
2079
+ if (state.tracked[id]) return;
2080
+
2081
+ loadVimeoAPI(function () {
2082
+ if (state.tracked[id]) return;
2083
+ var tracker = createTracker(id, 'vimeo', iframe, 0);
2084
+ state.tracked[id] = tracker;
2085
+
2086
+ var player = new Vimeo.Player(iframe);
2087
+ tracker._player = player;
2088
+
2089
+ player.getDuration().then(function (d) { tracker.duration = d || 0; });
2090
+
2091
+ player.on('play', function (data) {
2092
+ tracker.playing = true;
2093
+ tracker.lastPosition = data.seconds || 0;
2094
+ tracker._lastTimeUpdate = tracker.lastPosition;
2095
+ tracker.duration = data.duration || tracker.duration;
2096
+ sendEvent(tracker, 'media-play');
2097
+ startCheckpoints(tracker);
2098
+ });
2099
+
2100
+ player.on('pause', function (data) {
2101
+ if (!tracker.playing) return;
2102
+ tracker.playing = false;
2103
+ var pos = data.seconds || 0;
2104
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2105
+ tracker.lastPosition = pos;
2106
+ stopCheckpoints(tracker);
2107
+ sendEvent(tracker, 'media-pause');
2108
+ });
2109
+
2110
+ player.on('ended', function (data) {
2111
+ tracker.playing = false;
2112
+ var pos = data.seconds || tracker.duration;
2113
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2114
+ tracker.lastPosition = pos;
2115
+ stopCheckpoints(tracker);
2116
+ sendEvent(tracker, 'media-ended');
2117
+ });
2118
+
2119
+ player.on('seeked', function (data) {
2120
+ var from = tracker.lastPosition;
2121
+ tracker.lastPosition = data.seconds || 0;
2122
+ tracker._lastTimeUpdate = tracker.lastPosition;
2123
+ sendEvent(tracker, 'media-seeked', {
2124
+ from: Math.round(from),
2125
+ to: Math.round(tracker.lastPosition)
2126
+ });
2127
+ });
2128
+
2129
+ player.on('timeupdate', function (data) {
2130
+ var pos = data.seconds || 0;
2131
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2132
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2133
+ }
2134
+ tracker._lastTimeUpdate = pos;
2135
+ tracker.lastPosition = pos;
2136
+ });
2137
+ });
2138
+ }
2139
+
2140
+ // ── Auto-Discovery ──
2141
+
2142
+ function discoverMedia() {
2143
+ // Native elements
2144
+ var natives = document.querySelectorAll(state.options.mediaSelector);
2145
+ for (var i = 0; i < natives.length; i++) {
2146
+ trackNative(natives[i]);
2147
+ }
2148
+
2149
+ // Iframes — detect by src URL
2150
+ var iframes = document.querySelectorAll('iframe[src]');
2151
+ for (var j = 0; j < iframes.length; j++) {
2152
+ var src = iframes[j].src || '';
2153
+ if (/youtube\.com\/embed|youtube-nocookie\.com\/embed/.test(src)) {
2154
+ trackYouTube(iframes[j]);
2155
+ } else if (/player\.vimeo\.com/.test(src)) {
2156
+ trackVimeo(iframes[j]);
2157
+ } else if (/w\.soundcloud\.com\/player/.test(src)) {
2158
+ trackSoundCloud(iframes[j]);
2159
+ } else if (/dailymotion\.com\/embed/.test(src)) {
2160
+ trackDailymotion(iframes[j]);
2161
+ } else if (/open\.spotify\.com\/embed/.test(src)) {
2162
+ trackSpotify(iframes[j]);
2163
+ } else if (/player\.twitch\.tv/.test(src)) {
2164
+ trackTwitch(iframes[j]);
2165
+ } else if (/muse\.ai\/embed/.test(src)) {
2166
+ trackMuseAi(iframes[j]);
2167
+ }
2168
+ }
2169
+
2170
+ // Wistia — detected by class name, not iframe
2171
+ var wistias = document.querySelectorAll('[class*="wistia_embed"], [class*="wistia_async_"]');
2172
+ for (var k = 0; k < wistias.length; k++) {
2173
+ trackWistia(wistias[k]);
2174
+ }
2175
+
2176
+ // JW Player — detected by container class or data attribute
2177
+ var jwContainers = document.querySelectorAll('.jwplayer, [data-jw-id]');
2178
+ for (var l = 0; l < jwContainers.length; l++) {
2179
+ trackJWPlayer(jwContainers[l]);
2180
+ }
2181
+ }
2182
+
2183
+ // ── DOM Mutation Observer ──
2184
+
2185
+ function _startMutationObserver() {
2186
+ if (!window.MutationObserver || !state.options.observeDom) return;
2187
+ var root = state.options.observeRoot || document.body;
2188
+
2189
+ state._mutationObserver = new MutationObserver(function (mutations) {
2190
+ mutations.forEach(function (mutation) {
2191
+ mutation.addedNodes.forEach(function (node) {
2192
+ if (node.nodeType !== 1) return;
2193
+ // Check the node itself
2194
+ if (node.matches && node.matches('video, audio')) {
2195
+ trackNative(node);
2196
+ }
2197
+ if (node.tagName === 'IFRAME' && node.src) {
2198
+ var s = node.src;
2199
+ if (/youtube\.com\/embed/.test(s)) trackYouTube(node);
2200
+ else if (/player\.vimeo\.com/.test(s)) trackVimeo(node);
2201
+ else if (/w\.soundcloud\.com\/player/.test(s)) trackSoundCloud(node);
2202
+ else if (/dailymotion\.com\/embed/.test(s)) trackDailymotion(node);
2203
+ else if (/open\.spotify\.com\/embed/.test(s)) trackSpotify(node);
2204
+ else if (/player\.twitch\.tv/.test(s)) trackTwitch(node);
2205
+ else if (/muse\.ai\/embed/.test(s)) trackMuseAi(node);
2206
+ }
2207
+ // Wistia containers
2208
+ if (node.className && /wistia_embed|wistia_async_/.test(node.className)) {
2209
+ trackWistia(node);
2210
+ }
2211
+ // JW Player containers
2212
+ if (node.className && /jwplayer/.test(node.className)) {
2213
+ trackJWPlayer(node);
2214
+ }
2215
+ // Check descendants
2216
+ if (node.querySelectorAll) {
2217
+ var natives = node.querySelectorAll('video, audio');
2218
+ for (var i = 0; i < natives.length; i++) trackNative(natives[i]);
2219
+ var iframes = node.querySelectorAll('iframe[src]');
2220
+ for (var j = 0; j < iframes.length; j++) {
2221
+ var s = iframes[j].src || '';
2222
+ if (/youtube\.com\/embed/.test(s)) trackYouTube(iframes[j]);
2223
+ else if (/player\.vimeo\.com/.test(s)) trackVimeo(iframes[j]);
2224
+ else if (/w\.soundcloud\.com\/player/.test(s)) trackSoundCloud(iframes[j]);
2225
+ else if (/dailymotion\.com\/embed/.test(s)) trackDailymotion(iframes[j]);
2226
+ else if (/open\.spotify\.com\/embed/.test(s)) trackSpotify(iframes[j]);
2227
+ else if (/player\.twitch\.tv/.test(s)) trackTwitch(iframes[j]);
2228
+ else if (/muse\.ai\/embed/.test(s)) trackMuseAi(iframes[j]);
2229
+ }
2230
+ var wistias = node.querySelectorAll('[class*="wistia_embed"], [class*="wistia_async_"]');
2231
+ for (var k = 0; k < wistias.length; k++) trackWistia(wistias[k]);
2232
+ var jws = node.querySelectorAll('.jwplayer, [data-jw-id]');
2233
+ for (var l = 0; l < jws.length; l++) trackJWPlayer(jws[l]);
2234
+ }
2235
+ });
2236
+ });
2237
+ });
2238
+
2239
+ state._mutationObserver.observe(root, { childList: true, subtree: true });
2240
+ }
2241
+
2242
+ // ── Flush All Playing Media ──
2243
+
2244
+ function flushAll() {
2245
+ for (var id in state.tracked) {
2246
+ var tracker = state.tracked[id];
2247
+ if (tracker.playing) {
2248
+ // Update watched range one last time
2249
+ if (tracker.type === 'native' && tracker.el) {
2250
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.el.currentTime || tracker.lastPosition);
2251
+ tracker.lastPosition = tracker.el.currentTime || tracker.lastPosition;
2252
+ }
2253
+ sendEvent(tracker, 'media-checkpoint');
2254
+ }
2255
+ }
2256
+ }
2257
+
2258
+ // ── Pre-mark Initial State ──
2259
+
2260
+ function premarkInitialState() {
2261
+ // Nothing to pre-mark for media — we want to track all plays
2262
+ }
2263
+
2264
+ // ── Public API ──
2265
+
2266
+ var MT = {
2267
+
2268
+ /**
2269
+ * Initialize media tracking
2270
+ */
2271
+ init: function (options) {
2272
+ if (state.initialized) {
2273
+ console.warn('Metrics.MediaTracker already initialized');
2274
+ return this;
2275
+ }
2276
+
2277
+ var opts = {};
2278
+ var k;
2279
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
2280
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
2281
+ state.options = opts;
2282
+ state.initialized = true;
2283
+
2284
+ // Auto-init Metrics core if needed
2285
+ if (!Metrics._endpoint && options && options.endpoint) {
2286
+ Metrics.init({
2287
+ endpoint: options.endpoint, page: options.page,
2288
+ trackUnload: options.trackUnload
2289
+ });
2290
+ }
2291
+
2292
+ // Discover existing media
2293
+ if (opts.autoDiscover) {
2294
+ // Slight delay to let page render
2295
+ setTimeout(function () {
2296
+ discoverMedia();
2297
+ }, 500);
2298
+ }
2299
+
2300
+ // Watch for dynamically added media
2301
+ if (opts.observeDom) {
2302
+ _startMutationObserver();
2303
+ }
2304
+
2305
+ // Flush on page exit
2306
+ Metrics.onVisibilityChange(function (visible) {
2307
+ if (!visible) flushAll();
2308
+ }, 'MediaTracker.flush');
2309
+
2310
+ return this;
2311
+ },
2312
+
2313
+ /**
2314
+ * Manually track a native video/audio element
2315
+ * @param {Element} el — the <video> or <audio> element
2316
+ * @param {String} [id] — optional custom ID
2317
+ */
2318
+ trackNative: function (el, id) {
2319
+ if (id) el.id = id;
2320
+ trackNative(el);
2321
+ },
2322
+
2323
+ /**
2324
+ * Manually track a YouTube iframe
2325
+ * @param {Element} iframe
2326
+ * @param {String} [id]
2327
+ */
2328
+ trackYouTube: function (iframe, id) {
2329
+ if (id) iframe.id = id;
2330
+ trackYouTube(iframe);
2331
+ },
2332
+
2333
+ /**
2334
+ * Manually track a Vimeo iframe
2335
+ * @param {Element} iframe
2336
+ * @param {String} [id]
2337
+ */
2338
+ trackVimeo: function (iframe, id) {
2339
+ if (id) iframe.id = id;
2340
+ trackVimeo(iframe);
2341
+ },
2342
+
2343
+ trackSoundCloud: function (iframe, id) {
2344
+ if (id) iframe.id = id;
2345
+ trackSoundCloud(iframe);
2346
+ },
2347
+
2348
+ trackWistia: function (container, id) {
2349
+ if (id) container.id = id;
2350
+ trackWistia(container);
2351
+ },
2352
+
2353
+ trackJWPlayer: function (container, id) {
2354
+ if (id) container.id = id;
2355
+ trackJWPlayer(container);
2356
+ },
2357
+
2358
+ trackDailymotion: function (iframe, id) {
2359
+ if (id) iframe.id = id;
2360
+ trackDailymotion(iframe);
2361
+ },
2362
+
2363
+ trackSpotify: function (iframe, id) {
2364
+ if (id) iframe.id = id;
2365
+ trackSpotify(iframe);
2366
+ },
2367
+
2368
+ trackTwitch: function (iframe, id) {
2369
+ if (id) iframe.id = id;
2370
+ trackTwitch(iframe);
2371
+ },
2372
+
2373
+ trackMuseAi: function (iframe, id) {
2374
+ if (id) iframe.id = id;
2375
+ trackMuseAi(iframe);
2376
+ },
2377
+
2378
+ /**
2379
+ * Get all tracked media and their current state
2380
+ */
2381
+ getTracked: function () {
2382
+ var result = {};
2383
+ for (var id in state.tracked) {
2384
+ var t = state.tracked[id];
2385
+ result[id] = {
2386
+ id: t.id, type: t.type,
2387
+ playing: t.playing,
2388
+ position: Math.round(t.lastPosition),
2389
+ duration: Math.round(t.duration),
2390
+ watched: t.watched.total()
2391
+ };
2392
+ }
2393
+ return result;
2394
+ },
2395
+
2396
+ /**
2397
+ * Flush checkpoint for all currently playing media
2398
+ */
2399
+ flush: flushAll,
2400
+
2401
+ /**
2402
+ * Re-scan the page for new media elements
2403
+ */
2404
+ rescan: discoverMedia,
2405
+
2406
+ /**
2407
+ * Destroy — stop all tracking and remove observers
2408
+ */
2409
+ destroy: function () {
2410
+ for (var id in state.tracked) {
2411
+ var t = state.tracked[id];
2412
+ stopCheckpoints(t);
2413
+ if (t._pollTimer) clearInterval(t._pollTimer);
2414
+ }
2415
+ state.tracked = {};
2416
+ if (state._mutationObserver) {
2417
+ state._mutationObserver.disconnect();
2418
+ state._mutationObserver = null;
2419
+ }
2420
+ state.initialized = false;
2421
+ },
2422
+
2423
+ defaults: defaults,
2424
+ state: state
2425
+ };
2426
+
2427
+ Metrics.MediaTracker = MT;
2428
+
2429
+ })(typeof window !== 'undefined' ? window : this);
2430
+
2431
+ // ── SoundCloud Widget ──
2432
+
2433
+ function loadSoundCloudAPI(callback) {
2434
+ if (root.SC && root.SC.Widget) { callback(); return; }
2435
+ var script = document.createElement('script');
2436
+ script.src = 'https://w.soundcloud.com/player/api.js';
2437
+ script.onload = callback;
2438
+ document.head.appendChild(script);
2439
+ }
2440
+
2441
+ function trackSoundCloud(iframe) {
2442
+ var id = genId(iframe);
2443
+ if (state.tracked[id]) return;
2444
+
2445
+ loadSoundCloudAPI(function () {
2446
+ if (state.tracked[id]) return;
2447
+ var widget = SC.Widget(iframe);
2448
+ var tracker = createTracker(id, 'soundcloud', iframe, 0);
2449
+ state.tracked[id] = tracker;
2450
+
2451
+ widget.bind(SC.Widget.Events.READY, function () {
2452
+ widget.getDuration(function (d) { tracker.duration = (d || 0) / 1000; });
2453
+ });
2454
+ widget.bind(SC.Widget.Events.PLAY, function () {
2455
+ tracker.playing = true;
2456
+ widget.getPosition(function (p) {
2457
+ tracker.lastPosition = (p || 0) / 1000;
2458
+ tracker._lastTimeUpdate = tracker.lastPosition;
2459
+ sendEvent(tracker, 'media-play');
2460
+ startCheckpoints(tracker);
2461
+ });
2462
+ });
2463
+ widget.bind(SC.Widget.Events.PAUSE, function () {
2464
+ if (!tracker.playing) return;
2465
+ tracker.playing = false;
2466
+ widget.getPosition(function (p) {
2467
+ var pos = (p || 0) / 1000;
2468
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2469
+ tracker.lastPosition = pos;
2470
+ stopCheckpoints(tracker);
2471
+ sendEvent(tracker, 'media-pause');
2472
+ });
2473
+ });
2474
+ widget.bind(SC.Widget.Events.FINISH, function () {
2475
+ tracker.playing = false;
2476
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2477
+ tracker.lastPosition = tracker.duration;
2478
+ stopCheckpoints(tracker);
2479
+ sendEvent(tracker, 'media-ended');
2480
+ });
2481
+ widget.bind(SC.Widget.Events.SEEK, function (e) {
2482
+ var from = tracker.lastPosition;
2483
+ tracker.lastPosition = (e.currentPosition || 0) / 1000;
2484
+ tracker._lastTimeUpdate = tracker.lastPosition;
2485
+ sendEvent(tracker, 'media-seeked', {
2486
+ from: Math.round(from), to: Math.round(tracker.lastPosition)
2487
+ });
2488
+ });
2489
+ widget.bind(SC.Widget.Events.PLAY_PROGRESS, function (e) {
2490
+ var pos = (e.currentPosition || 0) / 1000;
2491
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2492
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2493
+ }
2494
+ tracker._lastTimeUpdate = pos;
2495
+ tracker.lastPosition = pos;
2496
+ });
2497
+ });
2498
+ }
2499
+
2500
+ // ── Wistia ──
2501
+
2502
+ function trackWistia(container) {
2503
+ var id = genId(container);
2504
+ if (state.tracked[id]) return;
2505
+
2506
+ var handleId = container.getAttribute('data-wistia-id')
2507
+ || (container.className.match(/wistia_async_(\w+)/) || [])[1]
2508
+ || id;
2509
+
2510
+ root._wq = root._wq || [];
2511
+ root._wq.push({
2512
+ id: handleId,
2513
+ onReady: function (video) {
2514
+ if (state.tracked[id]) return;
2515
+ var tracker = createTracker(id, 'wistia', container, video.duration() || 0);
2516
+ state.tracked[id] = tracker;
2517
+ tracker._player = video;
2518
+
2519
+ video.bind('play', function () {
2520
+ tracker.playing = true;
2521
+ tracker.lastPosition = video.time();
2522
+ tracker._lastTimeUpdate = tracker.lastPosition;
2523
+ tracker.duration = video.duration() || tracker.duration;
2524
+ sendEvent(tracker, 'media-play');
2525
+ startCheckpoints(tracker);
2526
+ });
2527
+ video.bind('pause', function () {
2528
+ if (!tracker.playing) return;
2529
+ tracker.playing = false;
2530
+ var pos = video.time();
2531
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2532
+ tracker.lastPosition = pos;
2533
+ stopCheckpoints(tracker);
2534
+ sendEvent(tracker, 'media-pause');
2535
+ });
2536
+ video.bind('end', function () {
2537
+ tracker.playing = false;
2538
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2539
+ tracker.lastPosition = tracker.duration;
2540
+ stopCheckpoints(tracker);
2541
+ sendEvent(tracker, 'media-ended');
2542
+ });
2543
+ video.bind('seek', function (currentTime, lastTime) {
2544
+ tracker.lastPosition = currentTime;
2545
+ tracker._lastTimeUpdate = currentTime;
2546
+ sendEvent(tracker, 'media-seeked', {
2547
+ from: Math.round(lastTime), to: Math.round(currentTime)
2548
+ });
2549
+ });
2550
+ video.bind('secondchange', function (s) {
2551
+ var pos = s;
2552
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2553
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2554
+ }
2555
+ tracker._lastTimeUpdate = pos;
2556
+ tracker.lastPosition = pos;
2557
+ });
2558
+ }
2559
+ });
2560
+
2561
+ // Load Wistia E-v1 if not present
2562
+ if (!root.Wistia) {
2563
+ var script = document.createElement('script');
2564
+ script.src = 'https://fast.wistia.com/assets/external/E-v1.js';
2565
+ script.async = true;
2566
+ document.head.appendChild(script);
2567
+ }
2568
+ }
2569
+
2570
+ // ── JW Player ──
2571
+
2572
+ function trackJWPlayer(container) {
2573
+ var id = genId(container);
2574
+ if (state.tracked[id]) return;
2575
+
2576
+ // JW Player instance might already exist
2577
+ var playerId = container.id || id;
2578
+ function _bind() {
2579
+ if (!root.jwplayer || typeof root.jwplayer !== 'function') return false;
2580
+ var player;
2581
+ try { player = jwplayer(playerId); } catch (e) { return false; }
2582
+ if (!player || !player.getState) return false;
2583
+
2584
+ var tracker = createTracker(id, 'jwplayer', container, player.getDuration() || 0);
2585
+ state.tracked[id] = tracker;
2586
+ tracker._player = player;
2587
+
2588
+ player.on('play', function () {
2589
+ tracker.playing = true;
2590
+ tracker.lastPosition = player.getPosition();
2591
+ tracker._lastTimeUpdate = tracker.lastPosition;
2592
+ tracker.duration = player.getDuration() || tracker.duration;
2593
+ sendEvent(tracker, 'media-play');
2594
+ startCheckpoints(tracker);
2595
+ });
2596
+ player.on('pause', function () {
2597
+ if (!tracker.playing) return;
2598
+ tracker.playing = false;
2599
+ var pos = player.getPosition();
2600
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2601
+ tracker.lastPosition = pos;
2602
+ stopCheckpoints(tracker);
2603
+ sendEvent(tracker, 'media-pause');
2604
+ });
2605
+ player.on('complete', function () {
2606
+ tracker.playing = false;
2607
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2608
+ tracker.lastPosition = tracker.duration;
2609
+ stopCheckpoints(tracker);
2610
+ sendEvent(tracker, 'media-ended');
2611
+ });
2612
+ player.on('seek', function (e) {
2613
+ tracker.lastPosition = e.offset;
2614
+ tracker._lastTimeUpdate = e.offset;
2615
+ sendEvent(tracker, 'media-seeked', {
2616
+ from: Math.round(e.position), to: Math.round(e.offset)
2617
+ });
2618
+ });
2619
+ player.on('time', function (e) {
2620
+ var pos = e.position;
2621
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2622
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2623
+ }
2624
+ tracker._lastTimeUpdate = pos;
2625
+ tracker.lastPosition = pos;
2626
+ tracker.duration = e.duration || tracker.duration;
2627
+ });
2628
+ return true;
2629
+ }
2630
+
2631
+ // Try immediately, retry after a delay if JW hasn't initialized yet
2632
+ if (!_bind()) {
2633
+ setTimeout(function () { _bind(); }, 2000);
2634
+ }
2635
+ }
2636
+
2637
+ // ── Dailymotion ──
2638
+
2639
+ function trackDailymotion(iframe) {
2640
+ var id = genId(iframe);
2641
+ if (state.tracked[id]) return;
2642
+
2643
+ function _loadAndBind() {
2644
+ if (!root.DM || !root.DM.player) {
2645
+ var script = document.createElement('script');
2646
+ script.src = 'https://api.dmcdn.net/all.js';
2647
+ script.onload = function () { _createPlayer(); };
2648
+ document.head.appendChild(script);
2649
+ } else {
2650
+ _createPlayer();
2651
+ }
2652
+ }
2653
+
2654
+ function _createPlayer() {
2655
+ if (state.tracked[id]) return;
2656
+ var tracker = createTracker(id, 'dailymotion', iframe, 0);
2657
+ state.tracked[id] = tracker;
2658
+
2659
+ var player = DM.player(iframe, { events: {
2660
+ playing: function () {
2661
+ tracker.playing = true;
2662
+ tracker.lastPosition = player.currentTime || 0;
2663
+ tracker._lastTimeUpdate = tracker.lastPosition;
2664
+ tracker.duration = player.duration || tracker.duration;
2665
+ sendEvent(tracker, 'media-play');
2666
+ startCheckpoints(tracker);
2667
+ },
2668
+ pause: function () {
2669
+ if (!tracker.playing) return;
2670
+ tracker.playing = false;
2671
+ var pos = player.currentTime || 0;
2672
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2673
+ tracker.lastPosition = pos;
2674
+ stopCheckpoints(tracker);
2675
+ sendEvent(tracker, 'media-pause');
2676
+ },
2677
+ end: function () {
2678
+ tracker.playing = false;
2679
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2680
+ tracker.lastPosition = tracker.duration;
2681
+ stopCheckpoints(tracker);
2682
+ sendEvent(tracker, 'media-ended');
2683
+ },
2684
+ seeking: function () {
2685
+ var from = tracker.lastPosition;
2686
+ tracker.lastPosition = player.currentTime || 0;
2687
+ tracker._lastTimeUpdate = tracker.lastPosition;
2688
+ sendEvent(tracker, 'media-seeked', {
2689
+ from: Math.round(from), to: Math.round(tracker.lastPosition)
2690
+ });
2691
+ },
2692
+ timeupdate: function () {
2693
+ var pos = player.currentTime || 0;
2694
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2695
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2696
+ }
2697
+ tracker._lastTimeUpdate = pos;
2698
+ tracker.lastPosition = pos;
2699
+ tracker.duration = player.duration || tracker.duration;
2700
+ }
2701
+ }});
2702
+ tracker._player = player;
2703
+ }
2704
+
2705
+ _loadAndBind();
2706
+ }
2707
+
2708
+ // ── Spotify Embed (limited — position only via playbackUpdate) ──
2709
+
2710
+ function trackSpotify(iframe) {
2711
+ var id = genId(iframe);
2712
+ if (state.tracked[id]) return;
2713
+
2714
+ var tracker = createTracker(id, 'spotify', iframe, 0);
2715
+ state.tracked[id] = tracker;
2716
+
2717
+ // Spotify Embed API uses window.onSpotifyIframeApiReady + postMessage
2718
+ window.addEventListener('message', function (e) {
2719
+ if (!e.data || e.source !== iframe.contentWindow) return;
2720
+ var data;
2721
+ try { data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
2722
+ catch (err) { return; }
2723
+ if (!data.type) return;
2724
+
2725
+ if (data.type === 'playback_update') {
2726
+ var pos = (data.payload && data.payload.position) || 0;
2727
+ pos = pos / 1000; // ms to seconds
2728
+ var dur = (data.payload && data.payload.duration) || 0;
2729
+ dur = dur / 1000;
2730
+ var isPaused = data.payload && data.payload.isPaused;
2731
+
2732
+ tracker.duration = dur || tracker.duration;
2733
+
2734
+ if (!isPaused && !tracker.playing) {
2735
+ tracker.playing = true;
2736
+ tracker.lastPosition = pos;
2737
+ tracker._lastTimeUpdate = pos;
2738
+ sendEvent(tracker, 'media-play');
2739
+ startCheckpoints(tracker);
2740
+ } else if (isPaused && tracker.playing) {
2741
+ tracker.playing = false;
2742
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2743
+ tracker.lastPosition = pos;
2744
+ stopCheckpoints(tracker);
2745
+ sendEvent(tracker, 'media-pause');
2746
+ } else if (!isPaused && tracker.playing && pos > tracker._lastTimeUpdate) {
2747
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2748
+ tracker._lastTimeUpdate = pos;
2749
+ tracker.lastPosition = pos;
2750
+ }
2751
+ }
2752
+ });
2753
+ }
2754
+
2755
+ // ── Twitch Player ──
2756
+
2757
+ function trackTwitch(iframe) {
2758
+ var id = genId(iframe);
2759
+ if (state.tracked[id]) return;
2760
+
2761
+ function _loadAndBind() {
2762
+ if (!root.Twitch || !root.Twitch.Player) {
2763
+ var script = document.createElement('script');
2764
+ script.src = 'https://player.twitch.tv/js/embed/v1.js';
2765
+ script.onload = function () { _createPlayer(); };
2766
+ document.head.appendChild(script);
2767
+ } else {
2768
+ _createPlayer();
2769
+ }
2770
+ }
2771
+
2772
+ function _createPlayer() {
2773
+ if (state.tracked[id]) return;
2774
+ if (!iframe.id) iframe.id = 'twitch-' + id;
2775
+ var tracker = createTracker(id, 'twitch', iframe, 0);
2776
+ state.tracked[id] = tracker;
2777
+
2778
+ var player = new Twitch.Player(iframe.id, {});
2779
+ tracker._player = player;
2780
+
2781
+ player.addEventListener(Twitch.Player.PLAY, function () {
2782
+ tracker.playing = true;
2783
+ tracker.lastPosition = player.getCurrentTime() || 0;
2784
+ tracker._lastTimeUpdate = tracker.lastPosition;
2785
+ tracker.duration = player.getDuration() || tracker.duration;
2786
+ sendEvent(tracker, 'media-play');
2787
+ startCheckpoints(tracker);
2788
+ tracker._pollTimer = setInterval(function () {
2789
+ var p = player.getCurrentTime() || 0;
2790
+ if (tracker.playing && p > tracker._lastTimeUpdate) {
2791
+ tracker.watched.add(tracker._lastTimeUpdate, p);
2792
+ }
2793
+ tracker._lastTimeUpdate = p;
2794
+ tracker.lastPosition = p;
2795
+ }, 1000);
2796
+ });
2797
+ player.addEventListener(Twitch.Player.PAUSE, function () {
2798
+ if (!tracker.playing) return;
2799
+ tracker.playing = false;
2800
+ var pos = player.getCurrentTime() || 0;
2801
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2802
+ tracker.lastPosition = pos;
2803
+ stopCheckpoints(tracker);
2804
+ clearInterval(tracker._pollTimer);
2805
+ sendEvent(tracker, 'media-pause');
2806
+ });
2807
+ player.addEventListener(Twitch.Player.ENDED, function () {
2808
+ tracker.playing = false;
2809
+ var pos = player.getCurrentTime() || tracker.duration;
2810
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2811
+ tracker.lastPosition = pos;
2812
+ stopCheckpoints(tracker);
2813
+ clearInterval(tracker._pollTimer);
2814
+ sendEvent(tracker, 'media-ended');
2815
+ });
2816
+ }
2817
+
2818
+ _loadAndBind();
2819
+ }
2820
+
2821
+ // ── Muse.ai (postMessage API) ──
2822
+
2823
+ function trackMuseAi(iframe) {
2824
+ var id = genId(iframe);
2825
+ if (state.tracked[id]) return;
2826
+
2827
+ var tracker = createTracker(id, 'museai', iframe, 0);
2828
+ state.tracked[id] = tracker;
2829
+
2830
+ window.addEventListener('message', function (e) {
2831
+ if (!e.data || e.source !== iframe.contentWindow) return;
2832
+ var data;
2833
+ try { data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
2834
+ catch (err) { return; }
2835
+
2836
+ if (data.event === 'play') {
2837
+ tracker.playing = true;
2838
+ tracker.lastPosition = data.currentTime || 0;
2839
+ tracker._lastTimeUpdate = tracker.lastPosition;
2840
+ tracker.duration = data.duration || tracker.duration;
2841
+ sendEvent(tracker, 'media-play');
2842
+ startCheckpoints(tracker);
2843
+ } else if (data.event === 'pause') {
2844
+ if (!tracker.playing) return;
2845
+ tracker.playing = false;
2846
+ var pos = data.currentTime || 0;
2847
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2848
+ tracker.lastPosition = pos;
2849
+ stopCheckpoints(tracker);
2850
+ sendEvent(tracker, 'media-pause');
2851
+ } else if (data.event === 'ended') {
2852
+ tracker.playing = false;
2853
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2854
+ tracker.lastPosition = tracker.duration;
2855
+ stopCheckpoints(tracker);
2856
+ sendEvent(tracker, 'media-ended');
2857
+ } else if (data.event === 'timeupdate') {
2858
+ var pos = data.currentTime || 0;
2859
+ tracker.duration = data.duration || tracker.duration;
2860
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2861
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2862
+ }
2863
+ tracker._lastTimeUpdate = pos;
2864
+ tracker.lastPosition = pos;
2865
+ }
2866
+ });
2867
+
2868
+ // Request the iframe to emit events
2869
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'play' }, '*'); } catch(e){}
2870
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'pause' }, '*'); } catch(e){}
2871
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'ended' }, '*'); } catch(e){}
2872
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'timeupdate' }, '*'); } catch(e){}
2873
+ }