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