1/*! 2 * PSYWAR Pixel v2 â outcome-linked viewer telemetry 3 * (c) PSYWAR â pro-grade tracking + iframe adapters + signed conversions 4 * 5 * Tag-based usage: 6 * <video id="vsl" src="..."></video> 7 * <script src="https://psywar.ai/psywar.js" 8 * data-pixel-key="psy_xxxx" 9 * data-video="#vsl" 10 * data-cta="[data-cta]" 11 * data-auto="1" 12 * async defer></script> 13 * 14 * Manual API: 15 * PSYWAR.init({ pixelKey, video, cta, apiBase, iframe, consent: true|false }) 16 * PSYWAR.embedPlayer({ mount, vslUrl, hlsUrl, poster, theme }) // injects our own video 17 * PSYWAR.convert({ value, externalRef, meta }) // signed conversion 18 * PSYWAR.custom(name, meta) 19 * 20 * Player support (auto-detected from iframe.src): 21 * - Native <video> tags (full feature set) 22 * - Wistia (window._wq queue) 23 * - Vimeo (player.js postMessage) 24 * - YouTube (IFrame Player API) 25 * - vTurb (SmartPlayer internal <video> + postMessage) 26 * - Vidalytics (vid_player_event postMessage) 27 * 28 * Consent: 29 * - Honors navigator.doNotTrack === '1' (drops to a no-op stub) 30 * - Honors window.__PSYWAR_CONSENT === false (same) 31 * - Set window.__PSYWAR_CONSENT = true to override DNT 32 */ 33(function () { 34 'use strict'; 35 if (typeof window === 'undefined') return; 36 37 var GLOBAL = 'PSYWAR'; 38 var VID_KEY = 'psywar_vid'; 39 var VARIANT_KEY_PREFIX = 'psywar_var_'; 40 var SECRET_KEY_PREFIX = 'psywar_sec_'; 41 var SESSION_KEY_PREFIX = 'psywar_ses_'; 42 var SESSION_TTL_MS = 30 * 60 * 1000; // 30min â stays sticky across reloads 43 44 // âââ Tier-1 deep telemetry: capture page-load time at IIFE evaluation. 45 // Used to compute "poster dwell" â ms between page-load and first PLAY. 46 // Strongest intent signal in the funnel; almost no one captures it cleanly. 47 var PAGE_LOAD_MS = Date.now(); 48 var PAGE_PERF_ORIGIN = (window.performance && window.performance.timeOrigin) || PAGE_LOAD_MS; 49 50 // âââââââââââââââââââââ Consent gate âââââââââââââââââââââ 51 var consent = (function () { 52 if (window.__PSYWAR_CONSENT === false) return false; 53 if (window.__PSYWAR_CONSENT === true) return true; 54 try { 55 if (navigator.doNotTrack === '1' || window.doNotTrack === '1') return false; 56 } catch (e) {} 57 return true; 58 })(); 59 60 if (!consent) { 61 window[GLOBAL] = { 62 init: function () { return window[GLOBAL]; }, 63 embedPlayer: function () {}, 64 convert: function () {}, 65 custom: function () {}, 66 flush: function () {}, 67 _consentDenied: true, 68 }; 69 return; 70 } 71 72 // âââââââââââââââââââââ Helpers âââââââââââââââââââââ 73 function uuid() { 74 try { 75 if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); 76 } catch (e) {} 77 var s = '', hex = '0123456789abcdef'; 78 for (var i = 0; i < 32; i++) s += hex[Math.floor(Math.random() * 16)]; 79 return s.slice(0,8)+'-'+s.slice(8,12)+'-4'+s.slice(13,16)+'-a'+s.slice(17,20)+'-'+s.slice(20); 80 } 81 82 // 16-hex batch id, same helper style as uuid(): crypto when available, 83 // Math.random fallback. Minted once per flushed batch. A REPLAYED batch 84 // (offline-queue drain, in-memory retry) reuses its ORIGINAL id so the 85 // server can drop duplicate batches at /api/pixel/ingest. 86 function batchIdHex() { 87 try { 88 if (window.crypto && window.crypto.getRandomValues) { 89 var b = new Uint8Array(8); 90 window.crypto.getRandomValues(b); 91 var out = ''; 92 for (var i = 0; i < 8; i++) out += ('0' + b[i].toString(16)).slice(-2); 93 return out; 94 } 95 } catch (e) {} 96 var s = '', hex = '0123456789abcdef'; 97 for (var j = 0; j < 16; j++) s += hex[Math.floor(Math.random() * 16)]; 98 return s; 99 } 100 101 function lsGet(k) { try { return window.localStorage.getItem(k); } catch (e) { return null; } } 102 function lsSet(k, v) { try { window.localStorage.setItem(k, v); } catch (e) {} } 103 function lsDel(k) { try { window.localStorage.removeItem(k); } catch (e) {} } 104 105 function getVisitorId() { 106 var v = lsGet(VID_KEY); 107 if (!v) { v = uuid(); lsSet(VID_KEY, v); } 108 return v; 109 } 110 111 function getStickyVariant(pixelKey) { 112 return lsGet(VARIANT_KEY_PREFIX + pixelKey); 113 } 114 function setStickyVariant(pixelKey, variantId) { 115 if (variantId) lsSet(VARIANT_KEY_PREFIX + pixelKey, variantId); 116 } 117 118 function getStickySession(pixelKey) { 119 var raw = lsGet(SESSION_KEY_PREFIX + pixelKey); 120 if (!raw) return null; 121 try { 122 var p = JSON.parse(raw); 123 if (Date.now() - (p.t || 0) > SESSION_TTL_MS) { 124 lsDel(SESSION_KEY_PREFIX + pixelKey); 125 return null; 126 } 127 return p; 128 } catch (e) { return null; } 129 } 130 function setStickySession(pixelKey, sessionId, secret) { 131 lsSet(SESSION_KEY_PREFIX + pixelKey, JSON.stringify({ s: sessionId, k: secret, t: Date.now() })); 132 if (secret) lsSet(SECRET_KEY_PREFIX + pixelKey, secret); 133 } 134 135 function getUtm() { 136 try { 137 var p = new URLSearchParams(window.location.search); 138 return { 139 utmSource: p.get('utm_source'), 140 utmMedium: p.get('utm_medium'), 141 utmCampaign: p.get('utm_campaign'), 142 utmTerm: p.get('utm_term'), 143 utmContent: p.get('utm_content'), 144 }; 145 } catch (e) { return {}; } 146 } 147 148 function deviceType() { 149 // Viewport-width-only classification mis-tags windowed desktop browsers 150 // (640-1023px wide) as 'tablet'. Real-world fix: trust User-Agent and 151 // input modality first, fall back to viewport only when the device is 152 // genuinely touch-capable and the UA is ambiguous. 153 var ua = (navigator.userAgent || '').toLowerCase(); 154 var maxTouch = navigator.maxTouchPoints || 0; 155 156 // Explicit tablet UA strings. 157 if (/ipad|playbook|silk|kindle|tablet|nexus 7|nexus 10|sm-t/.test(ua)) return 'tablet'; 158 159 // iPadOS 13+ poses as 'Macintosh' but reports multi-touch. Catch it. 160 if (/macintosh/.test(ua) && maxTouch > 1) return 'tablet'; 161 162 // Explicit mobile UA strings. 163 if (/iphone|ipod|android.*mobile|windows phone|blackberry|bb10|opera mini|mobi/.test(ua)) return 'mobile'; 164 165 // No clear UA hint. Use input modality. 166 var coarsePointer = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); 167 var hasTouch = maxTouch > 1 || coarsePointer; 168 var w = window.innerWidth || document.documentElement.clientWidth || 0; 169 170 if (hasTouch) { 171 // Touch-only device with no UA tag. Use viewport to split mobile/tablet. 172 return w < 640 ? 'mobile' : 'tablet'; 173 } 174 175 // Mouse/trackpad device. Always desktop regardless of window size. 176 return 'desktop'; 177 } 178 179 // âââââââââââââââââââââ Capability + hardware probes âââââââââââââââââââââ 180 function detectCodecs() { 181 var v = document.createElement('video'); 182 function ok(s) { try { return !!v.canPlayType(s); } catch (e) { return false; } } 183 return { 184 h264: ok('video/mp4; codecs="avc1.42E01E"'), 185 h265: ok('video/mp4; codecs="hvc1.1.6.L93.B0"'), 186 vp9: ok('video/webm; codecs="vp9"'), 187 av1: ok('video/mp4; codecs="av01.0.05M.08"'), 188 hls: ok('application/vnd.apple.mpegurl'),
189 dash: ok('application/dash+xml'), 190 }; 191 } 192 193 function detectDrm() { 194 var promises = ['com.widevine.alpha','com.apple.fps.1_0','com.microsoft.playready'].map(function (k) { 195 try { 196 return navigator.requestMediaKeySystemAccess && navigator.requestMediaKeySystemAccess(k, [{ 197 initDataTypes: ['cenc'], 198 videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.42E01E"' }], 199 }]).then(function () { return [k, true]; }).catch(function () { return [k, false]; }); 200 } catch (e) { return Promise.resolve([k, false]); } 201 }); 202 return Promise.all(promises).then(function (results) { 203 var o = { widevine: false, fairplay: false, playready: false }; 204 results.forEach(function (r) { 205 if (r[0] === 'com.widevine.alpha') o.widevine = r[1]; 206 if (r[0] === 'com.apple.fps.1_0') o.fairplay = r[1]; 207 if (r[0] === 'com.microsoft.playready') o.playready = r[1]; 208 }); 209 return o; 210 }).catch(function () { return { widevine: false, fairplay: false, playready: false }; }); 211 } 212 213 function detectGpu() { 214 try { 215 var canvas = document.createElement('canvas'); 216 var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); 217 if (!gl) return { vendor: null, renderer: null }; 218 var dbgRender = gl.getExtension('WEBGL_debug_renderer_info'); 219 return { 220 vendor: dbgRender ? gl.getParameter(dbgRender.UNMASKED_VENDOR_WEBGL) : (gl.getParameter(gl.VENDOR) || null), 221 renderer: dbgRender ? gl.getParameter(dbgRender.UNMASKED_RENDERER_WEBGL) : (gl.getParameter(gl.RENDERER) || null), 222 }; 223 } catch (e) { return { vendor: null, renderer: null }; } 224 } 225 226 function getConnection() { 227 var c = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 228 if (!c) return {}; 229 return { 230 connectionType: c.type || null, 231 effectiveType: c.effectiveType || null, 232 downlinkMbps: typeof c.downlink === 'number' ? c.downlink : null, 233 rttMs: typeof c.rtt === 'number' ? c.rtt : null, 234 saveData: !!c.saveData, 235 }; 236 } 237 238 // Cheap fingerprint: canvas+webgl features hashed. NOT crypto-grade, but stable 239 // enough to dedupe real visitors across cleared cookies/incognito. 240 function fingerprint() { 241 var parts = [ 242 navigator.userAgent || '', 243 navigator.language || '', 244 (navigator.languages || []).join(','), 245 window.screen.width + 'x' + window.screen.height + 'x' + (window.screen.colorDepth || 0), 246 window.devicePixelRatio || 1, 247 new Date().getTimezoneOffset(), 248 (navigator.hardwareConcurrency || 0) + ':' + (navigator.deviceMemory || 0), 249 ]; 250 try { 251 var c = document.createElement('canvas'); c.width = 200; c.height = 50; 252 var ctx = c.getContext('2d'); 253 ctx.textBaseline = 'top'; ctx.font = "14px 'Arial'"; 254 ctx.fillStyle = '#f60'; ctx.fillRect(0,0,200,50); 255 ctx.fillStyle = '#069'; ctx.fillText('PSYWAR', 2, 2); 256 parts.push(c.toDataURL().slice(-64)); 257 } catch (e) {} 258 var s = parts.join('|'), h = 0; 259 for (var i = 0; i < s.length; i++) { h = ((h << 5) - h) + s.charCodeAt(i); h |= 0; } 260 return ('00000000' + (h >>> 0).toString(16)).slice(-8); 261 } 262 263 // Click ID extraction (gclid, fbclid, ttclid, msclkid, twclid, wbraid, gbraid) 264 function extractClickId() { 265 try { 266 var p = new URLSearchParams(window.location.search); 267 var keys = ['gclid','fbclid','ttclid','msclkid','twclid','wbraid','gbraid']; 268 for (var i = 0; i < keys.length; i++) { 269 var v = p.get(keys[i]); 270 if (v) return { clickId: v, clickIdSource: keys[i] }; 271 } 272 } catch (e) {} 273 return { clickId: null, clickIdSource: null }; 274 } 275 276 // HMAC-SHA256 hex via SubtleCrypto. Falls back to "" on insecure context; 277 // the server will reject unsigned conversions, which is the correct behavior. 278 function hmacHex(secret, message) { 279 if (!window.crypto || !window.crypto.subtle) return Promise.resolve(''); 280 var enc = new TextEncoder(); 281 return window.crypto.subtle 282 .importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) 283 .then(function (k) { return window.crypto.subtle.sign('HMAC', k, enc.encode(message)); }) 284 .then(function (sig) { 285 var b = new Uint8Array(sig), s = ''; 286 for (var i = 0; i < b.length; i++) s += ('0' + b[i].toString(16)).slice(-2); 287 return s; 288 }) 289 .catch(function () { return ''; }); 290 } 291 292 // âââââââââââââââââââââ Player adapters âââââââââââââââââââââ 293 // Each adapter returns a uniform interface: { onPlay, onPause, onSeek, onEnded, 294 // onTime(cb), onMute, onUnmute, getCurrentTime(), destroy() }. 295 // 296 // Detection priority: explicit data-iframe selector > video tag > auto-detect
297 // an iframe by URL pattern. 298 function detectIframeKind(iframe) { 299 var src = (iframe.src || '').toLowerCase(); 300 if (/wistia\.(com|net)/.test(src)) return 'wistia'; 301 if (/player\.vimeo\.com/.test(src)) return 'vimeo'; 302 if (/youtube\.com\/embed|youtube-nocookie\.com/.test(src)) return 'youtube'; 303 if (/vidalytics\.(com|io)/.test(src)) return 'vidalytics'; 304 if (/converteai\.net|vturb|smartplayer/.test(src)) return 'vturb'; 305 return null; 306 } 307 308 // vTurb / ConverteAI SmartPlayer presence. v4 renders a <vturb-smartplayer> 309 // custom element (often with a #vid_<id> container) plus a window.smartplayer 310 // global; older builds use a converteai iframe. 311 function isVTurbPresent() { 312 try { 313 if (window.smartplayer) return true; 314 if (document.querySelector('vturb-smartplayer')) return true; 315 if (document.querySelector('[id^="vid_"], [id^="vid-"]')) return true; 316 var s = document.querySelectorAll('script'); 317 for (var i = 0; i < s.length; i++) { 318 if (/converteai\.net|smartplayer/i.test(s[i].src || '')) return true; 319 } 320 } catch (e) {} 321 return false; 322 } 323 function findVTurbHost() { 324 try { 325 return document.querySelector('vturb-smartplayer, [id^="vid_"], [id^="vid-"]') || null; 326 } catch (e) { return null; } 327 } 328 329 function makeNativeAdapter(videoEl, emit) { 330 var destroyed = false; 331 // Every listener registers through on() so destroy() can detach all of 332 // them. Without this, SPA rebinds on the same element stack duplicate 333 // listeners and double-fire PLAY/TICK. 334 var listeners = []; 335 function on(target, type, fn, opts) { 336 target.addEventListener(type, fn, opts); 337 listeners.push([target, type, fn, opts]); 338 } 339 var lastSec = -1; 340 var tickInt = null; 341 var droppedInt = null; 342 var loadStartMs = 0, ttfbReported = false; 343 var lastWidth = 0, lastHeight = 0; 344 var lastDropped = 0; 345 346 // Granular state we update inside every TICK so the server can compute 347 // attention/quality composites without needing a separate event stream. 348 var prevDecoded = 0, prevDropped = 0, prevPainted = 0; 349 var prevBufferedAhead = -1; 350 var rebufferStartedAt = 0, rebufferTotalMs = 0; 351 352 // Tier-1 deep telemetry: per-second view counts. 353 // watchCounts[s] = number of distinct ticks at video-second s. 354 // When a second crosses 1â2 we emit REPLAY_SEGMENT (re-watch heatmap). 355 var watchCounts = Object.create(null); 356 357 function startTick() { 358 if (tickInt) return; 359 // 2Hz polling â we still emit TICK at most once per integer second, 360 // but the half-second poll lets us catch buffer transitions and 361 // playback-quality samples between ticks more accurately. 362 tickInt = setInterval(function () { 363 if (videoEl.paused || videoEl.ended) return; 364 var s = Math.floor(videoEl.currentTime || 0); 365 366 // Buffer health â how many seconds we have queued ahead of the playhead. 367 // < 2s = at risk of stall; > 30s = healthy. Server can plot this curve. 368 var bufferedAhead = 0; 369 try { 370 var bb = videoEl.buffered; 371 for (var i = 0; i < bb.length; i++) { 372 if (videoEl.currentTime >= bb.start(i) && videoEl.currentTime <= bb.end(i)) { 373 bufferedAhead = bb.end(i) - videoEl.currentTime; break; 374 } 375 } 376 } catch (e) {} 377 378 // Frame statistics â what the GPU/decoder actually delivered 379 var fStats = null; 380 try { 381 if (videoEl.getVideoPlaybackQuality) { 382 var q = videoEl.getVideoPlaybackQuality(); 383 var dec = q.totalVideoFrames || 0; 384 var drp = q.droppedVideoFrames || 0; 385 var pnt = (q.totalVideoFrames - q.droppedVideoFrames) || 0; 386 fStats = { 387 decodedDelta: dec - prevDecoded, 388 droppedDelta: drp - prevDropped, 389 paintedDelta: pnt - prevPainted, 390 droppedTotal: drp, 391 decodedTotal: dec, 392 }; 393 prevDecoded = dec; prevDropped = drp; prevPainted = pnt; 394 } 395 } catch (e) {} 396 397 // Resolution change â ABR_SHIFT (kept as a discrete event for clean diffing) 398 var w = videoEl.videoWidth || 0, h = videoEl.videoHeight || 0; 399 if (w && h && (w !== lastWidth || h !== lastHeight)) { 400 if (lastWidth) emit('ABR_SHIFT', { tVideoSec: s, value: h, meta: { from: lastWidth + 'x' + lastHeight, to: w + 'x' + h, quality: h + 'p', bufferedAheadSec: Math.round(bufferedAhead * 10) / 10 } }); 401 lastWidth = w; lastHeight = h; 402 } 403 404 // Emit one enriched TICK per integer second. 405 if (s !== lastSec) { 406 lastSec = s; 407 // Tier-1 replay-segment detection. Increment the view count for this 408 // second; when we cross from 1 to 2, fire REPLAY_SEGMENT so the 409 // server can keep a discrete index for the re-watch heatmap. 410 watchCounts[s] = (watchCounts[s] || 0) + 1; 411 if (watchCounts[s] === 2) { 412 emit('REPLAY_SEGMENT', { tVideoSec: s, value: 2, meta: { count: 2 } }); 413 } 414 emit('TICK', { 415 tVideoSec: s, 416 meta: { 417 h: h || null, // height played 418 ba: Math.round(bufferedAhead * 10) / 10, // buffered ahead (s) 419 rate: videoEl.playbackRate, 420 vol: videoEl.muted ? 0 : Math.round(videoEl.volume * 100) / 100, 421 fps: fStats ? fStats.decodedDelta : null, // ~delta frames since last tick 422 drp: fStats ? fStats.droppedDelta : null, // dropped frames since last tick 423 rdy: videoEl.readyState, // 0..4 â 4 = HAVE_ENOUGH_DATA 424 vis: document.visibilityState === 'visible' ? 1 : 0, // tab visible? 425 dpr: window.devicePixelRatio || 1, 426 rep: watchCounts[s], // Tier-1: how many times we've watched this second 427 } 428 }); 429 } 430 431 // Buffer-health regime change â optional discrete event so sessions 432 // page can correlate dropoffs with stall events. Crossing the 2s 433 // threshold from above signals an at-risk session. 434 var crossed = (prevBufferedAhead > 2 && bufferedAhead <= 2) 435 || (prevBufferedAhead < 2 && bufferedAhead > 2 && prevBufferedAhead >= 0); 436 if (crossed) { 437 emit(bufferedAhead <= 2 ? 'BUFFER_START' : 'BUFFER_END', { 438 tVideoSec: videoEl.currentTime, 439 meta: { bufferedAheadSec: Math.round(bufferedAhead * 10) / 10 } 440 }); 441 } 442 prevBufferedAhead = bufferedAhead; 443 }, 500); 444 } 445 function stopTick() { if (tickInt) { clearInterval(tickInt); tickInt = null; } } 446 447 function startDroppedPoll() { 448 if (droppedInt) return; 449 droppedInt = setInterval(function () { 450 try { 451 if (videoEl.getVideoPlaybackQuality) { 452 var q = videoEl.getVideoPlaybackQuality(); 453 var d = q.droppedVideoFrames || 0; 454 var delta = d - lastDropped; 455 if (delta > 0) { 456 emit('DROPPED_FRAMES', { tVideoSec: videoEl.currentTime, value: delta, meta: { total: d } }); 457 lastDropped = d; 458 } 459 } else if (typeof videoEl.webkitDroppedFrameCount === 'number') { 460 var wd = videoEl.webkitDroppedFrameCount, wdelta = wd - lastDropped; 461 if (wdelta > 0) { 462 emit('DROPPED_FRAMES', { tVideoSec: videoEl.currentTime, value: wdelta, meta: { total: wd } }); 463 lastDropped = wd; 464 } 465 } 466 } catch (e) {} 467 }, 5000); 468 } 469 function stopDroppedPoll() { if (droppedInt) { clearInterval(droppedInt); droppedInt = null; } } 470 471 on(videoEl, 'loadstart', function () { loadStartMs = Date.now(); ttfbReported = false; }); 472 on(videoEl, 'play', function () { emit('PLAY', { tVideoSec: videoEl.currentTime }); startTick(); startDroppedPoll(); }); 473 on(videoEl, 'playing', function () { 474 if (!ttfbReported && loadStartMs > 0) { 475 ttfbReported = true; 476 emit('TTFB', { value: Date.now() - loadStartMs }); 477 } 478 }); 479 on(videoEl, 'pause', function () { emit('PAUSE', { tVideoSec: videoEl.currentTime }); stopTick(); stopDroppedPoll(); }); 480 on(videoEl, 'seeked', function () { emit('SEEK', { tVideoSec: videoEl.currentTime }); }); 481 on(videoEl, 'ended', function () { emit('ENDED', { tVideoSec: videoEl.currentTime }); stopTick(); stopDroppedPoll(); }); 482 on(videoEl, 'waiting', function () { emit('BUFFER_START', { tVideoSec: videoEl.currentTime }); }); 483 on(videoEl, 'canplay', function () { emit('BUFFER_END', { tVideoSec: videoEl.currentTime }); }); 484 on(videoEl, 'volumechange', function () { 485 emit(videoEl.muted ? 'MUTE' : 'UNMUTE', { value: videoEl.volume, tVideoSec: videoEl.currentTime }); 486 }); 487 on(videoEl, 'ratechange', function () { emit('RATE_CHANGE', { value: videoEl.playbackRate, tVideoSec: videoEl.currentTime }); }); 488 // Playback error telemetry â the difference between "boring VSL" and 489 // "video literally would not play" in the drop-off data. MEDIA_ERR codes: 490 // 1=ABORTED 2=NETWORK 3=DECODE 4=SRC_NOT_SUPPORTED. 491 on(videoEl, 'error', function () { 492 var err = videoEl.error || {}; 493 emit('CUSTOM', { tVideoSec: videoEl.currentTime, value: err.code || 0, meta: { 494 kind: 'playback_error', 495 code: err.code || 0, 496 message: String(err.message || '').slice(0, 200), 497 net: videoEl.networkState, 498 rdy: videoEl.readyState, 499 src: String(videoEl.currentSrc || '').split('?')[0].slice(0, 180), 500 } }); 501 }); 502 on(videoEl, 'enterpictureinpicture', function () { emit('PIP_ENTE
502R', { tVideoSec: videoEl.currentTime }); }); 503 on(videoEl, 'leavepictureinpicture', function () { emit('PIP_EXIT', { tVideoSec: videoEl.currentTime }); }); 504 on(document, 'fullscreenchange', function () { 505 emit('FULLSCREEN', { value: document.fullscreenElement ? 1 : 0, tVideoSec: videoEl.currentTime }); 506 }); 507 508 // Tier-1: pixel-level click heatmap on the player surface. 509 // Attaches to the immediate parent so clicks on overlays/controls 510 // (play button, sound prompt, CTA pill) are also captured. Coordinates 511 // are normalized to the player rect so heatmaps don't depend on size. 512 var playerHost = videoEl.parentElement || videoEl; 513 on(playerHost, 'click', function (e) { 514 try { 515 var rect = playerHost.getBoundingClientRect(); 516 var x = e.clientX - rect.left; 517 var y = e.clientY - rect.top; 518 var w = Math.max(1, rect.width); 519 var h = Math.max(1, rect.height); 520 var nx = Math.max(0, Math.min(1, x / w)); 521 var ny = Math.max(0, Math.min(1, y / h)); 522 var tag = (e.target && e.target.tagName) ? String(e.target.tagName).toLowerCase() : null; 523 var role = e.target && (e.target.getAttribute && e.target.getAttribute('data-role')) || null; 524 emit('PLAYER_CLICK', { 525 tVideoSec: videoEl.currentTime, 526 meta: { nx: Math.round(nx * 1000) / 1000, ny: Math.round(ny * 1000) / 1000, w: Math.round(w), h: Math.round(h), tag: tag, role: role }, 527 }); 528 } catch (_) {} 529 }, true); 530 531 return { 532 kind: 'native', 533 el: videoEl, 534 getCurrentTime: function () { return Number(videoEl.currentTime || 0); }, 535 destroy: function () { 536 if (destroyed) return; 537 destroyed = true; 538 stopTick(); 539 stopDroppedPoll(); 540 for (var i = 0; i < listeners.length; i++) { 541 try { listeners[i][0].removeEventListener(listeners[i][1], listeners[i][2], listeners[i][3]); } catch (e) {} 542 } 543 listeners.length = 0; 544 }, 545 }; 546 } 547 548 function makeVimeoAdapter(iframe, emit) { 549 function post(method, value) { 550 try { iframe.contentWindow.postMessage(JSON.stringify({ method: method, value: value }), '*'); } catch (e) {} 551 } 552 var currentTime = 0; 553 function onMsg(e) { 554 if (!e.data) return; 555 var d = e.data; 556 if (typeof d === 'string') { try { d = JSON.parse(d); } catch (err) { return; } } 557 if (!d || (d.player_id && iframe.src.indexOf(d.player_id) === -1)) return; 558 if (d.event === 'play') emit('PLAY', { tVideoSec: currentTime }); 559 else if (d.event === 'pause') emit('PAUSE', { tVideoSec: currentTime }); 560 else if (d.event === 'ended') emit('ENDED', { tVideoSec: currentTime }); 561 else if (d.event === 'seeked') emit('SEEK', { tVideoSec: currentTime }); 562 else if (d.event === 'timeupdate' && d.data && typeof d.data.seconds === 'number') { 563 currentTime = d.data.seconds; 564 var sec = Math.floor(currentTime); 565 if (sec !== onMsg._last) { onMsg._last = sec; emit('TICK', { tVideoSec: sec }); } 566 } 567 } 568 onMsg._last = -1; 569 window.addEventListener('message', onMsg); 570 // Subscribe to events 571 ['play','pause','ended','seeked','timeupdate'].forEach(function (e) { post('addEventListener', e); }); 572 573 return { 574 kind: 'vimeo', 575 el: iframe, 576 getCurrentTime: function () { return currentTime; }, 577 destroy: function () { window.removeEventListener('message', onMsg); }, 578 }; 579 } 580 581 function makeVidalyticsAdapter(iframe, emit) { 582 // Vidalytics embeds broadcast postMessages of shape 583 // { type: 'vid_player_event', name: 'play'|'pause'|'end'|'seek'|'time', time: <sec> }. 584 // We map them onto the uniform event set with per-second TICK dedupe. 585 var currentTime = 0, lastSec = -1; 586 function onMsg(e) { 587 var d = e.data; 588 if (typeof d === 'string') { try { d = JSON.parse(d); } catch (err) { return; } } 589 if (!d || typeof d !== 'object' || d.type !== 'vid_player_event') return; 590 // Only accept messages from our own iframe window to avoid crosstalk 591 // between multiple embeds on the same page.
592 try { 593 if (e.source && iframe && iframe.contentWindow && e.source !== iframe.contentWindow) return; 594 } catch (err) {} 595 if (typeof d.time === 'number' && isFinite(d.time) && d.time >= 0) currentTime = d.time; 596 var name = String(d.name || '').toLowerCase(); 597 if (name === 'play') emit('PLAY', { tVideoSec: currentTime }); 598 else if (name === 'pause') emit('PAUSE', { tVideoSec: currentTime }); 599 else if (name === 'end' || name === 'ended') emit('ENDED', { tVideoSec: currentTime }); 600 else if (name === 'seek' || name === 'seeked') emit('SEEK', { tVideoSec: currentTime }); 601 else if (name === 'time' || name === 'timeupdate' || name === 'progress') { 602 var s = Math.floor(currentTime); 603 if (s !== lastSec) { lastSec = s; emit('TICK', { tVideoSec: s }); } 604 } 605 } 606 window.addEventListener('message', onMsg); 607 return { 608 kind: 'vidalytics', 609 el: iframe, 610 getCurrentTime: function () { return currentTime; }, 611 destroy: function () { window.removeEventListener('message', onMsg); }, 612 }; 613 } 614 615 function makeYouTubeAdapter(iframe, emit) { 616 // Lazy-load the iframe API 617 var loaded = false, player = null, currentTime = 0, lastSec = -1, destroyed = false; 618 function loadApi(cb) { 619 if (window.YT && window.YT.Player) return cb(); 620 var s = document.createElement('script'); 621 s.src = 'https://www.youtube.com/iframe_api'; 622 s.async = true; 623 window.onYouTubeIframeAPIReady = cb; 624 document.head.appendChild(s); 625 } 626 // Force enablejsapi=1 627 try { 628 var u = new URL(iframe.src, window.location.href); 629 if (u.searchParams.get('enablejsapi') !== '1') { 630 u.searchParams.set('enablejsapi', '1'); 631 iframe.src = u.toString(); 632 } 633 } catch (e) {} 634 635 loadApi(function () { 636 player = new window.YT.Player(iframe, { 637 events: { 638 onStateChange: function (e) { 639 if (destroyed) return; 640 var T = window.YT.PlayerState; 641 if (e.data === T.PLAYING) emit('PLAY', { tVideoSec: currentTime }); 642 else if (e.data === T.PAUSED) emit('PAUSE', { tVideoSec: currentTime }); 643 else if (e.data === T.ENDED) emit('ENDED', { tVideoSec: currentTime }); 644 }, 645 }, 646 }); 647 }); 648 649 var pollInt = setInterval(function () { 650 if (player && player.getCurrentTime) { 651 var t = player.getCurrentTime() || 0; 652 currentTime = t; 653 var s = Math.floor(t); 654 if (s !== lastSec) { lastSec = s; emit('TICK', { tVideoSec: s }); } 655 } 656 }, 1000); 657 658 return { 659 kind: 'youtube', 660 el: iframe, 661 getCurrentTime: function () { return currentTime; }, 662 destroy: function () { destroyed = true; clearInterval(pollInt); }, 663 }; 664 } 665 666 function makeWistiaAdapter(iframe, emit) { 667 // Wistia injects window._wq; we hook every video matching the iframe. 668 // The _wq registration cannot be unregistered, so destroy() flips a flag 669 // that makes every bound callback inert (prevents duplicates after rebind). 670 var currentTime = 0, lastSec = -1, destroyed = false; 671 window._wq = window._wq || []; 672 var hashedId = null; 673 try { 674 var m = iframe.src.match(/medias\/([a-z0-9]+)/i); 675 hashedId = m && m[1]; 676 } catch (e) {} 677 if (!hashedId) { 678 return { kind: 'wistia', el: iframe, getCurrentTime: function () { return 0; }, destroy: function () {} }; 679 } 680 window._wq.push({ 681 id: hashedId, 682 onReady: function (video) { 683 if (destroyed) return; 684 video.bind('play', function () { if (!destroyed) emit('PLAY', { tVideoSec: currentTime }); }); 685 video.bind('pause', function () { if (!destroyed) emit('PAUSE', { tVideoSec: currentTime }); }); 686 video.bind('end', function () { if (!destroyed) emit('ENDED', { tVideoSec: currentTime }); }); 687 video.bind('seek', function () { if (!destroyed) emit('SEEK', { tVideoSec: currentTime }); }); 688 video.bind('secondchange', function (s) { 689 if (destroyed) return; 690 currentTime = s; 691 if (s !== lastSec) { lastSec = s; emit('TICK', { tVideoSec: s }); } 692 }); 693 }, 694 }); 695 return { kind: 'wistia', el: iframe, getCurrentTime: function () { return currentTime; }, destroy: function () { destroyed = true; } }; 696 } 697 698 function makeVTurbAdapter(host, emit) { 699 // vTurb's SmartPlayer ultimately renders a real HTML5 <video> (HLS via its 700 // own player). Rather than guess SmartPlayer's proprietary event API, we 701 // bind that internal <video> directly, which also gives us buffer/seek/ 702 // volume signals for free. We pierce shadow DOM, and keep polling because 703 // SmartPlayer swaps the video element when the real video starts after the 704 // thumbnail/ad. A postMessage listener covers the older iframed embed. 705 var currentTime = 0, lastSec = -1, boundVideo = null, pollTimer = null, polls = 0, destroyed = false;
706 // Track listeners per bound video so destroy() can detach them all, 707 // including from earlier videos SmartPlayer swapped out mid-session. 708 var vListeners = []; 709 function vOn(target, type, fn) { 710 target.addEventListener(type, fn); 711 vListeners.push([target, type, fn]); 712 } 713 714 function bindVideo(v) { 715 if (boundVideo === v || !v || destroyed) return; 716 boundVideo = v; 717 vOn(v, 'play', function () { emit('PLAY', { tVideoSec: v.currentTime }); }); 718 vOn(v, 'pause', function () { emit('PAUSE', { tVideoSec: v.currentTime }); }); 719 vOn(v, 'ended', function () { emit('ENDED', { tVideoSec: v.currentTime }); }); 720 vOn(v, 'seeked', function () { emit('SEEK', { tVideoSec: v.currentTime }); }); 721 vOn(v, 'waiting', function () { emit('BUFFER_START', { tVideoSec: v.currentTime }); }); 722 vOn(v, 'playing', function () { emit('BUFFER_END', { tVideoSec: v.currentTime }); }); 723 vOn(v, 'volumechange', function () { emit(v.muted || v.volume === 0 ? 'MUTE' : 'UNMUTE', { value: v.volume, tVideoSec: v.currentTime }); }); 724 vOn(v, 'timeupdate', function () { 725 currentTime = v.currentTime || 0; 726 var s = Math.floor(currentTime); 727 if (s !== lastSec) { lastSec = s; emit('TICK', { tVideoSec: s }); } 728 }); 729 } 730 731 function findVideo() { 732 var scopes = []; 733 if (host && host.querySelector) scopes.push(host); 734 scopes.push(document); 735 for (var i = 0; i < scopes.length; i++) { 736 var v = null; 737 try { v = scopes[i].querySelector('video'); } catch (e) {} 738 if (v) return v; 739 } 740 try { 741 var hosts = document.querySelectorAll('vturb-smartplayer, [id^="vid_"], [id^="vid-"]'); 742 for (var j = 0; j < hosts.length; j++) { 743 if (hosts[j].shadowRoot) { 744 var sv = hosts[j].shadowRoot.querySelector('video'); 745 if (sv) return sv; 746 } 747 } 748 } catch (e) {} 749 return null; 750 } 751 752 function poll() { 753 if (destroyed) return; 754 var v = findVideo(); 755 if (v && v !== boundVideo) bindVideo(v); 756 if (++polls < 60) pollTimer = setTimeout(poll, 500); 757 } 758 poll(); 759 760 function onMsg(e) { 761 var d = e.data; 762 if (typeof d === 'string') { try { d = JSON.parse(d); } catch (err) { return; } } 763 if (!d || typeof d !== 'object') return; 764 var name = d.event || d.name || d.type; 765 if (!name) return; 766 if (typeof d.currentTime === 'number') currentTime = d.currentTime; 767 else if (typeof d.time === 'number') currentTime = d.time; 768 var n = String(name).toLowerCase(); 769 if (/play/.test(n) && !/display|replay|autoplay/.test(n)) emit('PLAY', { tVideoSec: currentTime }); 770 else if (/pause/.test(n)) emit('PAUSE', { tVideoSec: currentTime }); 771 else if (/end|complete|finish/.test(n)) emit('ENDED', { tVideoSec: currentTime }); 772 else if (/seek/.test(n)) emit('SEEK', { tVideoSec: currentTime }); 773 else if (/time|progress/.test(n)) { 774 var s = Math.floor(currentTime); 775 if (s !== lastSec) { lastSec = s; emit('TICK', { tVideoSec: s }); } 776 } 777 } 778 window.addEventListener('message', onMsg); 779 780 return { 781 kind: 'vturb', 782 el: host || boundVideo || document.querySelector('video'), 783 getCurrentTime: function () { return boundVideo ? (boundVideo.currentTime || 0) : currentTime; }, 784 destroy: function () { 785 destroyed = true; 786 if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } 787 window.removeEventListener('message', onMsg); 788 for (var i = 0; i < vListeners.length; i++) { 789 try { vListeners[i][0].removeEventListener(vListeners[i][1], vListeners[i][2]); } catch (e) {} 790 } 791 vListeners.length = 0; 792 }, 793 }; 794 } 795 796 function pickAdapter(target, emit) { 797 if (!target) { 798 if (isVTurbPresent()) return makeVTurbAdapter(findVTurbHost(), emit); 799 return null; 800 } 801 if (target.tagName === 'VIDEO') return makeNativeAdapter(target, emit); 802 if (target.tagName === 'IFRAME') { 803 var kind = detectIframeKind(target); 804 if (kind === 'vimeo') return makeVimeoAdapter(target, emit); 805 if (kind === 'youtube') return makeYouTubeAdapter(target, emit); 806 if (kind === 'wistia') return makeWistiaAdapter(target, emit); 807 if (kind === 'vidalytics') return makeVidalyticsAdapter(target, emit); 808 if (kind === 'vturb') return makeVTurbAdapter(target, emit); 809 } 810 if (target.tagName === 'VTURB-SMARTPLAYER' || (target.id && /^vid[_-]/.test(target.id)) || isVTurbPresent()) { 811 return makeVTurbAdapter(target, emit); 812 } 813 return null; 814 } 815 816 // âââââââââââââââââââââ Client âââââââââââââââââââââ 817 function createClient(config) { 818 var pixelKey = config.pixelKey; 819 var apiBase = (config.apiBase || '').replace(/\/$/, ''); 820 var ctaSel = config.cta || '[data-cta]'; 821 if (!pixelKey) return { error: 'no-pixel-key' }; 822 823 // Target resolution lives in a function (not inline) so SPA rebinds can 824 // re-scan the DOM for a fresh player after a route change unmounts the 825 // original one. 826 function resolveTarget() { 827 var el = config.video && config.video.nodeType ? config.video : 828 (config.video ? document.querySelector(config.video) : null); 829 if (!el && config.iframe) { 830 el = config.iframe.nodeType ? config.iframe : document.querySelector(config.iframe); 831 } 832 if (!el) { 833 // Auto-detect. vTurb/SmartPlayer first: prefer its host element so the 834 // adapter can poll through the thumbnail-to-video swap (and fall back to 835 // postMessage for iframed embeds). Otherwise any <video>, then any 836 // supported iframe. 837 if (isVTurbPresent()) { 838 el = findVTurbHost(); 839 } else { 840 el = document.querySelector('video'); 841 if (!el) { 842 var iframes = document.querySelectorAll('iframe'); 843 for (var i = 0; i < iframes.length; i++) { 844 if (detectIframeKind(iframes[i])) { el = iframes[i]; break; } 845 } 846 } 847 } 848 } 849 return el; 850 } 851 var resolved = resolveTarget(); 852 853 var sessionId = null; 854 var variantId = null; 855 var convertSig = null; // hmacSecret returned by /session 856 var queue = []; 857 // Ready-made batches awaiting (re)send: [{ id, events }]. Populated by 858 // the offline-queue drain and by in-memory retries of failed sends. 859 // Always flushed BEFORE fresh queue events, each with its ORIGINAL 860 // batchId, so server-side batch dedupe can drop replays. 861 var pendingBatches = []; 862 // Monotonic per-session event sequence (meta.q). Resets on page load 863 // even when a sticky session resumes; server-side gap detection should 864 // segment runs via the LOAD event's meta.resumed flag. 865 var seqN = 0; 866 var sending = false;
867 var flushTimer = null; 868 var adapter = null; 869 870 var debugListeners = []; 871 var firstPlayFired = false; // Tier-1: gate for TIME_TO_PLAY one-shot 872 function emit(type, extra) { 873 var p = extra || {}; 874 // Tier-1: fire TIME_TO_PLAY *before* the first PLAY so the server can 875 // bind the dwell-time to the same session/tick. Resumed sessions skip 876 // this â re-entry isn't an "intent" signal. 877 if (type === 'PLAY' && !firstPlayFired) { 878 firstPlayFired = true; 879 var dwellMs = Date.now() - PAGE_LOAD_MS; 880 if (dwellMs >= 0 && dwellMs < 30 * 60 * 1000) { // sanity cap at 30min 881 queue.push({ 882 type: 'TIME_TO_PLAY', 883 tVideoSec: 0, 884 tRealMs: Date.now(), 885 value: dwellMs, 886 meta: { from: 'pageLoad', q: ++seqN }, 887 }); 888 } 889 } 890 // Defensive serialization: meta from PSYWAR.custom()/fire() can carry 891 // circular refs, which would sink the whole batch at JSON.stringify 892 // time. Probe it here and degrade to a marker instead of losing data. 893 var meta = p.meta || null; 894 if (meta != null) { 895 try { JSON.stringify(meta); } catch (err) { meta = { unserializable: 1 }; } 896 } 897 // Per-session sequence number (meta.q) so server-side analysis can 898 // detect gaps in the stream later. Cheap counter, no other behavior. 899 meta = (meta && typeof meta === 'object') ? meta : {}; 900 meta.q = ++seqN; 901 var ev = { 902 type: type, 903 tVideoSec: p.tVideoSec != null ? p.tVideoSec : (adapter ? adapter.getCurrentTime() : null), 904 tRealMs: Date.now(), 905 value: p.value != null ? p.value : null, 906 meta: meta, 907 }; 908 queue.push(ev); 909 // Fire debug listeners synchronously (test pages subscribe to this) 910 for (var i = 0; i < debugListeners.length; i++) { 911 try { debugListeners[i](ev); } catch (e) {} 912 } 913 scheduleFlush(); 914 } 915 function scheduleFlush() { 916 if (!sessionId) return; 917 if (flushTimer) return; 918 flushTimer = setTimeout(flush, 1500); 919 if (queue.length >= 32) flushNow(); 920 } 921 function flushNow() { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } flush(); } 922 923 // ââ Offline survival queue âââââââââââââââââââââââââââââââââââââââââ 924 // When a flush fails (subway wifi, page killed mid-send), the batch is 925 // persisted to localStorage and replayed on the NEXT page load with the 926 // same session. Industry pixels silently drop these events; we don't. 927 // Storage shape (v2): an array of batch objects [{ id, events }] so a 928 // replayed batch keeps its ORIGINAL batchId across page loads and the 929 // server can dedupe. Legacy shape (flat array of events, persisted by 930 // older pixels) is detected and wrapped with fresh ids on read. 931 var RQ_KEY = 'psywar_rq_' + pixelKey; 932 var RQ_MAX_EVENTS = 200; 933 function rqSave(batch) { 934 try { 935 var cur = []; 936 try { cur = JSON.parse(localStorage.getItem(RQ_KEY) || '[]') || []; } catch (e) {} 937 if (!cur || typeof cur.length !== 'number') cur = []; 938 // Legacy flat event array â wrap as one batch with a fresh id. 939 if (cur.length && !(cur[0] && Object.prototype.toString.call(cur[0].events) === '[object Array]')) { 940 cur = [{ id: batchIdHex(), events: cur }]; 941 } 942 // The same batch can fail more than once (in-memory retry). Keep a 943 // single copy per id so the drain never replays it twice locally. 944 var out = []; 945 for (var i = 0; i < cur.length; i++) { 946 if (cur[i] && cur[i].id !== batch.id) out.push(cur[i]); 947 } 948 out.push({ id: batch.id, events: batch.events }); 949 // Cap: total persisted events stays <= RQ_MAX_EVENTS, dropping the 950 // OLDEST batches first (same retention policy as the flat slice). 951 var total = 0; 952 for (var j = 0; j < out.length; j++) total += (out[j].events || []).length; 953 while (out.length > 1 && total > RQ_MAX_EVENTS) { 954 total -= (out[0].events || []).length; 955 out.shift(); 956 } 957 if (out.length === 1 && (out[0].events || []).length > RQ_MAX_EVENTS) { 958 out[0] = { id: out[0].id, events: out[0].events.slice(-RQ_MAX_EVENTS) }; 959 } 960 localStorage.setItem(RQ_KEY, JSON.stringify(out)); 961 } catch (e) {} 962 } 963 function rqDrain() {
964 try { 965 var raw = localStorage.getItem(RQ_KEY); 966 if (!raw) return; 967 // Remove BEFORE parsing so corrupt payloads are discarded once and 968 // can never wedge the drain on every load. 969 localStorage.removeItem(RQ_KEY); 970 var cur = null; 971 try { cur = JSON.parse(raw); } catch (e) { return; } 972 if (!cur || typeof cur.length !== 'number' || !cur.length) return; 973 var batches; 974 if (cur[0] && Object.prototype.toString.call(cur[0].events) === '[object Array]') { 975 batches = cur; 976 } else { 977 // Legacy flat event array: no batch identity existed, so chunk into 978 // fresh batches (<=128 events, matching the live flush size). 979 batches = []; 980 for (var off = 0; off < cur.length; off += 128) { 981 batches.push({ id: batchIdHex(), events: cur.slice(off, off + 128) }); 982 } 983 } 984 for (var i = 0; i < batches.length; i++) { 985 var b = batches[i]; 986 if (!b || Object.prototype.toString.call(b.events) !== '[object Array]' || !b.events.length) continue; 987 for (var k = 0; k < b.events.length; k++) { 988 var ev = b.events[k]; 989 if (!ev || typeof ev !== 'object') continue; 990 ev.meta = (ev.meta && typeof ev.meta === 'object') ? ev.meta : {}; 991 ev.meta.recovered = 1; 992 } 993 // Replays keep their ORIGINAL id; the server dedupes on it. 994 pendingBatches.push({ id: b.id || batchIdHex(), events: b.events }); 995 } 996 scheduleFlush(); 997 } catch (e) {} 998 } 999 1000 function flush() { 1001 if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } 1002 if (sending || !sessionId || (!queue.length && !pendingBatches.length)) return; 1003 // Replayed batches (offline drain, in-memory retry) go first and carry 1004 // their ORIGINAL batchId, per the server-side dedupe contract. A fresh 1005 // batch (new id, minted at flush time) forms only when none is pending. 1006 var batch; 1007 if (pendingBatches.length) { 1008 batch = pendingBatches.shift(); 1009 if (!batch || !batch.events || !batch.events.length) { 1010 if (pendingBatches.length || queue.length) scheduleFlush(); 1011 return; 1012 } 1013 } else { 1014 batch = { id: batchIdHex(), events: queue.splice(0, 128) }; 1015 } 1016 function serialize(b) { 1017 return JSON.stringify({ pixelKey: pixelKey, sessionId: sessionId, batchId: b.id, events: b.events }); 1018 } 1019 var payload; 1020 try { 1021 payload = serialize(batch); 1022 } catch (e) { 1023 // One unserializable event must not sink the batch: drop offenders. 1024 var clean = []; 1025 for (var si = 0; si < batch.events.length; si++) { 1026 try { JSON.stringify(batch.events[si]); clean.push(batch.events[si]); } catch (err) {} 1027 } 1028 batch.events = clean; 1029 if (!batch.events.length) { if (pendingBatches.length || queue.length) scheduleFlush(); return; } 1030 payload = serialize(batch); 1031 } 1032 // Transport ceiling: sendBeacon and keepalive fetch cap near 64KB and 1033 // fail silently above it. Split oversized batches: the head keeps the 1034 // batch id, the tail re-queues as a pending batch under a FRESH id 1035 // (the tail was never sent under the original id, so the server must 1036 // not dedupe it against the head). 1037 while (batch.events.length > 1 && payload.length > 60000) { 1038 var keep = Math.max(1, Math.floor(batch.events.length / 2)); 1039 pendingBatches.unshift({ id: batchIdHex(), events: batch.events.slice(keep) }); 1040 batch.events = batch.events.slice(0, keep); 1041 payload = serialize(batch); 1042 } 1043 sending = true; 1044 var url = apiBase + '/api/pixel/ingest'; 1045 var done = function () { sending = false; if (pendingBatches.length || queue.length) scheduleFlush(); }; 1046 try { 1047 if (navigator.sendBeacon && document.visibilityState === 'hidden' && payload.length <= 60000) { 1048 var ok = navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' })); 1049 if (!ok) rqSave(batch); 1050 done(); 1051 return; 1052 } 1053 } catch (e) {}
1054 try { 1055 fetch(url, { 1056 method: 'POST', 1057 headers: { 'Content-Type': 'application/json' }, 1058 body: payload, 1059 keepalive: true, 1060 credentials: 'omit', 1061 }).then(function (r) { 1062 if (r && !r.ok && r.status >= 500) rqSave(batch); 1063 done(); 1064 }, function () { 1065 // Network failure: the batch keeps its identity until acknowledged. 1066 // The in-memory retry resends the SAME batchId, and the persisted 1067 // copy (drained on a later page load) carries it too, so the server 1068 // drops whichever copy lands second. 1069 try { pendingBatches.unshift(batch); } catch (e) {} 1070 rqSave(batch); 1071 done(); 1072 }); 1073 } catch (e) { rqSave(batch); done(); } 1074 } 1075 1076 function openSession() { 1077 var utm = getUtm(); 1078 var sticky = getStickySession(pixelKey); 1079 // Reuse a sticky session within TTL â preserves engagement state across reloads 1080 if (sticky && sticky.s && sticky.k) { 1081 sessionId = sticky.s; 1082 convertSig = sticky.k; 1083 variantId = getStickyVariant(pixelKey); 1084 emit('LOAD', { meta: { variantId: variantId, resumed: true } }); 1085 bindAdapter(); 1086 rqDrain(); 1087 return; 1088 } 1089 var conn = getConnection(); 1090 var gpu = detectGpu(); 1091 var codecs = detectCodecs(); 1092 var clickInfo = extractClickId(); 1093 var fp = fingerprint(); 1094 var tz = ''; try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {} 1095 var body = { 1096 pixelKey: pixelKey, 1097 visitorId: getVisitorId(), 1098 variantId: getStickyVariant(pixelKey) || undefined, 1099 context: { 1100 ua: navigator.userAgent, 1101 viewport: window.innerWidth + 'x' + window.innerHeight, 1102 screenWxH: window.screen.width + 'x' + window.screen.height, 1103 devicePixelRatio: window.devicePixelRatio || 1, 1104 prefersReducedMotion: !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches), 1105 language: navigator.language || null, 1106 timezone: tz, 1107 referrer: document.referrer, 1108 deviceType: deviceType(), 1109 // Network 1110 connectionType: conn.connectionType, 1111 effectiveType: conn.effectiveType, 1112 downlinkMbps: conn.downlinkMbps, 1113 rttMs: conn.rttMs, 1114 saveData: conn.saveData, 1115 // Hardware 1116 gpuVendor: gpu.vendor, 1117 gpuRenderer: gpu.renderer, 1118 codecs: codecs, 1119 // Attribution 1120 utmSource: utm.utmSource, 1121 utmMedium: utm.utmMedium, 1122 utmCampaign: utm.utmCampaign, 1123 utmTerm: utm.utmTerm, 1124 utmContent: utm.utmContent, 1125 clickId: clickInfo.clickId, 1126 clickIdSource: clickInfo.clickIdSource, 1127 fingerprintHash: fp, 1128 }, 1129 }; 1130 fetch(apiBase + '/api/pixel/session', { 1131 method: 'POST', 1132 headers: { 'Content-Type': 'application/json' }, 1133 body: JSON.stringify(body), 1134 credentials: 'omit', 1135 }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) { 1136 if (!d || !d.sessionId) return; 1137 sessionId = d.sessionId; 1138 variantId = d.variantId || null; 1139 convertSig = d.convertSig || null; 1140 if (variantId) setStickyVariant(pixelKey, variantId); 1141 if (convertSig) setStickySession(pixelKey, sessionId, convertSig); 1142 // Variant VSL hot-swap (only if our adapter is native) 1143 if (d.variantVslUrl && adapter && adapter.kind === 'native') { 1144 try { 1145 var v = adapter.el; 1146 var wasPlaying = !v.paused; 1147 v.src = d.variantVslUrl; 1148 if (wasPlaying) v.play().catch(function () {}); 1149 } catch (e) {} 1150 } 1151 emit('LOAD', { meta: { variantId: variantId } }); 1152 rqDrain(); 1153 }).catch(function () {}); 1154 bindAdapter(); 1155 } 1156 1157 // The element the adapter is currently bound to. Kept separate from 1158 // `resolved` (the init-time element captured by one-shot trackers) so SPA 1159 // rebinds can move to a fresh player without mutating init-time closures. 1160 var bindTarget = resolved; 1161 function bindAdapter() { 1162 // Leak-free rebind: tear down the previous adapter (its listeners, 1163 // intervals and message handlers) before creating a new one. Calling 1164 // this twice in a row can never double-bind. 1165 if (adapter && typeof adapter.destroy === 'function') { 1166 try { adapter.destroy(); } catch (e) {} 1167 } 1168 adapter = null; 1169 // If the bound element left the DOM (SPA unmount), re-scan for a fresh player. 1170 if (!bindTarget || (bindTarget.nodeType === 1 && !document.contains(bindTarget))) { 1171 var next = resolveTarget(); 1172 if (next) bindTarget = next; 1173 } 1174 adapter = pickAdapter(bindTarget, emit); 1175 } 1176 1177 // ââ SPA support âââââââââââââââââââââââââââââââââââââââââââââââââââââ 1178 // React/Next/Vue funnels swap routes without a page load, so the video 1179 // the pixel bound to gets unmounted and the new one is never tracked. 1180 // Hook history navigation, then re-scan for a fresh player. Industry 1181 // pixels lose the session here; we re-bind and keep recording. 1182 (function () {
1183 var lastPath = location.pathname + location.search; 1184 function onRouteChange() { 1185 var path = location.pathname + location.search; 1186 if (path === lastPath) return; 1187 lastPath = path; 1188 emit('CUSTOM', { meta: { kind: 'spa_nav', path: path.slice(0, 200) } }); 1189 setTimeout(function () { 1190 try { 1191 var el = adapter && adapter.el; 1192 if (!adapter || (el && !document.contains(el))) bindAdapter(); 1193 } catch (e) {} 1194 }, 600); 1195 setTimeout(function () { 1196 try { 1197 var el2 = adapter && adapter.el; 1198 if (!adapter || (el2 && !document.contains(el2))) bindAdapter(); 1199 } catch (e) {} 1200 }, 2400); 1201 } 1202 try { 1203 var origPush = history.pushState, origReplace = history.replaceState; 1204 history.pushState = function () { var r = origPush.apply(this, arguments); onRouteChange(); return r; }; 1205 history.replaceState = function () { var r = origReplace.apply(this, arguments); onRouteChange(); return r; }; 1206 window.addEventListener('popstate', onRouteChange); 1207 } catch (e) {} 1208 })(); 1209 1210 // ââ CTA clicks (delegated, captures on any matching element) ââ 1211 // Also captures normalized click position (nx, ny â 0..1) relative to the 1212 // player host. Dashboard ripple animation reads these to render real 1213 // click locations instead of synthesizing Math.random() coordinates. 1214 document.addEventListener('click', function (e) { 1215 var tgt = e.target; 1216 while (tgt && tgt !== document) { 1217 if (tgt.matches && tgt.matches(ctaSel)) { 1218 var label = tgt.getAttribute('data-cta') || tgt.textContent || ''; 1219 var meta = { label: String(label).slice(0, 80), href: tgt.getAttribute && tgt.getAttribute('href') }; 1220 // Resolve click coords against the player host so analytics align 1221 // with the heatmap grid (same coordinate system as PLAYER_CLICK). 1222 try { 1223 var host = document.querySelector('[data-pixel-key], #player, [data-vsl-host]'); 1224 if (host) { 1225 var rect = host.getBoundingClientRect(); 1226 var w = Math.max(1, rect.width), h = Math.max(1, rect.height); 1227 meta.nx = Math.max(0, Math.min(1, (e.clientX - rect.left) / w)); 1228 meta.ny = Math.max(0, Math.min(1, (e.clientY - rect.top) / h)); 1229 meta.nx = Math.round(meta.nx * 1000) / 1000; 1230 meta.ny = Math.round(meta.ny * 1000) / 1000; 1231 } 1232 } catch (_) {} 1233 emit('CTA_CLICK', { meta: meta }); 1234 flushNow(); 1235 return; 1236 } 1237 tgt = tgt.parentNode; 1238 } 1239 }, true); 1240 1241 // ââ CTA magnetism (approach-avoidance) âââââââââââââââââââââââââââââ 1242 // Samples cursor distance to the primary CTA while it is on screen and 1243 // counts approach runs, retreat runs (turning away after getting close), 1244 // direction flips, and dwell time inside the 250px "decision zone". 1245 // A retreat after a close approach is approach-avoidance conflict, the 1246 // closest thing to watching loss-aversion happen live. Summaries flush 1247 // every 12s only when there was activity. Desktop pointers only. 1248 (function () { 1249 var lastX = -1, lastY = -1, lastDist = -1, lastDir = 0; 1250 var approaches = 0, retreats = 0, flips = 0, minDist = 1e9, nearMs = 0, nearSince = 0, active = false; 1251 var NEAR_PX = 250; 1252 document.addEventListener('mousemove', function (e) { lastX = e.clientX; lastY = e.clientY; }, { passive: true }); 1253 function sample() { 1254 try { 1255 if (lastX < 0 || document.visibilityState === 'hidden') return; 1256 var cta = document.querySelector(ctaSel); 1257 if (!cta) return; 1258 var r = cta.getBoundingClientRect(); 1259 if (r.width === 0 || r.bottom < 0 || r.top > window.innerHeight) { nearSince = 0; return; } 1260 var cx = r.left + r.width / 2, cy = r.top + r.height / 2; 1261 var dist = Math.sqrt((lastX - cx) * (lastX - cx) + (lastY - cy) * (lastY - cy)); 1262 if (dist < minDist) minDist = dist; 1263 if (dist <= NEAR_PX) { 1264 if (!nearSince) nearSince = Date.now(); 1265 } else if (nearSince) { 1266 nearMs += Date.now() - nearSince; nearSince = 0; 1267 } 1268 if (lastDist >= 0) { 1269 var dir = dist < lastDist - 4 ? 1 : dist > lastDist + 4 ? -1 : 0; 1270 if (dir !== 0) { 1271 if (dir === 1 && lastDir !== 1) approaches++; 1272 if (dir === -1 && lastDir === 1 && dist <= NEAR_PX * 1.6) { retreats++; flips++; } 1273 if (dir !== lastDir && lastDir !== 0) flips++; 1274 lastDir = dir; 1275 active = true; 1276 } 1277 } 1278 lastDist = dist; 1279 } catch (e) {} 1280 } 1281 function report() { 1282 if (!active) return; 1283 if (nearSince) { nearMs += Date.now() - nearSince; nearSince = Date.now(); } 1284 emit('CUSTOM', { meta: { 1285 kind: 'cta_magnetism', 1286 approaches: approaches, retreats: retreats, flips: Math.min(flips, 99), 1287 minPx: Math.round(minDist === 1e9 ? -1 : minDist), nearMs: Math.round(nearMs), 1288 } }); 1289 approaches = 0; retreats = 0; flips = 0; minDist = 1e9; nearMs = 0; active = false;
1290 } 1291 setInterval(sample, 400); 1292 setInterval(report, 12000); 1293 window.addEventListener('pagehide', report); 1294 })(); 1295 1296 // ââ Form interaction (focus / blur / submit) ââ 1297 document.addEventListener('focus', function (e) { 1298 var t = e.target; 1299 if (!t || (t.tagName !== 'INPUT' && t.tagName !== 'TEXTAREA' && t.tagName !== 'SELECT')) return; 1300 emit('FORM_INTERACTION', { meta: { kind: 'focus', name: t.name || t.id || null, type: t.type || null } }); 1301 }, true); 1302 document.addEventListener('submit', function (e) { 1303 var f = e.target; 1304 emit('FORM_INTERACTION', { meta: { kind: 'submit', formId: f && f.id, formName: f && f.name } }); 1305 flushNow(); 1306 }, true); 1307 1308 // ââ Visibility ââ 1309 document.addEventListener('visibilitychange', function () { 1310 if (document.visibilityState === 'hidden') { emit('VISIBILITY_HIDDEN'); flushNow(); } 1311 else { emit('VISIBILITY_VISIBLE'); } 1312 }); 1313 window.addEventListener('beforeunload', flushNow); 1314 1315 // ââ Scroll depth ââ 1316 var hitDepths = {}; 1317 window.addEventListener('scroll', function () { 1318 var doc = document.documentElement; 1319 var scrolled = window.scrollY || window.pageYOffset; 1320 var max = Math.max(1, doc.scrollHeight - window.innerHeight); 1321 var pct = Math.floor((scrolled / max) * 100); 1322 [25, 50, 75, 100].forEach(function (t) { 1323 if (pct >= t && !hitDepths[t]) { hitDepths[t] = 1; emit('SCROLL_DEPTH', { value: t }); } 1324 }); 1325 }, { passive: true }); 1326 1327 // ââ Exit intent: mouseleave through top edge, with 5-min cooldown ââ 1328 var lastExitIntentMs = 0; 1329 document.addEventListener('mouseleave', function (e) { 1330 if (e.clientY > 4) return; // only count top-edge exits 1331 if (Date.now() - lastExitIntentMs < 5 * 60 * 1000) return; 1332 lastExitIntentMs = Date.now(); 1333 emit('EXIT_INTENT', { meta: { reason: 'top-edge' } }); 1334 }); 1335 1336 // ââ Rage click: 5+ clicks within 800ms ââ 1337 var clickTimes = []; 1338 document.addEventListener('click', function () { 1339 var now = Date.now(); 1340 clickTimes.push(now); 1341 clickTimes = clickTimes.filter(function (t) { return now - t < 800; }); 1342 if (clickTimes.length >= 5) { 1343 emit('RAGE_CLICK', { value: clickTimes.length }); 1344 clickTimes = []; 1345 } 1346 }, true); 1347 1348 // ââ Capability report once per session ââ 1349 detectDrm().then(function (drm) { 1350 emit('CAPABILITY_REPORT', { meta: { 1351 codecs: detectCodecs(), 1352 drm: drm, 1353 gpu: detectGpu(), 1354 connection: getConnection(), 1355 screen: { w: window.screen.width, h: window.screen.height, dpr: window.devicePixelRatio || 1 }, 1356 } }); 1357 }); 1358 1359 // âââ Tier-1: Web Vitals via PerformanceObserver ââââââââââââââââââââââââââ 1360 // FCP / LCP / CLS / INP. We coalesce so we only emit each metric once with 1361 // a meaningful value; LCP and CLS update as the page settles, INP captures 1362 // the worst interaction latency. These map directly to retention â slow 1363 // LCP correlates with 30%+ exit before play. 1364 (function () { 1365 if (typeof window.PerformanceObserver !== 'function') return; 1366 var supportedTypes = (window.PerformanceObserver.supportedEntryTypes || []); 1367 function has(t) { return supportedTypes.indexOf(t) !== -1; } 1368 function safeObserve(type, cb, opts) { 1369 try { 1370 var po = new PerformanceObserver(cb); 1371 po.observe(opts || { type: type, buffered: true }); 1372 return po; 1373 } catch (e) { return null; } 1374 } 1375 // FCP â one-shot, take the first paint marked 'first-contentful-paint'. 1376 var fcpDone = false; 1377 if (has('paint')) safeObserve('paint', function (list) { 1378 list.getEntries().forEach(function (entry) { 1379 if (entry.name === 'first-contentful-paint' && !fcpDone) { 1380 fcpDone = true; 1381 emit('PERF_VITAL', { value: Math.round(entry.startTime), meta: { name: 'fcp' } }); 1382 } 1383 }); 1384 }); 1385 // LCP â track the latest, emit when document hides or after 8s settle. 1386 var lcpVal = 0, lcpFlushed = false;
1387 if (has('largest-contentful-paint')) { 1388 var lcpPo = safeObserve('largest-contentful-paint', function (list) { 1389 var entries = list.getEntries(); 1390 if (entries.length) lcpVal = Math.round(entries[entries.length - 1].startTime); 1391 }); 1392 function flushLcp() { 1393 if (lcpFlushed || !lcpVal) return; 1394 lcpFlushed = true; 1395 emit('PERF_VITAL', { value: lcpVal, meta: { name: 'lcp' } }); 1396 if (lcpPo) try { lcpPo.disconnect(); } catch (_) {} 1397 } 1398 document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flushLcp(); }); 1399 window.addEventListener('pagehide', flushLcp); 1400 setTimeout(flushLcp, 8000); // hard fallback if user stays 1401 } 1402 // CLS â accumulate, emit on hide. 1403 var clsVal = 0, clsFlushed = false; 1404 if (has('layout-shift')) { 1405 safeObserve('layout-shift', function (list) { 1406 list.getEntries().forEach(function (entry) { 1407 if (!entry.hadRecentInput) clsVal += entry.value || 0; 1408 }); 1409 }); 1410 function flushCls() { 1411 if (clsFlushed) return; clsFlushed = true; 1412 emit('PERF_VITAL', { value: Math.round(clsVal * 1000) / 1000, meta: { name: 'cls' } }); 1413 } 1414 document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flushCls(); }); 1415 window.addEventListener('pagehide', flushCls); 1416 } 1417 // INP â track worst interaction. Modern browsers expose 'event' entries 1418 // with a `duration` and `interactionId`. Fallback: 'first-input'. 1419 var inpMax = 0; 1420 function reportInp(ms) { 1421 if (ms > inpMax) { 1422 inpMax = ms; 1423 emit('PERF_VITAL', { value: Math.round(ms), meta: { name: 'inp' } }); 1424 } 1425 } 1426 if (has('event')) { 1427 safeObserve('event', function (list) { 1428 list.getEntries().forEach(function (entry) { 1429 if (entry.interactionId && entry.duration > 0) reportInp(entry.duration); 1430 }); 1431 }, { type: 'event', buffered: true, durationThreshold: 40 }); 1432 } else if (has('first-input')) { 1433 safeObserve('first-input', function (list) { 1434 list.getEntries().forEach(function (entry) { 1435 reportInp(Math.max(0, entry.processingStart - entry.startTime)); 1436 }); 1437 }); 1438 } 1439 })(); 1440 1441 // ââ Resume from last second on first play (only on hosted player or known native) ââ 1442 (function () { 1443 if (!resolved || resolved.tagName !== 'VIDEO') return; 1444 var el = resolved; 1445 var slug = (document.documentElement.getAttribute('data-psywar-slug') || pixelKey); 1446 var resumeKey = 'psywar_resume_' + slug; 1447 var stored = lsGet(resumeKey); 1448 if (stored) { 1449 var sec = Number(stored); 1450 if (sec > 5 && sec < 24 * 3600) { 1451 var doneOnce = false; 1452 el.addEventListener('loadedmetadata', function () { 1453 if (doneOnce) return; doneOnce = true; 1454 try { el.currentTime = sec; emit('RESUME', { tVideoSec: sec }); } catch (e) {} 1455 }); 1456 } 1457 } 1458 // Persist watch position every 5s. Self-clears when the element leaves 1459 // the DOM (SPA unmount) so stale intervals do not accumulate. 1460 var resumeInt = setInterval(function () { 1461 if (!document.contains(el)) { clearInterval(resumeInt); return; } 1462 if (!el.paused && !el.ended && el.currentTime > 5) { 1463 lsSet(resumeKey, String(Math.floor(el.currentTime))); 1464 } 1465 if (el.ended) lsDel(resumeKey); 1466 }, 5000); 1467 })(); 1468 1469 // âââ Tier-3 + Tier-4: WebRTC IP leak (real IP behind VPN) âââââââââââ 1470 // Browsers expose host + server-reflexive IPs through ICE candidates. 1471 // We extract them via a no-op RTCPeerConnection. Result: an array of 1472 // {ip, type, candidate} entries the server uses for VPN detection + 1473 // fraud scoring. We cap at 4 entries so payload stays tiny. 1474 (function () { 1475 try { 1476 if (typeof RTCPeerConnection !== 'function') return; 1477 var pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); 1478 var ips = []; 1479 var seen = {};
1480 pc.createDataChannel(''); 1481 pc.onicecandidate = function (e) { 1482 if (!e || !e.candidate) { 1483 if (ips.length > 0) emit('WEBRTC_IPS', { meta: { ips: ips } }); 1484 try { pc.close(); } catch (_) {} 1485 return; 1486 } 1487 var c = e.candidate.candidate || ''; 1488 var m = c.match(/(?:^|\s)((?:\d{1,3}\.){3}\d{1,3}|[a-f0-9:]+:[a-f0-9:]+)/i); 1489 if (!m) return; 1490 var ip = m[1]; 1491 if (seen[ip] || ips.length >= 4) return; 1492 seen[ip] = 1; 1493 var type = /typ ([a-z]+)/.exec(c); 1494 ips.push({ ip: ip, type: type ? type[1] : 'unknown', candidate: c.slice(0, 200) }); 1495 }; 1496 pc.createOffer().then(function (o) { pc.setLocalDescription(o); }).catch(function () {}); 1497 // Force-close after 4s to cap CPU/network 1498 setTimeout(function () { try { pc.close(); } catch (_) {} }, 4000); 1499 } catch (_) {} 1500 })(); 1501 1502 // âââ Tier-4: Audio fingerprint (stable across cookies/incognito) âââ 1503 // Renders a known waveform through OfflineAudioContext, hashes the output 1504 // sample buffer. Different hardware/audio-stack combos produce different 1505 // sample patterns â strong identity signal that survives clearing data. 1506 (function () { 1507 try { 1508 var Ctor = window.OfflineAudioContext || window.webkitOfflineAudioContext; 1509 if (!Ctor) return; 1510 var ctx = new Ctor(1, 44100, 44100); 1511 var osc = ctx.createOscillator(); 1512 osc.type = 'triangle'; osc.frequency.value = 10000; 1513 var compressor = ctx.createDynamicsCompressor(); 1514 if (compressor.threshold) compressor.threshold.value = -50; 1515 osc.connect(compressor); compressor.connect(ctx.destination); 1516 osc.start(0); 1517 ctx.startRendering().then(function (buf) { 1518 var data = buf.getChannelData(0); 1519 var sum = 0; 1520 // Sample 5000 points across the buffer 1521 var step = Math.floor(data.length / 5000); 1522 for (var i = 0; i < data.length; i += step) sum += Math.abs(data[i]); 1523 // FNV-1a hash on the floating-point bits 1524 var s = String(sum), h = 2166136261 >>> 0; 1525 for (var j = 0; j < s.length; j++) { h ^= s.charCodeAt(j); h = (h + ((h<<1)+(h<<4)+(h<<7)+(h<<8)+(h<<24))) >>> 0; } 1526 var hash = ('00000000' + h.toString(16)).slice(-8); 1527 emit('AUDIO_FP', { meta: { hash: hash } }); 1528 }).catch(function () {}); 1529 } catch (_) {} 1530 })(); 1531 1532 // âââ Tier-3: Connection sampling every 10s (correlate quality drops) â 1533 (function () { 1534 var c = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 1535 if (!c) return; 1536 var sampleInt = setInterval(function () { 1537 try { 1538 emit('CONNECTION_SAMPLE', { meta: { 1539 type: c.type || null, 1540 eff: c.effectiveType || null, 1541 dl: typeof c.downlink === 'number' ? c.downlink : null, 1542 rtt: typeof c.rtt === 'number' ? c.rtt : null, 1543 save: !!c.saveData, 1544 } }); 1545 } catch (_) {} 1546 }, 10_000); 1547 window.addEventListener('beforeunload', function () { clearInterval(sampleInt); }); 1548 })(); 1549 1550 // âââ Tier-2: Captions toggle on the player (CC engagement signal) ââ 1551 if (resolved && resolved.tagName === 'VIDEO' && resolved.textTracks) { 1552 try { 1553 var capVideo = resolved; 1554 for (var ti = 0; ti < capVideo.textTracks.length; ti++) { 1555 // We poll for mode changes; addEventListener('change') isn't standard. 1556 // Self-clears when the video leaves the DOM so SPA swaps don't leak 1557 // stacked intervals. 1558 (function (track) { 1559 var lm = track.mode; 1560 var capInt = setInterval(function () { 1561 if (!document.contains(capVideo)) { clearInterval(capInt); return; } 1562 if (track.mode !== lm) { 1563 emit('CAPTIONS_TOGGLE', { meta: { enabled: track.mode !== 'disabled', lang: track.language || null } }); 1564 lm = track.mode; 1565 } 1566 }, 1500); 1567 })(capVideo.textTracks[ti]); 1568 } 1569 } catch (_) {} 1570 } 1571 1572 // âââ Tier-2: Right-click attempt on player (theft / save-video signal) â 1573 if (resolved && resolved.tagName === 'VIDEO') { 1574 var playerHostForCtx = resolved.parentElement || resolved; 1575 playerHostForCtx.addEventListener('contextmenu', function (e) { 1576 try { 1577 emit('RIGHT_CLICK', { tVideoSec: resolved.currentTime, meta: { 1578 x: e.clientX, y: e.clientY, 1579 target: e.target && e.target.tagName ? String(e.target.tagName).toLowerCase() : null, 1580 } }); 1581 } catch (_) {} 1582 }, true); 1583 } 1584 1585 // âââ Tier-2: Keyboard player intent (space, k, m, j/l, arrows, 0-9) ââ 1586 document.addEventListener('keydown', function (e) {
1587 // Only fire when focus is on body or the video element â no false positives in inputs 1588 var ae = document.activeElement; 1589 if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return; 1590 var k = (e.key || '').toLowerCase(); 1591 var action = null; 1592 if (k === ' ' || k === 'spacebar' || k === 'k') action = 'toggle_play'; 1593 else if (k === 'm') action = 'toggle_mute'; 1594 else if (k === 'f') action = 'toggle_fullscreen'; 1595 else if (k === 'j' || k === 'arrowleft') action = 'seek_back'; 1596 else if (k === 'l' || k === 'arrowright') action = 'seek_forward'; 1597 else if (k === 'arrowup') action = 'volume_up'; 1598 else if (k === 'arrowdown') action = 'volume_down'; 1599 else if (/^[0-9]$/.test(k)) action = 'seek_to_pct_' + k; 1600 if (action) emit('KEYBOARD_PLAYER', { meta: { key: k, action: action } }); 1601 }); 1602 1603 // âââ Tier-2: Touch gestures (mobile â tap/hold/swipe/pinch) âââââââââ 1604 (function () { 1605 if (!('ontouchstart' in window)) return; 1606 var t0 = 0, x0 = 0, y0 = 0, multi = false; 1607 document.addEventListener('touchstart', function (e) { 1608 if (e.touches.length > 1) { multi = true; return; } 1609 t0 = Date.now(); x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; multi = false; 1610 }, { passive: true }); 1611 document.addEventListener('touchend', function (e) { 1612 if (multi) { 1613 emit('TOUCH_GESTURE', { meta: { kind: 'pinch' } }); 1614 multi = false; return; 1615 } 1616 var dt = Date.now() - t0; 1617 var ce = e.changedTouches[0]; 1618 if (!ce) return; 1619 var dx = ce.clientX - x0, dy = ce.clientY - y0; 1620 var dist = Math.sqrt(dx*dx + dy*dy); 1621 var kind = 'tap'; 1622 if (dist > 30) kind = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'swipe_right' : 'swipe_left') : (dy > 0 ? 'swipe_down' : 'swipe_up'); 1623 else if (dt > 600) kind = 'hold'; 1624 emit('TOUCH_GESTURE', { meta: { kind: kind, durMs: dt, dx: Math.round(dx), dy: Math.round(dy) } }); 1625 }, { passive: true }); 1626 })(); 1627 1628 // âââ Tier-2: Foreground/background duration tracking ââââââââââââââââ 1629 // Periodic (every 15s) emit of cumulative foreground vs background time. 1630 // Server increments PixelSession.foregroundMs/backgroundMs. 1631 (function () { 1632 var lastTickMs = Date.now(); 1633 var fgAcc = 0, bgAcc = 0; 1634 setInterval(function () { 1635 var now = Date.now(); 1636 var delta = now - lastTickMs; 1637 if (document.visibilityState === 'visible' && document.hasFocus && document.hasFocus()) fgAcc += delta; 1638 else bgAcc += delta; 1639 lastTickMs = now; 1640 if (fgAcc + bgAcc >= 15_000) { 1641 emit('VISIBILITY_DURATION', { meta: { fgMs: fgAcc, bgMs: bgAcc } }); 1642 fgAcc = 0; bgAcc = 0; 1643 } 1644 }, 5_000); 1645 window.addEventListener('beforeunload', function () { 1646 if (fgAcc + bgAcc > 0) emit('VISIBILITY_DURATION', { meta: { fgMs: fgAcc, bgMs: bgAcc } }); 1647 }); 1648 })(); 1649 1650 // âââ Tier-2: Seek pair tracking (fromâto chord diagram) âââââââââââââ 1651 if (resolved && resolved.tagName === 'VIDEO') { 1652 var seekFromSec = -1; 1653 resolved.addEventListener('seeking', function () { seekFromSec = resolved.currentTime; }); 1654 resolved.addEventListener('seeked', function () { 1655 if (seekFromSec >= 0 && Math.abs(resolved.currentTime - seekFromSec) >= 1) { 1656 emit('SEEK_PAIR', { tVideoSec: resolved.currentTime, meta: { 1657 from: Math.round(seekFromSec * 10) / 10, 1658 to: Math.round(resolved.currentTime * 10) / 10, 1659 durSec: Math.round((resolved.currentTime - seekFromSec) * 10) / 10, 1660 } }); 1661 } 1662 seekFromSec = -1; 1663 }); 1664 } 1665 1666 // âââ Tier-3: Headless / automation hint detection âââââââââââââââââââ 1667 // Fires once per session if any common bot signature is present. 1668 (function () { 1669 var hints = {};
1670 try { 1671 if (navigator.webdriver === true) hints.webdriver = true; 1672 if (navigator.plugins && navigator.plugins.length === 0) hints.zeroPlugins = true; 1673 if (navigator.languages && navigator.languages.length === 0) hints.zeroLanguages = true; 1674 if (window.chrome && !window.chrome.runtime) hints.chromeNoRuntime = true; 1675 // Permissions API leak â headless Chrome reports notification denied even for new contexts 1676 if (navigator.permissions && navigator.permissions.query) { 1677 navigator.permissions.query({ name: 'notifications' }).then(function (s) { 1678 if (s && Notification && Notification.permission === 'denied' && s.state === 'prompt') { 1679 emit('HEADLESS_SIGNAL', { meta: { kind: 'permissionsLeak', detail: true } }); 1680 } 1681 }).catch(function () {}); 1682 } 1683 if (Object.keys(hints).length > 0) emit('HEADLESS_SIGNAL', { meta: { kind: 'fingerprint', detail: hints } }); 1684 } catch (_) {} 1685 })(); 1686 1687 // âââ Tier-3: Storage quota probe ââââââââââââââââââââââââââââââââââââ 1688 (function () { 1689 try { 1690 if (navigator.storage && navigator.storage.estimate) { 1691 navigator.storage.estimate().then(function (s) { 1692 emit('STORAGE_QUOTA', { meta: { usage: s.usage || 0, quota: s.quota || 0 } }); 1693 }).catch(function () {}); 1694 } 1695 } catch (_) {} 1696 })(); 1697 1698 // âââ Tier-3: Permissions state for camera/mic/clipboard âââââââââââââ 1699 (function () { 1700 if (!navigator.permissions || !navigator.permissions.query) return; 1701 var perms = ['camera','microphone','clipboard-read','clipboard-write','geolocation']; 1702 var states = {}; 1703 Promise.all(perms.map(function (name) { 1704 return navigator.permissions.query({ name: name }).then(function (s) { 1705 states[name] = s.state; 1706 }).catch(function () { states[name] = 'unsupported'; }); 1707 })).then(function () { 1708 emit('PERMISSIONS_STATE', { meta: states }); 1709 }); 1710 })(); 1711 1712 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1713 // Tier-5: crazier-unknown signals â every data-point we can squeeze out. 1714 // Each block guarded with feature detection + try/catch so older browsers 1715 // stay silent instead of throwing. All payloads are tiny â server folds 1716 // into PixelSession aggregates + queryable PixelEvent rows. 1717 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1718 1719 // Hardware tier (RAM, CPU) 1720 try { 1721 if (typeof navigator.deviceMemory === 'number') emit('DEVICE_MEMORY', { value: navigator.deviceMemory }); 1722 if (typeof navigator.hardwareConcurrency === 'number') emit('CPU_CORES', { value: navigator.hardwareConcurrency }); 1723 } catch (_) {} 1724 1725 // WebGPU info â supersedes WebGL renderer for newer GPUs 1726 try { 1727 if ((navigator).gpu && (navigator).gpu.requestAdapter) { 1728 (navigator).gpu.requestAdapter().then(function (a) { 1729 if (!a) return; 1730 var info = a.info || {}; 1731 emit('WEBGPU_INFO', { meta: { 1732 vendor: info.vendor || null, 1733 architecture: info.architecture || null, 1734 device: info.device || null, 1735 description: info.description || null, 1736 } }); 1737 }).catch(function () {}); 1738 } 1739 } catch (_) {} 1740 1741 // Media Capabilities â hardware-accel decoding for h264/h265/vp9/av1 1742 try { 1743 if (navigator.mediaCapabilities && navigator.mediaCapabilities.decodingInfo) { 1744 var codecs = [ 1745 { contentType: 'video/mp4; codecs="avc1.42E01E"', label: 'h264' }, 1746 { contentType: 'video/mp4; codecs="hvc1.1.6.L93.B0"', label: 'h265' }, 1747 { contentType: 'video/webm; codecs="vp09.00.10.08"', label: 'vp9' }, 1748 { contentType: 'video/mp4; codecs="av01.0.05M.08"', label: 'av1' }, 1749 ]; 1750 Promise.all(codecs.map(function (c) { 1751 return navigator.mediaCapabilities.decodingInfo({ 1752 type: 'media-source',
1753 video: { contentType: c.contentType, width: 1920, height: 1080, bitrate: 5_000_000, framerate: 30 }, 1754 }).then(function (r) { return [c.label, { supported: r.supported, smooth: r.smooth, powerEfficient: r.powerEfficient }]; }) 1755 .catch(function () { return [c.label, null]; }); 1756 })).then(function (results) { 1757 var out = {}; results.forEach(function (r) { out[r[0]] = r[1]; }); 1758 emit('MEDIA_CAPABILITIES', { meta: out }); 1759 }); 1760 } 1761 } catch (_) {} 1762 1763 // CSS prefs â color-scheme, contrast, data-saver 1764 try { 1765 if (window.matchMedia) { 1766 emit('COLOR_SCHEME_PREF', { meta: { dark: window.matchMedia('(prefers-color-scheme: dark)').matches } }); 1767 emit('CONTRAST_PREF', { meta: { more: window.matchMedia('(prefers-contrast: more)').matches } }); 1768 emit('REDUCED_DATA_PREF', { meta: { on: window.matchMedia('(prefers-reduced-data: reduce)').matches } }); 1769 } 1770 } catch (_) {} 1771 1772 // Mobile keyboard open detection (visualViewport heuristic) 1773 try { 1774 if (window.visualViewport) { 1775 var lastVvH = window.visualViewport.height; 1776 var keyboardOpen = false; 1777 window.visualViewport.addEventListener('resize', function () { 1778 var h = window.visualViewport.height; 1779 var heightDelta = window.innerHeight - h; 1780 var nowOpen = heightDelta > 150; // threshold for keyboard open 1781 if (nowOpen !== keyboardOpen) { 1782 keyboardOpen = nowOpen; 1783 emit('KEYBOARD_OPEN', { meta: { open: nowOpen, heightDelta: Math.round(heightDelta) } }); 1784 } 1785 lastVvH = h; 1786 }); 1787 } 1788 } catch (_) {} 1789 1790 // Device posture (foldable phones) 1791 try { 1792 if ('devicePosture' in window || (navigator).devicePosture) { 1793 var dp = (navigator).devicePosture || (window).devicePosture; 1794 if (dp && dp.addEventListener) { 1795 dp.addEventListener('change', function () { 1796 emit('DEVICE_POSTURE', { meta: { posture: dp.type } }); 1797 }); 1798 emit('DEVICE_POSTURE', { meta: { posture: dp.type } }); 1799 } 1800 } 1801 } catch (_) {} 1802 1803 // Pointer pressure â stylus / iPad pencil 1804 (function () { 1805 var samples = []; 1806 var lastEmitMs = 0; 1807 window.addEventListener('pointermove', function (e) { 1808 if (e.pointerType !== 'pen' && e.pressure < 0.05) return; 1809 if (e.pressure <= 0) return; 1810 samples.push(e.pressure); 1811 if (samples.length > 30) samples.shift(); 1812 var now = Date.now(); 1813 if (now - lastEmitMs > 5000 && samples.length >= 5) { 1814 lastEmitMs = now; 1815 var avg = samples.reduce(function (s, v) { return s + v; }, 0) / samples.length; 1816 emit('POINTER_PRESSURE', { value: Math.round(avg * 1000) / 1000, meta: { type: e.pointerType, samples: samples.length } }); 1817 } 1818 }, { passive: true }); 1819 })(); 1820 1821 // Mouse jitter â variance of micro-movements (anxiety/focus proxy) 1822 (function () { 1823 var prev = null, jitterAcc = 0, count = 0; 1824 var lastEmitMs = Date.now(); 1825 window.addEventListener('mousemove', function (e) { 1826 if (prev) { 1827 var dx = e.clientX - prev.x, dy = e.clientY - prev.y; 1828 var d = Math.sqrt(dx*dx + dy*dy); 1829 if (d > 0 && d < 30) { jitterAcc += d; count++; } 1830 } 1831 prev = { x: e.clientX, y: e.clientY }; 1832 var now = Date.now(); 1833 if (now - lastEmitMs > 8000 && count > 20) { 1834 var jitter = jitterAcc / count; 1835 emit('MOUSE_JITTER', { value: Math.round(jitter * 100) / 100, meta: { samples: count } }); 1836 jitterAcc = 0; count = 0; lastEmitMs = now; 1837 } 1838 }, { passive: true }); 1839 })(); 1840 1841 // Reading speed â text-selection chars/ms 1842 (function () { 1843 var selStartMs = 0; 1844 document.addEventListener('selectionchange', function () { 1845 var sel = window.getSelection ? window.getSelection() : null; 1846 var len = sel && sel.toString ? sel.toString().length : 0; 1847 if (len === 0) { 1848 if (selStartMs > 0) selStartMs = 0; 1849 return; 1850 } 1851 if (selStartMs === 0) selStartMs = Date.now(); 1852 else { 1853 var ms = Date.now() - selStartMs; 1854 if (ms > 200 && ms < 60_000 && len > 5) { 1855 emit('READING_SPEED', { value: Math.round((len / ms) * 1000), meta: { chars: len, ms: ms } }); 1856 selStartMs = Date.now(); 1857 } 1858 } 1859 }); 1860 })(); 1861 1862 // Click velocity â interval between clicks 1863 (function () { 1864 var lastClickMs = 0; 1865 document.addEventListener('click', function () { 1866 var now = Date.now(); 1867 if (lastClickMs > 0) { 1868 var dt = now - lastClickMs; 1869 if (dt < 30_000 && dt > 50) emit('CLICK_VELOCITY', { value: dt }); 1870 } 1871 lastClickMs = now; 1872 }, true); 1873 })(); 1874 1875 // Speech synthesis voices fingerprint 1876 try { 1877 if (window.speechSynthesis && window.speechSynthesis.getVoices) { 1878 var fireVoices = function () { 1879 var voices = window.speechSynthesis.getVoices(); 1880 if (!voices || voices.length === 0) return; 1881 var fp = voices.map(function (v) { return v.lang + ':' + v.name; }).join('|'); 1882 var h = 0; for (var i = 0; i < fp.length; i++) { h = ((h << 5) - h) + fp.charCodeAt(i); h |= 0; } 1883 emit('SPEECH_VOICES', { meta: { count: voices.length, hash: ('00000000' + (h >>> 0).toString(16)).slice(-8) } }); 1884 }; 1885 if (window.speechSynthesis.getVoices().length > 0) fireVoices(); 1886 else window.speechSynthesis.addEventListener && window.speechSynthesis.addEventListener('voiceschanged', fireVoices); 1887 } 1888 } catch (_) {} 1889 1890 // Font list â canvas measurement of common fonts 1891 try { 1892 var fontsToTest = ['Arial','Helvetica','Times New Roman','Courier New','Georgia','Verdana','Calibri','Cambria','Consolas','Tahoma','Trebuchet MS','Impact','Comic Sans MS','Garamond','Lucida Console','Monaco','Menlo','Andale Mono','Palatino','Book Antiqua','Avenir','Roboto','Open Sans','Lato','Inter','SF Pro','-apple-system']; 1893 var c = document.createElement('canvas'); 1894 c.width = 200; c.height = 32; 1895 var ctx = c.getContext('2d'); 1896 ctx.font = '20px monospace'; 1897 var baseW = ctx.measureText('mmmmmmmmll').width; 1898 var detected = []; 1899 for (var i = 0; i < fontsToTest.length; i++) { 1900 ctx.font = '20px "' + fontsToTest[i] + '", monospace'; 1901 if (ctx.measureText('mmmmmmmmll').width !== baseW) detected.push(fontsToTest[i]); 1902 } 1903 var fpStr = detected.sort().join(','); var fh = 0; 1904 for (var k = 0; k < fpStr.length; k++) { fh = ((fh << 5) - fh) + fpStr.charCodeAt(k); fh |= 0; } 1905 emit('FONT_LIST', { meta: { count: detected.length, hash: ('00000000' + (fh >>> 0).toString(16)).slice(-8) } }); 1906 } catch (_) {} 1907 1908 // Timezone vs IP discrepancy (server side will check IP, client just sends tz offset) 1909 try { 1910 var tzOffset = -new Date().getTimezoneOffset(); 1911 var tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || null; 1912 emit('TZ_IP_DISCREPANCY', { meta: { tzOffsetMin: tzOffset, tzName: tzName } }); 1913 } catch (_) {} 1914 1915 // PWA installable prompt 1916 try { 1917 window.addEventListener('beforeinstallprompt', function () { 1918 emit('PWA_INSTALLABLE', { meta: { fired: true } }); 1919 }); 1920 } catch (_) {} 1921 1922 // Service worker active state 1923 try { 1924 if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) { 1925 navigator.serviceWorker.getRegistrations().then(function (regs) { 1926 emit('SERVICE_WORKER', { meta: { count: regs.length, scopes: regs.slice(0, 3).map(function (r) { return r.scope; }) } }); 1927 }).catch(function () {}); 1928 } 1929 } catch (_) {} 1930 1931 // Wake Lock support 1932 try { emit('WAKE_LOCK_SUPPORT', { meta: { supported: !!(navigator.wakeLock && navigator.wakeLock.request) } }); } catch (_) {} 1933 // Vibration support
1934 try { emit('VIBRATION_SUPPORT', { meta: { supported: typeof navigator.vibrate === 'function' } }); } catch (_) {} 1935 1936 // Idle Detection API 1937 try { 1938 if ((window).IdleDetector) { 1939 emit('IDLE_DETECTION_API', { meta: { available: true } }); 1940 } 1941 } catch (_) {} 1942 1943 // Gamepad 1944 try { 1945 if (typeof navigator.getGamepads === 'function') { 1946 var gp = navigator.getGamepads(); 1947 var has = false; 1948 for (var gi = 0; gi < gp.length; gi++) if (gp[gi]) { has = true; break; } 1949 emit('GAMEPAD_PRESENT', { meta: { connected: has } }); 1950 } 1951 } catch (_) {} 1952 1953 // MIDI fingerprint â REMOVED. 1954 // Brave + some Firefox configs show a "Control your MIDI devices" permission 1955 // prompt on requestMIDIAccess() calls. That looks like malware to a cold 1956 // viewer and tanks the play rate. Bot signal isn't worth the CVR hit. 1957 // DO NOT re-add navigator.requestMIDIAccess() to fingerprinting. 1958 1959 // XR / VR support 1960 try { 1961 if (navigator.xr && navigator.xr.isSessionSupported) { 1962 Promise.all([ 1963 navigator.xr.isSessionSupported('immersive-vr').catch(function () { return false; }), 1964 navigator.xr.isSessionSupported('immersive-ar').catch(function () { return false; }), 1965 ]).then(function (r) { 1966 emit('XR_SUPPORT', { meta: { vr: r[0], ar: r[1] } }); 1967 }); 1968 } 1969 } catch (_) {} 1970 1971 // Battery drop rate â sample every 30s, compute drop/min 1972 try { 1973 if (navigator.getBattery) { 1974 navigator.getBattery().then(function (bat) { 1975 var lastLevel = bat.level, lastSampledMs = Date.now(); 1976 setInterval(function () { 1977 var now = Date.now(); 1978 var dl = bat.level - lastLevel; // negative = dropping 1979 var dtMin = (now - lastSampledMs) / 60_000; 1980 if (dtMin > 0.4) { 1981 var ratePctPerMin = (dl / dtMin) * 100; 1982 emit('BATTERY_DROP_RATE', { value: Math.round(ratePctPerMin * 1000) / 1000, meta: { level: bat.level, charging: bat.charging } }); 1983 } 1984 lastLevel = bat.level; lastSampledMs = now; 1985 }, 30_000); 1986 }).catch(function () {}); 1987 } 1988 } catch (_) {} 1989 1990 // Tab-count hint via performance.memory + window count 1991 try { 1992 var pm = performance.memory; 1993 if (pm) { 1994 emit('TAB_COUNT_HINT', { meta: { 1995 jsHeapMb: Math.round((pm.usedJSHeapSize || 0) / 1_048_576), 1996 totalHeapMb: Math.round((pm.totalJSHeapSize || 0) / 1_048_576), 1997 limitMb: Math.round((pm.jsHeapSizeLimit || 0) / 1_048_576), 1998 } }); 1999 } 2000 } catch (_) {} 2001 2002 // Realtime engagement-peak â fires when cursor active + tab visible + recent click happened 2003 (function () { 2004 var cursorActiveAt = 0, lastClickAt = 0; 2005 window.addEventListener('mousemove', function () { cursorActiveAt = Date.now(); }, { passive: true }); 2006 document.addEventListener('click', function () { lastClickAt = Date.now(); }, true); 2007 var lastEmitMs = 0; 2008 setInterval(function () { 2009 var now = Date.now(); 2010 if (document.visibilityState !== 'visible') return; 2011 if (now - cursorActiveAt > 3_000) return; 2012 if (now - lastClickAt > 12_000) return; 2013 if (now - lastEmitMs < 6_000) return; 2014 lastEmitMs = now; 2015 emit('ENGAGEMENT_PEAK_NEW', { meta: { cursorAge: now - cursorActiveAt, clickAge: now - lastClickAt } }); 2016 }, 2000); 2017 })(); 2018 2019 // Composite real-time conversion-intent score (0..100) 2020 (function () { 2021 var hits = 0; 2022 window.addEventListener('mousemove', function () { hits = Math.min(60, hits + 0.5); }, { passive: true }); 2023 document.addEventListener('click', function () { hits = Math.min(100, hits + 8); }, true); 2024 window.addEventListener('scroll', function () { hits = Math.min(60, hits + 0.3); }, { passive: true }); 2025 setInterval(function () { 2026 // Decay over time so the score reflects RECENT engagement, not lifetime 2027 hits = Math.max(0, hits - 1.5); 2028 if (hits >= 30) emit('CONVERSION_INTENT', { value: Math.round(hits) }); 2029 }, 5_000); 2030 })(); 2031 2032 // ââ AirPlay / Chromecast availability detection (one-shot per session) ââ 2033 if (resolved && resolved.tagName === 'VIDEO') { 2034 // AirPlay (Safari)
2035 try { 2036 if (window.WebKitPlaybackTargetAvailabilityEvent) { 2037 resolved.addEventListener('webkitplaybacktargetavailabilitychanged', function (e) { 2038 if (e.availability === 'available') emit('AIRPLAY', { meta: { available: true } }); 2039 }); 2040 } 2041 } catch (e) {} 2042 // Chromecast (Chrome) 2043 try { 2044 if (window.cast && window.cast.framework) { 2045 emit('CHROMECAST', { meta: { available: true } }); 2046 } else if (window.chrome && window.chrome.cast) { 2047 emit('CHROMECAST', { meta: { available: true } }); 2048 } 2049 } catch (e) {} 2050 } 2051 2052 // ââ v4 max-data sensors ââ 2053 2054 // First-interaction marker (engagement moment) 2055 var firstInteractionFired = false; 2056 function fireFirstInteraction(kind) { 2057 if (firstInteractionFired) return; 2058 firstInteractionFired = true; 2059 emit('FIRST_INTERACTION', { meta: { kind: kind, msSinceLoad: Date.now() - (window.__psywar_t0 || Date.now()) } }); 2060 } 2061 ['mousemove','touchstart','keydown','wheel','scroll','click'].forEach(function (e) { 2062 document.addEventListener(e, function () { fireFirstInteraction(e); }, { once: true, passive: true, capture: true }); 2063 }); 2064 2065 // Completion milestones (25/50/75/100) 2066 var completionsFired = {}; 2067 if (resolved && resolved.tagName === 'VIDEO') { 2068 resolved.addEventListener('timeupdate', function () { 2069 var d = resolved.duration; if (!d || !isFinite(d)) return; 2070 var pct = (resolved.currentTime / d) * 100; 2071 [25, 50, 75, 100].forEach(function (m) { 2072 if (pct >= m && !completionsFired[m]) { 2073 completionsFired[m] = true; 2074 emit('COMPLETION_' + m, { tVideoSec: resolved.currentTime, value: m }); 2075 } 2076 }); 2077 }); 2078 // Replay detection â play after ENDED counts as a replay 2079 resolved.addEventListener('ended', function () { resolved.__psywar_ended = true; }); 2080 resolved.addEventListener('play', function () { 2081 if (resolved.__psywar_ended) { 2082 resolved.__psywar_ended = false; 2083 emit('REPLAY', { tVideoSec: resolved.currentTime }); 2084 } 2085 }); 2086 // BIG_JUMP â seek of more than 30s 2087 var lastT = 0; 2088 resolved.addEventListener('seeked', function () { 2089 var delta = resolved.currentTime - lastT; 2090 if (Math.abs(delta) > 30) { 2091 emit('BIG_JUMP', { tVideoSec: resolved.currentTime, value: delta, meta: { from: lastT, to: resolved.currentTime } }); 2092 } 2093 lastT = resolved.currentTime; 2094 }); 2095 resolved.addEventListener('timeupdate', function () { lastT = resolved.currentTime; }); 2096 // Autoplay blocked detection 2097 var autoplayCheck = function () { 2098 var p = resolved.play(); 2099 if (p && p.then) p.then(function () { /* ok */ }).catch(function () { 2100 emit('AUTOPLAY_BLOCKED'); 2101 }); 2102 }; 2103 // Audio level sample via Web Audio API (one sample every 5s while playing) 2104 try { 2105 var AC = window.AudioContext || window.webkitAudioContext; 2106 if (AC && resolved.crossOrigin !== null) { 2107 // Note: Web Audio source needs CORS-allowed media. Skip if cross-origin without CORS headers. 2108 } 2109 } catch (e) {} 2110 } 2111 2112 // CTA hover with duration 2113 var ctaHoverStart = null; 2114 document.addEventListener('mouseover', function (e) { 2115 var t = e.target; 2116 while (t && t !== document) { 2117 if (t.matches && t.matches(ctaSel)) { 2118 ctaHoverStart = Date.now(); 2119 return; 2120 } 2121 t = t.parentNode; 2122 } 2123 }, true); 2124 document.addEventListener('mouseout', function (e) { 2125 var t = e.target; 2126 while (t && t !== document) { 2127 if (t.matches && t.matches(ctaSel)) { 2128 if (ctaHoverStart) { 2129 var dur = Date.now() - ctaHoverStart; 2130 if (dur > 200) emit('HOVER_CTA', { value: dur, meta: { ms: dur } }); 2131 ctaHoverStart = null; 2132 } 2133 return; 2134 } 2135 t = t.parentNode; 2136 } 2137 }, true); 2138 2139 // Text selection / copy / paste 2140 document.addEventListener('selectionchange', (function () { 2141 var lastLen = 0, last = 0; 2142 return function () { 2143 var sel = window.getSelection && window.getSelection(); 2144 if (!sel) return; 2145 var len = sel.toString().length; 2146 if (len > 0 && len !== lastLen && Date.now() - last > 1000) { 2147 last = Date.now(); lastLen = len; 2148 emit('TEXT_SELECTION', { value: len, meta: { len: len } }); 2149 } 2150 }; 2151 })()); 2152 document.addEventListener('copy', function () { emit('TEXT_COPIED'); }); 2153 document.addEventListener('paste', function () { emit('TEXT_PASTED'); }); 2154 2155 // Scroll velocity + reverse 2156 var lastScrollY = window.scrollY || 0; 2157 var lastScrollT = Date.now(); 2158 var scrollSamples = []; 2159 window.addEventListener('scroll', function () { 2160 var now = Date.now(); 2161 var y = window.scrollY || 0; 2162 var dy = y - lastScrollY; 2163 var dt = Math.max(1, now - lastScrollT); 2164 var velocity = Math.abs(dy) / dt; // px per ms 2165 scrollSamples.push({ v: velocity, dy: dy }); 2166 if (scrollSamples.length > 8) scrollSamples.shift(); 2167 // Sample every 800ms â emit a velocity bucket 2168 if (now - (window.__psywar_lastScrollEmit || 0) > 800) { 2169 window.__psywar_lastScrollEmit = now; 2170 var avgV = scrollSamples.reduce(function (s, x) { return s + x.v; }, 0) / scrollSamples.length; 2171 var bucket = avgV > 1.5 ? 'fast' : avgV > 0.4 ? 'medium' : 'slow'; 2172 emit('SCROLL_VELOCITY', { value: avgV, meta: { bucket: bucket } }); 2173 // Reverse detection â were the last 4 samples mostly negative? 2174 var reverseCount = scrollSamples.filter(function (x) { return x.dy < 0; }).length; 2175 if (reverseCount >= 3) emit('SCROLL_REVERSE', { meta: { count: reverseCount } }); 2176 } 2177 lastScrollY = y; lastScrollT = now; 2178 }, { passive: true }); 2179 2180 // Idle detection (30s no input) + re-engagement 2181 var idleTimer = null; 2182 var isIdle = false;
2183 function resetIdle() { 2184 if (isIdle) { 2185 isIdle = false; 2186 emit('RE_ENGAGED'); 2187 } 2188 if (idleTimer) clearTimeout(idleTimer); 2189 idleTimer = setTimeout(function () { 2190 isIdle = true; 2191 emit('IDLE_DETECTED'); 2192 }, 30000); 2193 } 2194 ['mousemove','keydown','touchstart','scroll','click'].forEach(function (e) { 2195 document.addEventListener(e, resetIdle, { passive: true }); 2196 }); 2197 resetIdle(); 2198 2199 // DevTools open detection â width/height delta of inner vs outer 2200 var devtoolsOpenFired = false; 2201 setInterval(function () { 2202 if (devtoolsOpenFired) return; 2203 var threshold = 160; 2204 var widthDelta = Math.abs(window.outerWidth - window.innerWidth); 2205 var heightDelta = Math.abs(window.outerHeight - window.innerHeight); 2206 if (widthDelta > threshold || heightDelta > threshold) { 2207 devtoolsOpenFired = true; 2208 emit('DEVTOOLS_OPEN', { meta: { widthDelta: widthDelta, heightDelta: heightDelta } }); 2209 } 2210 }, 2000); 2211 2212 // Window blur / focus (distinct from tab visibility â catches alt-tab) 2213 window.addEventListener('blur', function () { emit('WINDOW_BLUR'); }); 2214 window.addEventListener('focus', function () { emit('WINDOW_FOCUS'); }); 2215 2216 // Page about to unload 2217 window.addEventListener('pagehide', function () { emit('PAGE_HIDE'); flushNow(); }); 2218 window.addEventListener('beforeunload', function () { emit('PAGE_HIDE'); flushNow(); }); 2219 2220 // Network change (connection effectiveType shifted) 2221 if (navigator.connection) { 2222 var lastEffectiveType = navigator.connection.effectiveType; 2223 navigator.connection.addEventListener('change', function () { 2224 var newType = navigator.connection.effectiveType; 2225 if (newType !== lastEffectiveType) { 2226 emit('NETWORK_CHANGE', { meta: { from: lastEffectiveType, to: newType, downlink: navigator.connection.downlink, rtt: navigator.connection.rtt } }); 2227 lastEffectiveType = newType; 2228 } 2229 }); 2230 } 2231 2232 // Orientation change (mobile) 2233 if (window.screen && window.screen.orientation) { 2234 window.screen.orientation.addEventListener('change', function () { 2235 emit('ORIENTATION_CHANGE', { meta: { type: window.screen.orientation.type, angle: window.screen.orientation.angle } }); 2236 }); 2237 } else if ('onorientationchange' in window) { 2238 window.addEventListener('orientationchange', function () { 2239 emit('ORIENTATION_CHANGE', { meta: { angle: window.orientation } }); 2240 }); 2241 } 2242 2243 // Battery state (mobile) 2244 if (navigator.getBattery) { 2245 navigator.getBattery().then(function (battery) { 2246 function emitBat() { 2247 emit('BATTERY_STATE', { value: battery.level, meta: { charging: battery.charging, level: battery.level } }); 2248 } 2249 battery.addEventListener('levelchange', emitBat); 2250 battery.addEventListener('chargingchange', emitBat); 2251 }).catch(function () {}); 2252 } 2253 2254 // Audio level via Web Audio API (sampled every 3s while playing) 2255 try { 2256 if (resolved && resolved.tagName === 'VIDEO' && (window.AudioContext || window.webkitAudioContext)) { 2257 var ACtor = window.AudioContext || window.webkitAudioContext; 2258 var audioCtx = null; 2259 var srcNode = null; 2260 var analyser = null; 2261 // Initialize on first play 2262 resolved.addEventListener('play', function () { 2263 if (!audioCtx) { 2264 try { 2265 audioCtx = new ACtor(); 2266 srcNode = audioCtx.createMediaElementSource(resolved); 2267 analyser = audioCtx.createAnalyser(); 2268 analyser.fftSize = 256; 2269 srcNode.connect(analyser); 2270 analyser.connect(audioCtx.destination); 2271 setInterval(function () { 2272 if (!analyser || resolved.paused) return; 2273 var arr = new Uint8Array(analyser.frequencyBinCount); 2274 analyser.getByteFrequencyData(arr); 2275 var sum = 0; for (var i = 0; i < arr.length; i++) sum += arr[i]; 2276 var loudness = sum / arr.length / 255; 2277 if (loudness > 0.05) emit('AUDIO_LEVEL', { value: loudness, tVideoSec: resolved.currentTime }); 2278 }, 3000); 2279 } catch (e) { /* CORS or browser doesn't allow; skip silently */ } 2280 } 2281 }, { once: true }); 2282 } 2283 } catch (e) {} 2284 2285 // ââ Cursor heatmap (5Hz, downsampled to 16Ã16 grid relative to player) ââ 2286 (function () { 2287 if (!resolved || resolved.tagName !== 'VIDEO') return; // only meaningful on native player 2288 var bucket = {}, lastFlush = 0; 2289 window.addEventListener('mousemove', function (ev) { 2290 var rect = resolved.getBoundingClientRect(); 2291 if (ev.clientX < rect.left || ev.clientX > rect.right || ev.clientY < rect.top || ev.clientY > rect.bottom) return; 2292 var gx = Math.max(0, Math.min(15, Math.floor(((ev.clientX - rect.left) / rect.width) * 16))); 2293 var gy = Math.max(0, Math.min(15, Math.floor(((ev.clientY - rect.top) / rect.height) * 16))); 2294 var k = gx + ':' + gy; 2295 bucket[k] = (bucket[k] || 0) + 1; 2296 var now = Date.now(); 2297 if (now - lastFlush > 200) { 2298 lastFlush = now; 2299 for (var key in bucket) { 2300 if (!Object.prototype.hasOwnProperty.call(bucket, key)) continue; 2301 var parts = key.split(':'); 2302 emit('CURSOR_HOVER', { tVideoSec: adapter ? adapter.getCurrentTime() : 0, meta: { gx: +parts[0], gy: +parts[1], w: bucket[key] } }); 2303 } 2304 bucket = {}; 2305 } 2306 }, { passive: true }); 2307 })(); 2308 2309 // Kick everything off 2310 openSession(); 2311 2312 // âââââââââââââââââââââ Public API âââââââââââââââââââââ 2313 function convert(opts) { 2314 var value = (opts && typeof opts.value === 'number') ? opts.value : null; 2315 var externalRef = (opts && typeof opts.externalRef === 'string') ? opts.externalRef : null;
2316 // Sign locally so the server can verify the payload wasn't drive-by spammed. 2317 var tRealMs = Date.now(); 2318 var msg = sessionId + ':' + (value == null ? '' : value) + ':' + tRealMs; 2319 // Customer-supplied meta can carry circular refs; probe before queueing 2320 // so the conversion event itself can never be lost to a stringify throw. 2321 var ref = (opts && opts.meta) || null; 2322 if (ref != null) { 2323 try { JSON.stringify(ref); } catch (e) { ref = null; } 2324 } 2325 hmacHex(convertSig || '', msg).then(function (sig) { 2326 queue.push({ 2327 type: 'CONVERTED', 2328 tVideoSec: adapter ? adapter.getCurrentTime() : null, 2329 tRealMs: tRealMs, 2330 value: value, 2331 meta: { sig: sig, externalRef: externalRef, ref: ref, q: ++seqN }, 2332 }); 2333 flushNow(); 2334 }); 2335 } 2336 2337 // âââ Tier-3: cross-device identity stitching ââââââââââââââââââââââââ 2338 // Customer code calls `PSYWAR.identify({ email, phone, externalId })` after 2339 // a form submit / login. We hash PII client-side (NEVER send raw email/phone) 2340 // then emit IDENTITY_LINK; server merges this anon visitor into a 2341 // PixelIdentity row so future sessions on other devices stitch together. 2342 function sha256HexLower(input) { 2343 if (!input) return Promise.resolve(null); 2344 var norm = String(input).trim().toLowerCase(); 2345 try { 2346 var enc = new TextEncoder(); 2347 return window.crypto.subtle.digest('SHA-256', enc.encode(norm)).then(function (buf) { 2348 var b = new Uint8Array(buf), h = ''; 2349 for (var i = 0; i < b.length; i++) h += ('0' + b[i].toString(16)).slice(-2); 2350 return h; 2351 }); 2352 } catch (e) { return Promise.resolve(null); } 2353 } 2354 function identify(payload) { 2355 payload = payload || {}; 2356 var jobs = []; 2357 jobs.push(payload.email ? sha256HexLower(payload.email) : Promise.resolve(null)); 2358 // Normalize phone to digits-only before hashing (best E.164 approximation client-side) 2359 var rawPhone = payload.phone ? String(payload.phone).replace(/[^\d+]/g, '') : null; 2360 jobs.push(rawPhone ? sha256HexLower(rawPhone) : Promise.resolve(null)); 2361 Promise.all(jobs).then(function (parts) { 2362 emit('IDENTITY_LINK', { meta: { 2363 hashedEmail: parts[0], 2364 hashedPhone: parts[1], 2365 externalId: payload.externalId ? String(payload.externalId).slice(0, 128) : null, 2366 source: payload.source || 'identify_call', 2367 } }); 2368 flushNow(); 2369 }); 2370 } 2371 2372 return { 2373 convert: convert, 2374 identify: identify, 2375 custom: function (name, meta) { emit('CUSTOM', { meta: { name: String(name).slice(0, 40), data: meta } }); }, 2376 // Programmatic event fire â used by the embedded player to surface HLS.js 2377 // ABR shifts, error recoveries, and other internal-but-trackable signals. 2378 // Whitelisted to known event types so external pages can't pollute the stream. 2379 fire: function (type, payload) { 2380 var allowed = { ABR_SHIFT: 1, BUFFER_START: 1, BUFFER_END: 1, DROPPED_FRAMES: 1, QUALITY_CHANGE: 1, CAPABILITY_REPORT: 1, ENGAGEMENT_PEAK: 1, CUSTOM: 1 }; 2381 if (!allowed[type]) return; 2382 emit(type, payload || {}); 2383 }, 2384 flush: flushNow, 2385 getSessionId: function () { return sessionId; }, 2386 getVariantId: function () { return variantId; }, 2387 // Update the CTA selector mid-session (funnels that swap CTAs after 2388 // init). All CTA trackers read the live selector, so this takes effect 2389 // on the next click/hover/sample. 2390 setCTASelector: function (sel) { if (sel && typeof sel === 'string') ctaSel = sel; }, 2391 // Debug hook â test pages subscribe here to show live events 2392 onEvent: function (cb) { 2393 if (typeof cb === 'function') debugListeners.push(cb); 2394 return function () { 2395 var i = debugListeners.indexOf(cb); 2396 if (i >= 0) debugListeners.splice(i, 1); 2397 }; 2398 }, 2399 }; 2400 } 2401 2402 // âââââââââââââââââââââ PSYWAR.embedPlayer âââââââââââââââââââââ 2403 function embedPlayer(opts) {
2404 var mount = opts.mount && opts.mount.nodeType ? opts.mount : document.querySelector(opts.mount); 2405 if (!mount) return null; 2406 mount.innerHTML = ''; 2407 var v = document.createElement('video'); 2408 v.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000;display:block'; 2409 if (opts.poster) v.poster = opts.poster; 2410 v.playsInline = true; 2411 v.controls = opts.controls !== false; 2412 if (opts.muted !== false) v.muted = true; 2413 if (opts.vslUrl) v.src = opts.vslUrl; 2414 mount.appendChild(v); 2415 if (opts.hlsUrl && v.canPlayType('application/vnd.apple.mpegurl') === '') { 2416 var s = document.createElement('script'); 2417 s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js'; 2418 s.onload = function () { 2419 if (window.Hls && window.Hls.isSupported()) { 2420 var hls = new window.Hls(); hls.loadSource(opts.hlsUrl); hls.attachMedia(v); 2421 } 2422 }; 2423 document.head.appendChild(s); 2424 } else if (opts.hlsUrl) { v.src = opts.hlsUrl; } 2425 return v; 2426 } 2427 2428 // âââââââââââââââââââââ Bootstrap âââââââââââââââââââââ 2429 var scriptTag = document.currentScript || (function () { 2430 var s = document.getElementsByTagName('script'); 2431 return s[s.length - 1]; 2432 })(); 2433 2434 function autoBoot() { 2435 if (!scriptTag) return; 2436 var key = scriptTag.getAttribute('data-pixel-key'); 2437 if (!key) return; 2438 var video = scriptTag.getAttribute('data-video') || null; 2439 var iframe = scriptTag.getAttribute('data-iframe') || null; 2440 var cta = scriptTag.getAttribute('data-cta') || '[data-cta]'; 2441 var apiBase = scriptTag.getAttribute('data-api-base') || (function () { 2442 try { return new URL(scriptTag.src).origin; } catch (e) { return ''; } 2443 })(); 2444 var auto = scriptTag.getAttribute('data-auto'); 2445 if (auto === '0' || auto === 'false') return; 2446 window[GLOBAL] = createClient({ pixelKey: key, video: video, iframe: iframe, cta: cta, apiBase: apiBase }); 2447 window[GLOBAL].embedPlayer = embedPlayer; 2448 } 2449 2450 window[GLOBAL] = window[GLOBAL] || { 2451 init: function (cfg) { window[GLOBAL] = createClient(cfg); window[GLOBAL].embedPlayer = embedPlayer; return window[GLOBAL]; }, 2452 embedPlayer: embedPlayer, 2453 _pending: true, 2454 }; 2455 2456 if (document.readyState === 'loading') { 2457 document.addEventListener('DOMContentLoaded', autoBoot); 2458 } else { 2459 autoBoot(); 2460 } 2461})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.