@qbix/q.js 1.0.2 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4885 @@
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
+ origin: location.origin,
105
+ page: Metrics._page || document.title,
106
+ label: label,
107
+ t: Date.now()
108
+ };
109
+ if (Metrics._extra) payload.extra = Metrics._extra;
110
+ if (data) payload.data = data;
111
+
112
+ var body = JSON.stringify(payload);
113
+ try {
114
+ if (navigator.sendBeacon) {
115
+ navigator.sendBeacon(Metrics._endpoint, new Blob([body], { type: 'text/plain' }));
116
+ } else {
117
+ fetch(Metrics._endpoint, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'text/plain' },
120
+ keepalive: true,
121
+ body: body
122
+ });
123
+ }
124
+ } catch (e) { /* silent */ }
125
+ };
126
+
127
+ // ── Visibility Detection ──
128
+ // Uses Q.onVisibilityChange when available, otherwise vendor-prefixed
129
+ // visibilitychange + mobile lifecycle events (Cordova/Capacitor)
130
+
131
+ Metrics._visible = true;
132
+ Metrics._visibilityCallbacks = [];
133
+ Metrics._visibilityBound = false;
134
+
135
+ /**
136
+ * Whether the page is currently visible
137
+ * @returns {Boolean}
138
+ */
139
+ Metrics.isVisible = function () {
140
+ return Metrics._visible;
141
+ };
142
+
143
+ /**
144
+ * Register a callback for visibility changes
145
+ * @param {Function} fn(isVisible) — called when visibility changes
146
+ * @param {String} [key] — optional key for deduplication
147
+ */
148
+ Metrics.onVisibilityChange = function (fn, key) {
149
+ if (key) {
150
+ // Replace existing callback with same key
151
+ for (var i = 0; i < Metrics._visibilityCallbacks.length; i++) {
152
+ if (Metrics._visibilityCallbacks[i].key === key) {
153
+ Metrics._visibilityCallbacks[i].fn = fn;
154
+ return;
155
+ }
156
+ }
157
+ }
158
+ Metrics._visibilityCallbacks.push({ fn: fn, key: key || null });
159
+ };
160
+
161
+ function _fireVisibility(isVisible) {
162
+ if (isVisible === Metrics._visible) return; // deduplicate
163
+ Metrics._visible = isVisible;
164
+ for (var i = 0; i < Metrics._visibilityCallbacks.length; i++) {
165
+ try { Metrics._visibilityCallbacks[i].fn(isVisible); } catch (e) {}
166
+ }
167
+ }
168
+
169
+ function _bindVisibility() {
170
+ if (Metrics._visibilityBound) return;
171
+ Metrics._visibilityBound = true;
172
+
173
+ // Detect vendor-prefixed visibility API
174
+ var visibilityChange = null;
175
+ var prefixes = ['', 'moz', 'ms', 'webkit', 'o'];
176
+ for (var i = 0; i < prefixes.length; i++) {
177
+ var k = prefixes[i];
178
+ var hidden = k ? k + 'Hidden' : 'hidden';
179
+ if (hidden in document) {
180
+ visibilityChange = k ? k + 'visibilitychange' : 'visibilitychange';
181
+ break;
182
+ }
183
+ }
184
+
185
+ function handleVisEvent(event) {
186
+ var isHidden;
187
+ if (event.type === 'pause' || event.type === 'resign') {
188
+ isHidden = true;
189
+ } else if (event.type === 'resume' || event.type === 'active') {
190
+ isHidden = false;
191
+ } else {
192
+ isHidden = document.visibilityState === 'hidden';
193
+ }
194
+ _fireVisibility(!isHidden);
195
+ }
196
+
197
+ if (visibilityChange) {
198
+ document.addEventListener(visibilityChange, handleVisEvent, false);
199
+ }
200
+ // Mobile lifecycle (Cordova / Capacitor)
201
+ document.addEventListener('pause', handleVisEvent, false);
202
+ document.addEventListener('resume', handleVisEvent, false);
203
+ document.addEventListener('resign', handleVisEvent, false);
204
+ document.addEventListener('active', handleVisEvent, false);
205
+ }
206
+
207
+ // ── Unload / bfcache ──
208
+
209
+ Metrics._unloadBound = false;
210
+
211
+ function _bindUnload() {
212
+ if (Metrics._unloadBound) return;
213
+ Metrics._unloadBound = true;
214
+
215
+ // Visibility-based exit (most reliable)
216
+ Metrics.onVisibilityChange(function (isVisible) {
217
+ if (!isVisible) {
218
+ _sendUnload();
219
+ } else {
220
+ Metrics._unloaded = false; // returned to page
221
+ }
222
+ }, 'Metrics.unload');
223
+
224
+ // pagehide fallback
225
+ window.addEventListener('pagehide', function () {
226
+ _sendUnload();
227
+ });
228
+
229
+ // bfcache restore
230
+ window.addEventListener('pageshow', function (e) {
231
+ if (e.persisted) {
232
+ Metrics._unloaded = false;
233
+ }
234
+ });
235
+ }
236
+
237
+ function _sendUnload() {
238
+ if (Metrics._unloaded) return;
239
+ Metrics._unloaded = true;
240
+ var elapsed = Math.round((Date.now() - Metrics._startTime) / 1000);
241
+ Metrics.send('unload:' + elapsed + 's');
242
+ }
243
+
244
+ /**
245
+ * Initialize standalone page tracking (no Q framework needed)
246
+ * @param {Object} options
247
+ * @param {String} options.endpoint — POST URL for beacons
248
+ * @param {String} [options.page] — page identifier
249
+ * @param {String} [options.sessionKey] — sessionStorage key
250
+ * @param {String} [options.sessionId] — override session ID
251
+ * @param {Object} [options.extra] — extra data with every event
252
+ * @param {Boolean} [options.trackUnload=true] — send unload beacon
253
+ */
254
+ Metrics._defaultEndpoint = 'https://invites.to/metrics';
255
+
256
+ Metrics.init = function (options) {
257
+ options = options || {};
258
+ Metrics._endpoint = options.endpoint || Metrics._defaultEndpoint;
259
+ if (options.page) Metrics._page = options.page;
260
+ if (options.sessionKey) Metrics._sessionKey = options.sessionKey;
261
+ if (options.sessionId) Metrics._sid = options.sessionId;
262
+ if (options.extra) Metrics._extra = options.extra;
263
+
264
+ _bindVisibility();
265
+ if (options.trackUnload !== false) {
266
+ _bindUnload();
267
+ }
268
+
269
+ Metrics.send('loaded');
270
+ return Metrics;
271
+ };
272
+
273
+ })(typeof window !== 'undefined' ? window : this);
274
+
275
+
276
+ // ── Q Framework Integration (only runs if Q exists) ──
277
+ if (typeof Q !== 'undefined') {
278
+ (function (Q) {
279
+
280
+ // Bridge: make Q.Metrics point to the global Metrics
281
+ Q.Metrics = Q.plugins.Metrics = window.Metrics;
282
+ var Metrics = window.Metrics;
283
+
284
+ Metrics.setState = function (state, extra) {
285
+ var url = Q.info.url;
286
+ Metrics.setState.pending[url] = Q.setTimeout(function () {
287
+ if (Metrics.setState.pending[url]) {
288
+ clearTimeout(Metrics.setState.pending[url]);
289
+ delete Metrics.setState.pending[url];
290
+ }
291
+ Q.req('Metrics/update', [], null, {
292
+ method: 'POST',
293
+ fields: {
294
+ navigatorUrl: location.href,
295
+ url: Q.info.url,
296
+ state: state,
297
+ extra: JSON.stringify(extra)
298
+ },
299
+ keepalive: true
300
+ });
301
+ }, 5000);
302
+ };
303
+ Metrics.setState.pending = {};
304
+
305
+ var dc = Q.extend.dontCopy;
306
+ dc["Q.Users.User"] = true;
307
+
308
+ Q.text.Metrics = {};
309
+
310
+ function ensureVisitInHash(visitId) {
311
+ var current = location.hash || '#';
312
+ var updated = current.queryField('v', visitId);
313
+ if (updated !== current) {
314
+ history.replaceState(
315
+ history.state,
316
+ document.title,
317
+ updated
318
+ );
319
+ }
320
+ }
321
+
322
+ Q.onReady.add(function () {
323
+ // If Q.onVisibilityChange exists, bridge it to Metrics visibility
324
+ if (Q.onVisibilityChange && Q.onVisibilityChange.set) {
325
+ Q.onVisibilityChange.set(function (shown) {
326
+ // Sync Q's visibility detection into Metrics
327
+ if (Metrics._visible !== shown) {
328
+ Metrics._visible = shown;
329
+ for (var i = 0; i < Metrics._visibilityCallbacks.length; i++) {
330
+ try { Metrics._visibilityCallbacks[i].fn(shown); } catch (e) {}
331
+ }
332
+ }
333
+ }, 'Metrics');
334
+ }
335
+
336
+ // Initialize NavigationTracker if configured
337
+ var stConfig = Q.getObject('Metrics.navigationTracker', Q.plugins)
338
+ || Q.getObject('Metrics.navigationTracker', Q);
339
+ if (stConfig && Metrics.NavigationTracker) {
340
+ stConfig.page = stConfig.page || Q.info.url || document.title;
341
+ Metrics.NavigationTracker.init(stConfig);
342
+ }
343
+
344
+ // Initialize MediaTracker if configured
345
+ var mtConfig = Q.getObject('Metrics.mediaTracker', Q.plugins)
346
+ || Q.getObject('Metrics.mediaTracker', Q);
347
+ if (mtConfig && Metrics.MediaTracker) {
348
+ Metrics.MediaTracker.init(mtConfig);
349
+ }
350
+
351
+ // ── Auto-wire into Q tools for navigation tracking ──
352
+ var NT = Metrics.NavigationTracker;
353
+ if (NT) {
354
+ // Q/tabs — track tab switches (debounced to avoid rapid fire)
355
+ var _tabDebounce = null;
356
+ Q.Tool.onActivate('Q/tabs').set(function () {
357
+ var tabsTool = this;
358
+ tabsTool.state.onCurrent.set(function (tab, tabName) {
359
+ clearTimeout(_tabDebounce);
360
+ var _name = tabName;
361
+ _tabDebounce = setTimeout(function () {
362
+ if (_name && NT.state.initialized) {
363
+ NT.opened('tab:' + _name);
364
+ }
365
+ }, 300);
366
+ }, 'Metrics.NavigationTracker');
367
+ }, 'Metrics.NavigationTracker');
368
+
369
+ // Q/columns — track column open/close
370
+ Q.Tool.onActivate('Q/columns').set(function () {
371
+ var columnsTool = this;
372
+ columnsTool.state.onActivate.set(function (div, options, index) {
373
+ if (!NT.state.initialized) return;
374
+ var name = (div && div.getAttribute('data-name')) || ('column-' + index);
375
+ NT.opened('column:' + name);
376
+ }, 'Metrics.NavigationTracker');
377
+ columnsTool.state.onClose.set(function (index, div) {
378
+ if (!NT.state.initialized) return;
379
+ var name = (div && div.getAttribute('data-name')) || ('column-' + index);
380
+ NT.closed('column:' + name);
381
+ }, 'Metrics.NavigationTracker');
382
+ }, 'Metrics.NavigationTracker');
383
+
384
+ // Q/expandable — track expand/collapse
385
+ Q.Tool.onActivate('Q/expandable').set(function () {
386
+ var expTool = this;
387
+ var expId = expTool.element.id
388
+ || expTool.element.getAttribute('data-name')
389
+ || expTool.id;
390
+ expTool.state.onExpand.set(function () {
391
+ if (!NT.state.initialized) return;
392
+ NT.opened('expandable:' + expId);
393
+ }, 'Metrics.NavigationTracker');
394
+ expTool.state.onCollapse.set(function () {
395
+ if (!NT.state.initialized) return;
396
+ NT.closed('expandable:' + expId);
397
+ }, 'Metrics.NavigationTracker');
398
+ }, 'Metrics.NavigationTracker');
399
+
400
+ // Q.Contextual — track contextual menu show/hide/item selection
401
+ if (Q.Contextual) {
402
+ Q.Contextual.onShow.set(function (contextual) {
403
+ if (!NT.state.initialized) return;
404
+ var $ctx = $(contextual);
405
+ var $trigger = $ctx.data('Q/contextual trigger');
406
+ var ctxId = ($trigger && $trigger.attr('data-name'))
407
+ || ($trigger && $trigger.attr('id'))
408
+ || 'contextual-' + Q.Contextual.current;
409
+ NT.opened('contextual:' + ctxId);
410
+
411
+ // Track links/items scrolling into view inside the contextual
412
+ var listing = contextual.querySelector
413
+ ? contextual.querySelector('.Q_listing_wrapper, .Q_listing')
414
+ : null;
415
+ if (listing) {
416
+ NT.observeNavContainer(listing);
417
+ }
418
+ }, 'Metrics.NavigationTracker');
419
+
420
+ Q.Contextual.onHide.set(function (contextual) {
421
+ if (!NT.state.initialized) return;
422
+ if (NT.state.activeSection
423
+ && NT.state.activeSection.indexOf('contextual:') === 0) {
424
+ NT.closed(NT.state.activeSection);
425
+ }
426
+ }, 'Metrics.NavigationTracker');
427
+
428
+ // Intercept contextual item selection
429
+ var _origItemHandler = Q.Contextual.itemSelectHandler;
430
+ if (_origItemHandler) {
431
+ Q.Contextual.itemSelectHandler = function (element, event) {
432
+ if (NT.state.initialized) {
433
+ var action = element.getAttribute('data-action')
434
+ || element.getAttribute('data-name')
435
+ || (element.textContent || '').trim().slice(0, 40);
436
+ Metrics.send('contextual-item:' + action);
437
+ }
438
+ return _origItemHandler.apply(this, arguments);
439
+ };
440
+ }
441
+ }
442
+ }
443
+
444
+ // Visit chaining — look for a parent visitId in the hash
445
+ var parentVisitId = location.hash.queryField('v');
446
+ if (!parentVisitId) {
447
+ return;
448
+ }
449
+ ensureVisitInHash(parentVisitId);
450
+
451
+ Q.req('Metrics/landed', {
452
+ method: 'POST',
453
+ fields: { trackerId: 'visitId:' + parentVisitId }
454
+ }, function (err, res) {
455
+ if (err) {
456
+ if (window.console) {
457
+ console.error('Metrics landed request failed', err);
458
+ }
459
+ return;
460
+ }
461
+ if (res && res.slots && res.slots.visitId) {
462
+ ensureVisitInHash(res.slots.visitId);
463
+ }
464
+ });
465
+ }, 'Metrics');
466
+
467
+ // Error telemetry
468
+ (function () {
469
+ function sendErrorTelemetry(errorInfo) {
470
+ var payload = JSON.stringify({ error: errorInfo });
471
+ Q.req('Metrics/update', [], null, {
472
+ method: 'POST',
473
+ fields: {
474
+ navigatorUrl: location.href,
475
+ url: Q.info && Q.info.url,
476
+ state: 'error',
477
+ extra: payload
478
+ },
479
+ keepalive: true
480
+ });
481
+ }
482
+
483
+ function formatErrorPayload(message, stack, details) {
484
+ var payload = {
485
+ message: message || '',
486
+ stack: stack || '',
487
+ url: location.href,
488
+ userAgent: navigator.userAgent,
489
+ timestamp: Date.now(),
490
+ performanceNow: performance.now()
491
+ };
492
+ if (details) {
493
+ payload.details = details;
494
+ }
495
+ return payload;
496
+ }
497
+
498
+ function handleError(reason, isRejection) {
499
+ var message = '';
500
+ var stack = '';
501
+ var details;
502
+
503
+ if (reason instanceof Error) {
504
+ message = reason.message;
505
+ stack = reason.stack;
506
+ } else if (typeof reason === 'string') {
507
+ message = reason;
508
+ } else if (reason && typeof reason === 'object') {
509
+ try {
510
+ details = JSON.stringify(reason);
511
+ } catch (e) {
512
+ details = '[unserializable reason]';
513
+ }
514
+ }
515
+
516
+ var errorInfo = formatErrorPayload(message, stack, details);
517
+ sendErrorTelemetry(errorInfo);
518
+ console.warn(isRejection ? 'Unhandled rejection:' : 'Unhandled error:', reason);
519
+
520
+ if (message && /indexedDB/i.test(message)) {
521
+ console.warn('[Recovery] Error suggests IndexedDB corruption. Triggering recovery...');
522
+ }
523
+ }
524
+
525
+ window.addEventListener('unhandledrejection', function (event) {
526
+ handleError(event.reason, true);
527
+ });
528
+
529
+ window.addEventListener('error', function (event) {
530
+ handleError(event.error || event.message, false);
531
+ });
532
+ })();
533
+
534
+ })(Q);
535
+ }
536
+
537
+ /**
538
+ * Metrics.ScrollTracker — Section-aware scroll telemetry
539
+ *
540
+ * Tracks which sections a user reads, how far they scroll,
541
+ * and what they click. Delegates transport, session, visibility,
542
+ * and unload handling to the core Metrics object.
543
+ *
544
+ * Usage (standalone):
545
+ * Metrics.init({ endpoint: '/telemetry.php', page: 'My Page' });
546
+ * Metrics.ScrollTracker.init({
547
+ * sections: 'h2[id], h3[id]',
548
+ * debounce: 1000
549
+ * });
550
+ *
551
+ * Usage (with Q framework):
552
+ * // Auto-initializes from config if endpoint is set
553
+ *
554
+ * @module Metrics
555
+ * @class Metrics.ScrollTracker
556
+ */
557
+ "use strict";
558
+ (function (root) {
559
+
560
+ var Metrics = root.Metrics;
561
+ if (!Metrics) {
562
+ console.warn('Metrics.ScrollTracker: Metrics core not loaded');
563
+ return;
564
+ }
565
+
566
+ var defaults = {
567
+ // CSS selector for sections to track
568
+ sections: 'h2[id], h3[id], section[id], [data-section]',
569
+
570
+ // Minimum pixel height for auto-detected containers
571
+ minSectionHeight: 100,
572
+
573
+ // Milliseconds to wait after scroll stops before firing
574
+ debounce: 1000,
575
+
576
+ // Milliseconds to wait on page load (ignores scroll restoration)
577
+ initDelay: 800,
578
+
579
+ // Milliseconds to suppress tracking after anchor click
580
+ anchorCooldown: 1500,
581
+
582
+ // Scroll depth milestones (percentage)
583
+ depthMilestones: [25, 50, 75, 100],
584
+
585
+ // Max px above viewport top to consider a section "current"
586
+ sectionLookback: 300,
587
+
588
+ // Scroll considered settled if moved less than this (px)
589
+ settleTolerance: 2,
590
+
591
+ // Recheck interval when not settled (ms)
592
+ recheckInterval: 500,
593
+
594
+ // Track link/anchor clicks
595
+ trackClicks: true,
596
+
597
+ // Visual TOC highlighting selector (real-time, not debounced)
598
+ tocSelector: null,
599
+ tocActiveClass: 'active',
600
+ tocSectionSelector: 'h2[id]'
601
+ };
602
+
603
+ // ── State ──
604
+ var state = {
605
+ initialized: false,
606
+ options: null,
607
+ sections: [],
608
+ tocSections: [],
609
+ seen: {},
610
+ depthHit: {},
611
+ scrollTimer: null,
612
+ anchorCooling: false,
613
+ _prevY: -1
614
+ };
615
+
616
+ // ── Section Discovery ──
617
+
618
+ function discoverSections(selector) {
619
+ var elements = document.querySelectorAll(selector);
620
+ var result = [];
621
+ var ordinal = 0;
622
+ var minH = (state.options && state.options.minSectionHeight) || 0;
623
+
624
+ for (var i = 0; i < elements.length; i++) {
625
+ var el = elements[i];
626
+ if (el.offsetHeight < minH) continue;
627
+
628
+ if (!el.id) {
629
+ var text = (el.textContent || '').trim().slice(0, 60);
630
+ var slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
631
+ el.id = slug || ('section-' + ordinal);
632
+ }
633
+
634
+ result.push({
635
+ el: el,
636
+ id: el.id,
637
+ tag: el.tagName.toLowerCase(),
638
+ ordinal: ordinal++,
639
+ snippet: (el.textContent || '').trim().slice(0, 80)
640
+ });
641
+ }
642
+ return result;
643
+ }
644
+
645
+ // ── Find Current Section ──
646
+
647
+ function findCurrentSection() {
648
+ var scrollY = window.scrollY || window.pageYOffset;
649
+ var opts = state.options;
650
+ var best = null;
651
+ var bestDist = Infinity;
652
+
653
+ for (var i = 0; i < state.sections.length; i++) {
654
+ var sec = state.sections[i];
655
+ var top = sec.el.offsetTop;
656
+ if (top <= scrollY + opts.sectionLookback) {
657
+ var dist = Math.abs(top - scrollY - 180);
658
+ if (dist < bestDist) {
659
+ bestDist = dist;
660
+ best = sec;
661
+ }
662
+ }
663
+ }
664
+ return best;
665
+ }
666
+
667
+ // ── Scroll Settle Detection ──
668
+
669
+ function onScrollSettle() {
670
+ var opts = state.options;
671
+ var curY = window.scrollY || window.pageYOffset;
672
+
673
+ if (Math.abs(curY - state._prevY) > opts.settleTolerance) {
674
+ state._prevY = curY;
675
+ state.scrollTimer = setTimeout(onScrollSettle, opts.recheckInterval);
676
+ return;
677
+ }
678
+
679
+ // Settled — report section
680
+ var current = findCurrentSection();
681
+ if (current && !state.seen[current.id]) {
682
+ state.seen[current.id] = true;
683
+ Metrics.send('section:' + current.id, {
684
+ tag: current.tag,
685
+ ordinal: current.ordinal,
686
+ snippet: current.snippet
687
+ });
688
+ }
689
+
690
+ // Report depth
691
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
692
+ if (docH > 0) {
693
+ var pct = Math.round((curY / docH) * 100);
694
+ for (var j = 0; j < opts.depthMilestones.length; j++) {
695
+ var m = opts.depthMilestones[j];
696
+ if (pct >= m && !state.depthHit[m]) {
697
+ state.depthHit[m] = true;
698
+ Metrics.send('depth:' + m + '%');
699
+ }
700
+ }
701
+ }
702
+ }
703
+
704
+ function onScroll() {
705
+ if (state.anchorCooling) return;
706
+ clearTimeout(state.scrollTimer);
707
+ state._prevY = window.scrollY || window.pageYOffset;
708
+ state.scrollTimer = setTimeout(onScrollSettle, state.options.debounce);
709
+ }
710
+
711
+ // ── TOC Highlighting (real-time) ──
712
+
713
+ function updateTocHighlight() {
714
+ var opts = state.options;
715
+ if (!opts.tocSelector) return;
716
+
717
+ var scrollY = window.scrollY || window.pageYOffset;
718
+ var currentId = '';
719
+
720
+ for (var i = 0; i < state.tocSections.length; i++) {
721
+ if (scrollY >= state.tocSections[i].el.offsetTop - 160) {
722
+ currentId = state.tocSections[i].id;
723
+ }
724
+ }
725
+
726
+ var links = document.querySelectorAll(opts.tocSelector);
727
+ for (var j = 0; j < links.length; j++) {
728
+ links[j].classList.remove(opts.tocActiveClass);
729
+ if (links[j].getAttribute('href') === '#' + currentId) {
730
+ links[j].classList.add(opts.tocActiveClass);
731
+ }
732
+ }
733
+ }
734
+
735
+ // ── Click Tracking ──
736
+
737
+ function onDocumentClick(e) {
738
+ var a = e.target.closest('a[href]');
739
+ if (!a) return;
740
+
741
+ var href = a.getAttribute('href') || '';
742
+
743
+ if (href.charAt(0) === '#') {
744
+ // Anchor click — cooldown to suppress scroll tracking
745
+ state.anchorCooling = true;
746
+ setTimeout(function () { state.anchorCooling = false; },
747
+ state.options.anchorCooldown);
748
+ Metrics.send('anchor:' + href.slice(1));
749
+ return;
750
+ }
751
+
752
+ // External link
753
+ var label = a.dataset.track || 'link:' + href;
754
+ Metrics.send(label);
755
+ }
756
+
757
+ // ── Pre-mark Initial State ──
758
+
759
+ function premarkInitialState() {
760
+ var scrollY = window.scrollY || window.pageYOffset;
761
+
762
+ for (var i = 0; i < state.sections.length; i++) {
763
+ var rect = state.sections[i].el.getBoundingClientRect();
764
+ if (rect.top >= -100 && rect.top < window.innerHeight) {
765
+ state.seen[state.sections[i].id] = true;
766
+ }
767
+ }
768
+
769
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
770
+ if (docH > 0) {
771
+ var pct = Math.round((scrollY / docH) * 100);
772
+ for (var j = 0; j < state.options.depthMilestones.length; j++) {
773
+ var m = state.options.depthMilestones[j];
774
+ if (pct >= m) state.depthHit[m] = true;
775
+ }
776
+ }
777
+ }
778
+
779
+ // ── Public API ──
780
+
781
+ Metrics.ScrollTracker = {
782
+
783
+ init: function (options) {
784
+ if (state.initialized) {
785
+ console.warn('Metrics.ScrollTracker already initialized');
786
+ return this;
787
+ }
788
+
789
+ var opts = {};
790
+ var k;
791
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
792
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
793
+ state.options = opts;
794
+ state.initialized = true;
795
+
796
+ // If Metrics core hasn't been initialized yet with an endpoint,
797
+ // initialize it now from our options
798
+ if (!Metrics._endpoint && options && options.endpoint) {
799
+ Metrics.init({
800
+ endpoint: options.endpoint,
801
+ page: options.page,
802
+ sessionKey: options.sessionKey,
803
+ sessionId: options.sessionId,
804
+ extra: options.extra,
805
+ trackUnload: options.trackUnload
806
+ });
807
+ }
808
+
809
+ // Delayed init — let scroll restoration settle
810
+ setTimeout(function () {
811
+ state.sections = discoverSections(opts.sections);
812
+
813
+ if (opts.tocSelector && opts.tocSectionSelector) {
814
+ state.tocSections = discoverSections(opts.tocSectionSelector);
815
+ }
816
+
817
+ premarkInitialState();
818
+
819
+ window.addEventListener('scroll', onScroll, { passive: true });
820
+
821
+ if (opts.tocSelector) {
822
+ window.addEventListener('scroll', updateTocHighlight, { passive: true });
823
+ updateTocHighlight();
824
+ }
825
+ }, opts.initDelay);
826
+
827
+ if (opts.trackClicks) {
828
+ document.addEventListener('click', onDocumentClick);
829
+ }
830
+
831
+ return this;
832
+ },
833
+
834
+ send: function (label, data) { Metrics.send(label, data); },
835
+ markSeen: function (id) { state.seen[id] = true; },
836
+ getSessionId: function () { return Metrics.getSessionId(); },
837
+ getSections: function () {
838
+ return state.sections.map(function (s) {
839
+ return { id: s.id, tag: s.tag, ordinal: s.ordinal, snippet: s.snippet };
840
+ });
841
+ },
842
+ getSeen: function () {
843
+ var copy = {};
844
+ for (var k in state.seen) copy[k] = true;
845
+ return copy;
846
+ },
847
+ reset: function () {
848
+ state.seen = {};
849
+ state.depthHit = {};
850
+ state.anchorCooling = false;
851
+ clearTimeout(state.scrollTimer);
852
+ state.sections = discoverSections(state.options.sections);
853
+ premarkInitialState();
854
+ },
855
+ destroy: function () {
856
+ window.removeEventListener('scroll', onScroll);
857
+ window.removeEventListener('scroll', updateTocHighlight);
858
+ document.removeEventListener('click', onDocumentClick);
859
+ clearTimeout(state.scrollTimer);
860
+ state.initialized = false;
861
+ },
862
+
863
+ defaults: defaults,
864
+ state: state
865
+ };
866
+
867
+ })(typeof window !== 'undefined' ? window : this);
868
+
869
+ /**
870
+ * Metrics.ScrollTracker — Section-aware scroll telemetry
871
+ *
872
+ * Tracks which sections a user reads, how far they scroll,
873
+ * and what they click. Delegates transport, session, visibility,
874
+ * and unload handling to the core Metrics object.
875
+ *
876
+ * Usage (standalone):
877
+ * Metrics.init({ endpoint: '/telemetry.php', page: 'My Page' });
878
+ * Metrics.ScrollTracker.init({
879
+ * sections: 'h2[id], h3[id]',
880
+ * debounce: 1000
881
+ * });
882
+ *
883
+ * Usage (with Q framework):
884
+ * // Auto-initializes from config if endpoint is set
885
+ *
886
+ * @module Metrics
887
+ * @class Metrics.ScrollTracker
888
+ */
889
+ "use strict";
890
+ (function (root) {
891
+
892
+ var Metrics = root.Metrics;
893
+ if (!Metrics) {
894
+ console.warn('Metrics.ScrollTracker: Metrics core not loaded');
895
+ return;
896
+ }
897
+
898
+ var defaults = {
899
+ // CSS selector for sections to track
900
+ sections: 'h2[id], h3[id], section[id], [data-section]',
901
+
902
+ // Minimum pixel height for auto-detected containers
903
+ minSectionHeight: 100,
904
+
905
+ // Milliseconds to wait after scroll stops before firing
906
+ debounce: 1000,
907
+
908
+ // Milliseconds to wait on page load (ignores scroll restoration)
909
+ initDelay: 800,
910
+
911
+ // Milliseconds to suppress tracking after anchor click
912
+ anchorCooldown: 1500,
913
+
914
+ // Scroll depth milestones (percentage)
915
+ depthMilestones: [25, 50, 75, 100],
916
+
917
+ // Max px above viewport top to consider a section "current"
918
+ sectionLookback: 300,
919
+
920
+ // Scroll considered settled if moved less than this (px)
921
+ settleTolerance: 2,
922
+
923
+ // Recheck interval when not settled (ms)
924
+ recheckInterval: 500,
925
+
926
+ // Track link/anchor clicks
927
+ trackClicks: true,
928
+
929
+ // Visual TOC highlighting selector (real-time, not debounced)
930
+ tocSelector: null,
931
+ tocActiveClass: 'active',
932
+ tocSectionSelector: 'h2[id]'
933
+ };
934
+
935
+ // ── State ──
936
+ var state = {
937
+ initialized: false,
938
+ options: null,
939
+ sections: [],
940
+ tocSections: [],
941
+ seen: {},
942
+ depthHit: {},
943
+ scrollTimer: null,
944
+ anchorCooling: false,
945
+ _prevY: -1
946
+ };
947
+
948
+ // ── Section Discovery ──
949
+
950
+ function discoverSections(selector) {
951
+ var elements = document.querySelectorAll(selector);
952
+ var result = [];
953
+ var ordinal = 0;
954
+ var minH = (state.options && state.options.minSectionHeight) || 0;
955
+
956
+ for (var i = 0; i < elements.length; i++) {
957
+ var el = elements[i];
958
+ if (el.offsetHeight < minH) continue;
959
+
960
+ if (!el.id) {
961
+ var text = (el.textContent || '').trim().slice(0, 60);
962
+ var slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
963
+ el.id = slug || ('section-' + ordinal);
964
+ }
965
+
966
+ result.push({
967
+ el: el,
968
+ id: el.id,
969
+ tag: el.tagName.toLowerCase(),
970
+ ordinal: ordinal++,
971
+ snippet: (el.textContent || '').trim().slice(0, 80)
972
+ });
973
+ }
974
+ return result;
975
+ }
976
+
977
+ // ── Find Current Section ──
978
+
979
+ function findCurrentSection() {
980
+ var scrollY = window.scrollY || window.pageYOffset;
981
+ var opts = state.options;
982
+ var best = null;
983
+ var bestDist = Infinity;
984
+
985
+ for (var i = 0; i < state.sections.length; i++) {
986
+ var sec = state.sections[i];
987
+ var top = sec.el.offsetTop;
988
+ if (top <= scrollY + opts.sectionLookback) {
989
+ var dist = Math.abs(top - scrollY - 180);
990
+ if (dist < bestDist) {
991
+ bestDist = dist;
992
+ best = sec;
993
+ }
994
+ }
995
+ }
996
+ return best;
997
+ }
998
+
999
+ // ── Scroll Settle Detection ──
1000
+
1001
+ function onScrollSettle() {
1002
+ var opts = state.options;
1003
+ var curY = window.scrollY || window.pageYOffset;
1004
+
1005
+ if (Math.abs(curY - state._prevY) > opts.settleTolerance) {
1006
+ state._prevY = curY;
1007
+ state.scrollTimer = setTimeout(onScrollSettle, opts.recheckInterval);
1008
+ return;
1009
+ }
1010
+
1011
+ // Settled — report section
1012
+ var current = findCurrentSection();
1013
+ if (current && !state.seen[current.id]) {
1014
+ state.seen[current.id] = true;
1015
+ Metrics.send('section:' + current.id, {
1016
+ tag: current.tag,
1017
+ ordinal: current.ordinal,
1018
+ snippet: current.snippet
1019
+ });
1020
+ }
1021
+
1022
+ // Report depth
1023
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
1024
+ if (docH > 0) {
1025
+ var pct = Math.round((curY / docH) * 100);
1026
+ for (var j = 0; j < opts.depthMilestones.length; j++) {
1027
+ var m = opts.depthMilestones[j];
1028
+ if (pct >= m && !state.depthHit[m]) {
1029
+ state.depthHit[m] = true;
1030
+ Metrics.send('depth:' + m + '%');
1031
+ }
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ function onScroll() {
1037
+ if (state.anchorCooling) return;
1038
+ clearTimeout(state.scrollTimer);
1039
+ state._prevY = window.scrollY || window.pageYOffset;
1040
+ state.scrollTimer = setTimeout(onScrollSettle, state.options.debounce);
1041
+ }
1042
+
1043
+ // ── TOC Highlighting (real-time) ──
1044
+
1045
+ function updateTocHighlight() {
1046
+ var opts = state.options;
1047
+ if (!opts.tocSelector) return;
1048
+
1049
+ var scrollY = window.scrollY || window.pageYOffset;
1050
+ var currentId = '';
1051
+
1052
+ for (var i = 0; i < state.tocSections.length; i++) {
1053
+ if (scrollY >= state.tocSections[i].el.offsetTop - 160) {
1054
+ currentId = state.tocSections[i].id;
1055
+ }
1056
+ }
1057
+
1058
+ var links = document.querySelectorAll(opts.tocSelector);
1059
+ for (var j = 0; j < links.length; j++) {
1060
+ links[j].classList.remove(opts.tocActiveClass);
1061
+ if (links[j].getAttribute('href') === '#' + currentId) {
1062
+ links[j].classList.add(opts.tocActiveClass);
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ // ── Click Tracking ──
1068
+
1069
+ function onDocumentClick(e) {
1070
+ var a = e.target.closest('a[href]');
1071
+ if (!a) return;
1072
+
1073
+ var href = a.getAttribute('href') || '';
1074
+
1075
+ if (href.charAt(0) === '#') {
1076
+ // Anchor click — cooldown to suppress scroll tracking
1077
+ state.anchorCooling = true;
1078
+ setTimeout(function () { state.anchorCooling = false; },
1079
+ state.options.anchorCooldown);
1080
+ Metrics.send('anchor:' + href.slice(1));
1081
+ return;
1082
+ }
1083
+
1084
+ // External link
1085
+ var label = a.dataset.track || 'link:' + href;
1086
+ Metrics.send(label);
1087
+ }
1088
+
1089
+ // ── Pre-mark Initial State ──
1090
+
1091
+ function premarkInitialState() {
1092
+ var scrollY = window.scrollY || window.pageYOffset;
1093
+
1094
+ for (var i = 0; i < state.sections.length; i++) {
1095
+ var rect = state.sections[i].el.getBoundingClientRect();
1096
+ if (rect.top >= -100 && rect.top < window.innerHeight) {
1097
+ state.seen[state.sections[i].id] = true;
1098
+ }
1099
+ }
1100
+
1101
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
1102
+ if (docH > 0) {
1103
+ var pct = Math.round((scrollY / docH) * 100);
1104
+ for (var j = 0; j < state.options.depthMilestones.length; j++) {
1105
+ var m = state.options.depthMilestones[j];
1106
+ if (pct >= m) state.depthHit[m] = true;
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ // ── Public API ──
1112
+
1113
+ Metrics.ScrollTracker = {
1114
+
1115
+ init: function (options) {
1116
+ if (state.initialized) {
1117
+ console.warn('Metrics.ScrollTracker already initialized');
1118
+ return this;
1119
+ }
1120
+
1121
+ var opts = {};
1122
+ var k;
1123
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
1124
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
1125
+ state.options = opts;
1126
+ state.initialized = true;
1127
+
1128
+ // If Metrics core hasn't been initialized yet with an endpoint,
1129
+ // initialize it now from our options
1130
+ if (!Metrics._endpoint && options && options.endpoint) {
1131
+ Metrics.init({
1132
+ endpoint: options.endpoint,
1133
+ page: options.page,
1134
+ sessionKey: options.sessionKey,
1135
+ sessionId: options.sessionId,
1136
+ extra: options.extra,
1137
+ trackUnload: options.trackUnload
1138
+ });
1139
+ }
1140
+
1141
+ // Delayed init — let scroll restoration settle
1142
+ setTimeout(function () {
1143
+ state.sections = discoverSections(opts.sections);
1144
+
1145
+ if (opts.tocSelector && opts.tocSectionSelector) {
1146
+ state.tocSections = discoverSections(opts.tocSectionSelector);
1147
+ }
1148
+
1149
+ premarkInitialState();
1150
+
1151
+ window.addEventListener('scroll', onScroll, { passive: true });
1152
+
1153
+ if (opts.tocSelector) {
1154
+ window.addEventListener('scroll', updateTocHighlight, { passive: true });
1155
+ updateTocHighlight();
1156
+ }
1157
+ }, opts.initDelay);
1158
+
1159
+ if (opts.trackClicks) {
1160
+ document.addEventListener('click', onDocumentClick);
1161
+ }
1162
+
1163
+ return this;
1164
+ },
1165
+
1166
+ send: function (label, data) { Metrics.send(label, data); },
1167
+ markSeen: function (id) { state.seen[id] = true; },
1168
+ getSessionId: function () { return Metrics.getSessionId(); },
1169
+ getSections: function () {
1170
+ return state.sections.map(function (s) {
1171
+ return { id: s.id, tag: s.tag, ordinal: s.ordinal, snippet: s.snippet };
1172
+ });
1173
+ },
1174
+ getSeen: function () {
1175
+ var copy = {};
1176
+ for (var k in state.seen) copy[k] = true;
1177
+ return copy;
1178
+ },
1179
+ reset: function () {
1180
+ state.seen = {};
1181
+ state.depthHit = {};
1182
+ state.anchorCooling = false;
1183
+ clearTimeout(state.scrollTimer);
1184
+ state.sections = discoverSections(state.options.sections);
1185
+ premarkInitialState();
1186
+ },
1187
+ destroy: function () {
1188
+ window.removeEventListener('scroll', onScroll);
1189
+ window.removeEventListener('scroll', updateTocHighlight);
1190
+ document.removeEventListener('click', onDocumentClick);
1191
+ clearTimeout(state.scrollTimer);
1192
+ state.initialized = false;
1193
+ },
1194
+
1195
+ defaults: defaults,
1196
+ state: state
1197
+ };
1198
+
1199
+ })(typeof window !== 'undefined' ? window : this);
1200
+
1201
+ /**
1202
+ * Metrics.NavigationTracker — Track how users navigate and explore content
1203
+ *
1204
+ * Tracks scroll-based sections, dynamic panels (tabs, columns, expandables,
1205
+ * contextual menus), and links that scroll into view in navigation containers.
1206
+ * Delegates transport, session, visibility, and unload to Metrics core.
1207
+ *
1208
+ * Event prefixes:
1209
+ * section:id — scroll-based section reached viewport
1210
+ * depth:N% — scroll depth milestone
1211
+ * anchor:id — clicked an anchor link
1212
+ * link:url — clicked an external link
1213
+ * tab:name — tab switched to
1214
+ * column:name — column opened/closed
1215
+ * expandable:id — expandable opened/closed
1216
+ * contextual:id — contextual menu shown/hidden
1217
+ * contextual-item:action — item selected from contextual menu
1218
+ * nav-link:id — link scrolled into view in a nav container
1219
+ * opened:prefix:id — first time viewing a dynamic section
1220
+ * switched:prefix:id — returned to previously viewed section
1221
+ * dwell:prefix:id — time spent in a dynamic section
1222
+ *
1223
+ * @module Metrics
1224
+ * @class Metrics.NavigationTracker
1225
+ */
1226
+ "use strict";
1227
+ (function (root) {
1228
+
1229
+ var Metrics = root.Metrics;
1230
+ if (!Metrics) {
1231
+ console.warn('Metrics.NavigationTracker: Metrics core not loaded');
1232
+ return;
1233
+ }
1234
+
1235
+ var defaults = {
1236
+ // ── Scroll-based section tracking ──
1237
+ sections: 'h2[id], h3[id], section[id], [data-section]',
1238
+ minSectionHeight: 100,
1239
+ debounce: 1000,
1240
+ initDelay: 800,
1241
+ anchorCooldown: 1500,
1242
+ depthMilestones: [25, 50, 75, 100],
1243
+ sectionLookback: 300,
1244
+ settleTolerance: 2,
1245
+ recheckInterval: 500,
1246
+
1247
+ // ── Dynamic section tracking ──
1248
+ observeDom: false,
1249
+ observeRoot: null,
1250
+ observeSelector: '[data-section], [data-track-section]',
1251
+ trackDwell: true,
1252
+
1253
+ // ── Navigation container link tracking ──
1254
+ // CSS selector for scrollable nav containers whose links should be
1255
+ // tracked as they scroll into view
1256
+ navContainers: null, // e.g. '.Q_listing_wrapper, .sidebar-nav'
1257
+
1258
+ // ── Contextual menu tracking ──
1259
+ trackContextuals: true,
1260
+
1261
+ // ── Click & TOC ──
1262
+ trackClicks: true,
1263
+ tocSelector: null,
1264
+ tocActiveClass: 'active',
1265
+ tocSectionSelector: 'h2[id]'
1266
+ };
1267
+
1268
+ // ── State ──
1269
+ var state = {
1270
+ initialized: false,
1271
+ options: null,
1272
+ sections: [],
1273
+ tocSections: [],
1274
+ seen: {},
1275
+ depthHit: {},
1276
+ scrollTimer: null,
1277
+ anchorCooling: false,
1278
+ _prevY: -1,
1279
+ // Dynamic sections
1280
+ dynamicSections: {},
1281
+ activeSection: null,
1282
+ activeSince: null,
1283
+ // DOM observer
1284
+ _mutationObserver: null,
1285
+ // Nav container observers
1286
+ _navObservers: [],
1287
+ _navLinksSeen: {}
1288
+ };
1289
+
1290
+ // ── Utilities ──
1291
+
1292
+ function slugify(text) {
1293
+ return text.toLowerCase()
1294
+ .replace(/[^a-z0-9]+/g, '-')
1295
+ .replace(/^-|-$/g, '')
1296
+ .slice(0, 60);
1297
+ }
1298
+
1299
+ function snippet(el) {
1300
+ return (el.textContent || '').trim().slice(0, 80);
1301
+ }
1302
+
1303
+ function now() { return Date.now(); }
1304
+
1305
+ // ── Section Discovery ──
1306
+
1307
+ function discoverSections(selector) {
1308
+ var elements = document.querySelectorAll(selector);
1309
+ var result = [];
1310
+ var ordinal = 0;
1311
+ var minH = (state.options && state.options.minSectionHeight) || 0;
1312
+ for (var i = 0; i < elements.length; i++) {
1313
+ var el = elements[i];
1314
+ if (el.offsetHeight < minH) continue;
1315
+ if (!el.id) {
1316
+ el.id = slugify(el.textContent || '') || ('section-' + ordinal);
1317
+ }
1318
+ result.push({
1319
+ el: el, id: el.id,
1320
+ tag: el.tagName.toLowerCase(),
1321
+ ordinal: ordinal++,
1322
+ snippet: snippet(el)
1323
+ });
1324
+ }
1325
+ return result;
1326
+ }
1327
+
1328
+ // ── Find Current Section ──
1329
+
1330
+ function findCurrentSection() {
1331
+ var scrollY = window.scrollY || window.pageYOffset;
1332
+ var opts = state.options;
1333
+ var best = null, bestDist = Infinity;
1334
+ for (var i = 0; i < state.sections.length; i++) {
1335
+ var sec = state.sections[i];
1336
+ var top = sec.el.offsetTop;
1337
+ if (top <= scrollY + opts.sectionLookback) {
1338
+ var dist = Math.abs(top - scrollY - 180);
1339
+ if (dist < bestDist) { bestDist = dist; best = sec; }
1340
+ }
1341
+ }
1342
+ return best;
1343
+ }
1344
+
1345
+ // ── Scroll Settle ──
1346
+
1347
+ function onScrollSettle() {
1348
+ var opts = state.options;
1349
+ var curY = window.scrollY || window.pageYOffset;
1350
+ if (Math.abs(curY - state._prevY) > opts.settleTolerance) {
1351
+ state._prevY = curY;
1352
+ state.scrollTimer = setTimeout(onScrollSettle, opts.recheckInterval);
1353
+ return;
1354
+ }
1355
+ var current = findCurrentSection();
1356
+ if (current && !state.seen[current.id]) {
1357
+ state.seen[current.id] = true;
1358
+ Metrics.send('section:' + current.id, {
1359
+ tag: current.tag, ordinal: current.ordinal, snippet: current.snippet
1360
+ });
1361
+ }
1362
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
1363
+ if (docH > 0) {
1364
+ var pct = Math.round((curY / docH) * 100);
1365
+ for (var j = 0; j < opts.depthMilestones.length; j++) {
1366
+ var m = opts.depthMilestones[j];
1367
+ if (pct >= m && !state.depthHit[m]) {
1368
+ state.depthHit[m] = true;
1369
+ Metrics.send('depth:' + m + '%');
1370
+ }
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ function onScroll() {
1376
+ if (state.anchorCooling) return;
1377
+ clearTimeout(state.scrollTimer);
1378
+ state._prevY = window.scrollY || window.pageYOffset;
1379
+ state.scrollTimer = setTimeout(onScrollSettle, state.options.debounce);
1380
+ }
1381
+
1382
+ // ── TOC Highlighting ──
1383
+
1384
+ function updateTocHighlight() {
1385
+ var opts = state.options;
1386
+ if (!opts.tocSelector) return;
1387
+ var scrollY = window.scrollY || window.pageYOffset;
1388
+ var currentId = '';
1389
+ for (var i = 0; i < state.tocSections.length; i++) {
1390
+ if (scrollY >= state.tocSections[i].el.offsetTop - 160) {
1391
+ currentId = state.tocSections[i].id;
1392
+ }
1393
+ }
1394
+ var links = document.querySelectorAll(opts.tocSelector);
1395
+ for (var j = 0; j < links.length; j++) {
1396
+ links[j].classList.remove(opts.tocActiveClass);
1397
+ if (links[j].getAttribute('href') === '#' + currentId) {
1398
+ links[j].classList.add(opts.tocActiveClass);
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ // ── Click Tracking ──
1404
+
1405
+ function onDocumentClick(e) {
1406
+ var a = e.target.closest('a[href]');
1407
+ if (!a) return;
1408
+ var href = a.getAttribute('href') || '';
1409
+ if (href.charAt(0) === '#') {
1410
+ state.anchorCooling = true;
1411
+ setTimeout(function () { state.anchorCooling = false; },
1412
+ state.options.anchorCooldown);
1413
+ Metrics.send('anchor:' + href.slice(1));
1414
+ return;
1415
+ }
1416
+ var label = a.dataset.track || 'link:' + href;
1417
+ Metrics.send(label);
1418
+ }
1419
+
1420
+ // ── Dynamic Section Tracking ──
1421
+
1422
+ function _closeCurrent() {
1423
+ if (!state.activeSection || !state.options.trackDwell) return;
1424
+ var elapsed = Math.round((now() - state.activeSince) / 1000);
1425
+ if (elapsed > 0) {
1426
+ var sec = state.dynamicSections[state.activeSection];
1427
+ Metrics.send('dwell:' + state.activeSection, {
1428
+ seconds: elapsed,
1429
+ name: sec ? sec.name : state.activeSection
1430
+ });
1431
+ }
1432
+ state.activeSection = null;
1433
+ state.activeSince = null;
1434
+ }
1435
+
1436
+ function _observeElement(el, opts) {
1437
+ var id = opts.id || el.id || el.getAttribute('data-section')
1438
+ || el.getAttribute('data-track-section')
1439
+ || slugify(el.textContent || '') || ('dyn-' + Object.keys(state.dynamicSections).length);
1440
+ if (!el.id) el.id = id;
1441
+ state.dynamicSections[id] = {
1442
+ el: el, id: id,
1443
+ name: opts.name || el.getAttribute('data-section-name') || el.getAttribute('title') || id,
1444
+ snippet: snippet(el), observedAt: now()
1445
+ };
1446
+ if (window.IntersectionObserver && opts.autoTrack !== false) {
1447
+ var io = new IntersectionObserver(function (entries) {
1448
+ entries.forEach(function (entry) {
1449
+ if (entry.isIntersecting && entry.intersectionRatio > 0.3) {
1450
+ NT.opened(id);
1451
+ } else if (!entry.isIntersecting && state.activeSection === id) {
1452
+ NT.closed(id);
1453
+ }
1454
+ });
1455
+ }, { threshold: [0, 0.3] });
1456
+ io.observe(el);
1457
+ state.dynamicSections[id]._io = io;
1458
+ }
1459
+ return id;
1460
+ }
1461
+
1462
+ // ── Nav Container Link Tracking ──
1463
+ // Watches scrollable navigation containers and fires 'nav-link:' events
1464
+ // when links scroll into view (like contextual menu items, sidebar nav, etc.)
1465
+
1466
+ function _setupNavContainerTracking() {
1467
+ var selector = state.options.navContainers;
1468
+ if (!selector || !window.IntersectionObserver) return;
1469
+
1470
+ var containers = document.querySelectorAll(selector);
1471
+ for (var c = 0; c < containers.length; c++) {
1472
+ _observeNavContainer(containers[c]);
1473
+ }
1474
+ }
1475
+
1476
+ function _observeNavContainer(container) {
1477
+ var links = container.querySelectorAll('a[href], li[data-action], li[data-name]');
1478
+ if (!links.length) return;
1479
+
1480
+ var io = new IntersectionObserver(function (entries) {
1481
+ entries.forEach(function (entry) {
1482
+ if (!entry.isIntersecting) return;
1483
+ var el = entry.target;
1484
+ var linkId = el.getAttribute('data-name')
1485
+ || el.getAttribute('data-action')
1486
+ || el.getAttribute('href')
1487
+ || el.textContent.trim().slice(0, 40);
1488
+ var key = 'nav-link:' + linkId;
1489
+ if (!state._navLinksSeen[key]) {
1490
+ state._navLinksSeen[key] = true;
1491
+ Metrics.send(key, { text: el.textContent.trim().slice(0, 60) });
1492
+ }
1493
+ });
1494
+ }, {
1495
+ root: container,
1496
+ threshold: 0.5
1497
+ });
1498
+
1499
+ for (var i = 0; i < links.length; i++) {
1500
+ io.observe(links[i]);
1501
+ }
1502
+ state._navObservers.push(io);
1503
+ }
1504
+
1505
+ // ── DOM Mutation Observer ──
1506
+
1507
+ function _startMutationObserver() {
1508
+ if (!window.MutationObserver || !state.options.observeDom) return;
1509
+ var root = state.options.observeRoot || document.body;
1510
+ var selector = state.options.observeSelector;
1511
+ state._mutationObserver = new MutationObserver(function (mutations) {
1512
+ mutations.forEach(function (mutation) {
1513
+ mutation.addedNodes.forEach(function (node) {
1514
+ if (node.nodeType !== 1) return;
1515
+ if (node.matches && node.matches(selector)) {
1516
+ _observeElement(node, {});
1517
+ }
1518
+ if (node.querySelectorAll) {
1519
+ var matches = node.querySelectorAll(selector);
1520
+ for (var i = 0; i < matches.length; i++) {
1521
+ _observeElement(matches[i], {});
1522
+ }
1523
+ }
1524
+ });
1525
+ });
1526
+ });
1527
+ state._mutationObserver.observe(root, { childList: true, subtree: true });
1528
+ }
1529
+
1530
+ // ── Pre-mark Initial State ──
1531
+
1532
+ function premarkInitialState() {
1533
+ var scrollY = window.scrollY || window.pageYOffset;
1534
+ for (var i = 0; i < state.sections.length; i++) {
1535
+ var rect = state.sections[i].el.getBoundingClientRect();
1536
+ if (rect.top >= -100 && rect.top < window.innerHeight) {
1537
+ state.seen[state.sections[i].id] = true;
1538
+ }
1539
+ }
1540
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
1541
+ if (docH > 0) {
1542
+ var pct = Math.round((scrollY / docH) * 100);
1543
+ for (var j = 0; j < state.options.depthMilestones.length; j++) {
1544
+ var m = state.options.depthMilestones[j];
1545
+ if (pct >= m) state.depthHit[m] = true;
1546
+ }
1547
+ }
1548
+ }
1549
+
1550
+ // ── Public API ──
1551
+
1552
+ var NT = {
1553
+
1554
+ init: function (options) {
1555
+ if (state.initialized) {
1556
+ console.warn('Metrics.NavigationTracker already initialized');
1557
+ return this;
1558
+ }
1559
+ var opts = {};
1560
+ var k;
1561
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
1562
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
1563
+ state.options = opts;
1564
+ state.initialized = true;
1565
+
1566
+ if (!Metrics._endpoint && options && options.endpoint) {
1567
+ Metrics.init({
1568
+ endpoint: options.endpoint, page: options.page,
1569
+ sessionKey: options.sessionKey, sessionId: options.sessionId,
1570
+ extra: options.extra, trackUnload: options.trackUnload
1571
+ });
1572
+ }
1573
+
1574
+ // Delayed init
1575
+ setTimeout(function () {
1576
+ state.sections = discoverSections(opts.sections);
1577
+ if (opts.tocSelector && opts.tocSectionSelector) {
1578
+ state.tocSections = discoverSections(opts.tocSectionSelector);
1579
+ }
1580
+ premarkInitialState();
1581
+ window.addEventListener('scroll', onScroll, { passive: true });
1582
+ if (opts.tocSelector) {
1583
+ window.addEventListener('scroll', updateTocHighlight, { passive: true });
1584
+ updateTocHighlight();
1585
+ }
1586
+ if (opts.observeDom) _startMutationObserver();
1587
+ if (opts.navContainers) _setupNavContainerTracking();
1588
+ }, opts.initDelay);
1589
+
1590
+ if (opts.trackClicks) {
1591
+ document.addEventListener('click', onDocumentClick);
1592
+ }
1593
+
1594
+ if (opts.trackDwell) {
1595
+ Metrics.onVisibilityChange(function (visible) {
1596
+ if (!visible) _closeCurrent();
1597
+ }, 'NavigationTracker.dwell');
1598
+ }
1599
+
1600
+ return this;
1601
+ },
1602
+
1603
+ /**
1604
+ * Observe a dynamic element as a trackable section
1605
+ */
1606
+ observe: function (el, opts) {
1607
+ return _observeElement(el, opts || {});
1608
+ },
1609
+
1610
+ /**
1611
+ * Signal a section/panel/tab/contextual was opened or switched to
1612
+ */
1613
+ opened: function (id) {
1614
+ if (state.activeSection === id) return;
1615
+ _closeCurrent();
1616
+ state.activeSection = id;
1617
+ state.activeSince = now();
1618
+ if (!state.seen[id]) {
1619
+ state.seen[id] = true;
1620
+ var sec = state.dynamicSections[id];
1621
+ Metrics.send('opened:' + id, {
1622
+ name: sec ? sec.name : id,
1623
+ snippet: sec ? sec.snippet : ''
1624
+ });
1625
+ } else {
1626
+ Metrics.send('switched:' + id, {
1627
+ name: (state.dynamicSections[id] || {}).name || id
1628
+ });
1629
+ }
1630
+ },
1631
+
1632
+ /**
1633
+ * Signal a section/panel/tab/contextual was closed
1634
+ */
1635
+ closed: function (id) {
1636
+ if (state.activeSection !== id) return;
1637
+ _closeCurrent();
1638
+ },
1639
+
1640
+ /**
1641
+ * Track a navigation container — observe its links scrolling into view
1642
+ */
1643
+ observeNavContainer: function (container) {
1644
+ _observeNavContainer(container);
1645
+ },
1646
+
1647
+ send: function (label, data) { Metrics.send(label, data); },
1648
+ markSeen: function (id) { state.seen[id] = true; },
1649
+ getSessionId: function () { return Metrics.getSessionId(); },
1650
+ getSections: function () {
1651
+ return state.sections.map(function (s) {
1652
+ return { id: s.id, tag: s.tag, ordinal: s.ordinal, snippet: s.snippet };
1653
+ });
1654
+ },
1655
+ getDynamicSections: function () {
1656
+ var result = {};
1657
+ for (var id in state.dynamicSections) {
1658
+ var s = state.dynamicSections[id];
1659
+ result[id] = { id: s.id, name: s.name, snippet: s.snippet };
1660
+ }
1661
+ return result;
1662
+ },
1663
+ getSeen: function () {
1664
+ var copy = {};
1665
+ for (var k in state.seen) copy[k] = true;
1666
+ return copy;
1667
+ },
1668
+ getActive: function () {
1669
+ if (!state.activeSection) return null;
1670
+ return {
1671
+ id: state.activeSection,
1672
+ since: state.activeSince,
1673
+ elapsed: Math.round((now() - state.activeSince) / 1000)
1674
+ };
1675
+ },
1676
+ reset: function () {
1677
+ _closeCurrent();
1678
+ state.seen = {};
1679
+ state.depthHit = {};
1680
+ state.anchorCooling = false;
1681
+ state._navLinksSeen = {};
1682
+ clearTimeout(state.scrollTimer);
1683
+ for (var id in state.dynamicSections) {
1684
+ if (state.dynamicSections[id]._io) state.dynamicSections[id]._io.disconnect();
1685
+ }
1686
+ state.dynamicSections = {};
1687
+ state.activeSection = null;
1688
+ state.activeSince = null;
1689
+ state.sections = discoverSections(state.options.sections);
1690
+ premarkInitialState();
1691
+ },
1692
+ destroy: function () {
1693
+ _closeCurrent();
1694
+ window.removeEventListener('scroll', onScroll);
1695
+ window.removeEventListener('scroll', updateTocHighlight);
1696
+ document.removeEventListener('click', onDocumentClick);
1697
+ clearTimeout(state.scrollTimer);
1698
+ if (state._mutationObserver) {
1699
+ state._mutationObserver.disconnect();
1700
+ state._mutationObserver = null;
1701
+ }
1702
+ for (var id in state.dynamicSections) {
1703
+ if (state.dynamicSections[id]._io) state.dynamicSections[id]._io.disconnect();
1704
+ }
1705
+ for (var n = 0; n < state._navObservers.length; n++) {
1706
+ state._navObservers[n].disconnect();
1707
+ }
1708
+ state._navObservers = [];
1709
+ state.initialized = false;
1710
+ },
1711
+
1712
+ defaults: defaults,
1713
+ state: state
1714
+ };
1715
+
1716
+ Metrics.NavigationTracker = NT;
1717
+ // Backward compat aliases
1718
+ Metrics.SectionTracker = NT;
1719
+ Metrics.ScrollTracker = NT;
1720
+
1721
+ })(typeof window !== 'undefined' ? window : this);
1722
+
1723
+ /**
1724
+ * Metrics.MediaTracker — Track video/audio engagement
1725
+ *
1726
+ * Auto-discovers native <video>/<audio> elements and YouTube/Vimeo
1727
+ * iframes. Hooks into play/pause/seek/ended events and sends
1728
+ * periodic checkpoints during playback.
1729
+ *
1730
+ * Events:
1731
+ * media-play:id — playback started
1732
+ * media-pause:id — playback paused
1733
+ * media-checkpoint:id — periodic position update during playback
1734
+ * media-ended:id — reached the end
1735
+ * media-seeked:id — user jumped to a position
1736
+ *
1737
+ * Usage:
1738
+ * Metrics.init({ endpoint: '/telemetry.php' });
1739
+ * Metrics.MediaTracker.init({
1740
+ * checkpointInterval: 10, // seconds
1741
+ * reloadIframes: false // don't reload to inject API params
1742
+ * });
1743
+ *
1744
+ * @module Metrics
1745
+ * @class Metrics.MediaTracker
1746
+ */
1747
+ "use strict";
1748
+ (function (root) {
1749
+
1750
+ var Metrics = root.Metrics;
1751
+ if (!Metrics) {
1752
+ console.warn('Metrics.MediaTracker: Metrics core not loaded');
1753
+ return;
1754
+ }
1755
+
1756
+ var defaults = {
1757
+ // Seconds between checkpoint events during playback
1758
+ checkpointInterval: 10,
1759
+
1760
+ // Auto-discover media elements on init
1761
+ autoDiscover: true,
1762
+
1763
+ // CSS selector for native media elements
1764
+ mediaSelector: 'video, audio',
1765
+
1766
+ // Whether to reload YouTube/Vimeo iframes to inject API params
1767
+ // Default false: logs a warning instead of disrupting playback
1768
+ reloadIframes: false,
1769
+
1770
+ // Observe DOM for dynamically added media
1771
+ observeDom: true,
1772
+
1773
+ // Root element to watch for mutations
1774
+ observeRoot: null,
1775
+
1776
+ // Debounce checkpoint sends (ms) — prevents burst on rapid seeks
1777
+ checkpointDebounce: 1000
1778
+ };
1779
+
1780
+ // ── State ──
1781
+ var state = {
1782
+ initialized: false,
1783
+ options: null,
1784
+ tracked: {}, // id → tracker object
1785
+ _counter: 0, // for generating IDs
1786
+ _mutationObserver: null,
1787
+ _ytApiLoaded: false,
1788
+ _ytApiLoading: false,
1789
+ _ytPendingPlayers: [], // iframes waiting for API
1790
+ _vimeoApiLoaded: false,
1791
+ _vimeoApiLoading: false
1792
+ };
1793
+
1794
+ // ── Utilities ──
1795
+
1796
+ function genId(el) {
1797
+ if (el.id) return el.id;
1798
+ var src = el.src || el.currentSrc || '';
1799
+ if (src) {
1800
+ // Extract meaningful part of URL
1801
+ var match = src.match(/(?:youtu\.be\/|youtube\.com\/embed\/|vimeo\.com\/video\/|vimeo\.com\/)([^?&#]+)/);
1802
+ if (match) return match[1];
1803
+ // Use filename
1804
+ var parts = src.split('/').pop().split('?')[0];
1805
+ if (parts && parts.length < 60) return parts;
1806
+ }
1807
+ return 'media-' + (state._counter++);
1808
+ }
1809
+
1810
+ function now() { return Date.now(); }
1811
+
1812
+ // ── Watched Seconds Tracker ──
1813
+ // Tracks unique seconds viewed (handles seeking/rewatching)
1814
+
1815
+ function WatchedTracker() {
1816
+ this.ranges = []; // [{start, end}] sorted, non-overlapping
1817
+ }
1818
+
1819
+ WatchedTracker.prototype.add = function (from, to) {
1820
+ if (to <= from) return;
1821
+ var newRange = { start: Math.floor(from), end: Math.ceil(to) };
1822
+ var merged = [];
1823
+ var inserted = false;
1824
+ for (var i = 0; i < this.ranges.length; i++) {
1825
+ var r = this.ranges[i];
1826
+ if (r.end < newRange.start) {
1827
+ merged.push(r);
1828
+ } else if (r.start > newRange.end) {
1829
+ if (!inserted) { merged.push(newRange); inserted = true; }
1830
+ merged.push(r);
1831
+ } else {
1832
+ newRange.start = Math.min(newRange.start, r.start);
1833
+ newRange.end = Math.max(newRange.end, r.end);
1834
+ }
1835
+ }
1836
+ if (!inserted) merged.push(newRange);
1837
+ this.ranges = merged;
1838
+ };
1839
+
1840
+ WatchedTracker.prototype.total = function () {
1841
+ var t = 0;
1842
+ for (var i = 0; i < this.ranges.length; i++) {
1843
+ t += this.ranges[i].end - this.ranges[i].start;
1844
+ }
1845
+ return t;
1846
+ };
1847
+
1848
+ // ── Core Tracker Object (per media element) ──
1849
+
1850
+ function createTracker(id, type, el, duration) {
1851
+ return {
1852
+ id: id,
1853
+ type: type, // 'native', 'youtube', 'vimeo'
1854
+ el: el,
1855
+ duration: duration || 0,
1856
+ playing: false,
1857
+ lastPosition: 0,
1858
+ lastCheckpointAt: 0, // timestamp of last checkpoint send
1859
+ watched: new WatchedTracker(),
1860
+ checkpointTimer: null,
1861
+ _lastTimeUpdate: 0 // position at last timeupdate
1862
+ };
1863
+ }
1864
+
1865
+ function sendEvent(tracker, event, extra) {
1866
+ var data = {
1867
+ type: tracker.type,
1868
+ position: Math.round(tracker.lastPosition),
1869
+ duration: Math.round(tracker.duration),
1870
+ watched: tracker.watched.total()
1871
+ };
1872
+ if (extra) {
1873
+ for (var k in extra) data[k] = extra[k];
1874
+ }
1875
+ Metrics.send(event + ':' + tracker.id, data);
1876
+ }
1877
+
1878
+ // ── Checkpoint Timer ──
1879
+
1880
+ function startCheckpoints(tracker) {
1881
+ stopCheckpoints(tracker);
1882
+ var interval = state.options.checkpointInterval * 1000;
1883
+ tracker.checkpointTimer = setInterval(function () {
1884
+ if (tracker.playing) {
1885
+ sendEvent(tracker, 'media-checkpoint');
1886
+ }
1887
+ }, interval);
1888
+ }
1889
+
1890
+ function stopCheckpoints(tracker) {
1891
+ if (tracker.checkpointTimer) {
1892
+ clearInterval(tracker.checkpointTimer);
1893
+ tracker.checkpointTimer = null;
1894
+ }
1895
+ }
1896
+
1897
+ // ── Native <video> / <audio> ──
1898
+
1899
+ function trackNative(el) {
1900
+ var id = genId(el);
1901
+ if (state.tracked[id]) return; // already tracking
1902
+
1903
+ var tracker = createTracker(id, 'native', el, el.duration || 0);
1904
+ state.tracked[id] = tracker;
1905
+
1906
+ el.addEventListener('loadedmetadata', function () {
1907
+ tracker.duration = el.duration || 0;
1908
+ });
1909
+
1910
+ el.addEventListener('play', function () {
1911
+ tracker.playing = true;
1912
+ tracker.lastPosition = el.currentTime;
1913
+ tracker._lastTimeUpdate = el.currentTime;
1914
+ sendEvent(tracker, 'media-play');
1915
+ startCheckpoints(tracker);
1916
+ });
1917
+
1918
+ el.addEventListener('pause', function () {
1919
+ if (!tracker.playing) return;
1920
+ tracker.playing = false;
1921
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
1922
+ tracker.lastPosition = el.currentTime;
1923
+ stopCheckpoints(tracker);
1924
+ sendEvent(tracker, 'media-pause');
1925
+ });
1926
+
1927
+ el.addEventListener('ended', function () {
1928
+ tracker.playing = false;
1929
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
1930
+ tracker.lastPosition = el.currentTime;
1931
+ stopCheckpoints(tracker);
1932
+ sendEvent(tracker, 'media-ended');
1933
+ });
1934
+
1935
+ el.addEventListener('seeked', function () {
1936
+ var from = tracker.lastPosition;
1937
+ tracker.lastPosition = el.currentTime;
1938
+ tracker._lastTimeUpdate = el.currentTime;
1939
+ sendEvent(tracker, 'media-seeked', {
1940
+ from: Math.round(from),
1941
+ to: Math.round(el.currentTime)
1942
+ });
1943
+ });
1944
+
1945
+ el.addEventListener('timeupdate', function () {
1946
+ // Track watched range
1947
+ if (tracker.playing && el.currentTime > tracker._lastTimeUpdate) {
1948
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
1949
+ }
1950
+ tracker._lastTimeUpdate = el.currentTime;
1951
+ tracker.lastPosition = el.currentTime;
1952
+ });
1953
+ }
1954
+
1955
+ // ── YouTube Iframe ──
1956
+
1957
+ function loadYouTubeAPI(callback) {
1958
+ if (state._ytApiLoaded) { callback(); return; }
1959
+ if (state._ytApiLoading) {
1960
+ state._ytPendingPlayers.push(callback);
1961
+ return;
1962
+ }
1963
+ state._ytApiLoading = true;
1964
+
1965
+ var prev = root.onYouTubeIframeAPIReady;
1966
+ root.onYouTubeIframeAPIReady = function () {
1967
+ state._ytApiLoaded = true;
1968
+ state._ytApiLoading = false;
1969
+ if (prev) prev();
1970
+ callback();
1971
+ for (var i = 0; i < state._ytPendingPlayers.length; i++) {
1972
+ state._ytPendingPlayers[i]();
1973
+ }
1974
+ state._ytPendingPlayers = [];
1975
+ };
1976
+
1977
+ var script = document.createElement('script');
1978
+ script.src = 'https://www.youtube.com/iframe_api';
1979
+ document.head.appendChild(script);
1980
+ }
1981
+
1982
+ function trackYouTube(iframe) {
1983
+ var src = iframe.src || '';
1984
+ var id = genId(iframe);
1985
+ if (state.tracked[id]) return;
1986
+
1987
+ // Check for enablejsapi=1
1988
+ if (src.indexOf('enablejsapi') === -1) {
1989
+ if (state.options.reloadIframes) {
1990
+ var separator = src.indexOf('?') === -1 ? '?' : '&';
1991
+ iframe.src = src + separator + 'enablejsapi=1&origin=' + encodeURIComponent(location.origin);
1992
+ // iframe will reload, we'll catch it again via mutation observer or re-scan
1993
+ } else {
1994
+ console.warn('Metrics.MediaTracker: YouTube iframe missing enablejsapi=1, '
1995
+ + 'set reloadIframes:true to auto-fix. iframe:', iframe);
1996
+ return;
1997
+ }
1998
+ }
1999
+
2000
+ // Ensure iframe has an id for the YT API
2001
+ if (!iframe.id) iframe.id = 'yt-' + id;
2002
+
2003
+ loadYouTubeAPI(function () {
2004
+ if (state.tracked[id]) return;
2005
+ var tracker = createTracker(id, 'youtube', iframe, 0);
2006
+ state.tracked[id] = tracker;
2007
+
2008
+ var player = new YT.Player(iframe.id, {
2009
+ events: {
2010
+ onReady: function (e) {
2011
+ tracker.duration = player.getDuration() || 0;
2012
+ },
2013
+ onStateChange: function (e) {
2014
+ var pos = player.getCurrentTime() || 0;
2015
+ tracker.lastPosition = pos;
2016
+
2017
+ switch (e.data) {
2018
+ case YT.PlayerState.PLAYING:
2019
+ tracker.playing = true;
2020
+ tracker._lastTimeUpdate = pos;
2021
+ tracker.duration = player.getDuration() || tracker.duration;
2022
+ sendEvent(tracker, 'media-play');
2023
+ startCheckpoints(tracker);
2024
+ // Poll position since YT has no timeupdate
2025
+ tracker._pollTimer = setInterval(function () {
2026
+ var p = player.getCurrentTime() || 0;
2027
+ if (tracker.playing && p > tracker._lastTimeUpdate) {
2028
+ tracker.watched.add(tracker._lastTimeUpdate, p);
2029
+ }
2030
+ tracker._lastTimeUpdate = p;
2031
+ tracker.lastPosition = p;
2032
+ }, 1000);
2033
+ break;
2034
+
2035
+ case YT.PlayerState.PAUSED:
2036
+ if (!tracker.playing) break;
2037
+ tracker.playing = false;
2038
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2039
+ stopCheckpoints(tracker);
2040
+ clearInterval(tracker._pollTimer);
2041
+ sendEvent(tracker, 'media-pause');
2042
+ break;
2043
+
2044
+ case YT.PlayerState.ENDED:
2045
+ tracker.playing = false;
2046
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2047
+ stopCheckpoints(tracker);
2048
+ clearInterval(tracker._pollTimer);
2049
+ sendEvent(tracker, 'media-ended');
2050
+ break;
2051
+ }
2052
+ }
2053
+ }
2054
+ });
2055
+ tracker._player = player;
2056
+ });
2057
+ }
2058
+
2059
+ // ── Vimeo Iframe ──
2060
+
2061
+ function loadVimeoAPI(callback) {
2062
+ if (state._vimeoApiLoaded) { callback(); return; }
2063
+ if (state._vimeoApiLoading) {
2064
+ setTimeout(function () { loadVimeoAPI(callback); }, 200);
2065
+ return;
2066
+ }
2067
+ state._vimeoApiLoading = true;
2068
+
2069
+ var script = document.createElement('script');
2070
+ script.src = 'https://player.vimeo.com/api/player.js';
2071
+ script.onload = function () {
2072
+ state._vimeoApiLoaded = true;
2073
+ state._vimeoApiLoading = false;
2074
+ callback();
2075
+ };
2076
+ document.head.appendChild(script);
2077
+ }
2078
+
2079
+ function trackVimeo(iframe) {
2080
+ var src = iframe.src || '';
2081
+ var id = genId(iframe);
2082
+ if (state.tracked[id]) return;
2083
+
2084
+ loadVimeoAPI(function () {
2085
+ if (state.tracked[id]) return;
2086
+ var tracker = createTracker(id, 'vimeo', iframe, 0);
2087
+ state.tracked[id] = tracker;
2088
+
2089
+ var player = new Vimeo.Player(iframe);
2090
+ tracker._player = player;
2091
+
2092
+ player.getDuration().then(function (d) { tracker.duration = d || 0; });
2093
+
2094
+ player.on('play', function (data) {
2095
+ tracker.playing = true;
2096
+ tracker.lastPosition = data.seconds || 0;
2097
+ tracker._lastTimeUpdate = tracker.lastPosition;
2098
+ tracker.duration = data.duration || tracker.duration;
2099
+ sendEvent(tracker, 'media-play');
2100
+ startCheckpoints(tracker);
2101
+ });
2102
+
2103
+ player.on('pause', function (data) {
2104
+ if (!tracker.playing) return;
2105
+ tracker.playing = false;
2106
+ var pos = data.seconds || 0;
2107
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2108
+ tracker.lastPosition = pos;
2109
+ stopCheckpoints(tracker);
2110
+ sendEvent(tracker, 'media-pause');
2111
+ });
2112
+
2113
+ player.on('ended', function (data) {
2114
+ tracker.playing = false;
2115
+ var pos = data.seconds || tracker.duration;
2116
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2117
+ tracker.lastPosition = pos;
2118
+ stopCheckpoints(tracker);
2119
+ sendEvent(tracker, 'media-ended');
2120
+ });
2121
+
2122
+ player.on('seeked', function (data) {
2123
+ var from = tracker.lastPosition;
2124
+ tracker.lastPosition = data.seconds || 0;
2125
+ tracker._lastTimeUpdate = tracker.lastPosition;
2126
+ sendEvent(tracker, 'media-seeked', {
2127
+ from: Math.round(from),
2128
+ to: Math.round(tracker.lastPosition)
2129
+ });
2130
+ });
2131
+
2132
+ player.on('timeupdate', function (data) {
2133
+ var pos = data.seconds || 0;
2134
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2135
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2136
+ }
2137
+ tracker._lastTimeUpdate = pos;
2138
+ tracker.lastPosition = pos;
2139
+ });
2140
+ });
2141
+ }
2142
+
2143
+ // ── Auto-Discovery ──
2144
+
2145
+ function discoverMedia() {
2146
+ // Native elements
2147
+ var natives = document.querySelectorAll(state.options.mediaSelector);
2148
+ for (var i = 0; i < natives.length; i++) {
2149
+ trackNative(natives[i]);
2150
+ }
2151
+
2152
+ // Iframes — detect by src URL
2153
+ var iframes = document.querySelectorAll('iframe[src]');
2154
+ for (var j = 0; j < iframes.length; j++) {
2155
+ var src = iframes[j].src || '';
2156
+ if (/youtube\.com\/embed|youtube-nocookie\.com\/embed/.test(src)) {
2157
+ trackYouTube(iframes[j]);
2158
+ } else if (/player\.vimeo\.com/.test(src)) {
2159
+ trackVimeo(iframes[j]);
2160
+ } else if (/w\.soundcloud\.com\/player/.test(src)) {
2161
+ trackSoundCloud(iframes[j]);
2162
+ } else if (/dailymotion\.com\/embed/.test(src)) {
2163
+ trackDailymotion(iframes[j]);
2164
+ } else if (/open\.spotify\.com\/embed/.test(src)) {
2165
+ trackSpotify(iframes[j]);
2166
+ } else if (/player\.twitch\.tv/.test(src)) {
2167
+ trackTwitch(iframes[j]);
2168
+ } else if (/muse\.ai\/embed/.test(src)) {
2169
+ trackMuseAi(iframes[j]);
2170
+ }
2171
+ }
2172
+
2173
+ // Wistia — detected by class name, not iframe
2174
+ var wistias = document.querySelectorAll('[class*="wistia_embed"], [class*="wistia_async_"]');
2175
+ for (var k = 0; k < wistias.length; k++) {
2176
+ trackWistia(wistias[k]);
2177
+ }
2178
+
2179
+ // JW Player — detected by container class or data attribute
2180
+ var jwContainers = document.querySelectorAll('.jwplayer, [data-jw-id]');
2181
+ for (var l = 0; l < jwContainers.length; l++) {
2182
+ trackJWPlayer(jwContainers[l]);
2183
+ }
2184
+ }
2185
+
2186
+ // ── DOM Mutation Observer ──
2187
+
2188
+ function _startMutationObserver() {
2189
+ if (!window.MutationObserver || !state.options.observeDom) return;
2190
+ var root = state.options.observeRoot || document.body;
2191
+
2192
+ state._mutationObserver = new MutationObserver(function (mutations) {
2193
+ mutations.forEach(function (mutation) {
2194
+ mutation.addedNodes.forEach(function (node) {
2195
+ if (node.nodeType !== 1) return;
2196
+ // Check the node itself
2197
+ if (node.matches && node.matches('video, audio')) {
2198
+ trackNative(node);
2199
+ }
2200
+ if (node.tagName === 'IFRAME' && node.src) {
2201
+ var s = node.src;
2202
+ if (/youtube\.com\/embed/.test(s)) trackYouTube(node);
2203
+ else if (/player\.vimeo\.com/.test(s)) trackVimeo(node);
2204
+ else if (/w\.soundcloud\.com\/player/.test(s)) trackSoundCloud(node);
2205
+ else if (/dailymotion\.com\/embed/.test(s)) trackDailymotion(node);
2206
+ else if (/open\.spotify\.com\/embed/.test(s)) trackSpotify(node);
2207
+ else if (/player\.twitch\.tv/.test(s)) trackTwitch(node);
2208
+ else if (/muse\.ai\/embed/.test(s)) trackMuseAi(node);
2209
+ }
2210
+ // Wistia containers
2211
+ if (node.className && /wistia_embed|wistia_async_/.test(node.className)) {
2212
+ trackWistia(node);
2213
+ }
2214
+ // JW Player containers
2215
+ if (node.className && /jwplayer/.test(node.className)) {
2216
+ trackJWPlayer(node);
2217
+ }
2218
+ // Check descendants
2219
+ if (node.querySelectorAll) {
2220
+ var natives = node.querySelectorAll('video, audio');
2221
+ for (var i = 0; i < natives.length; i++) trackNative(natives[i]);
2222
+ var iframes = node.querySelectorAll('iframe[src]');
2223
+ for (var j = 0; j < iframes.length; j++) {
2224
+ var s = iframes[j].src || '';
2225
+ if (/youtube\.com\/embed/.test(s)) trackYouTube(iframes[j]);
2226
+ else if (/player\.vimeo\.com/.test(s)) trackVimeo(iframes[j]);
2227
+ else if (/w\.soundcloud\.com\/player/.test(s)) trackSoundCloud(iframes[j]);
2228
+ else if (/dailymotion\.com\/embed/.test(s)) trackDailymotion(iframes[j]);
2229
+ else if (/open\.spotify\.com\/embed/.test(s)) trackSpotify(iframes[j]);
2230
+ else if (/player\.twitch\.tv/.test(s)) trackTwitch(iframes[j]);
2231
+ else if (/muse\.ai\/embed/.test(s)) trackMuseAi(iframes[j]);
2232
+ }
2233
+ var wistias = node.querySelectorAll('[class*="wistia_embed"], [class*="wistia_async_"]');
2234
+ for (var k = 0; k < wistias.length; k++) trackWistia(wistias[k]);
2235
+ var jws = node.querySelectorAll('.jwplayer, [data-jw-id]');
2236
+ for (var l = 0; l < jws.length; l++) trackJWPlayer(jws[l]);
2237
+ }
2238
+ });
2239
+ });
2240
+ });
2241
+
2242
+ state._mutationObserver.observe(root, { childList: true, subtree: true });
2243
+ }
2244
+
2245
+ // ── Flush All Playing Media ──
2246
+
2247
+ function flushAll() {
2248
+ for (var id in state.tracked) {
2249
+ var tracker = state.tracked[id];
2250
+ if (tracker.playing) {
2251
+ // Update watched range one last time
2252
+ if (tracker.type === 'native' && tracker.el) {
2253
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.el.currentTime || tracker.lastPosition);
2254
+ tracker.lastPosition = tracker.el.currentTime || tracker.lastPosition;
2255
+ }
2256
+ sendEvent(tracker, 'media-checkpoint');
2257
+ }
2258
+ }
2259
+ }
2260
+
2261
+ // ── Pre-mark Initial State ──
2262
+
2263
+ function premarkInitialState() {
2264
+ // Nothing to pre-mark for media — we want to track all plays
2265
+ }
2266
+
2267
+ // ── Public API ──
2268
+
2269
+ var MT = {
2270
+
2271
+ /**
2272
+ * Initialize media tracking
2273
+ */
2274
+ init: function (options) {
2275
+ if (state.initialized) {
2276
+ console.warn('Metrics.MediaTracker already initialized');
2277
+ return this;
2278
+ }
2279
+
2280
+ var opts = {};
2281
+ var k;
2282
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
2283
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
2284
+ state.options = opts;
2285
+ state.initialized = true;
2286
+
2287
+ // Auto-init Metrics core if needed
2288
+ if (!Metrics._endpoint && options && options.endpoint) {
2289
+ Metrics.init({
2290
+ endpoint: options.endpoint, page: options.page,
2291
+ trackUnload: options.trackUnload
2292
+ });
2293
+ }
2294
+
2295
+ // Discover existing media
2296
+ if (opts.autoDiscover) {
2297
+ // Slight delay to let page render
2298
+ setTimeout(function () {
2299
+ discoverMedia();
2300
+ }, 500);
2301
+ }
2302
+
2303
+ // Watch for dynamically added media
2304
+ if (opts.observeDom) {
2305
+ _startMutationObserver();
2306
+ }
2307
+
2308
+ // Flush on page exit
2309
+ Metrics.onVisibilityChange(function (visible) {
2310
+ if (!visible) flushAll();
2311
+ }, 'MediaTracker.flush');
2312
+
2313
+ return this;
2314
+ },
2315
+
2316
+ /**
2317
+ * Manually track a native video/audio element
2318
+ * @param {Element} el — the <video> or <audio> element
2319
+ * @param {String} [id] — optional custom ID
2320
+ */
2321
+ trackNative: function (el, id) {
2322
+ if (id) el.id = id;
2323
+ trackNative(el);
2324
+ },
2325
+
2326
+ /**
2327
+ * Manually track a YouTube iframe
2328
+ * @param {Element} iframe
2329
+ * @param {String} [id]
2330
+ */
2331
+ trackYouTube: function (iframe, id) {
2332
+ if (id) iframe.id = id;
2333
+ trackYouTube(iframe);
2334
+ },
2335
+
2336
+ /**
2337
+ * Manually track a Vimeo iframe
2338
+ * @param {Element} iframe
2339
+ * @param {String} [id]
2340
+ */
2341
+ trackVimeo: function (iframe, id) {
2342
+ if (id) iframe.id = id;
2343
+ trackVimeo(iframe);
2344
+ },
2345
+
2346
+ trackSoundCloud: function (iframe, id) {
2347
+ if (id) iframe.id = id;
2348
+ trackSoundCloud(iframe);
2349
+ },
2350
+
2351
+ trackWistia: function (container, id) {
2352
+ if (id) container.id = id;
2353
+ trackWistia(container);
2354
+ },
2355
+
2356
+ trackJWPlayer: function (container, id) {
2357
+ if (id) container.id = id;
2358
+ trackJWPlayer(container);
2359
+ },
2360
+
2361
+ trackDailymotion: function (iframe, id) {
2362
+ if (id) iframe.id = id;
2363
+ trackDailymotion(iframe);
2364
+ },
2365
+
2366
+ trackSpotify: function (iframe, id) {
2367
+ if (id) iframe.id = id;
2368
+ trackSpotify(iframe);
2369
+ },
2370
+
2371
+ trackTwitch: function (iframe, id) {
2372
+ if (id) iframe.id = id;
2373
+ trackTwitch(iframe);
2374
+ },
2375
+
2376
+ trackMuseAi: function (iframe, id) {
2377
+ if (id) iframe.id = id;
2378
+ trackMuseAi(iframe);
2379
+ },
2380
+
2381
+ /**
2382
+ * Get all tracked media and their current state
2383
+ */
2384
+ getTracked: function () {
2385
+ var result = {};
2386
+ for (var id in state.tracked) {
2387
+ var t = state.tracked[id];
2388
+ result[id] = {
2389
+ id: t.id, type: t.type,
2390
+ playing: t.playing,
2391
+ position: Math.round(t.lastPosition),
2392
+ duration: Math.round(t.duration),
2393
+ watched: t.watched.total()
2394
+ };
2395
+ }
2396
+ return result;
2397
+ },
2398
+
2399
+ /**
2400
+ * Flush checkpoint for all currently playing media
2401
+ */
2402
+ flush: flushAll,
2403
+
2404
+ /**
2405
+ * Re-scan the page for new media elements
2406
+ */
2407
+ rescan: discoverMedia,
2408
+
2409
+ /**
2410
+ * Destroy — stop all tracking and remove observers
2411
+ */
2412
+ destroy: function () {
2413
+ for (var id in state.tracked) {
2414
+ var t = state.tracked[id];
2415
+ stopCheckpoints(t);
2416
+ if (t._pollTimer) clearInterval(t._pollTimer);
2417
+ }
2418
+ state.tracked = {};
2419
+ if (state._mutationObserver) {
2420
+ state._mutationObserver.disconnect();
2421
+ state._mutationObserver = null;
2422
+ }
2423
+ state.initialized = false;
2424
+ },
2425
+
2426
+ defaults: defaults,
2427
+ state: state
2428
+ };
2429
+
2430
+ Metrics.MediaTracker = MT;
2431
+
2432
+ })(typeof window !== 'undefined' ? window : this);
2433
+
2434
+ // ── SoundCloud Widget ──
2435
+
2436
+ function loadSoundCloudAPI(callback) {
2437
+ if (root.SC && root.SC.Widget) { callback(); return; }
2438
+ var script = document.createElement('script');
2439
+ script.src = 'https://w.soundcloud.com/player/api.js';
2440
+ script.onload = callback;
2441
+ document.head.appendChild(script);
2442
+ }
2443
+
2444
+ function trackSoundCloud(iframe) {
2445
+ var id = genId(iframe);
2446
+ if (state.tracked[id]) return;
2447
+
2448
+ loadSoundCloudAPI(function () {
2449
+ if (state.tracked[id]) return;
2450
+ var widget = SC.Widget(iframe);
2451
+ var tracker = createTracker(id, 'soundcloud', iframe, 0);
2452
+ state.tracked[id] = tracker;
2453
+
2454
+ widget.bind(SC.Widget.Events.READY, function () {
2455
+ widget.getDuration(function (d) { tracker.duration = (d || 0) / 1000; });
2456
+ });
2457
+ widget.bind(SC.Widget.Events.PLAY, function () {
2458
+ tracker.playing = true;
2459
+ widget.getPosition(function (p) {
2460
+ tracker.lastPosition = (p || 0) / 1000;
2461
+ tracker._lastTimeUpdate = tracker.lastPosition;
2462
+ sendEvent(tracker, 'media-play');
2463
+ startCheckpoints(tracker);
2464
+ });
2465
+ });
2466
+ widget.bind(SC.Widget.Events.PAUSE, function () {
2467
+ if (!tracker.playing) return;
2468
+ tracker.playing = false;
2469
+ widget.getPosition(function (p) {
2470
+ var pos = (p || 0) / 1000;
2471
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2472
+ tracker.lastPosition = pos;
2473
+ stopCheckpoints(tracker);
2474
+ sendEvent(tracker, 'media-pause');
2475
+ });
2476
+ });
2477
+ widget.bind(SC.Widget.Events.FINISH, function () {
2478
+ tracker.playing = false;
2479
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2480
+ tracker.lastPosition = tracker.duration;
2481
+ stopCheckpoints(tracker);
2482
+ sendEvent(tracker, 'media-ended');
2483
+ });
2484
+ widget.bind(SC.Widget.Events.SEEK, function (e) {
2485
+ var from = tracker.lastPosition;
2486
+ tracker.lastPosition = (e.currentPosition || 0) / 1000;
2487
+ tracker._lastTimeUpdate = tracker.lastPosition;
2488
+ sendEvent(tracker, 'media-seeked', {
2489
+ from: Math.round(from), to: Math.round(tracker.lastPosition)
2490
+ });
2491
+ });
2492
+ widget.bind(SC.Widget.Events.PLAY_PROGRESS, function (e) {
2493
+ var pos = (e.currentPosition || 0) / 1000;
2494
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2495
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2496
+ }
2497
+ tracker._lastTimeUpdate = pos;
2498
+ tracker.lastPosition = pos;
2499
+ });
2500
+ });
2501
+ }
2502
+
2503
+ // ── Wistia ──
2504
+
2505
+ function trackWistia(container) {
2506
+ var id = genId(container);
2507
+ if (state.tracked[id]) return;
2508
+
2509
+ var handleId = container.getAttribute('data-wistia-id')
2510
+ || (container.className.match(/wistia_async_(\w+)/) || [])[1]
2511
+ || id;
2512
+
2513
+ root._wq = root._wq || [];
2514
+ root._wq.push({
2515
+ id: handleId,
2516
+ onReady: function (video) {
2517
+ if (state.tracked[id]) return;
2518
+ var tracker = createTracker(id, 'wistia', container, video.duration() || 0);
2519
+ state.tracked[id] = tracker;
2520
+ tracker._player = video;
2521
+
2522
+ video.bind('play', function () {
2523
+ tracker.playing = true;
2524
+ tracker.lastPosition = video.time();
2525
+ tracker._lastTimeUpdate = tracker.lastPosition;
2526
+ tracker.duration = video.duration() || tracker.duration;
2527
+ sendEvent(tracker, 'media-play');
2528
+ startCheckpoints(tracker);
2529
+ });
2530
+ video.bind('pause', function () {
2531
+ if (!tracker.playing) return;
2532
+ tracker.playing = false;
2533
+ var pos = video.time();
2534
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2535
+ tracker.lastPosition = pos;
2536
+ stopCheckpoints(tracker);
2537
+ sendEvent(tracker, 'media-pause');
2538
+ });
2539
+ video.bind('end', function () {
2540
+ tracker.playing = false;
2541
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2542
+ tracker.lastPosition = tracker.duration;
2543
+ stopCheckpoints(tracker);
2544
+ sendEvent(tracker, 'media-ended');
2545
+ });
2546
+ video.bind('seek', function (currentTime, lastTime) {
2547
+ tracker.lastPosition = currentTime;
2548
+ tracker._lastTimeUpdate = currentTime;
2549
+ sendEvent(tracker, 'media-seeked', {
2550
+ from: Math.round(lastTime), to: Math.round(currentTime)
2551
+ });
2552
+ });
2553
+ video.bind('secondchange', function (s) {
2554
+ var pos = s;
2555
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2556
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2557
+ }
2558
+ tracker._lastTimeUpdate = pos;
2559
+ tracker.lastPosition = pos;
2560
+ });
2561
+ }
2562
+ });
2563
+
2564
+ // Load Wistia E-v1 if not present
2565
+ if (!root.Wistia) {
2566
+ var script = document.createElement('script');
2567
+ script.src = 'https://fast.wistia.com/assets/external/E-v1.js';
2568
+ script.async = true;
2569
+ document.head.appendChild(script);
2570
+ }
2571
+ }
2572
+
2573
+ // ── JW Player ──
2574
+
2575
+ function trackJWPlayer(container) {
2576
+ var id = genId(container);
2577
+ if (state.tracked[id]) return;
2578
+
2579
+ // JW Player instance might already exist
2580
+ var playerId = container.id || id;
2581
+ function _bind() {
2582
+ if (!root.jwplayer || typeof root.jwplayer !== 'function') return false;
2583
+ var player;
2584
+ try { player = jwplayer(playerId); } catch (e) { return false; }
2585
+ if (!player || !player.getState) return false;
2586
+
2587
+ var tracker = createTracker(id, 'jwplayer', container, player.getDuration() || 0);
2588
+ state.tracked[id] = tracker;
2589
+ tracker._player = player;
2590
+
2591
+ player.on('play', function () {
2592
+ tracker.playing = true;
2593
+ tracker.lastPosition = player.getPosition();
2594
+ tracker._lastTimeUpdate = tracker.lastPosition;
2595
+ tracker.duration = player.getDuration() || tracker.duration;
2596
+ sendEvent(tracker, 'media-play');
2597
+ startCheckpoints(tracker);
2598
+ });
2599
+ player.on('pause', function () {
2600
+ if (!tracker.playing) return;
2601
+ tracker.playing = false;
2602
+ var pos = player.getPosition();
2603
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2604
+ tracker.lastPosition = pos;
2605
+ stopCheckpoints(tracker);
2606
+ sendEvent(tracker, 'media-pause');
2607
+ });
2608
+ player.on('complete', function () {
2609
+ tracker.playing = false;
2610
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2611
+ tracker.lastPosition = tracker.duration;
2612
+ stopCheckpoints(tracker);
2613
+ sendEvent(tracker, 'media-ended');
2614
+ });
2615
+ player.on('seek', function (e) {
2616
+ tracker.lastPosition = e.offset;
2617
+ tracker._lastTimeUpdate = e.offset;
2618
+ sendEvent(tracker, 'media-seeked', {
2619
+ from: Math.round(e.position), to: Math.round(e.offset)
2620
+ });
2621
+ });
2622
+ player.on('time', function (e) {
2623
+ var pos = e.position;
2624
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2625
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2626
+ }
2627
+ tracker._lastTimeUpdate = pos;
2628
+ tracker.lastPosition = pos;
2629
+ tracker.duration = e.duration || tracker.duration;
2630
+ });
2631
+ return true;
2632
+ }
2633
+
2634
+ // Try immediately, retry after a delay if JW hasn't initialized yet
2635
+ if (!_bind()) {
2636
+ setTimeout(function () { _bind(); }, 2000);
2637
+ }
2638
+ }
2639
+
2640
+ // ── Dailymotion ──
2641
+
2642
+ function trackDailymotion(iframe) {
2643
+ var id = genId(iframe);
2644
+ if (state.tracked[id]) return;
2645
+
2646
+ function _loadAndBind() {
2647
+ if (!root.DM || !root.DM.player) {
2648
+ var script = document.createElement('script');
2649
+ script.src = 'https://api.dmcdn.net/all.js';
2650
+ script.onload = function () { _createPlayer(); };
2651
+ document.head.appendChild(script);
2652
+ } else {
2653
+ _createPlayer();
2654
+ }
2655
+ }
2656
+
2657
+ function _createPlayer() {
2658
+ if (state.tracked[id]) return;
2659
+ var tracker = createTracker(id, 'dailymotion', iframe, 0);
2660
+ state.tracked[id] = tracker;
2661
+
2662
+ var player = DM.player(iframe, { events: {
2663
+ playing: function () {
2664
+ tracker.playing = true;
2665
+ tracker.lastPosition = player.currentTime || 0;
2666
+ tracker._lastTimeUpdate = tracker.lastPosition;
2667
+ tracker.duration = player.duration || tracker.duration;
2668
+ sendEvent(tracker, 'media-play');
2669
+ startCheckpoints(tracker);
2670
+ },
2671
+ pause: function () {
2672
+ if (!tracker.playing) return;
2673
+ tracker.playing = false;
2674
+ var pos = player.currentTime || 0;
2675
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2676
+ tracker.lastPosition = pos;
2677
+ stopCheckpoints(tracker);
2678
+ sendEvent(tracker, 'media-pause');
2679
+ },
2680
+ end: function () {
2681
+ tracker.playing = false;
2682
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2683
+ tracker.lastPosition = tracker.duration;
2684
+ stopCheckpoints(tracker);
2685
+ sendEvent(tracker, 'media-ended');
2686
+ },
2687
+ seeking: function () {
2688
+ var from = tracker.lastPosition;
2689
+ tracker.lastPosition = player.currentTime || 0;
2690
+ tracker._lastTimeUpdate = tracker.lastPosition;
2691
+ sendEvent(tracker, 'media-seeked', {
2692
+ from: Math.round(from), to: Math.round(tracker.lastPosition)
2693
+ });
2694
+ },
2695
+ timeupdate: function () {
2696
+ var pos = player.currentTime || 0;
2697
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2698
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2699
+ }
2700
+ tracker._lastTimeUpdate = pos;
2701
+ tracker.lastPosition = pos;
2702
+ tracker.duration = player.duration || tracker.duration;
2703
+ }
2704
+ }});
2705
+ tracker._player = player;
2706
+ }
2707
+
2708
+ _loadAndBind();
2709
+ }
2710
+
2711
+ // ── Spotify Embed (limited — position only via playbackUpdate) ──
2712
+
2713
+ function trackSpotify(iframe) {
2714
+ var id = genId(iframe);
2715
+ if (state.tracked[id]) return;
2716
+
2717
+ var tracker = createTracker(id, 'spotify', iframe, 0);
2718
+ state.tracked[id] = tracker;
2719
+
2720
+ // Spotify Embed API uses window.onSpotifyIframeApiReady + postMessage
2721
+ window.addEventListener('message', function (e) {
2722
+ if (!e.data || e.source !== iframe.contentWindow) return;
2723
+ var data;
2724
+ try { data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
2725
+ catch (err) { return; }
2726
+ if (!data.type) return;
2727
+
2728
+ if (data.type === 'playback_update') {
2729
+ var pos = (data.payload && data.payload.position) || 0;
2730
+ pos = pos / 1000; // ms to seconds
2731
+ var dur = (data.payload && data.payload.duration) || 0;
2732
+ dur = dur / 1000;
2733
+ var isPaused = data.payload && data.payload.isPaused;
2734
+
2735
+ tracker.duration = dur || tracker.duration;
2736
+
2737
+ if (!isPaused && !tracker.playing) {
2738
+ tracker.playing = true;
2739
+ tracker.lastPosition = pos;
2740
+ tracker._lastTimeUpdate = pos;
2741
+ sendEvent(tracker, 'media-play');
2742
+ startCheckpoints(tracker);
2743
+ } else if (isPaused && tracker.playing) {
2744
+ tracker.playing = false;
2745
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2746
+ tracker.lastPosition = pos;
2747
+ stopCheckpoints(tracker);
2748
+ sendEvent(tracker, 'media-pause');
2749
+ } else if (!isPaused && tracker.playing && pos > tracker._lastTimeUpdate) {
2750
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2751
+ tracker._lastTimeUpdate = pos;
2752
+ tracker.lastPosition = pos;
2753
+ }
2754
+ }
2755
+ });
2756
+ }
2757
+
2758
+ // ── Twitch Player ──
2759
+
2760
+ function trackTwitch(iframe) {
2761
+ var id = genId(iframe);
2762
+ if (state.tracked[id]) return;
2763
+
2764
+ function _loadAndBind() {
2765
+ if (!root.Twitch || !root.Twitch.Player) {
2766
+ var script = document.createElement('script');
2767
+ script.src = 'https://player.twitch.tv/js/embed/v1.js';
2768
+ script.onload = function () { _createPlayer(); };
2769
+ document.head.appendChild(script);
2770
+ } else {
2771
+ _createPlayer();
2772
+ }
2773
+ }
2774
+
2775
+ function _createPlayer() {
2776
+ if (state.tracked[id]) return;
2777
+ if (!iframe.id) iframe.id = 'twitch-' + id;
2778
+ var tracker = createTracker(id, 'twitch', iframe, 0);
2779
+ state.tracked[id] = tracker;
2780
+
2781
+ var player = new Twitch.Player(iframe.id, {});
2782
+ tracker._player = player;
2783
+
2784
+ player.addEventListener(Twitch.Player.PLAY, function () {
2785
+ tracker.playing = true;
2786
+ tracker.lastPosition = player.getCurrentTime() || 0;
2787
+ tracker._lastTimeUpdate = tracker.lastPosition;
2788
+ tracker.duration = player.getDuration() || tracker.duration;
2789
+ sendEvent(tracker, 'media-play');
2790
+ startCheckpoints(tracker);
2791
+ tracker._pollTimer = setInterval(function () {
2792
+ var p = player.getCurrentTime() || 0;
2793
+ if (tracker.playing && p > tracker._lastTimeUpdate) {
2794
+ tracker.watched.add(tracker._lastTimeUpdate, p);
2795
+ }
2796
+ tracker._lastTimeUpdate = p;
2797
+ tracker.lastPosition = p;
2798
+ }, 1000);
2799
+ });
2800
+ player.addEventListener(Twitch.Player.PAUSE, function () {
2801
+ if (!tracker.playing) return;
2802
+ tracker.playing = false;
2803
+ var pos = player.getCurrentTime() || 0;
2804
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2805
+ tracker.lastPosition = pos;
2806
+ stopCheckpoints(tracker);
2807
+ clearInterval(tracker._pollTimer);
2808
+ sendEvent(tracker, 'media-pause');
2809
+ });
2810
+ player.addEventListener(Twitch.Player.ENDED, function () {
2811
+ tracker.playing = false;
2812
+ var pos = player.getCurrentTime() || tracker.duration;
2813
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2814
+ tracker.lastPosition = pos;
2815
+ stopCheckpoints(tracker);
2816
+ clearInterval(tracker._pollTimer);
2817
+ sendEvent(tracker, 'media-ended');
2818
+ });
2819
+ }
2820
+
2821
+ _loadAndBind();
2822
+ }
2823
+
2824
+ // ── Muse.ai (postMessage API) ──
2825
+
2826
+ function trackMuseAi(iframe) {
2827
+ var id = genId(iframe);
2828
+ if (state.tracked[id]) return;
2829
+
2830
+ var tracker = createTracker(id, 'museai', iframe, 0);
2831
+ state.tracked[id] = tracker;
2832
+
2833
+ window.addEventListener('message', function (e) {
2834
+ if (!e.data || e.source !== iframe.contentWindow) return;
2835
+ var data;
2836
+ try { data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
2837
+ catch (err) { return; }
2838
+
2839
+ if (data.event === 'play') {
2840
+ tracker.playing = true;
2841
+ tracker.lastPosition = data.currentTime || 0;
2842
+ tracker._lastTimeUpdate = tracker.lastPosition;
2843
+ tracker.duration = data.duration || tracker.duration;
2844
+ sendEvent(tracker, 'media-play');
2845
+ startCheckpoints(tracker);
2846
+ } else if (data.event === 'pause') {
2847
+ if (!tracker.playing) return;
2848
+ tracker.playing = false;
2849
+ var pos = data.currentTime || 0;
2850
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2851
+ tracker.lastPosition = pos;
2852
+ stopCheckpoints(tracker);
2853
+ sendEvent(tracker, 'media-pause');
2854
+ } else if (data.event === 'ended') {
2855
+ tracker.playing = false;
2856
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
2857
+ tracker.lastPosition = tracker.duration;
2858
+ stopCheckpoints(tracker);
2859
+ sendEvent(tracker, 'media-ended');
2860
+ } else if (data.event === 'timeupdate') {
2861
+ var pos = data.currentTime || 0;
2862
+ tracker.duration = data.duration || tracker.duration;
2863
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
2864
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
2865
+ }
2866
+ tracker._lastTimeUpdate = pos;
2867
+ tracker.lastPosition = pos;
2868
+ }
2869
+ });
2870
+
2871
+ // Request the iframe to emit events
2872
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'play' }, '*'); } catch(e){}
2873
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'pause' }, '*'); } catch(e){}
2874
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'ended' }, '*'); } catch(e){}
2875
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'timeupdate' }, '*'); } catch(e){}
2876
+ }
2877
+
2878
+ /**
2879
+ * Metrics.ScrollTracker — Section-aware scroll telemetry
2880
+ *
2881
+ * Tracks which sections a user reads, how far they scroll,
2882
+ * and what they click. Delegates transport, session, visibility,
2883
+ * and unload handling to the core Metrics object.
2884
+ *
2885
+ * Usage (standalone):
2886
+ * Metrics.init({ endpoint: '/telemetry.php', page: 'My Page' });
2887
+ * Metrics.ScrollTracker.init({
2888
+ * sections: 'h2[id], h3[id]',
2889
+ * debounce: 1000
2890
+ * });
2891
+ *
2892
+ * Usage (with Q framework):
2893
+ * // Auto-initializes from config if endpoint is set
2894
+ *
2895
+ * @module Metrics
2896
+ * @class Metrics.ScrollTracker
2897
+ */
2898
+ "use strict";
2899
+ (function (root) {
2900
+
2901
+ var Metrics = root.Metrics;
2902
+ if (!Metrics) {
2903
+ console.warn('Metrics.ScrollTracker: Metrics core not loaded');
2904
+ return;
2905
+ }
2906
+
2907
+ var defaults = {
2908
+ // CSS selector for sections to track
2909
+ sections: 'h2[id], h3[id], section[id], [data-section]',
2910
+
2911
+ // Minimum pixel height for auto-detected containers
2912
+ minSectionHeight: 100,
2913
+
2914
+ // Milliseconds to wait after scroll stops before firing
2915
+ debounce: 1000,
2916
+
2917
+ // Milliseconds to wait on page load (ignores scroll restoration)
2918
+ initDelay: 800,
2919
+
2920
+ // Milliseconds to suppress tracking after anchor click
2921
+ anchorCooldown: 1500,
2922
+
2923
+ // Scroll depth milestones (percentage)
2924
+ depthMilestones: [25, 50, 75, 100],
2925
+
2926
+ // Max px above viewport top to consider a section "current"
2927
+ sectionLookback: 300,
2928
+
2929
+ // Scroll considered settled if moved less than this (px)
2930
+ settleTolerance: 2,
2931
+
2932
+ // Recheck interval when not settled (ms)
2933
+ recheckInterval: 500,
2934
+
2935
+ // Track link/anchor clicks
2936
+ trackClicks: true,
2937
+
2938
+ // Visual TOC highlighting selector (real-time, not debounced)
2939
+ tocSelector: null,
2940
+ tocActiveClass: 'active',
2941
+ tocSectionSelector: 'h2[id]'
2942
+ };
2943
+
2944
+ // ── State ──
2945
+ var state = {
2946
+ initialized: false,
2947
+ options: null,
2948
+ sections: [],
2949
+ tocSections: [],
2950
+ seen: {},
2951
+ depthHit: {},
2952
+ scrollTimer: null,
2953
+ anchorCooling: false,
2954
+ _prevY: -1
2955
+ };
2956
+
2957
+ // ── Section Discovery ──
2958
+
2959
+ function discoverSections(selector) {
2960
+ var elements = document.querySelectorAll(selector);
2961
+ var result = [];
2962
+ var ordinal = 0;
2963
+ var minH = (state.options && state.options.minSectionHeight) || 0;
2964
+
2965
+ for (var i = 0; i < elements.length; i++) {
2966
+ var el = elements[i];
2967
+ if (el.offsetHeight < minH) continue;
2968
+
2969
+ if (!el.id) {
2970
+ var text = (el.textContent || '').trim().slice(0, 60);
2971
+ var slug = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
2972
+ el.id = slug || ('section-' + ordinal);
2973
+ }
2974
+
2975
+ result.push({
2976
+ el: el,
2977
+ id: el.id,
2978
+ tag: el.tagName.toLowerCase(),
2979
+ ordinal: ordinal++,
2980
+ snippet: (el.textContent || '').trim().slice(0, 80)
2981
+ });
2982
+ }
2983
+ return result;
2984
+ }
2985
+
2986
+ // ── Find Current Section ──
2987
+
2988
+ function findCurrentSection() {
2989
+ var scrollY = window.scrollY || window.pageYOffset;
2990
+ var opts = state.options;
2991
+ var best = null;
2992
+ var bestDist = Infinity;
2993
+
2994
+ for (var i = 0; i < state.sections.length; i++) {
2995
+ var sec = state.sections[i];
2996
+ var top = sec.el.offsetTop;
2997
+ if (top <= scrollY + opts.sectionLookback) {
2998
+ var dist = Math.abs(top - scrollY - 180);
2999
+ if (dist < bestDist) {
3000
+ bestDist = dist;
3001
+ best = sec;
3002
+ }
3003
+ }
3004
+ }
3005
+ return best;
3006
+ }
3007
+
3008
+ // ── Scroll Settle Detection ──
3009
+
3010
+ function onScrollSettle() {
3011
+ var opts = state.options;
3012
+ var curY = window.scrollY || window.pageYOffset;
3013
+
3014
+ if (Math.abs(curY - state._prevY) > opts.settleTolerance) {
3015
+ state._prevY = curY;
3016
+ state.scrollTimer = setTimeout(onScrollSettle, opts.recheckInterval);
3017
+ return;
3018
+ }
3019
+
3020
+ // Settled — report section
3021
+ var current = findCurrentSection();
3022
+ if (current && !state.seen[current.id]) {
3023
+ state.seen[current.id] = true;
3024
+ Metrics.send('section:' + current.id, {
3025
+ tag: current.tag,
3026
+ ordinal: current.ordinal,
3027
+ snippet: current.snippet
3028
+ });
3029
+ }
3030
+
3031
+ // Report depth
3032
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
3033
+ if (docH > 0) {
3034
+ var pct = Math.round((curY / docH) * 100);
3035
+ for (var j = 0; j < opts.depthMilestones.length; j++) {
3036
+ var m = opts.depthMilestones[j];
3037
+ if (pct >= m && !state.depthHit[m]) {
3038
+ state.depthHit[m] = true;
3039
+ Metrics.send('depth:' + m + '%');
3040
+ }
3041
+ }
3042
+ }
3043
+ }
3044
+
3045
+ function onScroll() {
3046
+ if (state.anchorCooling) return;
3047
+ clearTimeout(state.scrollTimer);
3048
+ state._prevY = window.scrollY || window.pageYOffset;
3049
+ state.scrollTimer = setTimeout(onScrollSettle, state.options.debounce);
3050
+ }
3051
+
3052
+ // ── TOC Highlighting (real-time) ──
3053
+
3054
+ function updateTocHighlight() {
3055
+ var opts = state.options;
3056
+ if (!opts.tocSelector) return;
3057
+
3058
+ var scrollY = window.scrollY || window.pageYOffset;
3059
+ var currentId = '';
3060
+
3061
+ for (var i = 0; i < state.tocSections.length; i++) {
3062
+ if (scrollY >= state.tocSections[i].el.offsetTop - 160) {
3063
+ currentId = state.tocSections[i].id;
3064
+ }
3065
+ }
3066
+
3067
+ var links = document.querySelectorAll(opts.tocSelector);
3068
+ for (var j = 0; j < links.length; j++) {
3069
+ links[j].classList.remove(opts.tocActiveClass);
3070
+ if (links[j].getAttribute('href') === '#' + currentId) {
3071
+ links[j].classList.add(opts.tocActiveClass);
3072
+ }
3073
+ }
3074
+ }
3075
+
3076
+ // ── Click Tracking ──
3077
+
3078
+ function onDocumentClick(e) {
3079
+ var a = e.target.closest('a[href]');
3080
+ if (!a) return;
3081
+
3082
+ var href = a.getAttribute('href') || '';
3083
+
3084
+ if (href.charAt(0) === '#') {
3085
+ // Anchor click — cooldown to suppress scroll tracking
3086
+ state.anchorCooling = true;
3087
+ setTimeout(function () { state.anchorCooling = false; },
3088
+ state.options.anchorCooldown);
3089
+ Metrics.send('anchor:' + href.slice(1));
3090
+ return;
3091
+ }
3092
+
3093
+ // External link
3094
+ var label = a.dataset.track || 'link:' + href;
3095
+ Metrics.send(label);
3096
+ }
3097
+
3098
+ // ── Pre-mark Initial State ──
3099
+
3100
+ function premarkInitialState() {
3101
+ var scrollY = window.scrollY || window.pageYOffset;
3102
+
3103
+ for (var i = 0; i < state.sections.length; i++) {
3104
+ var rect = state.sections[i].el.getBoundingClientRect();
3105
+ if (rect.top >= -100 && rect.top < window.innerHeight) {
3106
+ state.seen[state.sections[i].id] = true;
3107
+ }
3108
+ }
3109
+
3110
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
3111
+ if (docH > 0) {
3112
+ var pct = Math.round((scrollY / docH) * 100);
3113
+ for (var j = 0; j < state.options.depthMilestones.length; j++) {
3114
+ var m = state.options.depthMilestones[j];
3115
+ if (pct >= m) state.depthHit[m] = true;
3116
+ }
3117
+ }
3118
+ }
3119
+
3120
+ // ── Public API ──
3121
+
3122
+ Metrics.ScrollTracker = {
3123
+
3124
+ init: function (options) {
3125
+ if (state.initialized) {
3126
+ console.warn('Metrics.ScrollTracker already initialized');
3127
+ return this;
3128
+ }
3129
+
3130
+ var opts = {};
3131
+ var k;
3132
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
3133
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
3134
+ state.options = opts;
3135
+ state.initialized = true;
3136
+
3137
+ // If Metrics core hasn't been initialized yet with an endpoint,
3138
+ // initialize it now from our options
3139
+ if (!Metrics._endpoint && options && options.endpoint) {
3140
+ Metrics.init({
3141
+ endpoint: options.endpoint,
3142
+ page: options.page,
3143
+ sessionKey: options.sessionKey,
3144
+ sessionId: options.sessionId,
3145
+ extra: options.extra,
3146
+ trackUnload: options.trackUnload
3147
+ });
3148
+ }
3149
+
3150
+ // Delayed init — let scroll restoration settle
3151
+ setTimeout(function () {
3152
+ state.sections = discoverSections(opts.sections);
3153
+
3154
+ if (opts.tocSelector && opts.tocSectionSelector) {
3155
+ state.tocSections = discoverSections(opts.tocSectionSelector);
3156
+ }
3157
+
3158
+ premarkInitialState();
3159
+
3160
+ window.addEventListener('scroll', onScroll, { passive: true });
3161
+
3162
+ if (opts.tocSelector) {
3163
+ window.addEventListener('scroll', updateTocHighlight, { passive: true });
3164
+ updateTocHighlight();
3165
+ }
3166
+ }, opts.initDelay);
3167
+
3168
+ if (opts.trackClicks) {
3169
+ document.addEventListener('click', onDocumentClick);
3170
+ }
3171
+
3172
+ return this;
3173
+ },
3174
+
3175
+ send: function (label, data) { Metrics.send(label, data); },
3176
+ markSeen: function (id) { state.seen[id] = true; },
3177
+ getSessionId: function () { return Metrics.getSessionId(); },
3178
+ getSections: function () {
3179
+ return state.sections.map(function (s) {
3180
+ return { id: s.id, tag: s.tag, ordinal: s.ordinal, snippet: s.snippet };
3181
+ });
3182
+ },
3183
+ getSeen: function () {
3184
+ var copy = {};
3185
+ for (var k in state.seen) copy[k] = true;
3186
+ return copy;
3187
+ },
3188
+ reset: function () {
3189
+ state.seen = {};
3190
+ state.depthHit = {};
3191
+ state.anchorCooling = false;
3192
+ clearTimeout(state.scrollTimer);
3193
+ state.sections = discoverSections(state.options.sections);
3194
+ premarkInitialState();
3195
+ },
3196
+ destroy: function () {
3197
+ window.removeEventListener('scroll', onScroll);
3198
+ window.removeEventListener('scroll', updateTocHighlight);
3199
+ document.removeEventListener('click', onDocumentClick);
3200
+ clearTimeout(state.scrollTimer);
3201
+ state.initialized = false;
3202
+ },
3203
+
3204
+ defaults: defaults,
3205
+ state: state
3206
+ };
3207
+
3208
+ })(typeof window !== 'undefined' ? window : this);
3209
+
3210
+ /**
3211
+ * Metrics.NavigationTracker — Track how users navigate and explore content
3212
+ *
3213
+ * Tracks scroll-based sections, dynamic panels (tabs, columns, expandables,
3214
+ * contextual menus), and links that scroll into view in navigation containers.
3215
+ * Delegates transport, session, visibility, and unload to Metrics core.
3216
+ *
3217
+ * Event prefixes:
3218
+ * section:id — scroll-based section reached viewport
3219
+ * depth:N% — scroll depth milestone
3220
+ * anchor:id — clicked an anchor link
3221
+ * link:url — clicked an external link
3222
+ * tab:name — tab switched to
3223
+ * column:name — column opened/closed
3224
+ * expandable:id — expandable opened/closed
3225
+ * contextual:id — contextual menu shown/hidden
3226
+ * contextual-item:action — item selected from contextual menu
3227
+ * nav-link:id — link scrolled into view in a nav container
3228
+ * opened:prefix:id — first time viewing a dynamic section
3229
+ * switched:prefix:id — returned to previously viewed section
3230
+ * dwell:prefix:id — time spent in a dynamic section
3231
+ *
3232
+ * @module Metrics
3233
+ * @class Metrics.NavigationTracker
3234
+ */
3235
+ "use strict";
3236
+ (function (root) {
3237
+
3238
+ var Metrics = root.Metrics;
3239
+ if (!Metrics) {
3240
+ console.warn('Metrics.NavigationTracker: Metrics core not loaded');
3241
+ return;
3242
+ }
3243
+
3244
+ var defaults = {
3245
+ // ── Scroll-based section tracking ──
3246
+ sections: 'h2[id], h3[id], section[id], [data-section]',
3247
+ minSectionHeight: 100,
3248
+ debounce: 1000,
3249
+ initDelay: 800,
3250
+ anchorCooldown: 1500,
3251
+ depthMilestones: [25, 50, 75, 100],
3252
+ sectionLookback: 300,
3253
+ settleTolerance: 2,
3254
+ recheckInterval: 500,
3255
+
3256
+ // ── Dynamic section tracking ──
3257
+ observeDom: false,
3258
+ observeRoot: null,
3259
+ observeSelector: '[data-section], [data-track-section]',
3260
+ trackDwell: true,
3261
+
3262
+ // ── Navigation container link tracking ──
3263
+ // CSS selector for scrollable nav containers whose links should be
3264
+ // tracked as they scroll into view
3265
+ navContainers: null, // e.g. '.Q_listing_wrapper, .sidebar-nav'
3266
+
3267
+ // ── Contextual menu tracking ──
3268
+ trackContextuals: true,
3269
+
3270
+ // ── Click & TOC ──
3271
+ trackClicks: true,
3272
+ tocSelector: null,
3273
+ tocActiveClass: 'active',
3274
+ tocSectionSelector: 'h2[id]'
3275
+ };
3276
+
3277
+ // ── State ──
3278
+ var state = {
3279
+ initialized: false,
3280
+ options: null,
3281
+ sections: [],
3282
+ tocSections: [],
3283
+ seen: {},
3284
+ depthHit: {},
3285
+ scrollTimer: null,
3286
+ anchorCooling: false,
3287
+ _prevY: -1,
3288
+ // Dynamic sections
3289
+ dynamicSections: {},
3290
+ activeSection: null,
3291
+ activeSince: null,
3292
+ // DOM observer
3293
+ _mutationObserver: null,
3294
+ // Nav container observers
3295
+ _navObservers: [],
3296
+ _navLinksSeen: {}
3297
+ };
3298
+
3299
+ // ── Utilities ──
3300
+
3301
+ function slugify(text) {
3302
+ return text.toLowerCase()
3303
+ .replace(/[^a-z0-9]+/g, '-')
3304
+ .replace(/^-|-$/g, '')
3305
+ .slice(0, 60);
3306
+ }
3307
+
3308
+ function snippet(el) {
3309
+ return (el.textContent || '').trim().slice(0, 80);
3310
+ }
3311
+
3312
+ function now() { return Date.now(); }
3313
+
3314
+ // ── Section Discovery ──
3315
+
3316
+ function discoverSections(selector) {
3317
+ var elements = document.querySelectorAll(selector);
3318
+ var result = [];
3319
+ var ordinal = 0;
3320
+ var minH = (state.options && state.options.minSectionHeight) || 0;
3321
+ for (var i = 0; i < elements.length; i++) {
3322
+ var el = elements[i];
3323
+ if (el.offsetHeight < minH) continue;
3324
+ if (!el.id) {
3325
+ el.id = slugify(el.textContent || '') || ('section-' + ordinal);
3326
+ }
3327
+ result.push({
3328
+ el: el, id: el.id,
3329
+ tag: el.tagName.toLowerCase(),
3330
+ ordinal: ordinal++,
3331
+ snippet: snippet(el)
3332
+ });
3333
+ }
3334
+ return result;
3335
+ }
3336
+
3337
+ // ── Find Current Section ──
3338
+
3339
+ function findCurrentSection() {
3340
+ var scrollY = window.scrollY || window.pageYOffset;
3341
+ var opts = state.options;
3342
+ var best = null, bestDist = Infinity;
3343
+ for (var i = 0; i < state.sections.length; i++) {
3344
+ var sec = state.sections[i];
3345
+ var top = sec.el.offsetTop;
3346
+ if (top <= scrollY + opts.sectionLookback) {
3347
+ var dist = Math.abs(top - scrollY - 180);
3348
+ if (dist < bestDist) { bestDist = dist; best = sec; }
3349
+ }
3350
+ }
3351
+ return best;
3352
+ }
3353
+
3354
+ // ── Scroll Settle ──
3355
+
3356
+ function onScrollSettle() {
3357
+ var opts = state.options;
3358
+ var curY = window.scrollY || window.pageYOffset;
3359
+ if (Math.abs(curY - state._prevY) > opts.settleTolerance) {
3360
+ state._prevY = curY;
3361
+ state.scrollTimer = setTimeout(onScrollSettle, opts.recheckInterval);
3362
+ return;
3363
+ }
3364
+ var current = findCurrentSection();
3365
+ if (current && !state.seen[current.id]) {
3366
+ state.seen[current.id] = true;
3367
+ Metrics.send('section:' + current.id, {
3368
+ tag: current.tag, ordinal: current.ordinal, snippet: current.snippet
3369
+ });
3370
+ }
3371
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
3372
+ if (docH > 0) {
3373
+ var pct = Math.round((curY / docH) * 100);
3374
+ for (var j = 0; j < opts.depthMilestones.length; j++) {
3375
+ var m = opts.depthMilestones[j];
3376
+ if (pct >= m && !state.depthHit[m]) {
3377
+ state.depthHit[m] = true;
3378
+ Metrics.send('depth:' + m + '%');
3379
+ }
3380
+ }
3381
+ }
3382
+ }
3383
+
3384
+ function onScroll() {
3385
+ if (state.anchorCooling) return;
3386
+ clearTimeout(state.scrollTimer);
3387
+ state._prevY = window.scrollY || window.pageYOffset;
3388
+ state.scrollTimer = setTimeout(onScrollSettle, state.options.debounce);
3389
+ }
3390
+
3391
+ // ── TOC Highlighting ──
3392
+
3393
+ function updateTocHighlight() {
3394
+ var opts = state.options;
3395
+ if (!opts.tocSelector) return;
3396
+ var scrollY = window.scrollY || window.pageYOffset;
3397
+ var currentId = '';
3398
+ for (var i = 0; i < state.tocSections.length; i++) {
3399
+ if (scrollY >= state.tocSections[i].el.offsetTop - 160) {
3400
+ currentId = state.tocSections[i].id;
3401
+ }
3402
+ }
3403
+ var links = document.querySelectorAll(opts.tocSelector);
3404
+ for (var j = 0; j < links.length; j++) {
3405
+ links[j].classList.remove(opts.tocActiveClass);
3406
+ if (links[j].getAttribute('href') === '#' + currentId) {
3407
+ links[j].classList.add(opts.tocActiveClass);
3408
+ }
3409
+ }
3410
+ }
3411
+
3412
+ // ── Click Tracking ──
3413
+
3414
+ function onDocumentClick(e) {
3415
+ var a = e.target.closest('a[href]');
3416
+ if (!a) return;
3417
+ var href = a.getAttribute('href') || '';
3418
+ if (href.charAt(0) === '#') {
3419
+ state.anchorCooling = true;
3420
+ setTimeout(function () { state.anchorCooling = false; },
3421
+ state.options.anchorCooldown);
3422
+ Metrics.send('anchor:' + href.slice(1));
3423
+ return;
3424
+ }
3425
+ var label = a.dataset.track || 'link:' + href;
3426
+ Metrics.send(label);
3427
+ }
3428
+
3429
+ // ── Dynamic Section Tracking ──
3430
+
3431
+ function _closeCurrent() {
3432
+ if (!state.activeSection || !state.options.trackDwell) return;
3433
+ var elapsed = Math.round((now() - state.activeSince) / 1000);
3434
+ if (elapsed > 0) {
3435
+ var sec = state.dynamicSections[state.activeSection];
3436
+ Metrics.send('dwell:' + state.activeSection, {
3437
+ seconds: elapsed,
3438
+ name: sec ? sec.name : state.activeSection
3439
+ });
3440
+ }
3441
+ state.activeSection = null;
3442
+ state.activeSince = null;
3443
+ }
3444
+
3445
+ function _observeElement(el, opts) {
3446
+ var id = opts.id || el.id || el.getAttribute('data-section')
3447
+ || el.getAttribute('data-track-section')
3448
+ || slugify(el.textContent || '') || ('dyn-' + Object.keys(state.dynamicSections).length);
3449
+ if (!el.id) el.id = id;
3450
+ state.dynamicSections[id] = {
3451
+ el: el, id: id,
3452
+ name: opts.name || el.getAttribute('data-section-name') || el.getAttribute('title') || id,
3453
+ snippet: snippet(el), observedAt: now()
3454
+ };
3455
+ if (window.IntersectionObserver && opts.autoTrack !== false) {
3456
+ var io = new IntersectionObserver(function (entries) {
3457
+ entries.forEach(function (entry) {
3458
+ if (entry.isIntersecting && entry.intersectionRatio > 0.3) {
3459
+ NT.opened(id);
3460
+ } else if (!entry.isIntersecting && state.activeSection === id) {
3461
+ NT.closed(id);
3462
+ }
3463
+ });
3464
+ }, { threshold: [0, 0.3] });
3465
+ io.observe(el);
3466
+ state.dynamicSections[id]._io = io;
3467
+ }
3468
+ return id;
3469
+ }
3470
+
3471
+ // ── Nav Container Link Tracking ──
3472
+ // Watches scrollable navigation containers and fires 'nav-link:' events
3473
+ // when links scroll into view (like contextual menu items, sidebar nav, etc.)
3474
+
3475
+ function _setupNavContainerTracking() {
3476
+ var selector = state.options.navContainers;
3477
+ if (!selector || !window.IntersectionObserver) return;
3478
+
3479
+ var containers = document.querySelectorAll(selector);
3480
+ for (var c = 0; c < containers.length; c++) {
3481
+ _observeNavContainer(containers[c]);
3482
+ }
3483
+ }
3484
+
3485
+ function _observeNavContainer(container) {
3486
+ var links = container.querySelectorAll('a[href], li[data-action], li[data-name]');
3487
+ if (!links.length) return;
3488
+
3489
+ var io = new IntersectionObserver(function (entries) {
3490
+ entries.forEach(function (entry) {
3491
+ if (!entry.isIntersecting) return;
3492
+ var el = entry.target;
3493
+ var linkId = el.getAttribute('data-name')
3494
+ || el.getAttribute('data-action')
3495
+ || el.getAttribute('href')
3496
+ || el.textContent.trim().slice(0, 40);
3497
+ var key = 'nav-link:' + linkId;
3498
+ if (!state._navLinksSeen[key]) {
3499
+ state._navLinksSeen[key] = true;
3500
+ Metrics.send(key, { text: el.textContent.trim().slice(0, 60) });
3501
+ }
3502
+ });
3503
+ }, {
3504
+ root: container,
3505
+ threshold: 0.5
3506
+ });
3507
+
3508
+ for (var i = 0; i < links.length; i++) {
3509
+ io.observe(links[i]);
3510
+ }
3511
+ state._navObservers.push(io);
3512
+ }
3513
+
3514
+ // ── DOM Mutation Observer ──
3515
+
3516
+ function _startMutationObserver() {
3517
+ if (!window.MutationObserver || !state.options.observeDom) return;
3518
+ var root = state.options.observeRoot || document.body;
3519
+ var selector = state.options.observeSelector;
3520
+ state._mutationObserver = new MutationObserver(function (mutations) {
3521
+ mutations.forEach(function (mutation) {
3522
+ mutation.addedNodes.forEach(function (node) {
3523
+ if (node.nodeType !== 1) return;
3524
+ if (node.matches && node.matches(selector)) {
3525
+ _observeElement(node, {});
3526
+ }
3527
+ if (node.querySelectorAll) {
3528
+ var matches = node.querySelectorAll(selector);
3529
+ for (var i = 0; i < matches.length; i++) {
3530
+ _observeElement(matches[i], {});
3531
+ }
3532
+ }
3533
+ });
3534
+ });
3535
+ });
3536
+ state._mutationObserver.observe(root, { childList: true, subtree: true });
3537
+ }
3538
+
3539
+ // ── Pre-mark Initial State ──
3540
+
3541
+ function premarkInitialState() {
3542
+ var scrollY = window.scrollY || window.pageYOffset;
3543
+ for (var i = 0; i < state.sections.length; i++) {
3544
+ var rect = state.sections[i].el.getBoundingClientRect();
3545
+ if (rect.top >= -100 && rect.top < window.innerHeight) {
3546
+ state.seen[state.sections[i].id] = true;
3547
+ }
3548
+ }
3549
+ var docH = document.documentElement.scrollHeight - window.innerHeight;
3550
+ if (docH > 0) {
3551
+ var pct = Math.round((scrollY / docH) * 100);
3552
+ for (var j = 0; j < state.options.depthMilestones.length; j++) {
3553
+ var m = state.options.depthMilestones[j];
3554
+ if (pct >= m) state.depthHit[m] = true;
3555
+ }
3556
+ }
3557
+ }
3558
+
3559
+ // ── Public API ──
3560
+
3561
+ var NT = {
3562
+
3563
+ init: function (options) {
3564
+ if (state.initialized) {
3565
+ console.warn('Metrics.NavigationTracker already initialized');
3566
+ return this;
3567
+ }
3568
+ var opts = {};
3569
+ var k;
3570
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
3571
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
3572
+ state.options = opts;
3573
+ state.initialized = true;
3574
+
3575
+ if (!Metrics._endpoint && options && options.endpoint) {
3576
+ Metrics.init({
3577
+ endpoint: options.endpoint, page: options.page,
3578
+ sessionKey: options.sessionKey, sessionId: options.sessionId,
3579
+ extra: options.extra, trackUnload: options.trackUnload
3580
+ });
3581
+ }
3582
+
3583
+ // Delayed init
3584
+ setTimeout(function () {
3585
+ state.sections = discoverSections(opts.sections);
3586
+ if (opts.tocSelector && opts.tocSectionSelector) {
3587
+ state.tocSections = discoverSections(opts.tocSectionSelector);
3588
+ }
3589
+ premarkInitialState();
3590
+ window.addEventListener('scroll', onScroll, { passive: true });
3591
+ if (opts.tocSelector) {
3592
+ window.addEventListener('scroll', updateTocHighlight, { passive: true });
3593
+ updateTocHighlight();
3594
+ }
3595
+ if (opts.observeDom) _startMutationObserver();
3596
+ if (opts.navContainers) _setupNavContainerTracking();
3597
+ }, opts.initDelay);
3598
+
3599
+ if (opts.trackClicks) {
3600
+ document.addEventListener('click', onDocumentClick);
3601
+ }
3602
+
3603
+ if (opts.trackDwell) {
3604
+ Metrics.onVisibilityChange(function (visible) {
3605
+ if (!visible) _closeCurrent();
3606
+ }, 'NavigationTracker.dwell');
3607
+ }
3608
+
3609
+ return this;
3610
+ },
3611
+
3612
+ /**
3613
+ * Observe a dynamic element as a trackable section
3614
+ */
3615
+ observe: function (el, opts) {
3616
+ return _observeElement(el, opts || {});
3617
+ },
3618
+
3619
+ /**
3620
+ * Signal a section/panel/tab/contextual was opened or switched to
3621
+ */
3622
+ opened: function (id) {
3623
+ if (state.activeSection === id) return;
3624
+ _closeCurrent();
3625
+ state.activeSection = id;
3626
+ state.activeSince = now();
3627
+ if (!state.seen[id]) {
3628
+ state.seen[id] = true;
3629
+ var sec = state.dynamicSections[id];
3630
+ Metrics.send('opened:' + id, {
3631
+ name: sec ? sec.name : id,
3632
+ snippet: sec ? sec.snippet : ''
3633
+ });
3634
+ } else {
3635
+ Metrics.send('switched:' + id, {
3636
+ name: (state.dynamicSections[id] || {}).name || id
3637
+ });
3638
+ }
3639
+ },
3640
+
3641
+ /**
3642
+ * Signal a section/panel/tab/contextual was closed
3643
+ */
3644
+ closed: function (id) {
3645
+ if (state.activeSection !== id) return;
3646
+ _closeCurrent();
3647
+ },
3648
+
3649
+ /**
3650
+ * Track a navigation container — observe its links scrolling into view
3651
+ */
3652
+ observeNavContainer: function (container) {
3653
+ _observeNavContainer(container);
3654
+ },
3655
+
3656
+ send: function (label, data) { Metrics.send(label, data); },
3657
+ markSeen: function (id) { state.seen[id] = true; },
3658
+ getSessionId: function () { return Metrics.getSessionId(); },
3659
+ getSections: function () {
3660
+ return state.sections.map(function (s) {
3661
+ return { id: s.id, tag: s.tag, ordinal: s.ordinal, snippet: s.snippet };
3662
+ });
3663
+ },
3664
+ getDynamicSections: function () {
3665
+ var result = {};
3666
+ for (var id in state.dynamicSections) {
3667
+ var s = state.dynamicSections[id];
3668
+ result[id] = { id: s.id, name: s.name, snippet: s.snippet };
3669
+ }
3670
+ return result;
3671
+ },
3672
+ getSeen: function () {
3673
+ var copy = {};
3674
+ for (var k in state.seen) copy[k] = true;
3675
+ return copy;
3676
+ },
3677
+ getActive: function () {
3678
+ if (!state.activeSection) return null;
3679
+ return {
3680
+ id: state.activeSection,
3681
+ since: state.activeSince,
3682
+ elapsed: Math.round((now() - state.activeSince) / 1000)
3683
+ };
3684
+ },
3685
+ reset: function () {
3686
+ _closeCurrent();
3687
+ state.seen = {};
3688
+ state.depthHit = {};
3689
+ state.anchorCooling = false;
3690
+ state._navLinksSeen = {};
3691
+ clearTimeout(state.scrollTimer);
3692
+ for (var id in state.dynamicSections) {
3693
+ if (state.dynamicSections[id]._io) state.dynamicSections[id]._io.disconnect();
3694
+ }
3695
+ state.dynamicSections = {};
3696
+ state.activeSection = null;
3697
+ state.activeSince = null;
3698
+ state.sections = discoverSections(state.options.sections);
3699
+ premarkInitialState();
3700
+ },
3701
+ destroy: function () {
3702
+ _closeCurrent();
3703
+ window.removeEventListener('scroll', onScroll);
3704
+ window.removeEventListener('scroll', updateTocHighlight);
3705
+ document.removeEventListener('click', onDocumentClick);
3706
+ clearTimeout(state.scrollTimer);
3707
+ if (state._mutationObserver) {
3708
+ state._mutationObserver.disconnect();
3709
+ state._mutationObserver = null;
3710
+ }
3711
+ for (var id in state.dynamicSections) {
3712
+ if (state.dynamicSections[id]._io) state.dynamicSections[id]._io.disconnect();
3713
+ }
3714
+ for (var n = 0; n < state._navObservers.length; n++) {
3715
+ state._navObservers[n].disconnect();
3716
+ }
3717
+ state._navObservers = [];
3718
+ state.initialized = false;
3719
+ },
3720
+
3721
+ defaults: defaults,
3722
+ state: state
3723
+ };
3724
+
3725
+ Metrics.NavigationTracker = NT;
3726
+ // Backward compat aliases
3727
+ Metrics.SectionTracker = NT;
3728
+ Metrics.ScrollTracker = NT;
3729
+
3730
+ })(typeof window !== 'undefined' ? window : this);
3731
+
3732
+ /**
3733
+ * Metrics.MediaTracker — Track video/audio engagement
3734
+ *
3735
+ * Auto-discovers native <video>/<audio> elements and YouTube/Vimeo
3736
+ * iframes. Hooks into play/pause/seek/ended events and sends
3737
+ * periodic checkpoints during playback.
3738
+ *
3739
+ * Events:
3740
+ * media-play:id — playback started
3741
+ * media-pause:id — playback paused
3742
+ * media-checkpoint:id — periodic position update during playback
3743
+ * media-ended:id — reached the end
3744
+ * media-seeked:id — user jumped to a position
3745
+ *
3746
+ * Usage:
3747
+ * Metrics.init({ endpoint: '/telemetry.php' });
3748
+ * Metrics.MediaTracker.init({
3749
+ * checkpointInterval: 10, // seconds
3750
+ * reloadIframes: false // don't reload to inject API params
3751
+ * });
3752
+ *
3753
+ * @module Metrics
3754
+ * @class Metrics.MediaTracker
3755
+ */
3756
+ "use strict";
3757
+ (function (root) {
3758
+
3759
+ var Metrics = root.Metrics;
3760
+ if (!Metrics) {
3761
+ console.warn('Metrics.MediaTracker: Metrics core not loaded');
3762
+ return;
3763
+ }
3764
+
3765
+ var defaults = {
3766
+ // Seconds between checkpoint events during playback
3767
+ checkpointInterval: 10,
3768
+
3769
+ // Auto-discover media elements on init
3770
+ autoDiscover: true,
3771
+
3772
+ // CSS selector for native media elements
3773
+ mediaSelector: 'video, audio',
3774
+
3775
+ // Whether to reload YouTube/Vimeo iframes to inject API params
3776
+ // Default false: logs a warning instead of disrupting playback
3777
+ reloadIframes: false,
3778
+
3779
+ // Observe DOM for dynamically added media
3780
+ observeDom: true,
3781
+
3782
+ // Root element to watch for mutations
3783
+ observeRoot: null,
3784
+
3785
+ // Debounce checkpoint sends (ms) — prevents burst on rapid seeks
3786
+ checkpointDebounce: 1000
3787
+ };
3788
+
3789
+ // ── State ──
3790
+ var state = {
3791
+ initialized: false,
3792
+ options: null,
3793
+ tracked: {}, // id → tracker object
3794
+ _counter: 0, // for generating IDs
3795
+ _mutationObserver: null,
3796
+ _ytApiLoaded: false,
3797
+ _ytApiLoading: false,
3798
+ _ytPendingPlayers: [], // iframes waiting for API
3799
+ _vimeoApiLoaded: false,
3800
+ _vimeoApiLoading: false
3801
+ };
3802
+
3803
+ // ── Utilities ──
3804
+
3805
+ function genId(el) {
3806
+ if (el.id) return el.id;
3807
+ var src = el.src || el.currentSrc || '';
3808
+ if (src) {
3809
+ // Extract meaningful part of URL
3810
+ var match = src.match(/(?:youtu\.be\/|youtube\.com\/embed\/|vimeo\.com\/video\/|vimeo\.com\/)([^?&#]+)/);
3811
+ if (match) return match[1];
3812
+ // Use filename
3813
+ var parts = src.split('/').pop().split('?')[0];
3814
+ if (parts && parts.length < 60) return parts;
3815
+ }
3816
+ return 'media-' + (state._counter++);
3817
+ }
3818
+
3819
+ function now() { return Date.now(); }
3820
+
3821
+ // ── Watched Seconds Tracker ──
3822
+ // Tracks unique seconds viewed (handles seeking/rewatching)
3823
+
3824
+ function WatchedTracker() {
3825
+ this.ranges = []; // [{start, end}] sorted, non-overlapping
3826
+ }
3827
+
3828
+ WatchedTracker.prototype.add = function (from, to) {
3829
+ if (to <= from) return;
3830
+ var newRange = { start: Math.floor(from), end: Math.ceil(to) };
3831
+ var merged = [];
3832
+ var inserted = false;
3833
+ for (var i = 0; i < this.ranges.length; i++) {
3834
+ var r = this.ranges[i];
3835
+ if (r.end < newRange.start) {
3836
+ merged.push(r);
3837
+ } else if (r.start > newRange.end) {
3838
+ if (!inserted) { merged.push(newRange); inserted = true; }
3839
+ merged.push(r);
3840
+ } else {
3841
+ newRange.start = Math.min(newRange.start, r.start);
3842
+ newRange.end = Math.max(newRange.end, r.end);
3843
+ }
3844
+ }
3845
+ if (!inserted) merged.push(newRange);
3846
+ this.ranges = merged;
3847
+ };
3848
+
3849
+ WatchedTracker.prototype.total = function () {
3850
+ var t = 0;
3851
+ for (var i = 0; i < this.ranges.length; i++) {
3852
+ t += this.ranges[i].end - this.ranges[i].start;
3853
+ }
3854
+ return t;
3855
+ };
3856
+
3857
+ // ── Core Tracker Object (per media element) ──
3858
+
3859
+ function createTracker(id, type, el, duration) {
3860
+ return {
3861
+ id: id,
3862
+ type: type, // 'native', 'youtube', 'vimeo'
3863
+ el: el,
3864
+ duration: duration || 0,
3865
+ playing: false,
3866
+ lastPosition: 0,
3867
+ lastCheckpointAt: 0, // timestamp of last checkpoint send
3868
+ watched: new WatchedTracker(),
3869
+ checkpointTimer: null,
3870
+ _lastTimeUpdate: 0 // position at last timeupdate
3871
+ };
3872
+ }
3873
+
3874
+ function sendEvent(tracker, event, extra) {
3875
+ var data = {
3876
+ type: tracker.type,
3877
+ position: Math.round(tracker.lastPosition),
3878
+ duration: Math.round(tracker.duration),
3879
+ watched: tracker.watched.total()
3880
+ };
3881
+ if (extra) {
3882
+ for (var k in extra) data[k] = extra[k];
3883
+ }
3884
+ Metrics.send(event + ':' + tracker.id, data);
3885
+ }
3886
+
3887
+ // ── Checkpoint Timer ──
3888
+
3889
+ function startCheckpoints(tracker) {
3890
+ stopCheckpoints(tracker);
3891
+ var interval = state.options.checkpointInterval * 1000;
3892
+ tracker.checkpointTimer = setInterval(function () {
3893
+ if (tracker.playing) {
3894
+ sendEvent(tracker, 'media-checkpoint');
3895
+ }
3896
+ }, interval);
3897
+ }
3898
+
3899
+ function stopCheckpoints(tracker) {
3900
+ if (tracker.checkpointTimer) {
3901
+ clearInterval(tracker.checkpointTimer);
3902
+ tracker.checkpointTimer = null;
3903
+ }
3904
+ }
3905
+
3906
+ // ── Native <video> / <audio> ──
3907
+
3908
+ function trackNative(el) {
3909
+ var id = genId(el);
3910
+ if (state.tracked[id]) return; // already tracking
3911
+
3912
+ var tracker = createTracker(id, 'native', el, el.duration || 0);
3913
+ state.tracked[id] = tracker;
3914
+
3915
+ el.addEventListener('loadedmetadata', function () {
3916
+ tracker.duration = el.duration || 0;
3917
+ });
3918
+
3919
+ el.addEventListener('play', function () {
3920
+ tracker.playing = true;
3921
+ tracker.lastPosition = el.currentTime;
3922
+ tracker._lastTimeUpdate = el.currentTime;
3923
+ sendEvent(tracker, 'media-play');
3924
+ startCheckpoints(tracker);
3925
+ });
3926
+
3927
+ el.addEventListener('pause', function () {
3928
+ if (!tracker.playing) return;
3929
+ tracker.playing = false;
3930
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
3931
+ tracker.lastPosition = el.currentTime;
3932
+ stopCheckpoints(tracker);
3933
+ sendEvent(tracker, 'media-pause');
3934
+ });
3935
+
3936
+ el.addEventListener('ended', function () {
3937
+ tracker.playing = false;
3938
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
3939
+ tracker.lastPosition = el.currentTime;
3940
+ stopCheckpoints(tracker);
3941
+ sendEvent(tracker, 'media-ended');
3942
+ });
3943
+
3944
+ el.addEventListener('seeked', function () {
3945
+ var from = tracker.lastPosition;
3946
+ tracker.lastPosition = el.currentTime;
3947
+ tracker._lastTimeUpdate = el.currentTime;
3948
+ sendEvent(tracker, 'media-seeked', {
3949
+ from: Math.round(from),
3950
+ to: Math.round(el.currentTime)
3951
+ });
3952
+ });
3953
+
3954
+ el.addEventListener('timeupdate', function () {
3955
+ // Track watched range
3956
+ if (tracker.playing && el.currentTime > tracker._lastTimeUpdate) {
3957
+ tracker.watched.add(tracker._lastTimeUpdate, el.currentTime);
3958
+ }
3959
+ tracker._lastTimeUpdate = el.currentTime;
3960
+ tracker.lastPosition = el.currentTime;
3961
+ });
3962
+ }
3963
+
3964
+ // ── YouTube Iframe ──
3965
+
3966
+ function loadYouTubeAPI(callback) {
3967
+ if (state._ytApiLoaded) { callback(); return; }
3968
+ if (state._ytApiLoading) {
3969
+ state._ytPendingPlayers.push(callback);
3970
+ return;
3971
+ }
3972
+ state._ytApiLoading = true;
3973
+
3974
+ var prev = root.onYouTubeIframeAPIReady;
3975
+ root.onYouTubeIframeAPIReady = function () {
3976
+ state._ytApiLoaded = true;
3977
+ state._ytApiLoading = false;
3978
+ if (prev) prev();
3979
+ callback();
3980
+ for (var i = 0; i < state._ytPendingPlayers.length; i++) {
3981
+ state._ytPendingPlayers[i]();
3982
+ }
3983
+ state._ytPendingPlayers = [];
3984
+ };
3985
+
3986
+ var script = document.createElement('script');
3987
+ script.src = 'https://www.youtube.com/iframe_api';
3988
+ document.head.appendChild(script);
3989
+ }
3990
+
3991
+ function trackYouTube(iframe) {
3992
+ var src = iframe.src || '';
3993
+ var id = genId(iframe);
3994
+ if (state.tracked[id]) return;
3995
+
3996
+ // Check for enablejsapi=1
3997
+ if (src.indexOf('enablejsapi') === -1) {
3998
+ if (state.options.reloadIframes) {
3999
+ var separator = src.indexOf('?') === -1 ? '?' : '&';
4000
+ iframe.src = src + separator + 'enablejsapi=1&origin=' + encodeURIComponent(location.origin);
4001
+ // iframe will reload, we'll catch it again via mutation observer or re-scan
4002
+ } else {
4003
+ console.warn('Metrics.MediaTracker: YouTube iframe missing enablejsapi=1, '
4004
+ + 'set reloadIframes:true to auto-fix. iframe:', iframe);
4005
+ return;
4006
+ }
4007
+ }
4008
+
4009
+ // Ensure iframe has an id for the YT API
4010
+ if (!iframe.id) iframe.id = 'yt-' + id;
4011
+
4012
+ loadYouTubeAPI(function () {
4013
+ if (state.tracked[id]) return;
4014
+ var tracker = createTracker(id, 'youtube', iframe, 0);
4015
+ state.tracked[id] = tracker;
4016
+
4017
+ var player = new YT.Player(iframe.id, {
4018
+ events: {
4019
+ onReady: function (e) {
4020
+ tracker.duration = player.getDuration() || 0;
4021
+ },
4022
+ onStateChange: function (e) {
4023
+ var pos = player.getCurrentTime() || 0;
4024
+ tracker.lastPosition = pos;
4025
+
4026
+ switch (e.data) {
4027
+ case YT.PlayerState.PLAYING:
4028
+ tracker.playing = true;
4029
+ tracker._lastTimeUpdate = pos;
4030
+ tracker.duration = player.getDuration() || tracker.duration;
4031
+ sendEvent(tracker, 'media-play');
4032
+ startCheckpoints(tracker);
4033
+ // Poll position since YT has no timeupdate
4034
+ tracker._pollTimer = setInterval(function () {
4035
+ var p = player.getCurrentTime() || 0;
4036
+ if (tracker.playing && p > tracker._lastTimeUpdate) {
4037
+ tracker.watched.add(tracker._lastTimeUpdate, p);
4038
+ }
4039
+ tracker._lastTimeUpdate = p;
4040
+ tracker.lastPosition = p;
4041
+ }, 1000);
4042
+ break;
4043
+
4044
+ case YT.PlayerState.PAUSED:
4045
+ if (!tracker.playing) break;
4046
+ tracker.playing = false;
4047
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4048
+ stopCheckpoints(tracker);
4049
+ clearInterval(tracker._pollTimer);
4050
+ sendEvent(tracker, 'media-pause');
4051
+ break;
4052
+
4053
+ case YT.PlayerState.ENDED:
4054
+ tracker.playing = false;
4055
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4056
+ stopCheckpoints(tracker);
4057
+ clearInterval(tracker._pollTimer);
4058
+ sendEvent(tracker, 'media-ended');
4059
+ break;
4060
+ }
4061
+ }
4062
+ }
4063
+ });
4064
+ tracker._player = player;
4065
+ });
4066
+ }
4067
+
4068
+ // ── Vimeo Iframe ──
4069
+
4070
+ function loadVimeoAPI(callback) {
4071
+ if (state._vimeoApiLoaded) { callback(); return; }
4072
+ if (state._vimeoApiLoading) {
4073
+ setTimeout(function () { loadVimeoAPI(callback); }, 200);
4074
+ return;
4075
+ }
4076
+ state._vimeoApiLoading = true;
4077
+
4078
+ var script = document.createElement('script');
4079
+ script.src = 'https://player.vimeo.com/api/player.js';
4080
+ script.onload = function () {
4081
+ state._vimeoApiLoaded = true;
4082
+ state._vimeoApiLoading = false;
4083
+ callback();
4084
+ };
4085
+ document.head.appendChild(script);
4086
+ }
4087
+
4088
+ function trackVimeo(iframe) {
4089
+ var src = iframe.src || '';
4090
+ var id = genId(iframe);
4091
+ if (state.tracked[id]) return;
4092
+
4093
+ loadVimeoAPI(function () {
4094
+ if (state.tracked[id]) return;
4095
+ var tracker = createTracker(id, 'vimeo', iframe, 0);
4096
+ state.tracked[id] = tracker;
4097
+
4098
+ var player = new Vimeo.Player(iframe);
4099
+ tracker._player = player;
4100
+
4101
+ player.getDuration().then(function (d) { tracker.duration = d || 0; });
4102
+
4103
+ player.on('play', function (data) {
4104
+ tracker.playing = true;
4105
+ tracker.lastPosition = data.seconds || 0;
4106
+ tracker._lastTimeUpdate = tracker.lastPosition;
4107
+ tracker.duration = data.duration || tracker.duration;
4108
+ sendEvent(tracker, 'media-play');
4109
+ startCheckpoints(tracker);
4110
+ });
4111
+
4112
+ player.on('pause', function (data) {
4113
+ if (!tracker.playing) return;
4114
+ tracker.playing = false;
4115
+ var pos = data.seconds || 0;
4116
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4117
+ tracker.lastPosition = pos;
4118
+ stopCheckpoints(tracker);
4119
+ sendEvent(tracker, 'media-pause');
4120
+ });
4121
+
4122
+ player.on('ended', function (data) {
4123
+ tracker.playing = false;
4124
+ var pos = data.seconds || tracker.duration;
4125
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4126
+ tracker.lastPosition = pos;
4127
+ stopCheckpoints(tracker);
4128
+ sendEvent(tracker, 'media-ended');
4129
+ });
4130
+
4131
+ player.on('seeked', function (data) {
4132
+ var from = tracker.lastPosition;
4133
+ tracker.lastPosition = data.seconds || 0;
4134
+ tracker._lastTimeUpdate = tracker.lastPosition;
4135
+ sendEvent(tracker, 'media-seeked', {
4136
+ from: Math.round(from),
4137
+ to: Math.round(tracker.lastPosition)
4138
+ });
4139
+ });
4140
+
4141
+ player.on('timeupdate', function (data) {
4142
+ var pos = data.seconds || 0;
4143
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
4144
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4145
+ }
4146
+ tracker._lastTimeUpdate = pos;
4147
+ tracker.lastPosition = pos;
4148
+ });
4149
+ });
4150
+ }
4151
+
4152
+ // ── Auto-Discovery ──
4153
+
4154
+ function discoverMedia() {
4155
+ // Native elements
4156
+ var natives = document.querySelectorAll(state.options.mediaSelector);
4157
+ for (var i = 0; i < natives.length; i++) {
4158
+ trackNative(natives[i]);
4159
+ }
4160
+
4161
+ // Iframes — detect by src URL
4162
+ var iframes = document.querySelectorAll('iframe[src]');
4163
+ for (var j = 0; j < iframes.length; j++) {
4164
+ var src = iframes[j].src || '';
4165
+ if (/youtube\.com\/embed|youtube-nocookie\.com\/embed/.test(src)) {
4166
+ trackYouTube(iframes[j]);
4167
+ } else if (/player\.vimeo\.com/.test(src)) {
4168
+ trackVimeo(iframes[j]);
4169
+ } else if (/w\.soundcloud\.com\/player/.test(src)) {
4170
+ trackSoundCloud(iframes[j]);
4171
+ } else if (/dailymotion\.com\/embed/.test(src)) {
4172
+ trackDailymotion(iframes[j]);
4173
+ } else if (/open\.spotify\.com\/embed/.test(src)) {
4174
+ trackSpotify(iframes[j]);
4175
+ } else if (/player\.twitch\.tv/.test(src)) {
4176
+ trackTwitch(iframes[j]);
4177
+ } else if (/muse\.ai\/embed/.test(src)) {
4178
+ trackMuseAi(iframes[j]);
4179
+ }
4180
+ }
4181
+
4182
+ // Wistia — detected by class name, not iframe
4183
+ var wistias = document.querySelectorAll('[class*="wistia_embed"], [class*="wistia_async_"]');
4184
+ for (var k = 0; k < wistias.length; k++) {
4185
+ trackWistia(wistias[k]);
4186
+ }
4187
+
4188
+ // JW Player — detected by container class or data attribute
4189
+ var jwContainers = document.querySelectorAll('.jwplayer, [data-jw-id]');
4190
+ for (var l = 0; l < jwContainers.length; l++) {
4191
+ trackJWPlayer(jwContainers[l]);
4192
+ }
4193
+ }
4194
+
4195
+ // ── DOM Mutation Observer ──
4196
+
4197
+ function _startMutationObserver() {
4198
+ if (!window.MutationObserver || !state.options.observeDom) return;
4199
+ var root = state.options.observeRoot || document.body;
4200
+
4201
+ state._mutationObserver = new MutationObserver(function (mutations) {
4202
+ mutations.forEach(function (mutation) {
4203
+ mutation.addedNodes.forEach(function (node) {
4204
+ if (node.nodeType !== 1) return;
4205
+ // Check the node itself
4206
+ if (node.matches && node.matches('video, audio')) {
4207
+ trackNative(node);
4208
+ }
4209
+ if (node.tagName === 'IFRAME' && node.src) {
4210
+ var s = node.src;
4211
+ if (/youtube\.com\/embed/.test(s)) trackYouTube(node);
4212
+ else if (/player\.vimeo\.com/.test(s)) trackVimeo(node);
4213
+ else if (/w\.soundcloud\.com\/player/.test(s)) trackSoundCloud(node);
4214
+ else if (/dailymotion\.com\/embed/.test(s)) trackDailymotion(node);
4215
+ else if (/open\.spotify\.com\/embed/.test(s)) trackSpotify(node);
4216
+ else if (/player\.twitch\.tv/.test(s)) trackTwitch(node);
4217
+ else if (/muse\.ai\/embed/.test(s)) trackMuseAi(node);
4218
+ }
4219
+ // Wistia containers
4220
+ if (node.className && /wistia_embed|wistia_async_/.test(node.className)) {
4221
+ trackWistia(node);
4222
+ }
4223
+ // JW Player containers
4224
+ if (node.className && /jwplayer/.test(node.className)) {
4225
+ trackJWPlayer(node);
4226
+ }
4227
+ // Check descendants
4228
+ if (node.querySelectorAll) {
4229
+ var natives = node.querySelectorAll('video, audio');
4230
+ for (var i = 0; i < natives.length; i++) trackNative(natives[i]);
4231
+ var iframes = node.querySelectorAll('iframe[src]');
4232
+ for (var j = 0; j < iframes.length; j++) {
4233
+ var s = iframes[j].src || '';
4234
+ if (/youtube\.com\/embed/.test(s)) trackYouTube(iframes[j]);
4235
+ else if (/player\.vimeo\.com/.test(s)) trackVimeo(iframes[j]);
4236
+ else if (/w\.soundcloud\.com\/player/.test(s)) trackSoundCloud(iframes[j]);
4237
+ else if (/dailymotion\.com\/embed/.test(s)) trackDailymotion(iframes[j]);
4238
+ else if (/open\.spotify\.com\/embed/.test(s)) trackSpotify(iframes[j]);
4239
+ else if (/player\.twitch\.tv/.test(s)) trackTwitch(iframes[j]);
4240
+ else if (/muse\.ai\/embed/.test(s)) trackMuseAi(iframes[j]);
4241
+ }
4242
+ var wistias = node.querySelectorAll('[class*="wistia_embed"], [class*="wistia_async_"]');
4243
+ for (var k = 0; k < wistias.length; k++) trackWistia(wistias[k]);
4244
+ var jws = node.querySelectorAll('.jwplayer, [data-jw-id]');
4245
+ for (var l = 0; l < jws.length; l++) trackJWPlayer(jws[l]);
4246
+ }
4247
+ });
4248
+ });
4249
+ });
4250
+
4251
+ state._mutationObserver.observe(root, { childList: true, subtree: true });
4252
+ }
4253
+
4254
+ // ── Flush All Playing Media ──
4255
+
4256
+ function flushAll() {
4257
+ for (var id in state.tracked) {
4258
+ var tracker = state.tracked[id];
4259
+ if (tracker.playing) {
4260
+ // Update watched range one last time
4261
+ if (tracker.type === 'native' && tracker.el) {
4262
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.el.currentTime || tracker.lastPosition);
4263
+ tracker.lastPosition = tracker.el.currentTime || tracker.lastPosition;
4264
+ }
4265
+ sendEvent(tracker, 'media-checkpoint');
4266
+ }
4267
+ }
4268
+ }
4269
+
4270
+ // ── Pre-mark Initial State ──
4271
+
4272
+ function premarkInitialState() {
4273
+ // Nothing to pre-mark for media — we want to track all plays
4274
+ }
4275
+
4276
+ // ── Public API ──
4277
+
4278
+ var MT = {
4279
+
4280
+ /**
4281
+ * Initialize media tracking
4282
+ */
4283
+ init: function (options) {
4284
+ if (state.initialized) {
4285
+ console.warn('Metrics.MediaTracker already initialized');
4286
+ return this;
4287
+ }
4288
+
4289
+ var opts = {};
4290
+ var k;
4291
+ for (k in defaults) { if (defaults.hasOwnProperty(k)) opts[k] = defaults[k]; }
4292
+ for (k in (options || {})) { if (options.hasOwnProperty(k) && options[k] !== undefined) opts[k] = options[k]; }
4293
+ state.options = opts;
4294
+ state.initialized = true;
4295
+
4296
+ // Auto-init Metrics core if needed
4297
+ if (!Metrics._endpoint && options && options.endpoint) {
4298
+ Metrics.init({
4299
+ endpoint: options.endpoint, page: options.page,
4300
+ trackUnload: options.trackUnload
4301
+ });
4302
+ }
4303
+
4304
+ // Discover existing media
4305
+ if (opts.autoDiscover) {
4306
+ // Slight delay to let page render
4307
+ setTimeout(function () {
4308
+ discoverMedia();
4309
+ }, 500);
4310
+ }
4311
+
4312
+ // Watch for dynamically added media
4313
+ if (opts.observeDom) {
4314
+ _startMutationObserver();
4315
+ }
4316
+
4317
+ // Flush on page exit
4318
+ Metrics.onVisibilityChange(function (visible) {
4319
+ if (!visible) flushAll();
4320
+ }, 'MediaTracker.flush');
4321
+
4322
+ return this;
4323
+ },
4324
+
4325
+ /**
4326
+ * Manually track a native video/audio element
4327
+ * @param {Element} el — the <video> or <audio> element
4328
+ * @param {String} [id] — optional custom ID
4329
+ */
4330
+ trackNative: function (el, id) {
4331
+ if (id) el.id = id;
4332
+ trackNative(el);
4333
+ },
4334
+
4335
+ /**
4336
+ * Manually track a YouTube iframe
4337
+ * @param {Element} iframe
4338
+ * @param {String} [id]
4339
+ */
4340
+ trackYouTube: function (iframe, id) {
4341
+ if (id) iframe.id = id;
4342
+ trackYouTube(iframe);
4343
+ },
4344
+
4345
+ /**
4346
+ * Manually track a Vimeo iframe
4347
+ * @param {Element} iframe
4348
+ * @param {String} [id]
4349
+ */
4350
+ trackVimeo: function (iframe, id) {
4351
+ if (id) iframe.id = id;
4352
+ trackVimeo(iframe);
4353
+ },
4354
+
4355
+ trackSoundCloud: function (iframe, id) {
4356
+ if (id) iframe.id = id;
4357
+ trackSoundCloud(iframe);
4358
+ },
4359
+
4360
+ trackWistia: function (container, id) {
4361
+ if (id) container.id = id;
4362
+ trackWistia(container);
4363
+ },
4364
+
4365
+ trackJWPlayer: function (container, id) {
4366
+ if (id) container.id = id;
4367
+ trackJWPlayer(container);
4368
+ },
4369
+
4370
+ trackDailymotion: function (iframe, id) {
4371
+ if (id) iframe.id = id;
4372
+ trackDailymotion(iframe);
4373
+ },
4374
+
4375
+ trackSpotify: function (iframe, id) {
4376
+ if (id) iframe.id = id;
4377
+ trackSpotify(iframe);
4378
+ },
4379
+
4380
+ trackTwitch: function (iframe, id) {
4381
+ if (id) iframe.id = id;
4382
+ trackTwitch(iframe);
4383
+ },
4384
+
4385
+ trackMuseAi: function (iframe, id) {
4386
+ if (id) iframe.id = id;
4387
+ trackMuseAi(iframe);
4388
+ },
4389
+
4390
+ /**
4391
+ * Get all tracked media and their current state
4392
+ */
4393
+ getTracked: function () {
4394
+ var result = {};
4395
+ for (var id in state.tracked) {
4396
+ var t = state.tracked[id];
4397
+ result[id] = {
4398
+ id: t.id, type: t.type,
4399
+ playing: t.playing,
4400
+ position: Math.round(t.lastPosition),
4401
+ duration: Math.round(t.duration),
4402
+ watched: t.watched.total()
4403
+ };
4404
+ }
4405
+ return result;
4406
+ },
4407
+
4408
+ /**
4409
+ * Flush checkpoint for all currently playing media
4410
+ */
4411
+ flush: flushAll,
4412
+
4413
+ /**
4414
+ * Re-scan the page for new media elements
4415
+ */
4416
+ rescan: discoverMedia,
4417
+
4418
+ /**
4419
+ * Destroy — stop all tracking and remove observers
4420
+ */
4421
+ destroy: function () {
4422
+ for (var id in state.tracked) {
4423
+ var t = state.tracked[id];
4424
+ stopCheckpoints(t);
4425
+ if (t._pollTimer) clearInterval(t._pollTimer);
4426
+ }
4427
+ state.tracked = {};
4428
+ if (state._mutationObserver) {
4429
+ state._mutationObserver.disconnect();
4430
+ state._mutationObserver = null;
4431
+ }
4432
+ state.initialized = false;
4433
+ },
4434
+
4435
+ defaults: defaults,
4436
+ state: state
4437
+ };
4438
+
4439
+ Metrics.MediaTracker = MT;
4440
+
4441
+ })(typeof window !== 'undefined' ? window : this);
4442
+
4443
+ // ── SoundCloud Widget ──
4444
+
4445
+ function loadSoundCloudAPI(callback) {
4446
+ if (root.SC && root.SC.Widget) { callback(); return; }
4447
+ var script = document.createElement('script');
4448
+ script.src = 'https://w.soundcloud.com/player/api.js';
4449
+ script.onload = callback;
4450
+ document.head.appendChild(script);
4451
+ }
4452
+
4453
+ function trackSoundCloud(iframe) {
4454
+ var id = genId(iframe);
4455
+ if (state.tracked[id]) return;
4456
+
4457
+ loadSoundCloudAPI(function () {
4458
+ if (state.tracked[id]) return;
4459
+ var widget = SC.Widget(iframe);
4460
+ var tracker = createTracker(id, 'soundcloud', iframe, 0);
4461
+ state.tracked[id] = tracker;
4462
+
4463
+ widget.bind(SC.Widget.Events.READY, function () {
4464
+ widget.getDuration(function (d) { tracker.duration = (d || 0) / 1000; });
4465
+ });
4466
+ widget.bind(SC.Widget.Events.PLAY, function () {
4467
+ tracker.playing = true;
4468
+ widget.getPosition(function (p) {
4469
+ tracker.lastPosition = (p || 0) / 1000;
4470
+ tracker._lastTimeUpdate = tracker.lastPosition;
4471
+ sendEvent(tracker, 'media-play');
4472
+ startCheckpoints(tracker);
4473
+ });
4474
+ });
4475
+ widget.bind(SC.Widget.Events.PAUSE, function () {
4476
+ if (!tracker.playing) return;
4477
+ tracker.playing = false;
4478
+ widget.getPosition(function (p) {
4479
+ var pos = (p || 0) / 1000;
4480
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4481
+ tracker.lastPosition = pos;
4482
+ stopCheckpoints(tracker);
4483
+ sendEvent(tracker, 'media-pause');
4484
+ });
4485
+ });
4486
+ widget.bind(SC.Widget.Events.FINISH, function () {
4487
+ tracker.playing = false;
4488
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
4489
+ tracker.lastPosition = tracker.duration;
4490
+ stopCheckpoints(tracker);
4491
+ sendEvent(tracker, 'media-ended');
4492
+ });
4493
+ widget.bind(SC.Widget.Events.SEEK, function (e) {
4494
+ var from = tracker.lastPosition;
4495
+ tracker.lastPosition = (e.currentPosition || 0) / 1000;
4496
+ tracker._lastTimeUpdate = tracker.lastPosition;
4497
+ sendEvent(tracker, 'media-seeked', {
4498
+ from: Math.round(from), to: Math.round(tracker.lastPosition)
4499
+ });
4500
+ });
4501
+ widget.bind(SC.Widget.Events.PLAY_PROGRESS, function (e) {
4502
+ var pos = (e.currentPosition || 0) / 1000;
4503
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
4504
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4505
+ }
4506
+ tracker._lastTimeUpdate = pos;
4507
+ tracker.lastPosition = pos;
4508
+ });
4509
+ });
4510
+ }
4511
+
4512
+ // ── Wistia ──
4513
+
4514
+ function trackWistia(container) {
4515
+ var id = genId(container);
4516
+ if (state.tracked[id]) return;
4517
+
4518
+ var handleId = container.getAttribute('data-wistia-id')
4519
+ || (container.className.match(/wistia_async_(\w+)/) || [])[1]
4520
+ || id;
4521
+
4522
+ root._wq = root._wq || [];
4523
+ root._wq.push({
4524
+ id: handleId,
4525
+ onReady: function (video) {
4526
+ if (state.tracked[id]) return;
4527
+ var tracker = createTracker(id, 'wistia', container, video.duration() || 0);
4528
+ state.tracked[id] = tracker;
4529
+ tracker._player = video;
4530
+
4531
+ video.bind('play', function () {
4532
+ tracker.playing = true;
4533
+ tracker.lastPosition = video.time();
4534
+ tracker._lastTimeUpdate = tracker.lastPosition;
4535
+ tracker.duration = video.duration() || tracker.duration;
4536
+ sendEvent(tracker, 'media-play');
4537
+ startCheckpoints(tracker);
4538
+ });
4539
+ video.bind('pause', function () {
4540
+ if (!tracker.playing) return;
4541
+ tracker.playing = false;
4542
+ var pos = video.time();
4543
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4544
+ tracker.lastPosition = pos;
4545
+ stopCheckpoints(tracker);
4546
+ sendEvent(tracker, 'media-pause');
4547
+ });
4548
+ video.bind('end', function () {
4549
+ tracker.playing = false;
4550
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
4551
+ tracker.lastPosition = tracker.duration;
4552
+ stopCheckpoints(tracker);
4553
+ sendEvent(tracker, 'media-ended');
4554
+ });
4555
+ video.bind('seek', function (currentTime, lastTime) {
4556
+ tracker.lastPosition = currentTime;
4557
+ tracker._lastTimeUpdate = currentTime;
4558
+ sendEvent(tracker, 'media-seeked', {
4559
+ from: Math.round(lastTime), to: Math.round(currentTime)
4560
+ });
4561
+ });
4562
+ video.bind('secondchange', function (s) {
4563
+ var pos = s;
4564
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
4565
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4566
+ }
4567
+ tracker._lastTimeUpdate = pos;
4568
+ tracker.lastPosition = pos;
4569
+ });
4570
+ }
4571
+ });
4572
+
4573
+ // Load Wistia E-v1 if not present
4574
+ if (!root.Wistia) {
4575
+ var script = document.createElement('script');
4576
+ script.src = 'https://fast.wistia.com/assets/external/E-v1.js';
4577
+ script.async = true;
4578
+ document.head.appendChild(script);
4579
+ }
4580
+ }
4581
+
4582
+ // ── JW Player ──
4583
+
4584
+ function trackJWPlayer(container) {
4585
+ var id = genId(container);
4586
+ if (state.tracked[id]) return;
4587
+
4588
+ // JW Player instance might already exist
4589
+ var playerId = container.id || id;
4590
+ function _bind() {
4591
+ if (!root.jwplayer || typeof root.jwplayer !== 'function') return false;
4592
+ var player;
4593
+ try { player = jwplayer(playerId); } catch (e) { return false; }
4594
+ if (!player || !player.getState) return false;
4595
+
4596
+ var tracker = createTracker(id, 'jwplayer', container, player.getDuration() || 0);
4597
+ state.tracked[id] = tracker;
4598
+ tracker._player = player;
4599
+
4600
+ player.on('play', function () {
4601
+ tracker.playing = true;
4602
+ tracker.lastPosition = player.getPosition();
4603
+ tracker._lastTimeUpdate = tracker.lastPosition;
4604
+ tracker.duration = player.getDuration() || tracker.duration;
4605
+ sendEvent(tracker, 'media-play');
4606
+ startCheckpoints(tracker);
4607
+ });
4608
+ player.on('pause', function () {
4609
+ if (!tracker.playing) return;
4610
+ tracker.playing = false;
4611
+ var pos = player.getPosition();
4612
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4613
+ tracker.lastPosition = pos;
4614
+ stopCheckpoints(tracker);
4615
+ sendEvent(tracker, 'media-pause');
4616
+ });
4617
+ player.on('complete', function () {
4618
+ tracker.playing = false;
4619
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
4620
+ tracker.lastPosition = tracker.duration;
4621
+ stopCheckpoints(tracker);
4622
+ sendEvent(tracker, 'media-ended');
4623
+ });
4624
+ player.on('seek', function (e) {
4625
+ tracker.lastPosition = e.offset;
4626
+ tracker._lastTimeUpdate = e.offset;
4627
+ sendEvent(tracker, 'media-seeked', {
4628
+ from: Math.round(e.position), to: Math.round(e.offset)
4629
+ });
4630
+ });
4631
+ player.on('time', function (e) {
4632
+ var pos = e.position;
4633
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
4634
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4635
+ }
4636
+ tracker._lastTimeUpdate = pos;
4637
+ tracker.lastPosition = pos;
4638
+ tracker.duration = e.duration || tracker.duration;
4639
+ });
4640
+ return true;
4641
+ }
4642
+
4643
+ // Try immediately, retry after a delay if JW hasn't initialized yet
4644
+ if (!_bind()) {
4645
+ setTimeout(function () { _bind(); }, 2000);
4646
+ }
4647
+ }
4648
+
4649
+ // ── Dailymotion ──
4650
+
4651
+ function trackDailymotion(iframe) {
4652
+ var id = genId(iframe);
4653
+ if (state.tracked[id]) return;
4654
+
4655
+ function _loadAndBind() {
4656
+ if (!root.DM || !root.DM.player) {
4657
+ var script = document.createElement('script');
4658
+ script.src = 'https://api.dmcdn.net/all.js';
4659
+ script.onload = function () { _createPlayer(); };
4660
+ document.head.appendChild(script);
4661
+ } else {
4662
+ _createPlayer();
4663
+ }
4664
+ }
4665
+
4666
+ function _createPlayer() {
4667
+ if (state.tracked[id]) return;
4668
+ var tracker = createTracker(id, 'dailymotion', iframe, 0);
4669
+ state.tracked[id] = tracker;
4670
+
4671
+ var player = DM.player(iframe, { events: {
4672
+ playing: function () {
4673
+ tracker.playing = true;
4674
+ tracker.lastPosition = player.currentTime || 0;
4675
+ tracker._lastTimeUpdate = tracker.lastPosition;
4676
+ tracker.duration = player.duration || tracker.duration;
4677
+ sendEvent(tracker, 'media-play');
4678
+ startCheckpoints(tracker);
4679
+ },
4680
+ pause: function () {
4681
+ if (!tracker.playing) return;
4682
+ tracker.playing = false;
4683
+ var pos = player.currentTime || 0;
4684
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4685
+ tracker.lastPosition = pos;
4686
+ stopCheckpoints(tracker);
4687
+ sendEvent(tracker, 'media-pause');
4688
+ },
4689
+ end: function () {
4690
+ tracker.playing = false;
4691
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
4692
+ tracker.lastPosition = tracker.duration;
4693
+ stopCheckpoints(tracker);
4694
+ sendEvent(tracker, 'media-ended');
4695
+ },
4696
+ seeking: function () {
4697
+ var from = tracker.lastPosition;
4698
+ tracker.lastPosition = player.currentTime || 0;
4699
+ tracker._lastTimeUpdate = tracker.lastPosition;
4700
+ sendEvent(tracker, 'media-seeked', {
4701
+ from: Math.round(from), to: Math.round(tracker.lastPosition)
4702
+ });
4703
+ },
4704
+ timeupdate: function () {
4705
+ var pos = player.currentTime || 0;
4706
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
4707
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4708
+ }
4709
+ tracker._lastTimeUpdate = pos;
4710
+ tracker.lastPosition = pos;
4711
+ tracker.duration = player.duration || tracker.duration;
4712
+ }
4713
+ }});
4714
+ tracker._player = player;
4715
+ }
4716
+
4717
+ _loadAndBind();
4718
+ }
4719
+
4720
+ // ── Spotify Embed (limited — position only via playbackUpdate) ──
4721
+
4722
+ function trackSpotify(iframe) {
4723
+ var id = genId(iframe);
4724
+ if (state.tracked[id]) return;
4725
+
4726
+ var tracker = createTracker(id, 'spotify', iframe, 0);
4727
+ state.tracked[id] = tracker;
4728
+
4729
+ // Spotify Embed API uses window.onSpotifyIframeApiReady + postMessage
4730
+ window.addEventListener('message', function (e) {
4731
+ if (!e.data || e.source !== iframe.contentWindow) return;
4732
+ var data;
4733
+ try { data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
4734
+ catch (err) { return; }
4735
+ if (!data.type) return;
4736
+
4737
+ if (data.type === 'playback_update') {
4738
+ var pos = (data.payload && data.payload.position) || 0;
4739
+ pos = pos / 1000; // ms to seconds
4740
+ var dur = (data.payload && data.payload.duration) || 0;
4741
+ dur = dur / 1000;
4742
+ var isPaused = data.payload && data.payload.isPaused;
4743
+
4744
+ tracker.duration = dur || tracker.duration;
4745
+
4746
+ if (!isPaused && !tracker.playing) {
4747
+ tracker.playing = true;
4748
+ tracker.lastPosition = pos;
4749
+ tracker._lastTimeUpdate = pos;
4750
+ sendEvent(tracker, 'media-play');
4751
+ startCheckpoints(tracker);
4752
+ } else if (isPaused && tracker.playing) {
4753
+ tracker.playing = false;
4754
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4755
+ tracker.lastPosition = pos;
4756
+ stopCheckpoints(tracker);
4757
+ sendEvent(tracker, 'media-pause');
4758
+ } else if (!isPaused && tracker.playing && pos > tracker._lastTimeUpdate) {
4759
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4760
+ tracker._lastTimeUpdate = pos;
4761
+ tracker.lastPosition = pos;
4762
+ }
4763
+ }
4764
+ });
4765
+ }
4766
+
4767
+ // ── Twitch Player ──
4768
+
4769
+ function trackTwitch(iframe) {
4770
+ var id = genId(iframe);
4771
+ if (state.tracked[id]) return;
4772
+
4773
+ function _loadAndBind() {
4774
+ if (!root.Twitch || !root.Twitch.Player) {
4775
+ var script = document.createElement('script');
4776
+ script.src = 'https://player.twitch.tv/js/embed/v1.js';
4777
+ script.onload = function () { _createPlayer(); };
4778
+ document.head.appendChild(script);
4779
+ } else {
4780
+ _createPlayer();
4781
+ }
4782
+ }
4783
+
4784
+ function _createPlayer() {
4785
+ if (state.tracked[id]) return;
4786
+ if (!iframe.id) iframe.id = 'twitch-' + id;
4787
+ var tracker = createTracker(id, 'twitch', iframe, 0);
4788
+ state.tracked[id] = tracker;
4789
+
4790
+ var player = new Twitch.Player(iframe.id, {});
4791
+ tracker._player = player;
4792
+
4793
+ player.addEventListener(Twitch.Player.PLAY, function () {
4794
+ tracker.playing = true;
4795
+ tracker.lastPosition = player.getCurrentTime() || 0;
4796
+ tracker._lastTimeUpdate = tracker.lastPosition;
4797
+ tracker.duration = player.getDuration() || tracker.duration;
4798
+ sendEvent(tracker, 'media-play');
4799
+ startCheckpoints(tracker);
4800
+ tracker._pollTimer = setInterval(function () {
4801
+ var p = player.getCurrentTime() || 0;
4802
+ if (tracker.playing && p > tracker._lastTimeUpdate) {
4803
+ tracker.watched.add(tracker._lastTimeUpdate, p);
4804
+ }
4805
+ tracker._lastTimeUpdate = p;
4806
+ tracker.lastPosition = p;
4807
+ }, 1000);
4808
+ });
4809
+ player.addEventListener(Twitch.Player.PAUSE, function () {
4810
+ if (!tracker.playing) return;
4811
+ tracker.playing = false;
4812
+ var pos = player.getCurrentTime() || 0;
4813
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4814
+ tracker.lastPosition = pos;
4815
+ stopCheckpoints(tracker);
4816
+ clearInterval(tracker._pollTimer);
4817
+ sendEvent(tracker, 'media-pause');
4818
+ });
4819
+ player.addEventListener(Twitch.Player.ENDED, function () {
4820
+ tracker.playing = false;
4821
+ var pos = player.getCurrentTime() || tracker.duration;
4822
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4823
+ tracker.lastPosition = pos;
4824
+ stopCheckpoints(tracker);
4825
+ clearInterval(tracker._pollTimer);
4826
+ sendEvent(tracker, 'media-ended');
4827
+ });
4828
+ }
4829
+
4830
+ _loadAndBind();
4831
+ }
4832
+
4833
+ // ── Muse.ai (postMessage API) ──
4834
+
4835
+ function trackMuseAi(iframe) {
4836
+ var id = genId(iframe);
4837
+ if (state.tracked[id]) return;
4838
+
4839
+ var tracker = createTracker(id, 'museai', iframe, 0);
4840
+ state.tracked[id] = tracker;
4841
+
4842
+ window.addEventListener('message', function (e) {
4843
+ if (!e.data || e.source !== iframe.contentWindow) return;
4844
+ var data;
4845
+ try { data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; }
4846
+ catch (err) { return; }
4847
+
4848
+ if (data.event === 'play') {
4849
+ tracker.playing = true;
4850
+ tracker.lastPosition = data.currentTime || 0;
4851
+ tracker._lastTimeUpdate = tracker.lastPosition;
4852
+ tracker.duration = data.duration || tracker.duration;
4853
+ sendEvent(tracker, 'media-play');
4854
+ startCheckpoints(tracker);
4855
+ } else if (data.event === 'pause') {
4856
+ if (!tracker.playing) return;
4857
+ tracker.playing = false;
4858
+ var pos = data.currentTime || 0;
4859
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4860
+ tracker.lastPosition = pos;
4861
+ stopCheckpoints(tracker);
4862
+ sendEvent(tracker, 'media-pause');
4863
+ } else if (data.event === 'ended') {
4864
+ tracker.playing = false;
4865
+ tracker.watched.add(tracker._lastTimeUpdate, tracker.duration);
4866
+ tracker.lastPosition = tracker.duration;
4867
+ stopCheckpoints(tracker);
4868
+ sendEvent(tracker, 'media-ended');
4869
+ } else if (data.event === 'timeupdate') {
4870
+ var pos = data.currentTime || 0;
4871
+ tracker.duration = data.duration || tracker.duration;
4872
+ if (tracker.playing && pos > tracker._lastTimeUpdate) {
4873
+ tracker.watched.add(tracker._lastTimeUpdate, pos);
4874
+ }
4875
+ tracker._lastTimeUpdate = pos;
4876
+ tracker.lastPosition = pos;
4877
+ }
4878
+ });
4879
+
4880
+ // Request the iframe to emit events
4881
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'play' }, '*'); } catch(e){}
4882
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'pause' }, '*'); } catch(e){}
4883
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'ended' }, '*'); } catch(e){}
4884
+ try { iframe.contentWindow.postMessage({ method: 'addEventListener', value: 'timeupdate' }, '*'); } catch(e){}
4885
+ }