// ==UserScript== // @name YTTV Auto-Mute (v4.5.2: CaptionGapReturn) // @namespace http://tampermonkey.net/ // @description Auto-mute ads on YouTube TV using signal-aggregation confidence scoring. 18 weighted signals (ad + program leaning) feed a 0-100 confidence meter — no single signal triggers a mute. Guest intro detection, imperative voice analysis, brand suppression, PhraseIndex with compiled regex, HUD with signal breakdown. // @version 4.5.2 // @updateURL https://raw.githubusercontent.com/HouseofTyrell/YTTV-CNBC-AutoMute/main/youtubetv-auto-mute.user.js // @downloadURL https://raw.githubusercontent.com/HouseofTyrell/YTTV-CNBC-AutoMute/main/youtubetv-auto-mute.user.js // @match https://tv.youtube.com/watch/* // @match https://tv.youtube.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_download // @grant GM_listValues // @grant GM_deleteValue // @run-at document-start // @license MIT // @noframes // ==/UserScript== (function(){ 'use strict'; /* ---------- SINGLE NAMESPACE ---------- */ const NSKEY='__yttpMute__'; const NS=window[NSKEY]=window[NSKEY]||{}; try{ if(NS.intervalId)clearInterval(NS.intervalId); if(NS.ccAttachTimer)clearInterval(NS.ccAttachTimer); if(NS.ccObserver?.disconnect)NS.ccObserver.disconnect(); if(NS.videoObserver?.disconnect)NS.videoObserver.disconnect(); // routeObserver removed — using History API interception if(NS.hudTimer)clearTimeout(NS.hudTimer); if(NS.hudAnimTimer)clearTimeout(NS.hudAnimTimer); }catch{} Object.assign(NS,{intervalId:null,ccAttachTimer:null,ccObserver:null, hudEl:null,panelEl:null,hudText:'',hudTimer:null,hudAnimTimer:null, flagBtn:null,btnContainer:null,settingsBtn:null,muteBtn:null,tuningBtn:null,softFlagBtn:null,addMinBtn:null,_lastUrl:location.href}); /* ---------- TRUSTED TYPES POLICY ---------- */ let _ttPolicy; try { _ttPolicy = (typeof trustedTypes!=='undefined'&&trustedTypes.createPolicy) ? trustedTypes.createPolicy('yttp',{createHTML:s=>s}) : null; } catch(e){ _ttPolicy=null; } function _html(s){ return _ttPolicy ? _ttPolicy.createHTML(s) : s; } /* ---------- STORAGE SHIMS ---------- */ const hasGM_get = typeof GM_getValue==='function'; const hasGM_set = typeof GM_setValue==='function'; const hasGM_dl = typeof GM_download==='function'; const hasGM_list = typeof GM_listValues==='function'; const hasGM_del = typeof GM_deleteValue==='function'; const kvGet=(k,d)=>{try{if(hasGM_get)return GM_getValue(k,d);const r=localStorage.getItem('yttp__'+k);return r?JSON.parse(r):d;}catch{return d;}}; const kvSet=(k,v)=>{try{if(hasGM_set)return GM_setValue(k,v);localStorage.setItem('yttp__'+k,JSON.stringify(v));}catch{}}; const kvClearAll=()=>{ let count=0; // Clear GM storage if(hasGM_list&&hasGM_del){try{const keys=GM_listValues();keys.forEach(k=>{GM_deleteValue(k);count++;});}catch{}} // Clear localStorage entries with yttp__ prefix const toRemove=[]; for(let i=0;i{localStorage.removeItem(k);count++;}); return count; }; const downloadText=(name,text)=>{ try{if(hasGM_dl){const url='data:text/plain;charset=utf-8,'+encodeURIComponent(text);GM_download({url,name,saveAs:false});return;}}catch{} const blob=new Blob([text],{type:'text/plain;charset=utf-8'});const url=URL.createObjectURL(blob); const a=document.createElement('a');a.href=url;a.download=name;document.documentElement.appendChild(a);a.click(); setTimeout(()=>{URL.revokeObjectURL(url);a.remove();},300); }; /* ---------- UTILITIES ---------- */ const truncate = (text, max = 140) => text ? (text.length > max ? text.slice(0, max - 3) + '\u2026' : text) : ''; /* ---------- DEBOUNCED LOG FLUSH ---------- */ let _logDirty = false; let _logFlushTimer = null; function scheduleLogFlush() { _logDirty = true; if (_logFlushTimer) return; _logFlushTimer = setTimeout(() => { _logFlushTimer = null; if (_logDirty) { kvSet(CAPLOG_KEY, window._captions_log); _logDirty = false; } }, 5000); } /* ---------- DEFAULTS ---------- */ const DEFAULTS={ useTrueMute:true, intervalMs:150, debug:true, // Review / tuning showTuningUI:true, // Caption visibility hideCaptions:true, // HUD showHUD:true, hudAutoOnMute:true, hudAutoDelayMs:1000, hudFadeMs:250, hudSlidePx:8, // Confidence Meter showConfidenceMeter:true, confidenceMeterStyle:'bar', // 'bar', 'numeric', 'both' confidenceThreshold:65, // Mute when confidence >= this value (0-100) showHudSlider:false, // Show threshold slider on HUD (can disable to reduce HUD size) // Timing / CC loss muteOnNoCCDelayMs:2500, // ms before muting on caption loss noCcHitsToMute:2, // needs consecutive hits unmuteDebounceMs:350, // reduced from 500 for faster unmute // Ad lock minAdLockMs:45000, // 45s covers typical CNBC ad breaks // Program gating programVotesNeeded:2, programQuorumLines:3, // reduced from 4 for faster unmute fastRecheckRAF:true, // Manual override window after FP flag (prevents immediate re-mute unless hard ad) manualOverrideMs:8000, captionWindowSize:5, // Number of recent caption lines to keep volumeRampMs:3000, // Volume ramp duration on unmute (0 = instant) tuningDurationMs:300000, // Tuning session length (5 min default) captionLogLimit:8000, // Passive logging passiveLogging:true, passiveLogIntervalMs:5000, passiveSaveIntervalMs:900000, // 15 min passiveLogCapacity:2500, // --- Phrase sets --- // Medicare / benefits: heavily weighted in hard/ad-context hardPhrases: [ // classic Rx disclaimers "ask your doctor","talk to your doctor","call your doctor","side effects include", "do not take if you are allergic","risk of serious","use as directed","available by prescription", "eligible patients", // additional pharma disclaimers "tell your doctor about all the medicines you take","important safety information", "results may vary","individual results","injection site reactions","risk of thyroid tumors", // paid programming "paid programming","paid advertisement","the following is a paid","the preceding was a paid", // finance/offer "terms apply","limited time offer","0% apr","zero percent apr","get started today","apply today", "see store for details","not available in all states", "get your money right","policy for only","guaranteed buyback option","own your place in the future", // MEDICARE/BENEFITS — strong ad signals kept here "licensed agent","call the number","tty users","tty number","tty relay", "$0 premium","$0 copay","speak to a licensed agent","talk to a licensed agent", "before investing, carefully read","investment objectives, risks, charges","read the prospectus" ].join('\n'), brandTerms: [ // Telecom "capital one","t-mobile","tmobile","verizon","at&t","comcast","comcast business","xfinity", // Insurance "liberty mutual","progressive","geico","state farm","allstate","nationwide","usaa","farmers","travelers", // Pharma "ozempic","mounjaro","trulicity","jardiance","humira","rinvoq","skyrizi", "dupixent","keytruda","eliquis","xarelto","otezla","cosentyx","entresto","farxiga","rybelsus","wegovy", // Medicare / health plan brands (moved from hardPhrases for context-aware scoring) "medicare","medicare advantage","part c","dual-eligible","special needs plan", "enrollment ends","annual election period","aep","open enrollment","enroll by", "humana","unitedhealthcare","anthem","aetna","cigna","centene","molina", "over-the-counter","otc","benefits card","allowance","prepaid card","supplemental benefits", "in-network","out-of-network","formulary","prescription drug coverage", // Financial (only brands that are primarily advertisers, not editorial subjects) "sofi","ally bank","chime", // Tech (only consumer product brands, not CNBC editorial staples) "iphone","samsung","google pixel", // Gold / precious metals "rosland capital","goldco","augusta precious metals","birch gold","noble gold", // Auto "toyota","ford","chevrolet","chevy","honda","hyundai","kia","nissan","bmw","mercedes","lexus", // Legal "if you or a loved one","class action","lawsuit","mesothelioma", // Other "aarp","whopper","subway","expedia","trivago","indeed","ziprecruiter", "invesco","coventry direct", "cdw","vrbo", "dexcom","fidelity trader", "shopify","e*trade","etrade", "godaddy","ameriprise","noom", "crowdstrike","servicenow","quo phone" ].join('\n'), adContext: [ "sponsored by","brought to you by","presented by","paid for by","underwritten by", "offer ends","apply now","apply today","sign up","join now", "get started","start today","enroll","enrollment","speak to an agent","licensed agent", "call now","call today","call the number","free shipping","save today", "see details","member fdic","not fdic insured", "get a quote","free quote","quote today","quote now", "promo code","use code","discount code","limited supply","while supplies last", "satisfaction guaranteed","money-back guarantee","no obligation","risk-free", "available at","sold at","find it at","order yours","order now","shop now", "for more information","available now","now available","act now","don't wait", "official sponsor","proud sponsor","proud partner","as seen on", "prospectus","gps voice","rerouting", "life insurance","qualify" ].join('\n'), ctaTerms: ["apply","sign up","join now","call us","visit","learn more","enroll","enrollment","get started","download","claim","see details","speak to an agent","licensed agent"], offerTerms: ["policy for only","only $","per month","per mo","per year","limited time","guarantee","guaranteed","get a quote","$0 premium","$0 copay","allowance","benefits card","prepaid card","over-the-counter"], // Strong program cues — instant allow override (clear lock + unmute) allowPhrases: [ "joining me now","joins us now","from washington","live in","live at", "earnings season","earnings beat","earnings miss","earnings surprise","reported earnings","earnings call", "guidance","conference call","analyst","beat estimates","raised guidance", "tariff","tariffs","supreme court","breaking news", "economic data","cpi data","cpi report","cpi number","consumer price","ppi data","ppi report","ppi number","producer price","jobs report","nonfarm payrolls", "market breadth","s&p","the nasdaq","nasdaq composite","nasdaq is","nasdaq was","the dow","dow jones","dow industrials","back to you","we're back","we are back","back with", "chief investment officer","portfolio manager","senior analyst","ceo","cfo","chair", "welcome to closing bell","overtime is back","welcome back", // CNBC show names "squawk box","squawk on the street","power lunch","fast money","mad money", "halftime report","money movers","last call","worldwide exchange","the exchange", "cnbc special report", // Welcome variants "welcome to squawk","welcome to power lunch","welcome to fast money", "welcome to the halftime report","welcome to mad money", // Conversational anchoring "let's get to","let's bring in","let's go to","i want to bring in", "thanks for being with us","thank you for joining us","good to have you", "appreciate your time","let's get a check on", // Market / macro terms "the ten-year","treasury yield","federal reserve","rate cut","rate hike","basis points", // Teaser / transition "take a look at this","straight ahead","still to come","coming up","up next on" ], // Explicit break cues — enter ad-lock quickly breakPhrases: [ "back after this","we'll be right back","we will be right back", "stay with us","more after the break","right after this break", "the exchange is back after this", "don't go anywhere","stick around for","we'll stick around","after the break","when we come back", "we'll have more after this","quick break","take a quick break","much more ahead", "we'll be back in two minutes","we'll be back in just a moment" ], // CNBC anchor names — program signal anchorNames: [ "sara eisen","scott wapner","jim cramer","carl quintanilla","david faber", "melissa lee","kelly evans","joe kernen","becky quick","andrew ross sorkin", "brian sullivan","tyler mathisen","rick santelli","steve liesman","mike santoli", "diana olick","robert frank","meg tirrell","dominic chu","leslie picker", "kate rooney","courtney reagan","deirdre bosa","julia boorstin","frank holland", "contessa brewer","seema mody","kristina partsinevelos","bertha coombs", "guy adami","karen finerman","tim seymour","dan nathan" ], // Named CNBC segments — program signal segmentNames: [ "final trades","lightning round","stop trading","call of the day","stock draft", "investment committee","options action","cramer's game plan","cramer's lightning round", "off the charts","the bottom line","market zone","halftime overtime","unusual activity" ], // Return-from-break phrases — strong program signal returnFromBreakPhrases: [ "and we are back","all right we are back","okay we are back", "welcome back everybody","welcome back to squawk","welcome back to closing bell", "welcome back to the halftime report","welcome back to power lunch", "welcome back to fast money","before the break we were","as we were discussing" ], }; const SETTINGS_KEY='yttp_settings_v4_5_0'; const MUTE_PERSIST_KEY='yttp_mute_persist'; const loadSettings=()=>({...DEFAULTS,...(kvGet(SETTINGS_KEY,{}) )}); const saveSettings=(s)=>kvSet(SETTINGS_KEY,s); let S=loadSettings(); /* ---------- WEIGHT CONSTANTS ---------- */ const WEIGHT = Object.freeze({ BASE: 50, HARD_PHRASE: 40, BREAK_CUE: 38, BRAND_DETECTED: 12, AD_CONTEXT: 10, CTA_DETECTED: 8, OFFER_DETECTED: 8, URL_PRESENT: 10, PHONE_PRESENT: 10, IMPERATIVE_VOICE: 8, SHORT_PUNCHY: 6, CAPS_HEAVY: 6, PUNCT_HEAVY: 4, PRICE_MENTION: 5, CAPTION_LOSS_MAX: 25, CAPTION_BOTTOMED: 10, PROGRAM_ALLOW: -45, RETURN_FROM_BREAK: -42, ANCHOR_NAME: -28, GUEST_INTRO: -22, SEGMENT_NAME: -18, CASE_SHIFT_AD: 28, CASE_SHIFT_PROGRAM: -28, SPEAKER_MARKER: -15, DOM_AD_SHOWING: 45, TESTIMONIAL_AD: 12, CONVERSATIONAL: -12, THIRD_PERSON: -8, LOCK_FLOOR: 65, QUORUM_REDUCTION_PER: 4, }); /* ---------- STATE ---------- */ const log=(...a)=>{if(S.debug)console.log('[YTTV-Mute]',...a);}; const nowStr=()=>{const t=new Date(),p=n=>String(n).padStart(2,'0');return `${p(t.getHours())}:${p(t.getMinutes())}:${p(t.getSeconds())}`;}; const CAPLOG_KEY='captions_log'; const _loadedLog = kvGet(CAPLOG_KEY, []); window._captions_log = Array.isArray(_loadedLog) ? _loadedLog : []; const FEEDBACK_KEY = 'yttp_feedback_log'; let _feedbackLog = kvGet(FEEDBACK_KEY, []); if (!Array.isArray(_feedbackLog)) _feedbackLog = []; const State = { enabled: true, videoRef: null, lastMuteState: null, lastCaptionLine: '', lastCcSeenMs: 0, lastProgramGoodMs: 0, lastStrongProgramMs: 0, rafScheduled: false, adLockUntil: 0, programVotes: 0, manualOverrideUntil: 0, noCcConsec: 0, bottomConsec: 0, programQuorumCount: 0, lastCaptionLossEndMs: 0, lastCaptionVisibility: null, currentConfidence: 0, manualMuteActive: false, lastTickMs: 0, bootGraceUntil: 0, captionReturnBoostUntil: 0, lastCaptionGapMs: 0, captionWindow: [], lastSignals: [], recentCapsRatios: [], // last N capsRatios for case shift detection _lastCapsText: '', tuningActive: false, tuningStartMs: 0, tuningEndMs: 0, tuningSnapshots: [], tuningFlags: [], tuningLogStartIdx: 0, // Passive logging passiveLog: [], passiveSessionStart: 0, passiveLastSnapshotMs: 0, passiveSaveTimer: null, passiveBoundaryState: 'program', // 'program' or 'ad' passiveBoundaryLastChangeMs: 0, reset(full = false) { if (full) { this.lastMuteState = null; this.lastCaptionLine = ''; this.videoRef = null; } this.lastCcSeenMs = Date.now(); this.lastProgramGoodMs = 0; this.lastStrongProgramMs = 0; this.adLockUntil = 0; this.programVotes = 0; this.manualOverrideUntil = 0; this.noCcConsec = 0; this.bottomConsec = 0; this.programQuorumCount = 0; this.lastCaptionLossEndMs = 0; this.lastCaptionVisibility = null; this.currentConfidence = 0; this.captionWindow = []; this.recentCapsRatios = []; this._lastCapsText = ''; } }; const URL_RE=/\b[a-z0-9][a-z0-9-]{3,}(?:\.[a-z0-9-]+)*\.(?:com|org|net|edu|gov|io|co|us|biz|info|tv|me|app|dev)\b/i; const PHONE_RE=/\b(?:\d{3}[-\s.]?\d{3}[-\s.]?\d{4})\b/; /* ---------- PHRASE INDEX ---------- */ const PhraseIndex = { _compiled: {}, lists: {}, rebuild(settings) { const norm = (val) => { const raw = Array.isArray(val) ? val.join('\n') : (val || ''); return raw.split('\n').map(s => s.trim().toLowerCase()).filter(Boolean); }; const compile = (phrases, wordBound) => { if (!phrases.length) return null; const escaped = phrases.map(p => { const esc = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Only apply \b for short terms (<6 chars) — longer terms are specific enough // and CNBC captions concatenate words (e.g., "TOSHOPIFY") which breaks \b return (wordBound && p.length < 6) ? '\\b' + esc + '\\b' : esc; }); return new RegExp('(' + escaped.join('|') + ')', 'i'); }; this.lists = { hard: norm(settings.hardPhrases), brand: norm(settings.brandTerms), adContext: norm(settings.adContext), cta: norm(settings.ctaTerms), offer: norm(settings.offerTerms), allow: norm(settings.allowPhrases), break_: norm(settings.breakPhrases), anchor: norm(settings.anchorNames || []), segment: norm(settings.segmentNames || []), returnBreak: norm(settings.returnFromBreakPhrases || []), }; for (const [key, list] of Object.entries(this.lists)) { this._compiled[key] = compile(list, key === 'brand' || key === 'cta'); } }, match(category, text) { const re = this._compiled[category]; if (!re) return null; const m = text.match(re); return m ? m[1] : null; }, }; PhraseIndex.rebuild(S); /* ---------- TEXT ANALYZER CONSTANTS ---------- */ const _PRONOUNS = new Set(['you', 'your', "you're", 'yourself']); const _IMPERATIVES = new Set(['get', 'call', 'ask', 'switch', 'start', 'save', 'protect', 'discover', 'order', 'apply', 'enroll', 'join', 'claim']); const _THIRD_PERSON = new Set(['they', 'their', 'them', 'analysts', 'investors']); const _ANALYTICAL = new Set(['reported', 'expects', 'estimates', 'revenue', 'growth', 'decline', 'forecast', 'quarter', 'consensus', 'guidance', 'earnings', 'economy', 'inflation', 'equities', 'market', 'stocks', 'rates', 'yields', 'fiscal', 'tariffs', 'monetary', 'cyclical', 'sector']); /* ---------- TEXT ANALYZER ---------- */ const TextAnalyzer = { analyze(text) { if (!text) return { capsRatio: 0, punctDensity: 0, shortText: false, priceCount: 0, wordCount: 0 }; let upper = 0, letter = 0, punct = 0, wordCount = 0, inWord = false; for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); if (c >= 65 && c <= 90) { upper++; letter++; } else if (c >= 97 && c <= 122) { letter++; } else if (c === 33 || c === 63) { punct++; } const isAlpha = (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57); if (isAlpha && !inWord) { wordCount++; inWord = true; } else if (!isAlpha) { inWord = false; } } const priceCount = (text.match(/\$\d+|\d+\s?(?:dollars?|cents?)/gi) || []).length; return { capsRatio: letter > 0 ? upper / letter : 0, punctDensity: text.length > 0 ? punct / text.length : 0, shortText: text.length > 0 && text.length < 50, priceCount, wordCount, }; }, imperativeScore(text) { const words = text.toLowerCase().split(/\s+/); if (words.length < 3) return 0; let pronouns = 0, verbs = 0; for (const w of words) { if (_IMPERATIVES.has(w)) verbs++; else if (_PRONOUNS.has(w)) pronouns++; } // Require at least one imperative verb — pronouns alone // ("you know", "what do you mean") are normal in interviews if (verbs === 0) return 0; return (pronouns + verbs) / words.length; }, conversationalScore(text) { const words = text.toLowerCase().split(/\s+/); if (words.length < 5) return 0; let count = 0; for (const w of words) { if (_THIRD_PERSON.has(w) || _ANALYTICAL.has(w)) count++; } return count / words.length; } }; /* ---------- SIGNAL COLLECTOR ---------- */ const SignalCollector = { signals: [], register(name, fn) { this.signals.push({ name, fn }); }, collectAll(ccText, env) { const results = []; for (const s of this.signals) { const r = s.fn(ccText, env); if (r) results.push({ source: s.name, ...r }); } return results; } }; // --- Ad-leaning signals --- SignalCollector.register('domAdShowing', (text, env) => { if (!env.domAdShowing) return null; return { weight: WEIGHT.DOM_AD_SHOWING, label: 'DOM ad-showing', match: 'player class' }; }); SignalCollector.register('hardPhrase', (text) => { const match = PhraseIndex.match('hard', text); return match ? { weight: WEIGHT.HARD_PHRASE, label: 'Hard ad phrase', match } : null; }); SignalCollector.register('breakCue', (text) => { const match = PhraseIndex.match('break_', text); return match ? { weight: WEIGHT.BREAK_CUE, label: 'Break cue', match } : null; }); SignalCollector.register('brandDetected', (text, env) => { const match = PhraseIndex.match('brand', text); if (!match) return null; if (env.guestIntroDetected) return null; const w = (State.adLockUntil > Date.now()) ? WEIGHT.BRAND_DETECTED : Math.round(WEIGHT.BRAND_DETECTED * 0.3); return { weight: w, label: 'Brand detected', match }; }); SignalCollector.register('adContext', (text) => { const match = PhraseIndex.match('adContext', text); return match ? { weight: WEIGHT.AD_CONTEXT, label: 'Ad context', match } : null; }); SignalCollector.register('ctaDetected', (text) => { const match = PhraseIndex.match('cta', text); return match ? { weight: WEIGHT.CTA_DETECTED, label: 'CTA', match } : null; }); SignalCollector.register('offerDetected', (text) => { const match = PhraseIndex.match('offer', text); return match ? { weight: WEIGHT.OFFER_DETECTED, label: 'Offer', match } : null; }); SignalCollector.register('urlOrPhone', (text) => { const hasUrl = URL_RE.test(text); const hasPhone = PHONE_RE.test(text); if (!hasUrl && !hasPhone) return null; const w = (hasUrl ? WEIGHT.URL_PRESENT : 0) + (hasPhone ? WEIGHT.PHONE_PRESENT : 0); return { weight: w, label: hasUrl && hasPhone ? 'URL+Phone' : hasUrl ? 'URL' : 'Phone', match: null }; }); SignalCollector.register('textFeatures', (text, env) => { const f = env.textFeatures; let w = 0, parts = []; const _hist = State.recentCapsRatios; const _avgCaps = _hist.length >= 3 ? _hist.reduce((s,v) => s+v, 0) / _hist.length : 0; if (f.capsRatio > 0.95 && !(_hist.length >= 3 && _avgCaps > 0.85)) { w += WEIGHT.CAPS_HEAVY; parts.push('caps'); } if (f.punctDensity > 0.05) { w += WEIGHT.PUNCT_HEAVY; parts.push('punct'); } // Suppress price signal on financial news — dollar amounts are normal on CNBC const recentProgram = State.lastStrongProgramMs && (Date.now() - State.lastStrongProgramMs < 90000); if (f.priceCount > 0) { if (!recentProgram) { w += f.priceCount * WEIGHT.PRICE_MENTION; parts.push('price'); } } // Cap total textFeatures weight during confirmed program content if ((recentProgram || State.programQuorumCount > 0) && w > 12) { w = 12; parts.push('dampened'); } return w > 0 ? { weight: w, label: 'Text features: ' + parts.join('+'), match: parts.join('+') } : null; }); SignalCollector.register('caseShift', (text, env) => { if (!text || text.length < 10) return null; const currentCaps = env.textFeatures.capsRatio; const history = State.recentCapsRatios; // Need at least 3 prior samples to establish a baseline if (history.length < 3) return null; const avgCaps = history.reduce((s, v) => s + v, 0) / history.length; // Mixed case line after ALL CAPS program → strong ad signal if (currentCaps < 0.5 && avgCaps > 0.85) { return { weight: WEIGHT.CASE_SHIFT_AD, label: 'Case shift → ad', match: `caps=${currentCaps.toFixed(2)},avg=${avgCaps.toFixed(2)}` }; } // ALL CAPS line after mixed case ads → strong program signal if (currentCaps > 0.85 && avgCaps < 0.5) { return { weight: WEIGHT.CASE_SHIFT_PROGRAM, label: 'Case shift → program', match: `caps=${currentCaps.toFixed(2)},avg=${avgCaps.toFixed(2)}` }; } return null; }); SignalCollector.register('imperativeVoice', (text, env) => { const score = env.imperativeScore; if (score <= 0.08) return null; const w = Math.min(WEIGHT.IMPERATIVE_VOICE, Math.round(score * 100)); return { weight: w, label: 'Imperative voice', match: `ratio=${score.toFixed(2)}` }; }); SignalCollector.register('shortPunchyLines', () => { const win = State.captionWindow; if (win.length < 3) return null; const avgLen = win.reduce((s, l) => s + l.length, 0) / win.length; if (avgLen >= 50) return null; return { weight: WEIGHT.SHORT_PUNCHY, label: 'Short punchy lines', match: `avgLen=${Math.round(avgLen)}` }; }); SignalCollector.register('captionLoss', (text, env) => { if (env.captionsExist) { State.noCcConsec = 0; return null; } if (Date.now() < State.bootGraceUntil) return null; // Suppress during boot/tab-wake grace period State.noCcConsec++; if (env.noCcMs < S.muteOnNoCCDelayMs || State.noCcConsec < S.noCcHitsToMute) return null; const w = Math.min(WEIGHT.CAPTION_LOSS_MAX, Math.round(env.noCcMs / 400)); return { weight: w, label: 'Caption loss', match: `noCcMs=${env.noCcMs}` }; }); // Caption gap return — boosts confidence when captions reappear after a 1s+ gap. // Fills the dead zone before captionLoss fires (2.5s gate), cutting mute latency by ~1-1.5s. SignalCollector.register('captionGapReturn', (text, env) => { if (!text) return null; const now = Date.now(); if (now > State.captionReturnBoostUntil) return null; if (now < State.bootGraceUntil) return null; return { weight: 8, label: 'Caption gap return', match: `gap=${State.lastCaptionGapMs}ms` }; }); SignalCollector.register('captionBottomed', (text, env) => { if (!env.captionsBottomed) { State.bottomConsec = 0; return null; } State.bottomConsec++; return State.bottomConsec >= 2 ? { weight: WEIGHT.CAPTION_BOTTOMED, label: 'Bottom captions', match: null } : null; }); SignalCollector.register('testimonialAd', (text, env) => { if (!env.captionsBottomed) return null; if (env.textFeatures.capsRatio >= 0.85) return null; if (!State.lastCaptionLossEndMs || Date.now() - State.lastCaptionLossEndMs > 60000) return null; const windowText = State.captionWindow.join(' '); if (windowText.includes('>>')) return null; if (PhraseIndex.match('anchor', windowText)) return null; return { weight: WEIGHT.TESTIMONIAL_AD, label: 'Testimonial ad pattern', match: null }; }); // --- Program-leaning signals --- SignalCollector.register('programAllow', (text) => { const match = PhraseIndex.match('allow', text); return match ? { weight: WEIGHT.PROGRAM_ALLOW, label: 'Program allow', match } : null; }); SignalCollector.register('returnFromBreak', (text) => { const match = PhraseIndex.match('returnBreak', text); return match ? { weight: WEIGHT.RETURN_FROM_BREAK, label: 'Return from break', match } : null; }); SignalCollector.register('anchorName', (text) => { const match = PhraseIndex.match('anchor', text); return match ? { weight: WEIGHT.ANCHOR_NAME, label: 'Anchor name', match } : null; }); SignalCollector.register('guestIntro', (text, env) => { if (!env.guestIntroDetected) return null; return { weight: WEIGHT.GUEST_INTRO, label: 'Guest intro', match: env.guestIntroMatch }; }); SignalCollector.register('segmentName', (text) => { const match = PhraseIndex.match('segment', text); return match ? { weight: WEIGHT.SEGMENT_NAME, label: 'Segment name', match } : null; }); SignalCollector.register('speakerMarker', (text) => { if (!text) return null; // CNBC live captions use >> for speaker changes; ads never have this if (text.includes('>>')) { return { weight: WEIGHT.SPEAKER_MARKER, label: 'Speaker marker >>', match: null }; } return null; }); SignalCollector.register('conversational', (text, env) => { const score = env.conversationalScore; const longLine = text.length > 80; if (score <= 0.05 && !longLine) return null; let w = 0; if (score > 0.05) w += Math.min(Math.abs(WEIGHT.CONVERSATIONAL), Math.round(score * 120)); if (longLine) w += Math.abs(WEIGHT.THIRD_PERSON); return { weight: -w, label: 'Conversational', match: `score=${score.toFixed(2)},len=${text.length}` }; }); /* ---------- CONFIDENCE SCORER ---------- */ function calculateConfidence(signalResults) { let score = WEIGHT.BASE; for (const signal of signalResults) { score += signal.weight; } if (State.adLockUntil > Date.now()) { const lockRemaining = State.adLockUntil - Date.now(); const dynamicFloor = WEIGHT.BASE + (WEIGHT.LOCK_FLOOR - WEIGHT.BASE) * Math.min(1, lockRemaining / S.minAdLockMs); if (score < dynamicFloor) score = dynamicFloor; } score -= State.programQuorumCount * WEIGHT.QUORUM_REDUCTION_PER; return Math.max(0, Math.min(100, Math.round(score))); } /* ---------- DECISION ENGINE ---------- */ function decide(confidence, signalResults) { const t = Date.now(); if (!State.enabled) return { shouldMute: false, reason: 'DISABLED' }; if (t < State.manualOverrideUntil) return { shouldMute: false, reason: 'MANUAL_OVERRIDE' }; const meetsThreshold = confidence >= S.confidenceThreshold; const hasReturnFromBreak = signalResults.some(s => s.source === 'returnFromBreak'); const hasProgramAllow = signalResults.some(s => s.source === 'programAllow'); if (hasReturnFromBreak) { State.adLockUntil = 0; State.programVotes = S.programVotesNeeded; State.programQuorumCount = S.programQuorumLines; if (State.manualMuteActive) return { shouldMute: true, reason: 'MANUAL_MUTE', virtualReason: 'PROGRAM_CONFIRMED' }; return { shouldMute: false, reason: 'PROGRAM_CONFIRMED' }; } // Expanded programAllow suppression — prevents false unmute on ads with market terms let programAllowSuppressed = false; if (hasProgramAllow) { const hasBrand = signalResults.some(s => s.source === 'brandDetected'); const hasBottomOrTestimonial = signalResults.some(s => s.source === 'captionBottomed' || s.source === 'testimonialAd'); const hasCorroboration = signalResults.some(s => s.source === 'speakerMarker' || s.source === 'anchorName'); const postCaptionLoss = State.lastCaptionLossEndMs > 0 && (t - State.lastCaptionLossEndMs < 30000); const suppress = hasBrand || hasBottomOrTestimonial || (postCaptionLoss && !hasCorroboration); if (!suppress) { State.adLockUntil = 0; State.programVotes = S.programVotesNeeded; State.programQuorumCount = S.programQuorumLines; if (State.manualMuteActive) return { shouldMute: true, reason: 'MANUAL_MUTE', virtualReason: 'PROGRAM_CONFIRMED' }; return { shouldMute: false, reason: 'PROGRAM_CONFIRMED' }; } programAllowSuppressed = true; const reason = hasBrand ? 'brand' : hasBottomOrTestimonial ? 'bottom/testimonial' : 'post-captionLoss'; signalResults.push({ source: 'programAllowSuppressed', weight: 0, label: 'programAllow suppressed: ' + reason, match: null }); } if (meetsThreshold && confidence >= 82) { State.adLockUntil = Math.max(State.adLockUntil, t + S.minAdLockMs); State.programVotes = 0; State.programQuorumCount = 0; State.lastProgramGoodMs = 0; } // Sustained captionLoss sets adLock — captionLoss IS an ad break indicator const hasSustainedCaptionLoss = signalResults.some(s => s.source === 'captionLoss' && s.weight >= 15); if (hasSustainedCaptionLoss) { State.adLockUntil = Math.max(State.adLockUntil, t + S.minAdLockMs); } // Soft adLock renewal: if lock is active and any ad signal fires, extend by 15s. // Bridges gaps where ad signals are intermittent (e.g. testimonial ads with sparse cues). if (t < State.adLockUntil) { const hasAdSignal = signalResults.some(s => (s.source === 'adContext' || s.source === 'ctaDetected' || s.source === 'brandDetected' || s.source === 'urlOrPhone') && s.weight > 0 ); if (hasAdSignal) { State.adLockUntil = Math.max(State.adLockUntil, t + 15000); } } let lockActive = t < State.adLockUntil; // Strong program signal breaks adLock — speakerMarker/anchorName/caseShift(program) // are reliable program-only indicators; if confidence is below threshold, release the lock if (lockActive && !meetsThreshold) { const programBreak = signalResults.some(s => s.source === 'speakerMarker' || s.source === 'anchorName' || (s.source === 'caseShift' && s.weight < 0) ); if (programBreak) { State.adLockUntil = 0; State.programVotes = S.programVotesNeeded; State.programQuorumCount = S.programQuorumLines; lockActive = false; if (State.manualMuteActive) return { shouldMute: true, reason: 'MANUAL_MUTE', virtualReason: 'PROGRAM_CONFIRMED' }; return { shouldMute: false, reason: 'PROGRAM_CONFIRMED' }; } } // Strong program signal: anchor, allow, segment, guest intro (not conversational at -8) // Exclude suppressed programAllow from counting as strong program signal const strongProgramSignal = signalResults.some(s => s.weight <= -12 && !(programAllowSuppressed && s.source === 'programAllow')); if (strongProgramSignal) State.lastStrongProgramMs = t; // Quorum decay on staleness — if no strong program signal in 20s, erode quorum if (State.lastStrongProgramMs && (t - State.lastStrongProgramMs > 20000)) { State.programQuorumCount = Math.max(0, State.programQuorumCount - 1); State.programVotes = Math.max(0, State.programVotes - 1); } // Quorum decay on ad-like signals — erode quorum when ad signals appear // (catches back-to-back ad breaks where quorum carries over from prior program) // Threshold 15 so mild signals (imperativeVoice+shortPunchy=14) don't erode quorum const adSignalWeight = signalResults.filter(s => s.weight > 0).reduce((sum, s) => sum + s.weight, 0); if (adSignalWeight >= 15) { State.programQuorumCount = Math.max(0, State.programQuorumCount - 1); State.programVotes = Math.max(0, State.programVotes - 1); } // Caption loss resets stale quorum const hasCaptionLoss = signalResults.some(s => s.source === 'captionLoss'); if (hasCaptionLoss) { State.programQuorumCount = 0; State.programVotes = 0; State.lastProgramGoodMs = 0; } if (strongProgramSignal && !lockActive) { State.programVotes = Math.min(S.programVotesNeeded, State.programVotes + 1); State.programQuorumCount = Math.min(S.programQuorumLines, State.programQuorumCount + 1); if (!State.lastProgramGoodMs) State.lastProgramGoodMs = t; } else if (meetsThreshold) { State.programQuorumCount = 0; State.programVotes = 0; State.lastProgramGoodMs = 0; } // Manual mute: state machine has fully updated above, now override the output. // Log the "virtual" reason so passive logs capture what would have happened. if (State.manualMuteActive) { let virtualReason = 'LOW_CONFIDENCE'; if (lockActive && meetsThreshold) virtualReason = 'AD_LOCK'; else if (lockActive) virtualReason = 'AD_LOCK_FADING'; else if (meetsThreshold) virtualReason = 'CONFIDENCE_HIGH'; else { const votesOK = State.programVotes >= S.programVotesNeeded; const quorumOK = State.programQuorumCount >= S.programQuorumLines; const timeOK = State.lastProgramGoodMs && (t - State.lastProgramGoodMs >= S.unmuteDebounceMs); const quorumFresh = State.lastStrongProgramMs && (t - State.lastStrongProgramMs < 15000); if (votesOK && quorumOK && timeOK && quorumFresh) virtualReason = 'PROGRAM_QUORUM_MET'; else if (confidence >= S.confidenceThreshold - 10) virtualReason = 'BUILDING_QUORUM'; } return { shouldMute: true, reason: 'MANUAL_MUTE', virtualReason }; } if (lockActive && meetsThreshold) return { shouldMute: true, reason: 'AD_LOCK' }; if (lockActive) return { shouldMute: State.lastMuteState === true, reason: 'AD_LOCK_FADING' }; if (meetsThreshold) return { shouldMute: true, reason: 'CONFIDENCE_HIGH' }; const votesOK = State.programVotes >= S.programVotesNeeded; const quorumOK = State.programQuorumCount >= S.programQuorumLines; const timeOK = State.lastProgramGoodMs && (t - State.lastProgramGoodMs >= S.unmuteDebounceMs); const quorumFresh = State.lastStrongProgramMs && (t - State.lastStrongProgramMs < 15000); if (votesOK && quorumOK && timeOK && quorumFresh) return { shouldMute: false, reason: 'PROGRAM_QUORUM_MET' }; if (confidence < S.confidenceThreshold - 10) return { shouldMute: false, reason: 'LOW_CONFIDENCE' }; return { shouldMute: State.lastMuteState === true, reason: 'BUILDING_QUORUM' }; } /* ---------- HUD ---------- */ function ensureHUD(){ if(NS.hudEl)return; const el=document.createElement('div'); el.style.cssText=[ 'position:fixed','left:50%','top:12px','z-index:2147483647', 'font:11px/1.2 system-ui,sans-serif','background:rgba(0,0,0,.8)','color:#fff', 'padding:6px 10px','border-radius:6px','pointer-events:none','white-space:nowrap', 'overflow:hidden', `opacity:0`,`transform:translateX(-50%) translateY(-${S.hudSlidePx|0}px)`, `transition:opacity ${S.hudFadeMs|0}ms ease,transform ${S.hudFadeMs|0}ms ease` ].join(';'); el.textContent=NS.hudText||'';document.documentElement.appendChild(el);NS.hudEl=el; } function hudFadeTo(v){ensureHUD();if(!NS.hudEl)return; if(NS.hudAnimTimer){clearTimeout(NS.hudAnimTimer);NS.hudAnimTimer=null;} NS.hudEl.style.opacity=v?'1':'0';NS.hudEl.style.transform=v?`translateX(-50%) translateY(0px)`:`translateX(-50%) translateY(-${S.hudSlidePx|0}px)`;} let _hudBuilt = false; const _hudRefs = {}; function _buildHUDInner() { if (_hudBuilt || !NS.hudEl) return; NS.hudEl.style.whiteSpace = 'normal'; NS.hudEl.style.maxWidth = '420px'; const thr = parseInt(S.confidenceThreshold, 10) || 65; NS.hudEl.innerHTML = _html( `
` + `` + `·` + `·` + `
` + `
` + `Thr:` + `` + `` + `%` + `
` + `
`); _hudRefs.status = NS.hudEl.querySelector('#yttp-hud-status'); _hudRefs.reason = NS.hudEl.querySelector('#yttp-hud-reason'); _hudRefs.meter = NS.hudEl.querySelector('#yttp-hud-meter'); _hudRefs.sliderWrap = NS.hudEl.querySelector('#yttp-hud-slider-wrap'); _hudRefs.slider = NS.hudEl.querySelector('#yttp-threshold-slider'); _hudRefs.numberInput = NS.hudEl.querySelector('#yttp-threshold-number'); _hudRefs.signals = NS.hudEl.querySelector('#yttp-hud-signals'); function syncThreshold(val) { val = Math.max(0, Math.min(100, parseInt(val, 10) || 65)); S.confidenceThreshold = val; if (_hudRefs.slider) _hudRefs.slider.value = val; if (_hudRefs.numberInput) _hudRefs.numberInput.value = val; saveSettings(S); } if (_hudRefs.slider) { _hudRefs.slider.addEventListener('input', (e) => syncThreshold(e.target.value)); } if (_hudRefs.numberInput) { _hudRefs.numberInput.addEventListener('change', (e) => syncThreshold(e.target.value)); } _hudBuilt = true; } function updateHUDText(t, confidence, signals, isMuted) { NS.hudText = t; if (!NS.hudEl) return; _buildHUDInner(); const parts = t.split('\n').filter(Boolean); const status = parts[0] || ''; const reason = parts[1] ? parts[1].replace('Reason: ', '') : ''; if (_hudRefs.status) { _hudRefs.status.textContent = status; _hudRefs.status.style.color = isMuted ? '#ff4444' : '#44dd55'; } if (_hudRefs.reason) _hudRefs.reason.textContent = reason; if (S.showConfidenceMeter && _hudRefs.meter) { const thr = S.confidenceThreshold; const aboveThr = confidence >= thr; if (S.confidenceMeterStyle === 'bar' || S.confidenceMeterStyle === 'both') { const barWidth = 20; const notchPos = Math.min(barWidth - 1, Math.max(0, Math.round(thr / 100 * barWidth))); const filledTotal = Math.round((confidence / 100) * barWidth); let safePart = '', dangerPart = ''; for (let i = 0; i < notchPos; i++) safePart += i < filledTotal ? '\u2593' : '\u2591'; for (let i = notchPos + 1; i <= barWidth; i++) dangerPart += i < filledTotal ? '\u2588' : '\u2591'; // Quorum/lock suffix let suffix = ''; const lockMs = Math.max(0, State.adLockUntil - Date.now()); if (lockMs > 0) { suffix = ' \uD83D\uDD12' + Math.ceil(lockMs / 1000) + 's'; } else if (State.programQuorumCount >= S.programQuorumLines) { suffix = ' \u2713' + State.programQuorumCount + '/' + S.programQuorumLines; } else if (State.programQuorumCount > 0 || State.programVotes > 0) { suffix = ' \u27F3' + Math.max(State.programQuorumCount, State.programVotes) + '/' + S.programQuorumLines; } const labelColor = aboveThr ? '#ff4444' : '#cccccc'; _hudRefs.meter.innerHTML = _html( '' + safePart + '' + '\u2502' + '' + dangerPart + '' + ' ' + confidence + '/' + thr + '' + (suffix ? '' + suffix + '' : '')); } else { const labelColor = aboveThr ? '#ff4444' : '#cccccc'; _hudRefs.meter.innerHTML = _html('' + confidence + '/' + thr + ''); } _hudRefs.meter.style.display = ''; } else if (_hudRefs.meter) { _hudRefs.meter.style.display = 'none'; } if (_hudRefs.sliderWrap) { _hudRefs.sliderWrap.style.display = (S.showConfidenceMeter && S.showHudSlider) ? '' : 'none'; } // Show top 3 signals if (_hudRefs.signals && S.showHUD && signals && signals.length > 0) { const topSignals = [...signals].sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight)).slice(0, 3); _hudRefs.signals.textContent = topSignals.map(s => `${s.weight > 0 ? '+' : ''}${s.weight} ${s.source}`).join(', '); } else if (_hudRefs.signals) { _hudRefs.signals.textContent = ''; } } function scheduleHudVisibility(desired){if(NS.hudTimer)clearTimeout(NS.hudTimer);const tok=Symbol('hud');NS._hudDesiredToken=tok; NS.hudTimer=setTimeout(()=>{if(NS._hudDesiredToken!==tok)return;const vis=S.showHUD||(S.hudAutoOnMute&&desired);hudFadeTo(vis);},Math.max(0,S.hudAutoDelayMs|0));} /* ---------- SETTINGS & MANUAL MUTE BUTTONS ---------- */ function ensureSettingsButton(){ if(NS.settingsBtn)return; const btn=document.createElement('button'); btn.textContent='⚙️'; btn.title='Settings (Ctrl+Shift+S)'; btn.style.cssText=[ 'position:fixed','right:72px','top:12px','z-index:2147483647', 'background:#1f6feb','color:#fff','border:none','border-radius:8px', 'padding:8px 12px','font:16px/1 system-ui,sans-serif', 'box-shadow:0 6px 18px rgba(0,0,0,.3)','cursor:pointer','pointer-events:auto' ].join(';'); btn.addEventListener('click',togglePanel); document.documentElement.appendChild(btn); NS.settingsBtn=btn; ensureManualMuteButton(); } function ensureManualMuteButton(){ if(NS.muteBtn)return; const btn=document.createElement('button'); btn.textContent='🔇'; btn.title='Manual Mute Toggle'; btn.style.cssText=[ 'position:fixed','right:128px','top:12px','z-index:2147483647', 'background:#444','color:#fff','border:none','border-radius:8px', 'padding:8px 12px','font:16px/1 system-ui,sans-serif', 'box-shadow:0 6px 18px rgba(0,0,0,.3)','cursor:pointer','pointer-events:auto' ].join(';'); btn.addEventListener('click',()=>{ State.manualMuteActive = !State.manualMuteActive; btn.textContent = State.manualMuteActive ? '🔇' : '🔊'; btn.style.background = State.manualMuteActive ? '#8b0000' : '#444'; btn.title = State.manualMuteActive ? 'Manual Mute Active (Click to Unmute)' : 'Manual Mute Toggle'; if (S.passiveLogging) { passiveEvent(State.manualMuteActive ? 'manual_mute_on' : 'manual_mute_off'); schedulePassiveFlush(); } log(`Manual mute ${State.manualMuteActive?'ENABLED':'DISABLED'}`); scheduleImmediateCheck(); }); document.documentElement.appendChild(btn); NS.muteBtn=btn; } /* ---------- LOG ---------- */ function pushCaption(text){ const entry=`[${nowStr()}] ${text}`; window._captions_log.push(entry); if(window._captions_log.length>S.captionLogLimit*1.25)window._captions_log=window._captions_log.slice(-S.captionLogLimit); scheduleLogFlush(); } function pushEventLog(kind,p={}){ const line=[ `[${nowStr()}] >>> ${kind}`, p.reason?`reason=${p.reason}`:null, p.match?`match="${p.match}"`:null, p.noCcMs!==undefined?`noCcMs=${p.noCcMs}`:null, p.ccSnippet?`cc="${p.ccSnippet}"`:null, p.url?`url=${p.url}`:null, p.lock?`adLockMsLeft=${p.lock}`:null, p.pv!==undefined?`programVotes=${p.pv}`:null, p.quorum!==undefined?`programQuorum=${p.quorum}`:null ].filter(Boolean).join(' | '); window._captions_log.push(line); if(window._captions_log.length>S.captionLogLimit*1.25)window._captions_log=window._captions_log.slice(-S.captionLogLimit); scheduleLogFlush(); } function downloadCaptionsNow(){ const pad=n=>String(n).padStart(2,'0'),d=new Date(); const name=`youtubetv_captions_${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}.txt`; downloadText(name,window._captions_log.join('\n')||'(no captions logged yet)'); } /* ---------- PASSIVE LOG ---------- */ const PASSIVE_LOG_KEY = 'yttp_passive_log'; const PASSIVE_META_KEY = 'yttp_passive_meta'; // Load persisted passive log const _loadedPassive = kvGet(PASSIVE_LOG_KEY, []); if (Array.isArray(_loadedPassive)) State.passiveLog = _loadedPassive; const _passiveMeta = kvGet(PASSIVE_META_KEY, {}); State.passiveSessionStart = _passiveMeta.sessionStart || Date.now(); function passiveLogPush(record) { if (!S.passiveLogging) return; record.t = Date.now(); record.ts = nowStr(); State.passiveLog.push(record); if (State.passiveLog.length > S.passiveLogCapacity * 1.25) { State.passiveLog = State.passiveLog.slice(-S.passiveLogCapacity); } } function passiveSnapshot(confidence, decision, signals, ccText) { const rec = { conf: confidence, muted: decision.shouldMute, reason: decision.reason, signals: signals.map(s => ({ s: s.source, w: s.weight, m: s.match || undefined })), caption: truncate(ccText, 200), adLock: Date.now() < State.adLockUntil, quorum: State.programQuorumCount, pv: State.programVotes, clEnd: State.lastCaptionLossEndMs || undefined, }; if (decision.virtualReason) rec.vr = decision.virtualReason; passiveLogPush(rec); } function passiveTransition(type, confidence, decision, signals, ccText) { const rec = { event: type, conf: confidence, muted: decision.shouldMute, reason: decision.reason, signals: signals.map(s => ({ s: s.source, w: s.weight, m: s.match || undefined })), caption: truncate(ccText, 200), adLock: Date.now() < State.adLockUntil, quorum: State.programQuorumCount, pv: State.programVotes, }; if (decision.virtualReason) rec.vr = decision.virtualReason; passiveLogPush(rec); } function passiveEvent(eventName, extra) { passiveLogPush({ event: eventName, ...extra }); } let _passiveDirty = false; let _passiveFlushTimer = null; function schedulePassiveFlush() { _passiveDirty = true; if (_passiveFlushTimer) return; _passiveFlushTimer = setTimeout(() => { _passiveFlushTimer = null; if (_passiveDirty) { kvSet(PASSIVE_LOG_KEY, State.passiveLog); kvSet(PASSIVE_META_KEY, { sessionStart: State.passiveSessionStart, version: '4.5.2' }); _passiveDirty = false; } }, 10000); } function passiveBoundaryCheck(confidence, decision, signals) { if (!S.passiveLogging) return; const t = Date.now(); const prev = State.passiveBoundaryState; let next = prev; let trigger = null; if (prev === 'program') { if (decision.reason === 'CONFIDENCE_HIGH' && confidence >= S.confidenceThreshold) { next = 'ad'; trigger = 'confidence_crossed'; } else if (decision.reason === 'AD_LOCK') { next = 'ad'; trigger = 'ad_lock_engaged'; } else if (signals.some(s => s.source === 'domAdShowing')) { next = 'ad'; trigger = 'dom_ad_showing'; } else if (signals.some(s => s.source === 'captionLoss' && s.weight >= 15)) { next = 'ad'; trigger = 'caption_loss'; } } else { if (decision.reason === 'PROGRAM_CONFIRMED') { next = 'program'; trigger = 'program_confirmed'; } else if (decision.reason === 'PROGRAM_QUORUM_MET') { next = 'program'; trigger = 'program_quorum_met'; } else if (signals.some(s => s.source === 'caseShift' && s.weight < 0)) { next = 'program'; trigger = 'case_shift_program'; } } if (next !== prev) { if (t - State.passiveBoundaryLastChangeMs < 15000) return; State.passiveBoundaryState = next; State.passiveBoundaryLastChangeMs = t; passiveLogPush({ event: 'boundary', type: next === 'ad' ? 'ad_start' : 'ad_end', trigger, conf: confidence, }); } } function passiveAutoSave() { if (!S.passiveLogging || State.passiveLog.length === 0) return; const boundaries = State.passiveLog .filter(r => r.event === 'boundary') .map(r => ({ type: r.type, t: r.t, trigger: r.trigger })); const report = { version: '4.5.2', format: 'passive_log', sessionStart: new Date(State.passiveSessionStart).toISOString(), savedAt: new Date().toISOString(), settings: { confidenceThreshold: S.confidenceThreshold, minAdLockMs: S.minAdLockMs, muteOnNoCCDelayMs: S.muteOnNoCCDelayMs, programVotesNeeded: S.programVotesNeeded, programQuorumLines: S.programQuorumLines, captionWindowSize: S.captionWindowSize, }, entries: State.passiveLog, boundaries, }; const pad = n => String(n).padStart(2, '0'); const d = new Date(State.passiveSessionStart); const n = new Date(); const name = `yttp_passive_${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}-${pad(n.getHours())}${pad(n.getMinutes())}_v4.5.2.json`; downloadText(name, JSON.stringify(report, null, 2)); log('Passive log auto-saved:', name, `(${State.passiveLog.length} entries)`); } function startPassiveSaveTimer() { stopPassiveSaveTimer(); if (!S.passiveLogging) return; State.passiveSaveTimer = setInterval(passiveAutoSave, S.passiveSaveIntervalMs); } function stopPassiveSaveTimer() { if (State.passiveSaveTimer) { clearInterval(State.passiveSaveTimer); State.passiveSaveTimer = null; } } /* ---------- VOLUME RAMP ---------- */ let _rampTimer = null; let _rampTargetVolume = 1.0; function applyMute(video, shouldMute) { if (!video) return; if (shouldMute) { // Cancel any active ramp when muting if (_rampTimer) { cancelAnimationFrame(_rampTimer); _rampTimer = null; } if (S.useTrueMute) { video.muted = true; } else { video.volume = 0.01; } return; } // Already unmuted — let any active ramp continue, don't restart if (_rampTimer) return; if (!video.muted && video.volume > 0.1) return; // Unmute with optional ramp if (S.volumeRampMs <= 0 || !S.useTrueMute) { if (S.useTrueMute) video.muted = false; else video.volume = Math.max(_rampTargetVolume, 0.5); return; } // Ramp: set volume to 0 BEFORE unmuting to prevent pop video.volume = 0; video.muted = false; const startTime = performance.now(); const startVol = 0; const endVol = _rampTargetVolume || 1.0; const duration = S.volumeRampMs; function step(now) { const elapsed = now - startTime; if (elapsed >= duration) { video.volume = endVol; _rampTimer = null; return; } const progress = elapsed / duration; // ease-in quadratic: starts slow, accelerates video.volume = startVol + (endVol - startVol) * (progress * progress); _rampTimer = requestAnimationFrame(step); } _rampTimer = requestAnimationFrame(step); } /* ---------- MUTE/UNMUTE ---------- */ function setMuted(video,shouldMute,info){ if(!video)return; if(State.manualMuteActive){ shouldMute = true; } else if(!State.enabled){ shouldMute=false; } const changed=(State.lastMuteState!==shouldMute); // Save volume before muting for ramp target — only from steady state, not mid-ramp if (shouldMute && !State.lastMuteState && !_rampTimer && video.volume > 0.1) { _rampTargetVolume = video.volume; } // Set lastMuteState BEFORE applyMute so volumechange watchdog sees correct intent State.lastMuteState=shouldMute; applyMute(video, shouldMute); if(changed){ const lockMsLeft=Math.max(0,State.adLockUntil-Date.now()); pushEventLog(shouldMute?'MUTED':'UNMUTED', { reason:info.reason,match:info.match,ccSnippet:info.ccSnippet,url:location.href, noCcMs:info.noCcMs,lock:lockMsLeft,pv:State.programVotes,quorum:State.programQuorumCount }); // Passive log transition if (S.passiveLogging) { passiveTransition( shouldMute ? 'mute' : 'unmute', State.currentConfidence, { shouldMute, reason: info.reason }, info.signals || State.lastSignals || [], info.ccSnippet || '' ); schedulePassiveFlush(); } if(S.hudAutoOnMute) scheduleHudVisibility(shouldMute); else if(S.showHUD) hudFadeTo(true); } if(changed) kvSet(MUTE_PERSIST_KEY, { muted: shouldMute, t: Date.now() }); let statusPrefix = State.manualMuteActive ? '[MANUAL MUTE] ' : (State.enabled?'':'[PAUSED] '); if (State.tuningActive) { const remaining = Math.max(0, State.tuningEndMs - Date.now()); const min = Math.floor(remaining / 60000); const sec = Math.floor((remaining % 60000) / 1000); statusPrefix = '[TUNING ' + min + ':' + String(sec).padStart(2, '0') + '] ' + statusPrefix; } // Truncate CC snippet for HUD display stability (max 60 chars) const hudCcSnippet = truncate(info.ccSnippet, 60); updateHUDText( statusPrefix+`${shouldMute?'MUTED':'UNMUTED'}\n`+ `Reason: ${info.reason}\n`+ (info.match?`Match: "${truncate(info.match, 30)}"\n`:'' )+ (hudCcSnippet?`CC: "${hudCcSnippet}"`:'' ), info.confidence || State.currentConfidence, info.signals, shouldMute ); } /* ---------- DOM / LOOP ---------- */ let _cachedVideo = null, _cachedCaptionWindow = null, _cacheValidUntil = 0; function detectNodes(){ const now = Date.now(); if (now < _cacheValidUntil && _cachedVideo?.isConnected && _cachedCaptionWindow?.isConnected) { return { video: _cachedVideo, captionWindow: _cachedCaptionWindow }; } const video = document.querySelector('video.html5-main-video') || document.querySelector('video'); const captionWindow = document.querySelector('div.caption-window') || document.querySelector('.ytp-caption-window-container') || document.querySelector('.ytp-caption-window'); _cachedVideo = video; _cachedCaptionWindow = captionWindow; // Only cache when both nodes found; retry faster when missing _cacheValidUntil = (video && captionWindow) ? now + 2000 : 0; return { video, captionWindow }; } /* ---------- VIDEO WATCHDOG ---------- */ // Detect external unmutes (DAI, YTTV JS) and re-assert mute during adLock function attachVideoWatchdog(video) { if (!video) return; // volumechange listener — fires when video.volume or video.muted changes externally video.addEventListener('volumechange', () => { if (State.lastMuteState === true && Date.now() < State.adLockUntil) { if (!video.muted || video.volume > 0.02) { if (S.useTrueMute) video.muted = true; else video.volume = 0.01; if (S.passiveLogging) passiveEvent('volume_breach', { vol: Math.round(video.volume * 100) / 100, vmuted: video.muted, lockLeft: Math.ceil((State.adLockUntil - Date.now()) / 1000) }); } } }); // MutationObserver — detect DAI video element swaps if (NS.videoObserver) { try { NS.videoObserver.disconnect(); } catch {} } if (video.parentNode) { NS.videoObserver = new MutationObserver((mutations) => { for (const mut of mutations) { for (const node of mut.addedNodes) { if (node.nodeName === 'VIDEO') { _cachedVideo = null; _cacheValidUntil = 0; if (State.lastMuteState === true || Date.now() < State.adLockUntil) { if (S.useTrueMute) node.muted = true; else node.volume = 0.01; } log('New