PageSourceSearch

https://psywar.ai/psywar.js

js psywar.ai collected 2026-10-05 14:01:42 UTC 110,716 bytes, 2,461 lines download raw bytes

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.