/* BM WATCH TRACKER v1 (ops, Aug 29 2026) One module, two consumers: localStorage watch history (feeds the rails) and GA4 custom events (replaces enhanced-measurement video, which misses facade players). Usage on an episode page, after the lite-youtube facade upgrades to a real player: const player = await document.querySelector('lite-youtube').getYTPlayer(); BMTracker.attach(player, EPISODE_ID, CATALOG_ENTRY); // entry = its object from catalog.json On up-next swap (same player, new video): BMTracker.attach(player, nextId, nextEntry) again. */ (function () { var KEY = 'bm_watch_v1'; var MILESTONES = [10, 25, 50, 75, 90]; var COMPLETE_PCT = 95; var THEME_CREDIT_PCT = 70; function load() { try { return JSON.parse(localStorage.getItem(KEY)) || { v: 1, episodes: {}, lastWatched: null, themeCounts: {} }; } catch (e) { return { v: 1, episodes: {}, lastWatched: null, themeCounts: {} }; } } function save(s) { try { localStorage.setItem(KEY, JSON.stringify(s)); } catch (e) { /* private mode: session-only */ } } function ga(name, params) { if (typeof gtag === 'function') gtag('event', name, params); } var current = null; // { player, id, entry, timer, played, lastT, milestonesFired, themeCredited } function tick() { if (!current) return; var p = current.player; var t, d; try { t = p.getCurrentTime(); d = p.getDuration(); } catch (e) { return; } if (!d) return; var delta = t - current.lastT; if (delta > 0 && delta < 3) current.played += delta; // seeks don't inflate current.lastT = t; var pct = Math.min(100, Math.round((t / d) * 100)); var s = load(); var e = s.episodes[current.id] || {}; e.t = Math.floor(t); e.d = Math.floor(d); e.pct = Math.max(e.pct || 0, pct); e.last = Date.now(); if (pct >= COMPLETE_PCT) e.done = true; s.episodes[current.id] = e; s.lastWatched = current.id; if (!current.themeCredited && pct >= THEME_CREDIT_PCT && current.entry && current.entry.tags) { current.entry.tags.forEach(function (tag) { s.themeCounts[tag] = (s.themeCounts[tag] || 0) + 1; }); current.themeCredited = true; } save(s); MILESTONES.forEach(function (m) { if (pct >= m && current.milestonesFired.indexOf(m) === -1) { current.milestonesFired.push(m); ga('video_progress', { video_id: current.id, video_title: current.entry ? current.entry.title : '', percent: m }); } }); } function flushWatchTime(reason) { if (!current || current.played < 1) return; ga('video_watch_time', { video_id: current.id, seconds: Math.round(current.played), reason: reason }); current.played = 0; } window.BMTracker = { attach: function (player, id, entry) { if (current) { flushWatchTime('swap'); clearInterval(current.timer); } current = { player: player, id: id, entry: entry || null, timer: null, played: 0, lastT: 0, milestonesFired: [], themeCredited: false }; var resume = (load().episodes[id] || {}); if (resume.t && resume.pct < COMPLETE_PCT && resume.t > 10) { try { player.seekTo(resume.t, true); } catch (e) {} } player.addEventListener('onStateChange', function (ev) { if (ev.data === 1) { // playing if (!current.startedFired) { current.startedFired = true; ga('video_start', { video_id: id, video_title: entry ? entry.title : '' }); } if (!current.timer) current.timer = setInterval(tick, 1000); } else { if (current.timer) { clearInterval(current.timer); current.timer = null; } if (ev.data === 2) flushWatchTime('pause'); if (ev.data === 0) { // ended tick(); flushWatchTime('ended'); var s = load(); var e = s.episodes[id] || {}; e.done = true; e.pct = 100; s.episodes[id] = e; save(s); ga('video_complete', { video_id: id }); document.dispatchEvent(new CustomEvent('bm:ended', { detail: { id: id } })); // up-next overlay listens for this } } }); document.addEventListener('visibilitychange', function () { if (document.hidden) { tick(); flushWatchTime('hidden'); } }); window.addEventListener('pagehide', function () { tick(); flushWatchTime('unload'); }); }, nextAuto: function (fromId, toId) { ga('next_video_auto', { from: fromId, to: toId }); }, history: load // rails call BMTracker.history() to build continue-watching etc. }; })();