diff --git a/content.js b/content.js index 281d28d..24b9d77 100644 --- a/content.js +++ b/content.js @@ -184,9 +184,9 @@ async function speak(text, clear) { chrome.runtime.onMessage.addListener((msg) => { console.log('onMessage: msg', msg); switch (msg?.type) { - case 'AD_SEGMENT_DETECTED': { - // First ad segment fetched; playback will switch soon - speak('Ad segment detected', false); + case 'PROGRAM_MODE_AD_SEGMENT': { + const index = Number.isFinite(msg.index) ? msg.index : 0; + speak(`Ad segment ${index}`, false); break; } case "AD_START": { @@ -207,9 +207,14 @@ async function speak(text, clear) { speak('Program bumper detected', false); break; } - case 'PROG_SEGMENT_DETECTED': { - // First program segment fetched during an ad; resume soon - speak('Program segment detected', false); + case 'AD_MODE_PROGRAM_BUMPER': { + const index = Number.isFinite(msg.index) ? msg.index : 0; + speak(`Program bumper ${index}`, false); + break; + } + case 'AD_MODE_PROGRAM_SEGMENT': { + const index = Number.isFinite(msg.index) ? msg.index : 0; + speak(`Program segment ${index}`, false); break; } case "PROGRAM": { diff --git a/sw.js b/sw.js index 4506c1d..c3b44ca 100644 --- a/sw.js +++ b/sw.js @@ -1,62 +1,9 @@ // // ---------- Ad detector with verbose logging ---------- -// --- Playback alignment (precise, segment-based) --- -const SEG_DUR_DEFAULT_MS = 6000; // until measured -const LEAD_START_SEGS = 2.0; // ~2 segments until playback flips to ad -const LEAD_END_SEGS = 2.0; // ~2 segments until playback flips back to program - -// Absolute-time scheduler (reschedules both earlier and later) -function scheduleAt(s, key, dueMsEpoch, fn, meta = {}) { - if (!s._timers) s._timers = {}; - const existing = s._timers[key]; - // Re-arm if no existing or due changed by > 200ms - if (existing && Math.abs(existing.due - dueMsEpoch) <= 200) return existing.due; - if (existing) { try { clearTimeout(existing.id); } catch {} } - const delay = Math.max(0, dueMsEpoch - Date.now()); - const id = setTimeout(() => { - if (s._timers && s._timers[key] && s._timers[key].id === id) { - delete s._timers[key]; - fn(); - } - }, delay); - s._timers[key] = { id, due: dueMsEpoch, meta }; - return dueMsEpoch; -} -function cancelTimer(s, key) { - if (s._timers && s._timers[key]) { - try { clearTimeout(s._timers[key].id); } catch {} - delete s._timers[key]; - } -} -function medianMs(arr) { - if (!arr || !arr.length) return null; - const a = arr.slice().sort((x,y)=>x-y); - const n = a.length; - return n & 1 ? a[(n-1)>>1] : Math.round((a[n/2-1] + a[n/2]) / 2); -} - -// ---- Timing log helper (current estimate + predicted edges) ---- -function logSegmentTiming(tabId, s, label) { - const segMs = s._adSegMs || SEG_DUR_DEFAULT_MS; - const adStartDue = s._firstAdAt != null ? s._firstAdAt + (s._leadStartSegs ?? LEAD_START_SEGS) * segMs : null; - const adEndDue = s._firstProgAfterAdAt != null ? s._firstProgAfterAdAt + (s._leadEndSegs ?? LEAD_END_SEGS) * segMs : null; - const now = Date.now(); - debugLog('SEG_TIMING', { - tabId, - label, - segMs, - lastCreative: s._lastAdCreative || null, - lastIdx: Number.isFinite(s._lastAdIdx) ? s._lastAdIdx : null, - deltas: s._adDeltas || [], - adStartDue, - adStartISO: adStartDue ? new Date(adStartDue).toISOString() : null, - inMsToAdStart: adStartDue ? (adStartDue - now) : null, - adEndDue, - adEndISO: adEndDue ? new Date(adEndDue).toISOString() : null, - inMsToAdEnd: adEndDue ? (adEndDue - now) : null - }); -} +// --- Simple segment counting thresholds --- +const AD_SEGMENTS_REQUIRED = 4; +const PROGRAM_SEGMENTS_REQUIRED = 2; // Broader patterns (ad/program) and tolerant to query strings + CMAF // Broader patterns (ad/program) and tolerant to query strings + CMAF @@ -128,7 +75,20 @@ function setBadge(tabId, mode) { } function ensureTabState(tabId) { if (!stateByTab.has(tabId)) { - stateByTab.set(tabId, { mode: "PROGRAM", breakStart: null, dedupe: new Set(), lastSeen: Date.now() }); + stateByTab.set(tabId, { + mode: "PROGRAM", + breakStart: null, + dedupe: new Set(), + progDedupe: new Set(), + bumperDedupe: new Set(), + adSegmentsSeen: 0, + programSegmentsSeen: 0, + firstAdUrl: null, + programModeAdCount: 0, + adModeProgramCount: 0, + adModeBumperCount: 0, + lastSeen: Date.now(), + }); } return stateByTab.get(tabId); } @@ -140,9 +100,6 @@ function maybeNotify(tabId, kind, payload) { chrome.tabs.onUpdated.addListener((tabId, info) => { if (info.status === 'loading' && stateByTab.has(tabId)) { - const s = stateByTab.get(tabId); - cancelTimer(s, 'adStart'); - cancelTimer(s, 'adEnd'); stateByTab.delete(tabId); debugLog('TAB_RESET', { tabId }); } @@ -176,70 +133,49 @@ chrome.webRequest.onBeforeRequest.addListener( // Dedupe key (creative:index if available) const key = (creative && Number.isFinite(segIdx)) ? `${creative}:${segIdx}` : url; - if (!s.dedupe.has(key)) s.dedupe.add(key); - - // --- Learn segment duration from consecutive indices of same creative --- - if (creative && Number.isFinite(segIdx)) { - if (s._lastAdCreative === creative && Number.isFinite(s._lastAdIdx) && segIdx === s._lastAdIdx + 1) { - const dt = now - (s._lastAdAt || now); - if (dt > 500 && dt < 20000) { - s._adDeltas = s._adDeltas || []; - s._adDeltas.push(dt); - if (s._adDeltas.length > 7) s._adDeltas.shift(); + const isNewSegment = !s.dedupe.has(key); + if (isNewSegment) { + s.dedupe.add(key); + s.adSegmentsSeen = s.dedupe.size; + if (!s.firstAdUrl) s.firstAdUrl = url; + if (s.mode !== 'AD') { + const index = s.programModeAdCount; + debugLog('PROGRAM_MODE_AD_SEGMENT', { tabId, url, index }); + maybeNotify(tabId, 'PROGRAM_MODE_AD_SEGMENT', { at: now, url, index }); + s.programModeAdCount += 1; + + debugLog('AD_SEGMENT_COUNT', { tabId, url, adSegmentsSeen: s.adSegmentsSeen, required: AD_SEGMENTS_REQUIRED }); + if (s.adSegmentsSeen >= AD_SEGMENTS_REQUIRED) { + s.mode = 'AD'; + s.breakStart = now; + s.programSegmentsSeen = 0; + s.adModeProgramCount = 0; + s.adModeBumperCount = 0; + s.progDedupe.clear(); + s.bumperDedupe.clear(); + debugLog('AD_MODE_ENTER', { tabId, url: s.firstAdUrl || url, adSegments: s.adSegmentsSeen }); + maybeNotify(tabId, 'AD_START', { at: s.breakStart, url: s.firstAdUrl || url }); } + } else { + s.programSegmentsSeen = 0; + s.adModeProgramCount = 0; + s.adModeBumperCount = 0; + s.progDedupe.clear(); + s.bumperDedupe.clear(); } - s._lastAdCreative = creative; - s._lastAdIdx = segIdx; - s._lastAdAt = now; + } else if (s.mode === 'AD') { + s.programSegmentsSeen = 0; + s.adModeProgramCount = 0; + s.adModeBumperCount = 0; + s.bumperDedupe.clear(); } - const segMsMeasured = medianMs(s._adDeltas) || s._adSegMs || SEG_DUR_DEFAULT_MS; - s._adSegMs = Math.min(12000, Math.max(3000, segMsMeasured)); - debugLog('AD_URL', { tabId, url, creative, segIdx, dedupeSize: s.dedupe.size, - segMsEst: s._adSegMs, deltas: s._adDeltas || [] + adSegmentsSeen: s.adSegmentsSeen, + mode: s.mode, }); - - // If we were counting down to AD_END (thinking resume imminent), cancel; ads still going - cancelTimer(s, 'adEnd'); - - // First ad boundary timestamp (one-shot) - if (s._firstAdAt == null) { - s._firstAdAt = now; - s._firstAdUrl = url; - if (!s._firstAdNotified) { - s._firstAdNotified = true; - maybeNotify(tabId, "AD_SEGMENT_DETECTED", { at: now, url }); - } - } - - // Already "in AD"? nothing else to do. - if (s.mode === 'AD') { - logSegmentTiming(tabId, s, 'AD_MEASURE_IN_AD'); - return; - } - - // --- Playback-aligned AD_START: fire at absolute T = firstAdAt + lead*segMs --- - s._leadStartSegs = s._leadStartSegs ?? LEAD_START_SEGS; - const due = s._firstAdAt + s._leadStartSegs * s._adSegMs; - - const scheduled = scheduleAt(s, 'adStart', due, () => { - if (s.mode !== 'AD') { - s.mode = 'AD'; - s.breakStart = now; - debugLog('AD_START_TIMER_FIRED', { - tabId, url: s._firstAdUrl || url, - segMs: s._adSegMs, leadSegs: s._leadStartSegs - }); - logSegmentTiming(tabId, s, 'AD_START_FIRED'); - maybeNotify(tabId, 'AD_START', { at: s.breakStart, url: s._firstAdUrl || url }); - } - }, { url, segMs: s._adSegMs, leadSegs: s._leadStartSegs }); - - debugLog('AD_START_SCHEDULED', { tabId, due: scheduled, inMs: scheduled - now, segMs: s._adSegMs, leadSegs: s._leadStartSegs }); - logSegmentTiming(tabId, s, 'AD_SCHEDULED'); return; } @@ -249,9 +185,21 @@ chrome.webRequest.onBeforeRequest.addListener( const progAnyMatch = !progFullMatch && !progBumperMatch && isMatch(url, RE.PROG_ANY); if (progBumperMatch) { - // Ignore bumpers/time fillers for state changes - debugLog('PROG_BUMPER', { tabId, url }); - maybeNotify(tabId, "PROG_BUMPER", { at: now, url }); + debugLog('PROG_BUMPER', { tabId, url, mode: s.mode }); + + if (s.mode === 'AD') { + const key = url; + const isNewBumper = !s.bumperDedupe.has(key); + if (isNewBumper) { + s.bumperDedupe.add(key); + const index = s.adModeBumperCount; + debugLog('AD_MODE_PROGRAM_BUMPER', { tabId, url, index }); + maybeNotify(tabId, 'AD_MODE_PROGRAM_BUMPER', { at: now, url, index }); + s.adModeBumperCount += 1; + } + } else { + maybeNotify(tabId, 'PROG_BUMPER', { at: now, url }); + } return; } @@ -260,42 +208,47 @@ chrome.webRequest.onBeforeRequest.addListener( debugLog('PROG_FULLSHOW', { tabId, url }); if (s.mode === 'AD') { - // First program segment after ad boundary (one-shot) - if (s._firstProgAfterAdAt == null) { - s._firstProgAfterAdAt = now; - s._firstProgUrl = url; - if (!s._firstProgNotified) { - s._firstProgNotified = true; - maybeNotify(tabId, "PROG_SEGMENT_DETECTED", { at: now, url }); - } - } - - s._leadEndSegs = s._leadEndSegs ?? LEAD_END_SEGS; - const segMs = s._adSegMs || SEG_DUR_DEFAULT_MS; // last good estimate - const due = s._firstProgAfterAdAt + s._leadEndSegs * segMs; - - const scheduled = scheduleAt(s, 'adEnd', due, () => { - if (s.mode === 'AD') { + const isNewProgSegment = !s.progDedupe.has(url); + if (isNewProgSegment) { + s.progDedupe.add(url); + s.programSegmentsSeen += 1; + const index = s.adModeProgramCount; + debugLog('AD_MODE_PROGRAM_SEGMENT', { tabId, url, index }); + maybeNotify(tabId, 'AD_MODE_PROGRAM_SEGMENT', { at: now, url, index }); + s.adModeProgramCount += 1; + debugLog('PROG_SEGMENT_COUNT', { tabId, url, programSegmentsSeen: s.programSegmentsSeen, required: PROGRAM_SEGMENTS_REQUIRED }); + if (s.programSegmentsSeen >= PROGRAM_SEGMENTS_REQUIRED) { const at = now; const durationMs = s.breakStart ? at - s.breakStart : 0; const segments = s.dedupe.size; - debugLog('AD_END_TIMER_FIRED', { tabId, at, durationMs, segments, segMs, leadSegs: s._leadEndSegs }); - logSegmentTiming(tabId, s, 'AD_END_FIRED'); + debugLog('AD_MODE_EXIT', { tabId, url, durationMs, segments, programSegments: s.programSegmentsSeen }); maybeNotify(tabId, 'AD_END', { at, durationMs, segments }); - // reset state for next break (keep segMs history) s.mode = 'PROGRAM'; s.breakStart = null; s.dedupe.clear(); - s._firstAdAt = null; s._firstAdUrl = null; s._firstAdNotified = false; - s._firstProgAfterAdAt = null; s._firstProgUrl = null; s._firstProgNotified = false; + s.progDedupe.clear(); + s.bumperDedupe.clear(); + s.adSegmentsSeen = 0; + s.programSegmentsSeen = 0; + s.firstAdUrl = null; + s.programModeAdCount = 0; + s.adModeProgramCount = 0; + s.adModeBumperCount = 0; } - }, { url, segMs, leadSegs: s._leadEndSegs }); - - debugLog('AD_END_SCHEDULED', { tabId, due: scheduled, inMs: scheduled - now, segMs, leadSegs: s._leadEndSegs }); - logSegmentTiming(tabId, s, 'PROG_SCHEDULED'); + } } else { - // Not in AD: only announce PROGRAM on full show URLs + s.progDedupe.clear(); + s.bumperDedupe.clear(); + s.programSegmentsSeen = 0; + if (s.dedupe.size || s.adSegmentsSeen) { + s.dedupe.clear(); + s.adSegmentsSeen = 0; + } + s.firstAdUrl = null; + s.programModeAdCount = 0; + s.adModeProgramCount = 0; + s.adModeBumperCount = 0; maybeNotify(tabId, 'PROGRAM', { url }); } return; @@ -321,7 +274,7 @@ debugLog('SW_BOOT', { tabId: -1, url: 'service-worker', version: chrome.runtime.getManifest().version, - knobs: { SEG_DUR_DEFAULT_MS, LEAD_START_SEGS, LEAD_END_SEGS } + knobs: { AD_SEGMENTS_REQUIRED, PROGRAM_SEGMENTS_REQUIRED } }); //