Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
245 changes: 99 additions & 146 deletions sw.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
Expand All @@ -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 }
});

//
Expand Down