// ==UserScript== // @name 全局网页音频响度平衡器 // @namespace http://tampermonkey.net/ // @version 6.4 // @description 在网页上对所有音频源进行响度规格化处理,支持跨域存储和本域名存储模式,可自动选择最佳音频源,支持播放补偿 EQ(10 段图示均衡器,含预设与信号链位置切换)。 // @icon data:image/svg+xml;charset=utf-8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiI+CiAgPGRlZnM+CiAgICA8IS0tIOiDjOaZr+a4kOWPmCAtLT4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iYmdHcmFkIiB4MT0iMCUiIHkxPSIwJSIgeDI9IjEwMCUiIHkyPSIxMDAlIj4KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzBmMTcyYSIgLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMWUyOTNiIiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICAgIDwhLS0g5Z2H6KGh5Zmo5riQ5Y+YIC0tPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJlcUdyYWQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMCUiIHkyPSIxMDAlIj4KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzNiODJmNiIgLz4KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjOGI1Y2Y2IiAvPgogICAgPC9saW5lYXJHcmFkaWVudD4KICA8L2RlZnM+CgogIDwhLS0gMS4g5Zu+5qCH6IOM5pmv77yI5ZyG6KeS5pa55b2i77yJIC0tPgogIDxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiByeD0iMjQiIGZpbGw9InVybCgjYmdHcmFkKSIgLz4KCiAgPCEtLSAyLiDlhajnkIPnvZHmoLzvvIjku6PooajigJzlhajlsYDigJ3nvZHpobXpn7PpopHlpITnkIbvvIkgLS0+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM0MTU1IiBzdHJva2Utd2lkdGg9IjEuNSIgb3BhY2l0eT0iMC42Ij4KICAgIDxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIHI9IjMyIiAvPgogICAgPGVsbGlwc2UgY3g9IjUwIiBjeT0iNTAiIHJ4PSIzMiIgcnk9IjEwIiAvPgogICAgPGxpbmUgeDE9IjE4IiB5MT0iNTAiIHgyPSI4MiIgeTI9IjUwIiAvPgogICAgPGxpbmUgeDE9IjUwIiB5MT0iMTgiIHgyPSI1MCIgeTI9IjgyIiAvPgogIDwvZz4KCiAgPCEtLSAzLiDlk43luqblvZLkuIDljJblpJrlsJbop5Lnrq3lpLTvvIjigJw+Pj7igJ3po47moLzvvIzmt6Hok53oibLvvIzljYrpgI/mmI7vvIkgLS0+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjBhNWZhIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgb3BhY2l0eT0iMC41Ij4KICAgIDwhLS0g6aG26YOo566t5aS077yI5ZCR5LiL5Y6L57yp77yJ77ya5LiJ5Liq5bm25o6S55qE5bCW6KeSIC0tPgogICAgPHBhdGggZD0iTSA0NCwxOCBMIDUwLDI0IEwgNTYsMTgiIC8+CiAgICA8cGF0aCBkPSJNIDQ0LDI0IEwgNTAsMzAgTCA1NiwyNCIgLz4KICAgIDxwYXRoIGQ9Ik0gNDQsMzAgTCA1MCwzNiBMIDU2LDMwIiAvPgogICAgPCEtLSDlupXpg6jnrq3lpLTvvIjlkJHkuIrmj5DljYfvvInvvJrkuInkuKrlubbmjpLnmoTlsJbop5LvvIznv7vovazmlrnlkJEgLS0+CiAgICA8cGF0aCBkPSJNIDQ0LDgyIEwgNTAsNzYgTCA1Niw4MiIgLz4KICAgIDxwYXRoIGQ9Ik0gNDQsNzYgTCA1MCw3MCBMIDU2LDc2IiAvPgogICAgPHBhdGggZD0iTSA0NCw3MCBMIDUwLDY0IEwgNTYsNzAiIC8+CiAgPC9nPgoKICA8IS0tIDQuIDEwIOauteWbvuekuuWdh+ihoeWZqO+8iOS4remXtOWHuemZt++8jOmBv+WFjemBruaMoeeureWktO+8iSAtLT4KICA8ZyBmaWxsPSJ1cmwoI2VxR3JhZCkiPgogICAgPCEtLSDnrKwx5q61IC0tPgogICAgPHJlY3QgeD0iMTAiIHk9IjQ1IiB3aWR0aD0iNCIgaGVpZ2h0PSIxNSIgcng9IjEiIC8+CiAgICA8IS0tIOesrDLmrrUgLS0+CiAgICA8cmVjdCB4PSIxOCIgeT0iMzUiIHdpZHRoPSI0IiBoZWlnaHQ9IjI1IiByeD0iMSIgLz4KICAgIDwhLS0g56ysM+autSAtLT4KICAgIDxyZWN0IHg9IjI2IiB5PSI1MCIgd2lkdGg9IjQiIGhlaWdodD0iMTAiIHJ4PSIxIiAvPgogICAgPCEtLSDnrKw05q61IC0tPgogICAgPHJlY3QgeD0iMzQiIHk9IjI1IiB3aWR0aD0iNCIgaGVpZ2h0PSIzNSIgcng9IjEiIC8+CiAgICA8IS0tIOesrDXmrrXvvIjkuK3pg6jpmY3kvY7vvIkgLS0+CiAgICA8cmVjdCB4PSI0MiIgeT0iNDAiIHdpZHRoPSI0IiBoZWlnaHQ9IjIwIiByeD0iMSIgLz4KICAgIDwhLS0g56ysNuaute+8iOS4reWkruWzsOWAvOmZjeS9ju+8jOmBv+WFjemBruaMoeeureWktO+8iSAtLT4KICAgIDxyZWN0IHg9IjUwIiB5PSI0NSIgd2lkdGg9IjQiIGhlaWdodD0iMTUiIHJ4PSIxIiBmaWxsPSIjMDZiNmQ0IiAvPgogICAgPCEtLSDnrKw35q6177yI5Lit6YOo6ZmN5L2O77yJIC0tPgogICAgPHJlY3QgeD0iNTgiIHk9IjQwIiB3aWR0aD0iNCIgaGVpZ2h0PSIyMCIgcng9IjEiIC8+CiAgICA8IS0tIOesrDjmrrUgLS0+CiAgICA8cmVjdCB4PSI2NiIgeT0iNDAiIHdpZHRoPSI0IiBoZWlnaHQ9IjIwIiByeD0iMSIgLz4KICAgIDwhLS0g56ysOeautSAtLT4KICAgIDxyZWN0IHg9Ijc0IiB5PSI0NSIgd2lkdGg9IjQiIGhlaWdodD0iMTUiIHJ4PSIxIiAvPgogICAgPCEtLSDnrKwxMOautSAtLT4KICAgIDxyZWN0IHg9IjgyIiB5PSI1NSIgd2lkdGg9IjQiIGhlaWdodD0iNSIgcng9IjEiIC8+CiAgPC9nPgo8L3N2Zz4= // @author niweizhuan // @license MIT // @match *://*/* // @run-at document-start // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // ==/UserScript== (function () { 'use strict'; // ---------- 持久化存储(支持「全局 GM_」和「本域名 localStorage」两种模式)---------- const STORAGE_KEY = 'universal_audio_normalizer_settings'; const POS_KEY = 'universal_audio_normalizer_position'; const MODE_KEY = 'universal_audio_normalizer_storage_mode'; // 存储模式(单独存放,便于引导读取) const MIGRATE_KEY = 'universal_audio_normalizer_migrated'; const NOTICE_KEY = 'universal_audio_normalizer_first_notice_shown'; const HAS_GM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; function lsGet(key) { try { const raw = localStorage.getItem(key); if (raw !== null) return JSON.parse(raw); } catch (e) { } return undefined; } function lsSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) { } } // 存储模式读写(始终双写,便于在两种模式下都能读到模式本身) function getStorageMode() { if (HAS_GM) { try { const v = GM_getValue(MODE_KEY); if (v) return v; } catch (e) { } } return lsGet(MODE_KEY) || 'global'; } function setStorageMode(mode) { if (HAS_GM) { try { GM_setValue(MODE_KEY, mode); } catch (e) { } } lsSet(MODE_KEY, mode); } // 全局模式:GM_ 优先、localStorage 兜底;双写保证一致性 function globalGet(key) { if (HAS_GM) { try { const v = GM_getValue(key); if (v !== undefined) return v; } catch (e) { } const lsVal = lsGet(key); if (lsVal !== undefined) return lsVal; } return lsGet(key); } function globalSet(key, val) { if (HAS_GM) { try { GM_setValue(key, val); } catch (e) { } } lsSet(key, val); // 备份 } // 本域名模式:仅 localStorage function localGet(key) { return lsGet(key); } function localSet(key, val) { lsSet(key, val); } // 当前模式的统一入口 function storeGet(key) { return getStorageMode() === 'local' ? localGet(key) : globalGet(key); } function storeSet(key, val) { if (getStorageMode() === 'local') localSet(key, val); else globalSet(key, val); } // 首次打开提示标记(全局一次性,跨域名共享) function hasShownFirstRunNotice() { return !!globalGet(NOTICE_KEY); } function markFirstRunNoticeShown() { globalSet(NOTICE_KEY, true); } // 首次打开时弹出一段安全提示 function showFirstRunNotice() { try { if (!document.body) return; const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed; inset:0; z-index:2147483647; background:rgba(0,0,0,0.55); display:flex; align-items:center; justify-content:center; font-family:Arial, "Microsoft YaHei", sans-serif;'; const box = document.createElement('div'); box.style.cssText = 'background:#1e1e1e; color:#fff; max-width:420px; width:86%; padding:18px 20px; border-radius:10px; box-shadow:0 8px 28px rgba(0,0,0,0.5); font-size:13px; line-height:1.7;'; const title = document.createElement('div'); title.style.cssText = 'font-weight:bold; font-size:15px; margin-bottom:10px;'; title.textContent = '欢迎使用响度平衡脚本工具'; const body = document.createElement('div'); body.style.cssText = 'margin-bottom:14px;'; body.innerHTML = '本脚本会持续监听在网页,对所有音频源并进行响度规格化处理,有效避免声音大小不一的问题,支持多种参数调节。

注意:
本脚本需要监听并拦截网页音频数据流以实现功能,可能会被部分网站的安全策略拦截。
若出现问题,可以关闭规格化或禁用该网站。'; const btn = document.createElement('button'); btn.textContent = '我知道了'; btn.style.cssText = 'padding:6px 18px; border:none; border-radius:5px; background:#fff; color:#000; cursor:pointer; font-size:13px;'; btn.addEventListener('click', () => { overlay.remove(); }); box.appendChild(title); box.appendChild(body); box.appendChild(btn); overlay.appendChild(box); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); document.body.appendChild(overlay); } catch (e) { } } // 一次性迁移:将旧 localStorage 数据导入 GM_ 存储 function migrateOldStorage() { if (!HAS_GM) return; try { if (GM_getValue(MIGRATE_KEY)) return; // 已迁移过 } catch (e) { } let migrated = false; const oldSettings = lsGet(STORAGE_KEY); if (oldSettings && typeof oldSettings === 'object') { try { GM_setValue(STORAGE_KEY, oldSettings); migrated = true; } catch (e) { } } const oldPos = lsGet(POS_KEY); if (oldPos && typeof oldPos === 'object') { try { GM_setValue(POS_KEY, oldPos); migrated = true; } catch (e) { } } try { GM_setValue(MIGRATE_KEY, true); } catch (e) { } if (migrated) console.log('[响度平衡] 已将旧设置迁移至跨域存储'); } migrateOldStorage(); // 当切换存储模式时,把当前数据复制到目标存储,避免空白 function copyStoreTo(srcMode, dstMode) { const settingsVal = (srcMode === 'local') ? localGet(STORAGE_KEY) : globalGet(STORAGE_KEY); const posVal = (srcMode === 'local') ? localGet(POS_KEY) : globalGet(POS_KEY); if (dstMode === 'local') { if (settingsVal !== undefined) localSet(STORAGE_KEY, settingsVal); if (posVal !== undefined) localSet(POS_KEY, posVal); } else { if (settingsVal !== undefined) globalSet(STORAGE_KEY, settingsVal); if (posVal !== undefined) globalSet(POS_KEY, posVal); } } function switchStorageMode(newMode) { const oldMode = getStorageMode(); if (oldMode === newMode) return; copyStoreTo(oldMode, newMode); setStorageMode(newMode); console.log('[响度平衡] 存储模式切换为: ' + (newMode === 'local' ? '本域名 (localStorage)' : '全局 (GM_)')); } function loadSettings() { // 全局与本地冲突时以全局为准:将本地设置覆盖为全局设置 if (HAS_GM) { try { const g = globalGet(STORAGE_KEY); if (g !== undefined) { const l = localGet(STORAGE_KEY); if (l === undefined || JSON.stringify(l) !== JSON.stringify(g)) { localSet(STORAGE_KEY, g); console.log('[响度平衡] 全局与本地设置不一致,已以全局为准覆盖本地'); } } } catch (e) { } } const v = storeGet(STORAGE_KEY); if (typeof v === 'string') { try { return JSON.parse(v); } catch (e) { } } return (v && typeof v === 'object') ? v : null; } function saveSettings(s) { storeSet(STORAGE_KEY, s); } function loadPosition() { const v = storeGet(POS_KEY); if (typeof v === 'string') { try { return JSON.parse(v); } catch (e) { } } return (v && typeof v === 'object') ? v : null; } function savePosition(p) { storeSet(POS_KEY, p); } // 保存主悬浮球位置时,保留 EQ 面板的 eqTop/eqLeft 字段 function saveWrapperPosition(top, left) { const cur = loadPosition() || {}; cur.top = top; cur.left = left; savePosition(cur); } const defaultSettings = { threshold: -24, knee: 30, ratio: 12, attack: 0.003, release: 0.25, targetDBFS: -23, maxGainDB: 12, gainUpStep: 6, gainDownStep: 50, noiseGate: -50, outputLimit: -0.3, normalizerEnabled: true, weightingMode: 'A', fftSmoothing: 0.6, windowCompensation: 3.0, minFreq: 20, maxFreq: 20000, weightingIntensity: 100, autoSelectSource: true, excludeList: [], // 排除列表(白名单):匹配的 URL 不启用响度处理 excludeMode: 'blocklist', // blocklist: 列表内的排除 / allowlist: 仅列表内的启用 storageMode: 'global', // global: 跨域 GM_ 共享 / local: 仅本域名 localStorage // 播放补偿 EQ(10 段图示均衡器) eqEnabled: false, // EQ 总开关 eqBands: [0,0,0,0,0,0,0,0,0,0], // 10 段增益(dB),对应 31/62/125/250/500/1k/2k/4k/8k/16k Hz eqPosition: 'output', // 'output'(limiter 后) / 'input'(analyser 前) eqPreset: 'flat', // 当前预设名,手动调滑块后自动改为 'custom' }; const saved = loadSettings() || {}; const settings = Object.assign({}, defaultSettings, saved); // storageMode 以独立键为准(优先级高于 settings 内的旧值) settings.storageMode = getStorageMode(); if (settings.gainUpStep < 0.5) settings.gainUpStep *= 5; if (settings.gainDownStep < 0.5) settings.gainDownStep *= 5; settings.gainUpStep = Math.min(30, Math.max(0.1, settings.gainUpStep || 6)); settings.gainDownStep = Math.min(100, Math.max(0.1, settings.gainDownStep || 50)); if (settings.noiseGate === undefined) settings.noiseGate = defaultSettings.noiseGate; if (settings.outputLimit === undefined) settings.outputLimit = defaultSettings.outputLimit; if (settings.weightingMode === undefined) settings.weightingMode = defaultSettings.weightingMode; if (settings.autoSelectSource === undefined) settings.autoSelectSource = true; if (!Array.isArray(settings.excludeList)) settings.excludeList = []; if (!settings.excludeMode || (settings.excludeMode !== 'blocklist' && settings.excludeMode !== 'allowlist')) { settings.excludeMode = 'blocklist'; } // EQ 字段防御性归一化 if (typeof settings.eqEnabled !== 'boolean') settings.eqEnabled = false; if (!Array.isArray(settings.eqBands)) settings.eqBands = defaultSettings.eqBands.slice(); if (settings.eqBands.length < 10) { while (settings.eqBands.length < 10) settings.eqBands.push(0); } else if (settings.eqBands.length > 10) { settings.eqBands = settings.eqBands.slice(0, 10); } settings.eqBands = settings.eqBands.map(v => (typeof v === 'number' && isFinite(v)) ? Math.max(-12, Math.min(12, v)) : 0); if (settings.eqPosition !== 'input' && settings.eqPosition !== 'output') settings.eqPosition = 'output'; if (typeof settings.eqPreset !== 'string' || !settings.eqPreset) settings.eqPreset = 'flat'; saveSettings(settings); // 一键恢复出厂:清空全局与本域名的设置、悬浮球位置及存储模式,重置为默认值并刷新页面 function factoryReset() { if (HAS_GM) { try { GM_deleteValue(STORAGE_KEY); } catch (e) { } try { GM_deleteValue(POS_KEY); } catch (e) { } try { GM_deleteValue(MODE_KEY); } catch (e) { } } try { localStorage.removeItem(STORAGE_KEY); } catch (e) { } try { localStorage.removeItem(POS_KEY); } catch (e) { } try { localStorage.removeItem(MODE_KEY); } catch (e) { } setStorageMode('global'); // 存储模式恢复为默认的全局跨域 storeSet(STORAGE_KEY, Object.assign({}, defaultSettings, { storageMode: 'global' })); console.log('[响度平衡] 已恢复设置'); location.reload(); } // ---------- URL 排除匹配(支持通配符 * 和正则 /pattern/ 两种写法)---------- let tmpExcludedOverride = false; // 本次会话临时覆盖(即使被排除也强行启用) function wildcardToRegex(pattern) { // 以 / 开头和结尾的视为正则 if (pattern.length >= 2 && pattern.startsWith('/') && pattern.endsWith('/')) { try { return new RegExp(pattern.slice(1, -1), 'i'); } catch (e) { return null; } } // 否则按通配符处理:* 匹配任意字符,? 匹配单个字符 let regexStr = '^'; for (let i = 0; i < pattern.length; i++) { const c = pattern[i]; switch (c) { case '*': regexStr += '.*'; break; case '?': regexStr += '.'; break; case '.': case '+': case '^': case '$': case '{': case '}': case '[': case ']': case '|': case '(': case ')': case '\\': regexStr += '\\' + c; break; default: regexStr += c; } } regexStr += '$'; try { return new RegExp(regexStr, 'i'); } catch (e) { return null; } } function matchUrlAgainstPattern(url, pattern) { if (!pattern) return false; const re = wildcardToRegex(pattern); if (!re) return false; // 匹配完整 URL,也尝试匹配 host + pathname 部分 try { if (re.test(url)) return true; const u = new URL(url); const hostPath = u.host + u.pathname; if (re.test(hostPath)) return true; if (re.test(u.host)) return true; } catch (e) { } return false; } function isSiteExcludedBySettings(url = location.href) { const list = settings.excludeList || []; const matched = list.some(p => matchUrlAgainstPattern(url, p)); if (settings.excludeMode === 'allowlist') { // 白名单模式:仅匹配到的才启用(没匹配到=被排除) return !matched; } else { // 黑名单模式:匹配到的排除 return matched; } } function isSiteCurrentlyExcluded() { if (tmpExcludedOverride) return false; return isSiteExcludedBySettings(); } // ---------- 播放补偿 EQ 常量 ---------- const EQ_FREQS = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; // ISO 1/1 倍频程 const EQ_Q = 1.41; // 1 倍频程带宽对应 Q const EQ_BAND_MIN = -12, EQ_BAND_MAX = 12; const EQ_PRESETS = { flat: { name: '平坦', gains: [0,0,0,0,0,0,0,0,0,0] }, bass: { name: '低音增强', gains: [8,6,4,2,0,0,0,0,0,0] }, vocal: { name: '人声突出', gains: [0,0,-2,3,5,4,3,0,-2,0] }, treble: { name: '高音清晰', gains: [0,0,0,0,0,2,4,6,8,6] }, basscut: { name: '低音削减', gains: [-6,-4,-2,0,0,0,0,0,0,0] }, loudness:{ name: '响度补偿', gains: [4,2,0,-2,-4,-2,0,2,4,4] }, custom: { name: '自定义', gains: null }, // gains=null 表示沿用当前 eqBands }; // ---------- 全局状态 ---------- let audioCtx = null, sourceNode = null, analyser = null, compressor = null, gainNode = null, limiter = null; let normalizerEnabled = settings.normalizerEnabled; let animationFrameId = null, currentMedia = null; // 是否应接管音频:规格化开启 且 当前站点未被排除 function isNormalizerActive() { return normalizerEnabled && !isSiteCurrentlyExcluded(); } let uiCreated = false; let agcParams = { currentGainDB: 0 }; // 等响度加权缓存 let aWeightCache = null; let cWeightCache = null; let freqDomainData = null; // 音频源管理 let knownMediaElements = new Set(); let sourceListPanel = null, sourceListVisible = false, sourceBtn = null; // 排除列表管理 UI 引用 let excludeListPanel = null, excludeBtn = null, excludeListVisible = false; // EQ 状态与 UI 引用 let eqFilters = []; // 10 个 BiquadFilterNode let eqPanel = null, eqPanelVisible = false, eqBtn = null; // UI 元素引用 let wrapper, collapsedBar, expandedPanel, miniMeterFill, bigMeterFill; let miniLabel, inputLabel, reductionLabel, gainLabel, toggleBtn, sourceLabel, excludedLabel; let normalizeHint = null; let settingsBtn, settingsPanel, settingsVisible = false; let sliderRefs = {}; let isDragging = false; // 健康检查 let healthCheckTimer = null; let silentFrames = 0; const HEALTH_CHECK_INTERVAL = 2000; const SILENT_THRESHOLD = 5; // 全局 MutationObserver let globalObserver = null; // ---------- 离屏媒体元素捕获(网页直播的 RTC SDK 常在不插入 DOM 的情况下播放音频)---------- const offDomMediaElements = new Set(); // 未插入 DOM 但可能正在播放的媒体元素 let rescanTimer = null; // ---------- 静音轮换(当前源长时间无声时自动尝试其他源)---------- let silentSince = 0; // 当前源开始持续无声的时间戳(0=有声) let lastSilentRotateAt = 0; let lastSilentLogAt = 0; const SILENT_ROTATE_MIN_MS = 8000; // 持续无声多久后才切换源 const SILENT_ROTATE_COOLDOWN = 8000; // 两次自动切换源的最小间隔 const SILENT_BLACKLIST_MS = 60000; // 被判定为无声的源临时拉黑时长 const silentSourceBlacklist = new Map(); // media -> 拉黑截止时间戳 // ---------- MediaStreamSource 回退时的元素静音记账 ---------- let mutedFallbackEl = null, mutedFallbackPrevMuted = false; function onMutedFallbackVolumeChange() { // 绑定期间保持元素静音,避免「元素直放 + 本脚本处理」双重声音 if (mutedFallbackEl && currentMedia === mutedFallbackEl) { try { mutedFallbackEl.muted = true; } catch (e) { } } } function clearMutedFallback() { if (!mutedFallbackEl) return; try { mutedFallbackEl.removeEventListener('volumechange', onMutedFallbackVolumeChange); } catch (e) { } try { mutedFallbackEl.muted = mutedFallbackPrevMuted; } catch (e) { } mutedFallbackEl = null; } // 当前绑定是否仍在正常播放(离屏元素同样有效) function isCurrentMediaUsable() { if (!currentMedia) return false; if (currentMedia.paused) return false; if (currentMedia.muted && mutedFallbackEl !== currentMedia) return false; return true; } function registerOffDomMedia(el) { if (!isNormalizerActive()) return; try { if (!el || !(el instanceof HTMLMediaElement)) return; if (el.tagName !== 'VIDEO' && el.tagName !== 'AUDIO') return; if (document.body && document.body.contains(el)) return; if (!offDomMediaElements.has(el)) { offDomMediaElements.add(el); scheduleMediaRescan(); } } catch (e) { } } function scheduleMediaRescan() { if (rescanTimer) return; rescanTimer = setTimeout(() => { rescanTimer = null; try { refreshKnownMedia(); if (sourceListVisible) renderSourceList(); if (isSiteCurrentlyExcluded()) return; // 自动模式下:当前源不可用或持续无声时,尝试切换到新发现的源 if (settings.autoSelectSource && (!isCurrentMediaUsable() || silentFrames >= SILENT_THRESHOLD)) { const best = selectBestMedia(); if (best && best !== currentMedia && !best.paused) { if (!uiCreated) createUI(); bindToMedia(best); } } } catch (e) { } }, 300); } // 静音轮换:切换到其他正在播放且未被拉黑的源 function rotateSourceOnSilence() { const now = Date.now(); if (now - lastSilentRotateAt < SILENT_ROTATE_COOLDOWN) return; lastSilentRotateAt = now; for (const [m, until] of Array.from(silentSourceBlacklist)) { if (until <= now) silentSourceBlacklist.delete(m); } const candidates = getAllMediaElements().filter(m => { if (m === currentMedia) return false; if (m.paused || m.readyState < 2 || m.muted) return false; const until = silentSourceBlacklist.get(m); return !(until && until > now); }); if (currentMedia) silentSourceBlacklist.set(currentMedia, now + SILENT_BLACKLIST_MS); if (candidates.length === 0) return; candidates.sort((a, b) => getMediaScore(b) - getMediaScore(a)); const target = candidates[0]; console.log('[响度平衡] 当前源持续无声,自动切换到: ' + getMediaLabel(target, 0)); if (!uiCreated) createUI(); bindToMedia(target); } // 挂钩浏览器 API,捕获「不插入 DOM」的媒体元素(配合 @run-at document-start) let mediaPatchesInstalled = false; function installMediaPatches() { if (mediaPatchesInstalled) return; mediaPatchesInstalled = true; // document.createElement('video'/'audio') try { const origCreateElement = document.createElement.bind(document); document.createElement = function () { const el = origCreateElement.apply(document, arguments); try { const t = String(arguments[0]).toLowerCase(); if (t === 'video' || t === 'audio') registerOffDomMedia(el); } catch (e) { } return el; }; } catch (e) { } // new Audio() try { const OrigAudio = window.Audio; if (typeof OrigAudio === 'function') { const PatchedAudio = function (...args) { const el = new OrigAudio(...args); registerOffDomMedia(el); return el; }; try { PatchedAudio.prototype = OrigAudio.prototype; } catch (e) { } try { window.Audio = PatchedAudio; } catch (e) { } } } catch (e) { } // play():任何媒体开始播放都会经过这里,兜底捕获离屏元素 try { const origPlay = HTMLMediaElement.prototype.play; HTMLMediaElement.prototype.play = function () { registerOffDomMedia(this); return origPlay.apply(this, arguments); }; } catch (e) { } // srcObject 赋值:RTC 播放的特征 try { const desc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject'); if (desc && desc.set) { const origSet = desc.set; const origGet = desc.get || function () { return null; }; Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { get: function () { return origGet.call(this); }, set: function (v) { registerOffDomMedia(this); return origSet.call(this, v); }, configurable: true, }); } } catch (e) { } } function updateToggleStyle() { if (!toggleBtn) return; if (normalizerEnabled) { toggleBtn.style.background = '#ffffff'; toggleBtn.style.color = '#000'; toggleBtn.style.borderColor = '#ffffff'; } else { toggleBtn.style.background = '#808080'; toggleBtn.style.color = '#fff'; toggleBtn.style.borderColor = '#808080'; } } function updateSettingsBtnStyle() { if (!settingsBtn) return; if (settingsVisible) { settingsBtn.style.background = '#ffffff'; settingsBtn.style.color = '#000'; settingsBtn.style.borderColor = '#ffffff'; } else { settingsBtn.style.background = '#808080'; settingsBtn.style.color = '#fff'; settingsBtn.style.borderColor = '#808080'; } } function updateSourceBtnStyle() { if (!sourceBtn) return; const count = knownMediaElements.size; sourceBtn.title = `音频源 (${count})`; if (sourceListVisible) { sourceBtn.style.background = '#ffffff'; sourceBtn.style.color = '#000'; sourceBtn.style.borderColor = '#ffffff'; } else { sourceBtn.style.background = '#808080'; sourceBtn.style.color = '#fff'; sourceBtn.style.borderColor = '#808080'; } } function updateEQBtnStyle() { if (!eqBtn) return; eqBtn.title = '播放补偿 EQ' + (settings.eqEnabled ? '(已启用)' : '(未启用)'); if (eqPanelVisible) { eqBtn.style.background = '#ffffff'; eqBtn.style.color = '#000'; eqBtn.style.borderColor = '#ffffff'; } else if (settings.eqEnabled) { eqBtn.style.background = '#4caf50'; eqBtn.style.color = '#fff'; eqBtn.style.borderColor = '#4caf50'; } else { eqBtn.style.background = '#808080'; eqBtn.style.color = '#fff'; eqBtn.style.borderColor = '#808080'; } } // 开启 / 关闭规格化(统一入口):关闭时完全停止接管与监听 function setNormalizerEnabled(on) { normalizerEnabled = on; settings.normalizerEnabled = on; saveSettings(settings); if (on) { agcParams.currentGainDB = 0; if (isSiteCurrentlyExcluded()) return; installMediaPatches(); startGlobalObserver(); startUrlObserver(); startHealthCheck(); initialScanAndBind(); } else { destroyAudioGraph(); currentMedia = null; if (globalObserver) { try { globalObserver.disconnect(); } catch (e) { } globalObserver = null; } stopUrlObserver(); if (healthCheckTimer) { clearInterval(healthCheckTimer); healthCheckTimer = null; } } } // ---------- 音频图 ---------- // 创建 10 段 EQ 链(BiquadFilterNode 串联),增益取自 settings.eqBands function createEQChain(ctx) { eqFilters = EQ_FREQS.map((freq, i) => { const f = ctx.createBiquadFilter(); f.type = 'peaking'; f.frequency.value = freq; f.Q.value = EQ_Q; f.gain.value = (settings.eqBands && typeof settings.eqBands[i] === 'number') ? settings.eqBands[i] : 0; return f; }); } // 把 settings.eqBands 同步到已有 EQ 滤波器 function applyEQGains() { eqFilters.forEach((f, i) => { if (f && typeof settings.eqBands[i] === 'number') f.gain.value = settings.eqBands[i]; }); } // 统一路由:依据 normalizerEnabled / eqEnabled / eqPosition 重建信号链 function rebuildRouting() { if (!audioCtx || !sourceNode || !analyser) return; // 1. 全量断开 try { sourceNode.disconnect(); } catch (e) { } try { analyser.disconnect(); } catch (e) { } if (compressor) { try { compressor.disconnect(); } catch (e) { } } if (gainNode) { try { gainNode.disconnect(); } catch (e) { } } if (limiter) { try { limiter.disconnect(); } catch (e) { } } eqFilters.forEach(f => { try { f.disconnect(); } catch (e) { } }); // 2. 输入段:EQ 位于 analyser 前 const inputEQOn = settings.eqEnabled && settings.eqPosition === 'input' && eqFilters.length === 10; if (inputEQOn) { sourceNode.connect(eqFilters[0]); for (let i = 0; i < 9; i++) eqFilters[i].connect(eqFilters[i + 1]); eqFilters[9].connect(analyser); } else { sourceNode.connect(analyser); } // 3. 中段:规格化开启走压缩链,关闭则 analyser 直通 let midOut; if (normalizerEnabled && compressor && gainNode && limiter) { analyser.connect(compressor); compressor.connect(gainNode); gainNode.connect(limiter); midOut = limiter; } else { midOut = analyser; } // 4. 输出段:EQ 位于 destination 前 const outEQOn = settings.eqEnabled && settings.eqPosition === 'output' && eqFilters.length === 10; if (outEQOn) { midOut.connect(eqFilters[0]); for (let i = 0; i < 9; i++) eqFilters[i].connect(eqFilters[i + 1]); eqFilters[9].connect(audioCtx.destination); } else { midOut.connect(audioCtx.destination); } } function createAudioGraph(media) { if (!media) return false; try { if (audioCtx) { try { audioCtx.close(); } catch (e) { } } audioCtx = new (window.AudioContext || window.webkitAudioContext)(); clearMutedFallback(); try { sourceNode = audioCtx.createMediaElementSource(media); } catch (e) { // 元素已被站点自身的音频图占用(如抖音直播内置的 online_loudnorm 处理链)。 // 回退方案:直接从元素的 MediaStream 取源,并静音元素避免「站点直放 + 本脚本」双重声音。 if (media.srcObject && typeof audioCtx.createMediaStreamSource === 'function') { sourceNode = audioCtx.createMediaStreamSource(media.srcObject); mutedFallbackEl = media; mutedFallbackPrevMuted = media.muted; try { media.muted = true; } catch (e2) { } media.addEventListener('volumechange', onMutedFallbackVolumeChange); console.log('[响度平衡] 媒体元素已被站点音频图占用,改用 MediaStreamSource 接管'); } else { throw e; } } analyser = audioCtx.createAnalyser(); analyser.fftSize = 1024; analyser.smoothingTimeConstant = settings.fftSmoothing; freqDomainData = new Uint8Array(analyser.frequencyBinCount); const tables = precomputeWeightTables(audioCtx.sampleRate, analyser.frequencyBinCount); aWeightCache = tables.aTable; cWeightCache = tables.cTable; compressor = audioCtx.createDynamicsCompressor(); compressor.threshold.value = settings.threshold; compressor.knee.value = settings.knee; compressor.ratio.value = settings.ratio; compressor.attack.value = settings.attack; compressor.release.value = settings.release; gainNode = audioCtx.createGain(); gainNode.gain.value = 1; limiter = audioCtx.createDynamicsCompressor(); limiter.threshold.value = settings.outputLimit; limiter.knee.value = 0; limiter.ratio.value = 20; limiter.attack.value = 0.001; limiter.release.value = 0.05; createEQChain(audioCtx); rebuildRouting(); agcParams.currentGainDB = 0; gainNode.gain.value = 1; if (audioCtx.state === 'suspended') { audioCtx.resume(); } silentFrames = 0; silentSince = 0; return true; } catch (e) { console.warn('[响度平衡] 创建音频图失败', e); return false; } } function destroyAudioGraph() { if (animationFrameId) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } clearMutedFallback(); if (sourceNode) { try { sourceNode.disconnect(); } catch (e) { } sourceNode = null; } if (compressor) { try { compressor.disconnect(); } catch (e) { } compressor = null; } if (gainNode) { try { gainNode.disconnect(); } catch (e) { } gainNode = null; } if (limiter) { try { limiter.disconnect(); } catch (e) { } limiter = null; } eqFilters.forEach(f => { try { f.disconnect(); } catch (e) { } }); eqFilters = []; if (analyser) { try { analyser.disconnect(); } catch (e) { } analyser = null; } if (audioCtx) { try { audioCtx.close(); } catch (e) { } audioCtx = null; } aWeightCache = null; cWeightCache = null; freqDomainData = null; } // ---------- 等响度加权 ---------- function precomputeWeightTables(sampleRate, binCount) { const aTable = new Float32Array(binCount); const cTable = new Float32Array(binCount); const nyquist = sampleRate / 2; const f1_2 = Math.pow(20.6, 2); const f2_2 = Math.pow(107.7, 2); const f3_2 = Math.pow(737.9, 2); const f4_2 = Math.pow(12200, 2); const f1k = 1000; const f1k2 = f1k * f1k; const aNormDen = (f1k2 + f1_2) * Math.sqrt((f1k2 + f2_2) * (f1k2 + f3_2)) * (f1k2 + f4_2); const aNormNum = f4_2 * Math.pow(f1k, 4); const aNorm = aNormDen / aNormNum; const cNormDen = (f1k2 + f1_2) * (f1k2 + f4_2); const cNormNum = f4_2 * f1k2; const cNorm = cNormDen / cNormNum; for (let i = 0; i < binCount; i++) { const f = (i / binCount) * nyquist; const f2 = f * f; if (f <= 0.5) { aTable[i] = 1e-10; cTable[i] = 1e-10; continue; } const aDen = (f2 + f1_2) * Math.sqrt((f2 + f2_2) * (f2 + f3_2)) * (f2 + f4_2); const aNum = f4_2 * f2 * f2; aTable[i] = (aNum / aDen) * aNorm; const cDen = (f2 + f1_2) * (f2 + f4_2); const cNum = f4_2 * f2; cTable[i] = (cNum / cDen) * cNorm; } return { aTable, cTable }; } function computeWeightedDBFS(weightMode) { if (!analyser || !aWeightCache || !cWeightCache || weightMode === 'Off') { return null; } analyser.getByteFrequencyData(freqDomainData); const sr = audioCtx.sampleRate; const binWidth = sr / analyser.fftSize; const minBin = Math.max(1, Math.round(settings.minFreq / binWidth)); const maxBin = Math.min(freqDomainData.length - 1, Math.round(settings.maxFreq / binWidth)); const cache = weightMode === 'A' ? aWeightCache : cWeightCache; let weightedSum = 0, linearSum = 0, count = 0; for (let i = minBin; i <= maxBin; i++) { const mag = freqDomainData[i] / 255; const w = cache[i]; weightedSum += (mag * mag) * w * w; linearSum += mag * mag; count++; } if (count === 0) return null; const weightedRMS = Math.sqrt(weightedSum / count); const linearRMS = Math.sqrt(linearSum / count); const intensity = Math.max(0, Math.min(1, settings.weightingIntensity / 100)); const blendRMS = linearRMS * (1 - intensity) + weightedRMS * intensity; return 20 * Math.log10(blendRMS + 1e-10) + settings.windowCompensation; } function getWeightSuffix(mode) { return mode === 'A' ? ' dBA' : mode === 'C' ? ' dBC' : ' dBFS'; } // ---------- 主循环 ---------- const timeData = new Uint8Array(256); let lastUiUpdate = 0; const UI_UPDATE_INTERVAL = 200; const METER_MIN_DB = -60; function getMeterColor(db) { if (db > -6) return '#f44336'; if (db > -20) return '#ff9800'; if (db > -40) return '#4caf50'; return '#2196f3'; } function updateHorizontalMeter(fill, db) { if (!fill) return; const percent = Math.max(0, Math.min(100, (db - METER_MIN_DB) / (-METER_MIN_DB) * 100)); fill.style.width = percent + '%'; fill.style.background = getMeterColor(db); } function updateVolume(timestamp) { if (!analyser || !audioCtx) { animationFrameId = requestAnimationFrame(updateVolume); return; } analyser.getByteTimeDomainData(timeData); let sumSquares = 0; for (let i = 0; i < timeData.length; i++) { const sample = (timeData[i] - 128) / 128; sumSquares += sample * sample; } const rms = Math.sqrt(sumSquares / timeData.length); const linearDBFS = 20 * Math.log10(rms + 1e-10); if (linearDBFS < -190) { silentFrames++; if (!silentSince) silentSince = Date.now(); } else { silentFrames = 0; silentSince = 0; if (currentMedia) silentSourceBlacklist.delete(currentMedia); } if (timestamp - lastUiUpdate >= UI_UPDATE_INTERVAL) { const weightedDBFS = computeWeightedDBFS(settings.weightingMode); const effectiveDBFS = (weightedDBFS !== null) ? weightedDBFS : linearDBFS; const suffix = getWeightSuffix(settings.weightingMode); if (miniMeterFill) updateHorizontalMeter(miniMeterFill, effectiveDBFS); if (bigMeterFill) updateHorizontalMeter(bigMeterFill, effectiveDBFS); if (miniLabel) miniLabel.textContent = `${effectiveDBFS.toFixed(1)}${suffix}`; if (inputLabel) inputLabel.textContent = `输入 ${effectiveDBFS.toFixed(1)}${suffix}`; if (normalizerEnabled && compressor && gainNode) { const reduction = compressor.reduction || 0; if (reductionLabel) reductionLabel.textContent = `衰减 ${reduction.toFixed(1)} dBFS`; let desiredGain = 0; if (linearDBFS > agcParams.noiseGate) { desiredGain = agcParams.targetDBFS - effectiveDBFS; desiredGain = Math.max(0, Math.min(agcParams.maxGainDB, desiredGain)); } const upStepPerTick = agcParams.gainUpStep * 0.2; const downStepPerTick = agcParams.gainDownStep * 0.2; if (desiredGain < agcParams.currentGainDB) { agcParams.currentGainDB = Math.max(desiredGain, agcParams.currentGainDB - downStepPerTick); } else { agcParams.currentGainDB = Math.min(desiredGain, agcParams.currentGainDB + upStepPerTick); } gainNode.gain.value = Math.pow(10, agcParams.currentGainDB / 20); if (gainLabel) gainLabel.textContent = `增益 +${agcParams.currentGainDB.toFixed(1)} dBFS`; } else { if (reductionLabel) reductionLabel.textContent = '衰减 关闭'; if (gainLabel) gainLabel.textContent = '增益 关闭'; } lastUiUpdate = timestamp; } animationFrameId = requestAnimationFrame(updateVolume); } // ---------- 音频元素检测与选择(通用版,支持 video + audio)---------- function isMediaVisible(media) { if (!media) return false; if (media.tagName === 'AUDIO') return true; // audio 元素通常不可见 if (media.offsetParent === null) { // 考虑可能的全屏或特殊布局 const style = getComputedStyle(media); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { // video 不可见,但仍然可能在播放音频,仍需考虑 return media.readyState >= 2 && !media.paused && media.muted === false; } } const style = getComputedStyle(media); if (style.display === 'none' || style.visibility === 'hidden') { if (!(media.readyState >= 2 && !media.paused && media.muted === false)) return false; } const rect = media.getBoundingClientRect(); if (rect.width < 20 && rect.height < 20) { if (!(media.readyState >= 2 && !media.paused && media.muted === false)) return false; } return true; } function getMediaScore(media) { // 综合打分:正在播放 > 有src > 可见 > 面积大 > readyState高 let score = 0; if (!media.paused && media.readyState >= 2 && media.muted === false) score += 1000; // WebRTC / MediaStream 播放源(网页直播的音频常走这条路,且往往才是真正发声的元素) if (media.srcObject && !media.paused && media.muted === false) score += 800; if (media.src || (media.currentSrc && media.currentSrc.length > 0)) score += 500; if (isMediaVisible(media)) score += 200; if (media.tagName === 'VIDEO') { const area = (media.videoWidth * media.videoHeight) || (media.clientWidth * media.clientHeight); score += Math.min(area / 1000, 100); } score += media.readyState * 10; return score; } function getAllMediaElements() { const set = new Set(document.querySelectorAll('video, audio')); // 合并未插入 DOM 的离屏媒体元素(RTC 直播 SDK 播放音频时常不挂载到页面) for (const el of offDomMediaElements) set.add(el); return Array.from(set); } // ---------- 媒体状态变化监听(用于自动切换源)---------- // 当媒体播放/暂停状态变化时,自动评估并切换到最佳源 const stateBoundElements = new WeakSet(); let stateChangeDebounceTimer = null; function onMediaPlay(evt) { if (!isNormalizerActive()) return; if (!settings.autoSelectSource) return; const media = evt.currentTarget; if (currentMedia === media) return; // 已绑定到此媒体 if (media.muted) return; // 静音媒体不作为切换目标 if (stateChangeDebounceTimer) clearTimeout(stateChangeDebounceTimer); stateChangeDebounceTimer = setTimeout(() => { stateChangeDebounceTimer = null; // 再次检查,避免在 debounce 期间状态变化 if (currentMedia !== media && !media.paused && !media.muted && settings.autoSelectSource) { // 仅当当前绑定不可用,或新源是 MediaStream(直播 RTC 音频,往往才是真正发声的元素)时才切换,避免频繁抢绑 if (!isCurrentMediaUsable() || media.srcObject) { if (!uiCreated) createUI(); bindToMedia(media); } } }, 150); } function onMediaPause(evt) { if (!isNormalizerActive()) return; if (!settings.autoSelectSource) return; const media = evt.currentTarget; if (currentMedia !== media) return; // 仅处理当前绑定的媒体 if (stateChangeDebounceTimer) clearTimeout(stateChangeDebounceTimer); stateChangeDebounceTimer = setTimeout(() => { stateChangeDebounceTimer = null; if (currentMedia !== media || !media.paused) return; // 当前媒体被暂停,寻找其他正在播放的源 const all = getAllMediaElements(); let best = null, bestScore = -1; for (const m of all) { if (m === media) continue; if (m.paused || m.readyState < 2 || m.muted) continue; const s = getMediaScore(m); if (s > bestScore) { bestScore = s; best = m; } } if (best) { if (!uiCreated) createUI(); bindToMedia(best); } }, 150); } function attachStateListeners(media) { if (!isNormalizerActive()) return; if (!media || stateBoundElements.has(media)) return; stateBoundElements.add(media); media.addEventListener('play', onMediaPlay); media.addEventListener('playing', onMediaPlay); media.addEventListener('pause', onMediaPause); // 直播常见:先以静音自动播放,再由用户手动开声 → volumechange media.addEventListener('volumechange', onMediaPlay); } function refreshKnownMedia() { const all = getAllMediaElements(); const newOnes = []; for (const m of all) { if (!knownMediaElements.has(m)) { knownMediaElements.add(m); newOnes.push(m); } // 给元素附加 play/pause 状态监听器(用于自动切换源) attachStateListeners(m); } // 清理:已挂载到 DOM 的离屏登记项可直接由 DOM 扫描发现,移除登记 for (const el of Array.from(offDomMediaElements)) { if (document.body && document.body.contains(el)) offDomMediaElements.delete(el); } // 清理已被移出 DOM 的元素:仍在播放或有活动流的收养为离屏元素,其余丢弃 for (const m of Array.from(knownMediaElements)) { if (document.body && document.body.contains(m)) continue; const st = m.srcObject; const playing = !m.paused && m.readyState >= 2; const streamActive = !!(st && st.active === true); if (playing || streamActive) { if (!offDomMediaElements.has(m)) offDomMediaElements.add(m); // 移出 DOM 但仍有效 } else { offDomMediaElements.delete(m); knownMediaElements.delete(m); } } return newOnes; } function selectBestMedia() { const all = getAllMediaElements(); if (all.length === 0) return null; // 按打分排序 all.sort((a, b) => getMediaScore(b) - getMediaScore(a)); return all[0]; } function getMediaLabel(media, idx) { const tag = media.tagName.toLowerCase(); let src = media.currentSrc || media.src || (media.srcObject ? '(MediaStream)' : ''); try { if (src.length > 50) src = src.substring(0, 20) + '...' + src.substring(src.length - 20); } catch (e) { } const state = !media.paused && media.readyState >= 2 ? '▶' : media.paused ? '⏸' : '?'; const muted = media.muted ? '🔇' : '🔊'; const desc = src || (media.id ? ('#' + media.id) : (media.className ? '.' + media.className.split(' ')[0] : '<无标识>')); return `[${idx + 1}] ${tag} ${state}${muted} ${desc}`; } // ---------- 绑定逻辑 ---------- function bindToMedia(media, manual = false) { if (!isNormalizerActive()) return false; if (!media) return false; if (currentMedia === media) return false; destroyAudioGraph(); currentMedia = null; const success = createAudioGraph(media); if (success) { currentMedia = media; if (!animationFrameId) { animationFrameId = requestAnimationFrame(updateVolume); } if (toggleBtn) { toggleBtn.textContent = normalizerEnabled ? '规格化 开' : '规格化 关'; updateToggleStyle(); } if (sourceLabel) { const tag = media.tagName.toLowerCase(); sourceLabel.textContent = `源: ${tag}${media.muted ? '(静音)' : ''}`; } return true; } return false; } function tryRebind() { const best = selectBestMedia(); if (best && best !== currentMedia) { if (!uiCreated) createUI(); bindToMedia(best); return true; } return false; } // ---------- 音频源列表渲染 ---------- function renderSourceList() { if (!sourceListPanel) return; // 清空 while (sourceListPanel.firstChild) { sourceListPanel.removeChild(sourceListPanel.firstChild); } const all = Array.from(knownMediaElements); if (all.length === 0) { const empty = document.createElement('div'); empty.style.cssText = 'padding:8px; text-align:center; opacity:0.7; font-size:12px;'; empty.textContent = '未检测到音频元素'; sourceListPanel.appendChild(empty); return; } // 标题栏 const header = document.createElement('div'); header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding:4px 8px; border-bottom:1px solid rgba(255,255,255,0.2); font-weight:bold; font-size:12px;'; header.textContent = `音频源列表 (${all.length})`; sourceListPanel.appendChild(header); const autoRow = document.createElement('div'); autoRow.style.cssText = 'display:flex; align-items:center; gap:6px; padding:6px 8px; font-size:11px; border-bottom:1px solid rgba(255,255,255,0.1);'; const autoCb = document.createElement('input'); autoCb.type = 'checkbox'; autoCb.checked = settings.autoSelectSource; autoCb.style.margin = '0'; const autoLabel = document.createElement('span'); autoLabel.textContent = '自动选择最佳音频源'; autoRow.appendChild(autoCb); autoRow.appendChild(autoLabel); autoCb.addEventListener('change', () => { settings.autoSelectSource = autoCb.checked; saveSettings(settings); if (autoCb.checked) tryRebind(); }); sourceListPanel.appendChild(autoRow); all.forEach((media, idx) => { const row = document.createElement('div'); row.style.cssText = 'display:flex; align-items:center; gap:6px; padding:6px 8px; cursor:pointer; border-radius:4px; font-size:11px; line-height:1.4;'; if (media === currentMedia) { row.style.background = 'rgba(255,255,255,0.2)'; row.style.fontWeight = 'bold'; } row.addEventListener('mouseenter', () => { row.style.background = media === currentMedia ? 'rgba(255,255,255,0.25)' : 'rgba(255,255,255,0.1)'; }); row.addEventListener('mouseleave', () => { row.style.background = media === currentMedia ? 'rgba(255,255,255,0.2)' : 'transparent'; }); const radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'audio-src-select'; radio.checked = media === currentMedia; radio.style.margin = '0'; radio.addEventListener('change', () => { bindToMedia(media, true); renderSourceList(); }); const label = document.createElement('span'); label.style.cssText = 'flex:1; word-break:break-all;'; label.textContent = getMediaLabel(media, idx); label.title = (media.currentSrc || media.src || ''); const bindBtn = document.createElement('button'); bindBtn.textContent = media === currentMedia ? '✓' : '绑定'; bindBtn.style.cssText = 'font-size:10px; padding:1px 6px; border-radius:3px; border:1px solid #888; background:rgba(255,255,255,0.1); color:#fff; cursor:pointer;'; bindBtn.addEventListener('click', (e) => { e.stopPropagation(); bindToMedia(media, true); renderSourceList(); }); row.appendChild(radio); row.appendChild(label); row.appendChild(bindBtn); row.addEventListener('click', () => { bindToMedia(media, true); renderSourceList(); }); sourceListPanel.appendChild(row); }); // 刷新按钮 const refreshRow = document.createElement('div'); refreshRow.style.cssText = 'display:flex; justify-content:center; padding:6px; border-top:1px solid rgba(255,255,255,0.1);'; const refreshBtn = document.createElement('button'); refreshBtn.textContent = '🔄 刷新音频源'; refreshBtn.style.cssText = 'font-size:11px; padding:3px 10px; border-radius:4px; border:1px solid #888; background:rgba(255,255,255,0.1); color:#fff; cursor:pointer;'; refreshBtn.addEventListener('click', () => { refreshKnownMedia(); renderSourceList(); }); refreshRow.appendChild(refreshBtn); sourceListPanel.appendChild(refreshRow); } // ---------- 通用等待与监听策略 ---------- function bindWhenReady(media) { if (!media) return; // 已经在播放就立即绑定 if (!media.paused && media.readyState >= 2) { bindToMedia(media); return; } const onReady = () => { media.removeEventListener('playing', onReady); media.removeEventListener('play', onReady); media.removeEventListener('loadeddata', onReady); if (settings.autoSelectSource) bindToMedia(media); }; media.addEventListener('playing', onReady, { once: true }); media.addEventListener('play', onReady, { once: true }); media.addEventListener('loadeddata', onReady, { once: true }); setTimeout(() => { media.removeEventListener('playing', onReady); media.removeEventListener('play', onReady); media.removeEventListener('loadeddata', onReady); if (!currentMedia && media.readyState >= 2 && settings.autoSelectSource) { bindToMedia(media); } }, 45000); } function initialScanAndBind() { // 被排除的站点不做音频绑定 const excluded = isSiteCurrentlyExcluded(); refreshKnownMedia(); if (!uiCreated) createUI(); // 无论排除与否都创建UI,便于用户操作 // 规格化关闭或站点被排除时,完全不接管音频、不插入任何监听 if (!isNormalizerActive()) { if (miniLabel) miniLabel.textContent = excluded ? '已排除' : '规格化关'; if (miniMeterFill) { miniMeterFill.style.width = '0%'; miniMeterFill.style.background = '#9e9e9e'; } if (typeof window.__syncExcludedIndicator === 'function') window.__syncExcludedIndicator(); return; } if (knownMediaElements.size > 0) { if (settings.autoSelectSource) { const best = selectBestMedia(); if (best) bindWhenReady(best); } } else { if (miniLabel) miniLabel.textContent = '待机中'; } if (typeof window.__syncExcludedIndicator === 'function') window.__syncExcludedIndicator(); } function startGlobalObserver() { if (globalObserver || !normalizerEnabled) return; globalObserver = new MutationObserver((mutations) => { let mediaChanged = false; for (const m of mutations) { if (m.type === 'childList') { for (const node of m.addedNodes) { if (node.nodeName === 'VIDEO' || node.nodeName === 'AUDIO' || (node.querySelector && (node.querySelector('video') || node.querySelector('audio')))) { mediaChanged = true; break; } } if (!mediaChanged) { for (const node of m.removedNodes) { if (node.nodeName === 'VIDEO' || node.nodeName === 'AUDIO' || (node.querySelector && (node.querySelector('video') || node.querySelector('audio')))) { mediaChanged = true; break; } } } } else if (m.type === 'attributes' && m.attributeName === 'src') { if (m.target.nodeName === 'VIDEO' || m.target.nodeName === 'AUDIO') { mediaChanged = true; } } if (mediaChanged) break; } if (mediaChanged) { refreshKnownMedia(); // 如果源列表面板是打开的,重绘 if (sourceListVisible) renderSourceList(); // 自动选择模式下,若当前绑定不可用(暂停/静音/丢失)则尝试切换 // 注意:离屏元素不在 DOM 中也有效,不能用 body.contains 判断 if (settings.autoSelectSource) { const needRebind = !isCurrentMediaUsable(); if (needRebind) { setTimeout(() => { const best = selectBestMedia(); if (best && best !== currentMedia && !best.paused) bindToMedia(best); }, 200); } } } }); try { globalObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'], }); } catch (e) { // documentElement 不可用时退到 body setTimeout(() => { try { globalObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); } catch (e2) { } }, 500); } } // SPA URL 变化监听 let urlObserverTimer = null; function startUrlObserver() { if (urlObserverTimer || !normalizerEnabled) return; let lastUrl = location.href; urlObserverTimer = setInterval(() => { if (location.href !== lastUrl) { lastUrl = location.href; console.log('[响度平衡] 检测到 URL 变化,重新扫描音频源...'); destroyAudioGraph(); currentMedia = null; knownMediaElements.clear(); setTimeout(initialScanAndBind, 300); } }, 800); } function stopUrlObserver() { if (urlObserverTimer) { clearInterval(urlObserverTimer); urlObserverTimer = null; } } // 健康检查 function startHealthCheck() { if (healthCheckTimer || !normalizerEnabled) return; healthCheckTimer = setInterval(() => { if (settings.autoSelectSource && !isSiteCurrentlyExcluded() && silentFrames >= SILENT_THRESHOLD) { if (silentSince && Date.now() - silentSince >= SILENT_ROTATE_MIN_MS) { // 当前源看似在播放却长时间读不到声音(直播站常见:绑定的视频元素无声, // 真正的音频在另一个离屏 RTC audio 元素上)→ 轮换到其他源 rotateSourceOnSilence(); } else if (!isCurrentMediaUsable()) { const now = Date.now(); if (now - lastSilentLogAt > 30000) { lastSilentLogAt = now; console.log('[响度平衡] 检测到长时间无音频,尝试重绑...'); } silentFrames = 0; silentSince = 0; tryRebind(); } } // 顺便刷新源计数 updateSourceBtnStyle(); }, HEALTH_CHECK_INTERVAL); } // ---------- EQ 独立悬浮窗 ---------- function formatBandGain(v) { if (typeof v !== 'number' || !isFinite(v) || v === 0) return '0'; return (v > 0 ? '+' : '') + v.toFixed(1); } function formatBandFreq(f) { if (f >= 1000) return (f / 1000) + 'k'; return String(f); } function createEQPanel() { if (eqPanel) return eqPanel; // 注入 EQ 滑块样式(仅一次) if (!document.getElementById('audio-eq-slider-style')) { const styleEl = document.createElement('style'); styleEl.id = 'audio-eq-slider-style'; styleEl.textContent = ` #audio-eq-panel .eq-slider { -webkit-appearance: slider-vertical; appearance: slider-vertical; writing-mode: vertical-lr; direction: rtl; width: 16px; height: 120px; margin: 0; padding: 0; cursor: pointer; } #audio-eq-panel .eq-band-col { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 26px; } #audio-eq-panel .eq-gain-label { font-family: "SF Mono","Consolas","Monaco",monospace; font-size: 10px; color: #fff; height: 12px; line-height: 12px; } #audio-eq-panel .eq-freq-label { font-family: "SF Mono","Consolas","Monaco",monospace; font-size: 10px; color: #fff; opacity: 0.85; } `; document.head.appendChild(styleEl); } eqPanel = document.createElement('div'); eqPanel.id = 'audio-eq-panel'; const savedPos = loadPosition() || {}; const defTop = (wrapper ? wrapper.offsetTop : 80); const defLeft = (wrapper ? wrapper.offsetLeft + wrapper.offsetWidth + 12 : 16); let initTop = (typeof savedPos.eqTop === 'number') ? savedPos.eqTop : defTop; let initLeft = (typeof savedPos.eqLeft === 'number') ? savedPos.eqLeft : defLeft; const panelW = 380, panelH = 230; initTop = Math.max(0, Math.min(window.innerHeight - 40, initTop)); initLeft = Math.max(0, Math.min(window.innerWidth - 60, initLeft)); Object.assign(eqPanel.style, { position: 'fixed', top: initTop + 'px', left: initLeft + 'px', zIndex: '999998', display: 'none', flexDirection: 'column', gap: '8px', width: panelW + 'px', background: 'rgba(0,0,0,0.82)', backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', color: '#fff', fontFamily: 'Arial, sans-serif', fontSize: '12px', padding: '10px', borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.4)', userSelect: 'none', pointerEvents: 'auto', }); // 标题栏(拖动柄) const header = document.createElement('div'); header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; cursor:move; border-bottom:1px solid rgba(255,255,255,0.25); padding-bottom:6px;'; const title = document.createElement('span'); title.style.cssText = 'font-weight:bold; font-size:13px;'; title.textContent = '播放补偿 EQ'; header.appendChild(title); const closeBtn = document.createElement('button'); closeBtn.innerHTML = '✕'; closeBtn.title = '关闭 EQ 面板'; closeBtn.style.cssText = 'font-size:12px; padding:1px 7px; border-radius:3px; border:1px solid #888; background:rgba(255,255,255,0.1); color:#fff; cursor:pointer; line-height:1.3;'; closeBtn.addEventListener('click', (e) => { e.stopPropagation(); eqPanelVisible = false; eqPanel.style.display = 'none'; updateEQBtnStyle(); }); header.appendChild(closeBtn); eqPanel.appendChild(header); // 启用 + 位置 行 const enableRow = document.createElement('div'); enableRow.style.cssText = 'display:flex; align-items:center; gap:12px; flex-wrap:wrap;'; const enableLabel = document.createElement('label'); enableLabel.style.cssText = 'display:flex; align-items:center; gap:4px; cursor:pointer;'; const enableCb = document.createElement('input'); enableCb.type = 'checkbox'; enableCb.checked = settings.eqEnabled; enableCb.style.margin = '0'; const enableText = document.createElement('span'); enableText.textContent = '启用 EQ'; enableLabel.appendChild(enableCb); enableLabel.appendChild(enableText); enableCb.addEventListener('change', () => { settings.eqEnabled = enableCb.checked; saveSettings(settings); rebuildRouting(); updateEQBtnStyle(); }); enableRow.appendChild(enableLabel); const posWrap = document.createElement('label'); posWrap.style.cssText = 'display:flex; align-items:center; gap:4px;'; const posLabel = document.createElement('span'); posLabel.textContent = '位置'; const posSelect = document.createElement('select'); posSelect.style.cssText = 'background:#333; color:#fff; border:1px solid #666; border-radius:3px; padding:2px; font-size:11px;'; [ { v: 'output', t: '输出端(不影响 AGC,推荐)' }, { v: 'input', t: '输入端(影响响度测量)' }, ].forEach(o => { const op = document.createElement('option'); op.value = o.v; op.textContent = o.t; if (o.v === settings.eqPosition) op.selected = true; posSelect.appendChild(op); }); posSelect.addEventListener('change', () => { settings.eqPosition = posSelect.value; saveSettings(settings); rebuildRouting(); }); posWrap.appendChild(posLabel); posWrap.appendChild(posSelect); enableRow.appendChild(posWrap); eqPanel.appendChild(enableRow); // 预设 行 const presetRow = document.createElement('div'); presetRow.style.cssText = 'display:flex; align-items:center; gap:6px;'; const presetLabel = document.createElement('span'); presetLabel.textContent = '预设'; presetLabel.style.minWidth = '30px'; const presetSelect = document.createElement('select'); presetSelect.style.cssText = 'flex:1; background:#333; color:#fff; border:1px solid #666; border-radius:3px; padding:2px; font-size:11px;'; Object.keys(EQ_PRESETS).forEach(key => { const op = document.createElement('option'); op.value = key; op.textContent = EQ_PRESETS[key].name; if (key === settings.eqPreset) op.selected = true; presetSelect.appendChild(op); }); presetSelect.addEventListener('change', () => { const key = presetSelect.value; const preset = EQ_PRESETS[key]; if (preset && preset.gains) { settings.eqBands = preset.gains.slice(); settings.eqPreset = key; applyEQGains(); saveSettings(settings); syncEQSliders(); } }); presetRow.appendChild(presetLabel); presetRow.appendChild(presetSelect); eqPanel.appendChild(presetRow); // 滑块组 const slidersBox = document.createElement('div'); slidersBox.style.cssText = 'display:flex; justify-content:space-between; align-items:flex-end; padding:6px 4px 2px; background:rgba(255,255,255,0.04); border-radius:4px;'; const sliderRefs = []; EQ_FREQS.forEach((freq, i) => { const col = document.createElement('div'); col.className = 'eq-band-col'; const gainLabel = document.createElement('span'); gainLabel.className = 'eq-gain-label'; gainLabel.textContent = formatBandGain(settings.eqBands[i]); const slider = document.createElement('input'); slider.type = 'range'; slider.className = 'eq-slider'; slider.min = String(EQ_BAND_MIN); slider.max = String(EQ_BAND_MAX); slider.step = '0.5'; slider.value = String(settings.eqBands[i]); slider.title = `${freq}Hz`; const freqLabel = document.createElement('span'); freqLabel.className = 'eq-freq-label'; freqLabel.textContent = formatBandFreq(freq); slider.addEventListener('input', () => { const v = parseFloat(slider.value); settings.eqBands[i] = v; gainLabel.textContent = formatBandGain(v); applyEQGains(); // 手动调节后转为自定义预设 if (settings.eqPreset !== 'custom') { settings.eqPreset = 'custom'; presetSelect.value = 'custom'; } saveSettings(settings); }); // 双击归零 slider.addEventListener('dblclick', () => { slider.value = '0'; settings.eqBands[i] = 0; gainLabel.textContent = '0'; applyEQGains(); if (settings.eqPreset !== 'custom') { settings.eqPreset = 'custom'; presetSelect.value = 'custom'; } saveSettings(settings); }); col.appendChild(gainLabel); col.appendChild(slider); col.appendChild(freqLabel); slidersBox.appendChild(col); sliderRefs.push({ slider, gainLabel }); }); eqPanel.appendChild(slidersBox); // 同步滑块 UI 与当前 settings.eqBands(预设切换/外部恢复后调用) function syncEQSliders() { sliderRefs.forEach((ref, i) => { const v = settings.eqBands[i]; ref.slider.value = String(v); ref.gainLabel.textContent = formatBandGain(v); }); // 同步预设下拉框 presetSelect.value = settings.eqPreset; } // 底部提示 const eqExplanation = document.createElement('div'); eqExplanation.style.cssText = 'font-size:10px; opacity:0.7; line-height:1.4; padding-top:4px;'; eqExplanation.textContent = '均衡器(EQ)用于调整不同频率分量的增益。增加低频可增强厚度,中频可改善清晰度,高频可影响亮度与细节。'; eqPanel.appendChild(eqExplanation); const hint = document.createElement('div'); hint.style.cssText = 'font-size:10px; opacity:0.7; line-height:1.4; padding-top:2px; border-top:1px dashed rgba(255,255,255,0.15);'; hint.innerHTML = '±12 dB / 1 倍频程。双击滑块归零。手动调节后自动转为「自定义」。'; eqPanel.appendChild(hint); // 暴露 syncEQSliders 供外部(如预设恢复)调用 eqPanel._syncEQSliders = syncEQSliders; // ---------- 拖动(鼠标 + 触摸)---------- let dragState = null; function onHeaderMouseDown(e) { if (e.target.closest('button, input, select, textarea')) return; dragState = { startX: e.clientX, startY: e.clientY, origLeft: eqPanel.offsetLeft, origTop: eqPanel.offsetTop, moved: false, }; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); e.preventDefault(); } function onMouseMove(e) { if (!dragState) return; const dx = e.clientX - dragState.startX; const dy = e.clientY - dragState.startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragState.moved = true; eqPanel.style.left = Math.max(0, Math.min(window.innerWidth - 40, dragState.origLeft + dx)) + 'px'; eqPanel.style.top = Math.max(0, Math.min(window.innerHeight - 40, dragState.origTop + dy)) + 'px'; } function onMouseUp() { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); if (dragState && dragState.moved) { const cur = loadPosition() || {}; cur.eqTop = eqPanel.offsetTop; cur.eqLeft = eqPanel.offsetLeft; savePosition(cur); } dragState = null; } header.addEventListener('mousedown', onHeaderMouseDown); // 触摸拖动 header.addEventListener('touchstart', (e) => { if (e.target.closest('button, input, select, textarea')) return; const t = e.touches[0]; dragState = { startX: t.clientX, startY: t.clientY, origLeft: eqPanel.offsetLeft, origTop: eqPanel.offsetTop, moved: false, }; }, { passive: true }); header.addEventListener('touchmove', (e) => { if (!dragState) return; const t = e.touches[0]; const dx = t.clientX - dragState.startX; const dy = t.clientY - dragState.startY; if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { dragState.moved = true; e.preventDefault(); } eqPanel.style.left = Math.max(0, Math.min(window.innerWidth - 40, dragState.origLeft + dx)) + 'px'; eqPanel.style.top = Math.max(0, Math.min(window.innerHeight - 40, dragState.origTop + dy)) + 'px'; }, { passive: false }); header.addEventListener('touchend', () => { if (dragState && dragState.moved) { const cur = loadPosition() || {}; cur.eqTop = eqPanel.offsetTop; cur.eqLeft = eqPanel.offsetLeft; savePosition(cur); } dragState = null; }); // 阻止面板内部交互冒泡到 document 的 click 关闭逻辑(仅阻止 collapseUI 影响面板自身不可见问题——面板独立,不受影响,但点击不应被误判) eqPanel.addEventListener('click', (e) => { e.stopPropagation(); }); document.body.appendChild(eqPanel); // 窗口尺寸变化时,确保 EQ 面板可见在屏幕内 window.addEventListener('resize', () => { if (!eqPanel) return; const w = eqPanel.offsetWidth || panelW; const h = eqPanel.offsetHeight || panelH; let l = eqPanel.offsetLeft, t = eqPanel.offsetTop; let changed = false; if (l + w > window.innerWidth) { l = Math.max(0, window.innerWidth - w); changed = true; } if (t + h > window.innerHeight) { t = Math.max(0, window.innerHeight - h); changed = true; } if (l < 0) { l = 0; changed = true; } if (t < 0) { t = 0; changed = true; } if (changed) { eqPanel.style.left = l + 'px'; eqPanel.style.top = t + 'px'; } }); return eqPanel; } // ---------- UI 创建 ---------- function createUI() { if (uiCreated || document.getElementById('audio-monitor-wrapper')) return; uiCreated = true; // 检查是否应该隐藏悬浮球(本页不再显示) const currentHost = location.host; const hiddenDomains = settings.hiddenDomains || []; const shouldHide = hiddenDomains.includes(currentHost); // 注入滚动条样式(与悬浮面板风格契合) if (!document.getElementById('audio-monitor-scrollbar-style')) { const styleEl = document.createElement('style'); styleEl.id = 'audio-monitor-scrollbar-style'; styleEl.textContent = ` #audio-monitor-wrapper ::-webkit-scrollbar { width: 6px; height: 6px; } #audio-monitor-wrapper ::-webkit-scrollbar-track { background: transparent; } #audio-monitor-wrapper ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.25); border-radius: 3px; } #audio-monitor-wrapper ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); } #audio-monitor-wrapper { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.25) transparent; } `; document.head.appendChild(styleEl); } wrapper = document.createElement('div'); wrapper.id = 'audio-monitor-wrapper'; const savedPos = loadPosition(); const initTop = savedPos ? savedPos.top : window.innerHeight - 80; const initLeft = savedPos ? savedPos.left : 16; Object.assign(wrapper.style, { position: 'fixed', top: Math.max(0, Math.min(window.innerHeight - 32, initTop)) + 'px', left: Math.max(0, Math.min(window.innerWidth - 200, initLeft)) + 'px', zIndex: '999999', userSelect: 'none', pointerEvents: 'auto', display: shouldHide ? 'none' : 'block', }); collapsedBar = document.createElement('div'); collapsedBar.style.cssText = 'position: relative; display: inline-block; min-width: 120px; height: 30px; cursor: move; border-radius: 8px; box-shadow: 0 0 0 1px rgba(255,255,255,0.9), 0 0 4px 2px rgba(255,255,255,0.6), 0 0 8px 4px rgba(255,255,255,0.3), 0 4px 12px rgba(0,0,0,0.4); overflow: hidden;'; const miniMeterContainer = document.createElement('div'); miniMeterContainer.style.cssText = 'position: relative; width: 100%; height: 100%; background: rgba(0,0,0,0.75); border-radius: 8px; overflow: hidden;'; miniMeterFill = document.createElement('div'); miniMeterFill.style.cssText = 'position: absolute; left: 0; top: 0; height: 100%; width: 0%; background: #4caf50; transition: width 0.08s linear, background 0.2s; border-radius: 8px;'; miniMeterContainer.appendChild(miniMeterFill); collapsedBar.appendChild(miniMeterContainer); miniLabel = document.createElement('span'); miniLabel.style.cssText = 'position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #fff; font-family: "SF Mono", "Consolas", "Monaco", monospace; font-size: 13px; text-shadow: 0 0 4px rgba(0,0,0,0.8); pointer-events: none; white-space: nowrap;'; miniLabel.textContent = '-- dBFS'; collapsedBar.appendChild(miniLabel); expandedPanel = document.createElement('div'); expandedPanel.id = 'expanded-panel'; expandedPanel.style.cssText = 'display: none; flex-direction: column; gap: 8px;'; const infoPanel = document.createElement('div'); infoPanel.style.cssText = 'background: rgba(0,0,0,0.75); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-family: "SF Mono", "Consolas", "Monaco", monospace; font-size: 13px; border-radius: 8px; box-shadow: 0 0 0 1px rgba(255,255,255,0.9), 0 0 4px 2px rgba(255,255,255,0.6), 0 0 8px 4px rgba(255,255,255,0.3), 0 4px 12px rgba(0,0,0,0.4); display: flex; flex-direction: column; padding: 8px 12px; gap: 6px; min-width: 240px;'; const bigMeterContainer = document.createElement('div'); bigMeterContainer.style.cssText = 'width: 100%; height: 6px; border-radius: 3px; background: rgba(255,255,255,0.15); overflow: hidden;'; bigMeterFill = document.createElement('div'); bigMeterFill.style.cssText = 'height: 100%; width: 0%; background: #4caf50; border-radius: 3px; transition: width 0.08s linear, background 0.2s;'; bigMeterContainer.appendChild(bigMeterFill); infoPanel.appendChild(bigMeterContainer); const textRow = document.createElement('div'); textRow.style.cssText = 'display: flex; flex-direction: column; gap: 2px;'; inputLabel = document.createElement('span'); inputLabel.textContent = '输入 -- dBFS'; textRow.appendChild(inputLabel); reductionLabel = document.createElement('span'); reductionLabel.style.fontSize = '11px'; reductionLabel.style.opacity = '0.85'; reductionLabel.textContent = '衰减 0 dBFS'; textRow.appendChild(reductionLabel); gainLabel = document.createElement('span'); gainLabel.style.fontSize = '11px'; gainLabel.style.opacity = '0.85'; gainLabel.textContent = '增益 +0 dBFS'; textRow.appendChild(gainLabel); sourceLabel = document.createElement('span'); sourceLabel.style.fontSize = '11px'; sourceLabel.style.opacity = '0.75'; sourceLabel.textContent = '源: 未绑定'; textRow.appendChild(sourceLabel); excludedLabel = document.createElement('span'); excludedLabel.style.cssText = 'font-size:11px; opacity:0.9; font-weight:bold; color:#ffc107; display:none;'; excludedLabel.textContent = ''; textRow.appendChild(excludedLabel); infoPanel.appendChild(textRow); const btnRow = document.createElement('div'); btnRow.style.cssText = 'display: flex; gap: 6px; align-items: center; flex-wrap: wrap;'; toggleBtn = document.createElement('button'); toggleBtn.textContent = normalizerEnabled ? '规格化 开' : '规格化 关'; Object.assign(toggleBtn.style, { border: '1px solid', fontSize: '11px', padding: '2px 8px', borderRadius: '4px', cursor: 'pointer', transition: 'background 0.2s, color 0.2s', lineHeight: '1.3', }); toggleBtn.addEventListener('click', (e) => { e.stopPropagation(); setNormalizerEnabled(!normalizerEnabled); toggleBtn.textContent = normalizerEnabled ? '规格化 开' : '规格化 关'; updateToggleStyle(); if (normalizeHint) normalizeHint.style.display = 'none'; }); btnRow.appendChild(toggleBtn); // 音频源选择按钮 sourceBtn = document.createElement('button'); sourceBtn.innerHTML = '♫'; sourceBtn.title = '音频源'; Object.assign(sourceBtn.style, { border: '1px solid', fontSize: '16px', padding: '2px 6px', borderRadius: '4px', cursor: 'pointer', transition: 'background 0.2s, color 0.2s', lineHeight: '1', }); sourceBtn.addEventListener('click', (e) => { e.stopPropagation(); // 阻止冒泡到 document click sourceListVisible = !sourceListVisible; sourceListPanel.style.display = sourceListVisible ? 'flex' : 'none'; // 互斥:展开当前则折叠其余 if (sourceListVisible) { if (settingsVisible) { settingsVisible = false; settingsPanel.style.display = 'none'; updateSettingsBtnStyle(); } if (excludeListVisible) { excludeListVisible = false; excludeListPanel.style.display = 'none'; if (typeof window.__syncExcludedIndicator === 'function') window.__syncExcludedIndicator(); } renderSourceList(); } updateSourceBtnStyle(); }); btnRow.appendChild(sourceBtn); // 排除列表按钮(🚫) excludeBtn = document.createElement('button'); excludeBtn.innerHTML = '🚫'; excludeBtn.title = '排除列表 / 白名单'; Object.assign(excludeBtn.style, { border: '1px solid', fontSize: '14px', padding: '2px 6px', borderRadius: '4px', cursor: 'pointer', transition: 'background 0.2s, color 0.2s', lineHeight: '1', }); const updateExcludeBtnStyle = () => { if (!excludeBtn) return; const curExcluded = isSiteExcludedBySettings(); excludeBtn.title = curExcluded ? '排除列表(当前页已排除)' : '排除列表(当前页启用)'; if (excludeListVisible) { excludeBtn.style.background = curExcluded ? '#ff9800' : '#ffffff'; excludeBtn.style.color = '#000'; excludeBtn.style.borderColor = curExcluded ? '#ff9800' : '#ffffff'; } else { excludeBtn.style.background = curExcluded ? '#c77700' : '#808080'; excludeBtn.style.color = '#fff'; excludeBtn.style.borderColor = curExcluded ? '#c77700' : '#808080'; } }; excludeBtn.addEventListener('click', (e) => { e.stopPropagation(); excludeListVisible = !excludeListVisible; excludeListPanel.style.display = excludeListVisible ? 'flex' : 'none'; // 互斥:展开当前则折叠其余 if (excludeListVisible) { if (settingsVisible) { settingsVisible = false; settingsPanel.style.display = 'none'; updateSettingsBtnStyle(); } if (sourceListVisible) { sourceListVisible = false; sourceListPanel.style.display = 'none'; updateSourceBtnStyle(); } renderExcludeList(); } updateExcludeBtnStyle(); }); // 把 excludedLabel 显示状态联动也放这里 const syncExcludedIndicator = () => { const excluded = isSiteCurrentlyExcluded() || isSiteExcludedBySettings(); if (excluded) { excludedLabel.style.display = 'block'; if (tmpExcludedOverride) { excludedLabel.textContent = '⚡ 临时启用(默认' + (settings.excludeMode === 'allowlist' ? '不在允许列表' : '已排除') + ')'; excludedLabel.style.color = '#8bc34a'; } else { excludedLabel.textContent = isSiteExcludedBySettings() ? (settings.excludeMode === 'allowlist' ? '🛈 未在允许列表' : '🛈 站点已排除(响度不调整)') : ''; excludedLabel.style.color = '#ffc107'; } } else { excludedLabel.style.display = 'none'; } updateExcludeBtnStyle(); }; // 把 sync 函数挂到全局变量(方便外部调用) window.__syncExcludedIndicator = syncExcludedIndicator; // 稍后在 createUI 结尾调用一次 btnRow.appendChild(excludeBtn); // 播放补偿 EQ 按钮(独立悬浮窗入口) eqBtn = document.createElement('button'); eqBtn.innerHTML = '♪'; eqBtn.title = '播放补偿 EQ'; Object.assign(eqBtn.style, { border: '1px solid', fontSize: '16px', padding: '2px 6px', borderRadius: '4px', cursor: 'pointer', transition: 'background 0.2s, color 0.2s', lineHeight: '1', }); eqBtn.addEventListener('click', (e) => { e.stopPropagation(); if (!eqPanel) createEQPanel(); eqPanelVisible = !eqPanelVisible; eqPanel.style.display = eqPanelVisible ? 'flex' : 'none'; updateEQBtnStyle(); }); btnRow.appendChild(eqBtn); settingsBtn = document.createElement('button'); settingsBtn.innerHTML = '⚙'; settingsBtn.title = '参数设置'; Object.assign(settingsBtn.style, { border: '1px solid', fontSize: '16px', padding: '2px 6px', borderRadius: '4px', cursor: 'pointer', transition: 'background 0.2s, color 0.2s', lineHeight: '1', }); btnRow.appendChild(settingsBtn); // 关闭按钮 let closeMenuVisible = false; const closeBtn = document.createElement('button'); closeBtn.innerHTML = '✕'; closeBtn.title = '关闭悬浮球'; Object.assign(closeBtn.style, { border: '1px solid', fontSize: '14px', padding: '2px 6px', borderRadius: '4px', cursor: 'pointer', transition: 'background 0.2s, color 0.2s', lineHeight: '1', background: '#808080', color: '#fff', borderColor: '#808080', }); const closeMenu = document.createElement('div'); closeMenu.style.cssText = 'display:none; position:absolute; bottom:100%; right:0; background:rgba(0,0,0,0.9); backdrop-filter:blur(8px); border-radius:6px; box-shadow:0 0 0 2px rgba(255,255,255,0.8), 0 4px 12px rgba(0,0,0,0.4); padding:4px; z-index:1000000; min-width:140px;'; const mkCloseOption = (text, handler) => { const opt = document.createElement('div'); opt.textContent = text; opt.style.cssText = 'padding:6px 10px; cursor:pointer; color:#fff; font-size:12px; border-radius:4px; white-space:nowrap;'; opt.addEventListener('mouseenter', () => { opt.style.background = 'rgba(255,255,255,0.2)'; }); opt.addEventListener('mouseleave', () => { opt.style.background = 'transparent'; }); opt.addEventListener('click', (e) => { e.stopPropagation(); handler(); closeMenu.style.display = 'none'; closeMenuVisible = false; }); return opt; }; const tempCloseOpt = mkCloseOption('本次关闭', () => { wrapper.style.display = 'none'; window.__loudnessBallTempClosed = true; }); const permCloseOpt = mkCloseOption('本页不再显示', () => { const host = location.host; if (!settings.hiddenDomains) settings.hiddenDomains = []; if (!settings.hiddenDomains.includes(host)) { settings.hiddenDomains.push(host); saveSettings(settings); } wrapper.style.display = 'none'; window.__loudnessBallPermClosed = true; }); closeMenu.appendChild(tempCloseOpt); closeMenu.appendChild(permCloseOpt); btnRow.style.position = 'relative'; btnRow.appendChild(closeMenu); closeBtn.addEventListener('click', (e) => { e.stopPropagation(); closeMenuVisible = !closeMenuVisible; closeMenu.style.display = closeMenuVisible ? 'block' : 'none'; }); btnRow.appendChild(closeBtn); infoPanel.appendChild(btnRow); // 悬浮在「规格化 开」按钮上的安全提示(底部小字) normalizeHint = document.createElement('div'); normalizeHint.style.cssText = 'display:none; font-size:10px; opacity:0.85; color:#ffc107; line-height:1.5; padding-top:4px; border-top:1px dashed rgba(255,255,255,0.18);'; normalizeHint.textContent = '开启后将持续监听音频,如网页访问出现问题,可尝试关闭。'; infoPanel.appendChild(normalizeHint); toggleBtn.addEventListener('mouseenter', () => { if (normalizerEnabled) normalizeHint.style.display = 'block'; }); toggleBtn.addEventListener('mouseleave', () => { normalizeHint.style.display = 'none'; }); expandedPanel.appendChild(infoPanel); // 音频源列表面板 sourceListPanel = document.createElement('div'); sourceListPanel.id = 'source-list-panel'; sourceListPanel.style.cssText = 'background: rgba(0,0,0,0.8); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: #fff; font-family: Arial, sans-serif; font-size: 12px; padding: 0; border-radius: 8px; box-shadow: 0 0 0 1px rgba(255,255,255,0.9), 0 0 4px 2px rgba(255,255,255,0.6), 0 0 8px 4px rgba(255,255,255,0.3), 0 4px 12px rgba(0,0,0,0.4); display: none; flex-direction: column; max-width: 300px; max-height: 300px; overflow-y: auto;'; expandedPanel.appendChild(sourceListPanel); // 排除列表管理面板 excludeListPanel = document.createElement('div'); excludeListPanel.id = 'exclude-list-panel'; excludeListPanel.style.cssText = 'background: rgba(0,0,0,0.85); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: #fff; font-family: Arial, sans-serif; font-size: 12px; padding: 10px; border-radius: 8px; box-shadow: 0 0 0 1px rgba(255,255,255,0.9), 0 0 4px 2px rgba(255,255,255,0.6), 0 0 8px 4px rgba(255,255,255,0.3), 0 4px 12px rgba(0,0,0,0.4); display: none; flex-direction: column; gap: 8px; max-width: 320px; max-height: 380px; overflow-y: auto;'; expandedPanel.appendChild(excludeListPanel); function getCurrentHostPattern() { try { return location.host; } catch (e) { return '*'; } } function getCurrentHostPathPattern() { try { return location.host + location.pathname; } catch (e) { return '*'; } } // 渲染排除列表 function renderExcludeList() { if (!excludeListPanel) return; while (excludeListPanel.firstChild) excludeListPanel.removeChild(excludeListPanel.firstChild); // 标题 const header = document.createElement('div'); header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; font-weight:bold; font-size:13px; border-bottom:1px solid rgba(255,255,255,0.25); padding-bottom:4px;'; header.textContent = '排除列表 / 白名单'; excludeListPanel.appendChild(header); // 模式切换 const modeRow = document.createElement('div'); modeRow.style.cssText = 'display:flex; align-items:center; gap:8px; font-size:11px;'; const modeLabel = document.createElement('span'); modeLabel.style.minWidth = '70px'; modeLabel.textContent = '匹配模式'; const modeSelect = document.createElement('select'); modeSelect.style.cssText = 'flex:1; background:#333; color:#fff; border:1px solid #666; border-radius:3px; padding:2px; font-size:11px;'; [ { v: 'blocklist', t: '黑名单(列表内排除,其余启用)' }, { v: 'allowlist', t: '白名单(仅列表内启用,其余排除)' }, ].forEach(o => { const op = document.createElement('option'); op.value = o.v; op.textContent = o.t; if (o.v === settings.excludeMode) op.selected = true; modeSelect.appendChild(op); }); modeSelect.addEventListener('change', () => { settings.excludeMode = modeSelect.value; saveSettings(settings); syncExcludedIndicator(); renderExcludeList(); if (isSiteCurrentlyExcluded()) { destroyAudioGraph(); currentMedia = null; if (miniLabel) miniLabel.textContent = '已排除'; if (excludedLabel) excludedLabel.textContent = settings.excludeMode === 'allowlist' ? '未在允许列表' : '站点已排除'; } else { // 重新启用:走一次初始化扫描(规格化关闭时不启动任何监听) destroyAudioGraph(); currentMedia = null; if (isNormalizerActive()) { if (!globalObserver) startGlobalObserver(); startHealthCheck(); } initialScanAndBind(); } }); modeRow.appendChild(modeLabel); modeRow.appendChild(modeSelect); excludeListPanel.appendChild(modeRow); // 当前状态 + 快捷操作 const curRow = document.createElement('div'); curRow.style.cssText = 'display:flex; flex-direction:column; gap:4px; padding:6px 8px; background:rgba(255,255,255,0.05); border-radius:4px; font-size:11px;'; const curInfo = document.createElement('div'); const curHost = getCurrentHostPattern(); const excludedNow = isSiteExcludedBySettings(); curInfo.innerHTML = `当前页: ${curHost}\n状态: ${excludedNow ? (settings.excludeMode === 'allowlist' ? '未在允许列表(已排除)' : '命中排除(已跳过)') : '启用中'}`; curRow.appendChild(curInfo); const quickBtns = document.createElement('div'); quickBtns.style.cssText = 'display:flex; gap:6px; flex-wrap:wrap;'; const mkBtn = (txt, title, handler) => { const b = document.createElement('button'); b.textContent = txt; b.title = title || ''; b.style.cssText = 'font-size:11px; padding:3px 8px; border-radius:3px; border:1px solid #888; background:rgba(255,255,255,0.08); color:#fff; cursor:pointer;'; b.addEventListener('click', handler); return b; }; const btnAddHost = mkBtn('加入域名 ' + curHost, '按整个域名加入/允许', () => { const p = curHost; if (!settings.excludeList.includes(p)) { settings.excludeList.push(p); saveSettings(settings); } syncExcludedIndicator(); renderExcludeList(); }); const btnAddPath = mkBtn('加入路径 ' + (getCurrentHostPathPattern().length > 30 ? getCurrentHostPathPattern().slice(0, 30) + '…' : getCurrentHostPathPattern()), '按当前 host+path 精确加入', () => { const p = getCurrentHostPathPattern(); if (!settings.excludeList.includes(p)) { settings.excludeList.push(p); saveSettings(settings); } syncExcludedIndicator(); renderExcludeList(); }); const btnRemove = mkBtn('从列表移除当前', '', () => { const url = location.href; const host = curHost; const hp = getCurrentHostPathPattern(); settings.excludeList = (settings.excludeList || []).filter(p => { if (!p) return false; // 精确匹配则移除;模糊匹配需用户手动删 if (p === url || p === host || p === hp) return false; return true; }); saveSettings(settings); syncExcludedIndicator(); renderExcludeList(); }); const btnTmpOverride = mkBtn(tmpExcludedOverride ? '取消临时启用' : '本次会话临时启用', tmpExcludedOverride ? '恢复排除状态' : '即使被排除也强行启用(刷新后失效)', () => { tmpExcludedOverride = !tmpExcludedOverride; syncExcludedIndicator(); renderExcludeList(); if (tmpExcludedOverride) { // 走启用流程 if (!globalObserver) startGlobalObserver(); startHealthCheck(); destroyAudioGraph(); currentMedia = null; initialScanAndBind(); } else { destroyAudioGraph(); currentMedia = null; if (miniLabel) miniLabel.textContent = '已排除'; } }); // 按模式展示不同按钮 if (settings.excludeMode === 'blocklist') { if (!excludedNow) quickBtns.appendChild(btnAddHost); if (!excludedNow) quickBtns.appendChild(btnAddPath); if (excludedNow) quickBtns.appendChild(btnRemove); } else { if (excludedNow) quickBtns.appendChild(btnAddHost); if (excludedNow) quickBtns.appendChild(btnAddPath); if (!excludedNow) quickBtns.appendChild(btnRemove); } if (isSiteExcludedBySettings() && !tmpExcludedOverride) quickBtns.appendChild(btnTmpOverride); else if (tmpExcludedOverride) quickBtns.appendChild(btnTmpOverride); curRow.appendChild(quickBtns); excludeListPanel.appendChild(curRow); // 添加新规则行 const addRow = document.createElement('div'); addRow.style.cssText = 'display:flex; gap:4px; align-items:stretch;'; const addInput = document.createElement('input'); addInput.type = 'text'; addInput.placeholder = '模式: *.example.com 或 /regex/'; addInput.style.cssText = 'flex:1; min-width:0; background:#222; color:#fff; border:1px solid #666; border-radius:3px; padding:3px 6px; font-size:11px;'; addInput.title = '通配符示例: *.youtube.com, music.*.com。正则示例: /^https?:\/\/music\./'; const btnAdd = document.createElement('button'); btnAdd.textContent = '+添加'; btnAdd.style.cssText = 'font-size:11px; padding:3px 8px; border-radius:3px; border:1px solid #4caf50; background:#2e7d32; color:#fff; cursor:pointer;'; btnAdd.addEventListener('click', () => { const v = (addInput.value || '').trim(); if (!v) return; // 校验正则 if (v.startsWith('/') && v.endsWith('/')) { try { new RegExp(v.slice(1, -1), 'i'); } catch (e) { alert('正则语法错误: ' + e.message); return; } } if (settings.excludeList.includes(v)) { addInput.value = ''; return; } settings.excludeList.push(v); saveSettings(settings); addInput.value = ''; syncExcludedIndicator(); renderExcludeList(); }); addRow.appendChild(addInput); addRow.appendChild(btnAdd); excludeListPanel.appendChild(addRow); // 现有规则列表 const listTitle = document.createElement('div'); listTitle.style.cssText = 'font-weight:bold; font-size:11px; opacity:0.9; margin-top:4px;'; listTitle.textContent = `现有规则 (${(settings.excludeList || []).length})`; excludeListPanel.appendChild(listTitle); const listBox = document.createElement('div'); listBox.style.cssText = 'display:flex; flex-direction:column; gap:2px;'; if (!settings.excludeList || settings.excludeList.length === 0) { const empty = document.createElement('div'); empty.style.cssText = 'font-size:11px; opacity:0.6; text-align:center; padding:8px;'; empty.textContent = '(暂无规则)'; listBox.appendChild(empty); } else { settings.excludeList.forEach((p, idx) => { const row = document.createElement('div'); row.style.cssText = 'display:flex; align-items:center; gap:6px; padding:3px 4px; border-radius:3px; background:rgba(255,255,255,0.04); font-size:11px;'; const label = document.createElement('span'); label.style.cssText = 'flex:1; word-break:break-all;'; const hit = matchUrlAgainstPattern(location.href, p); label.innerHTML = `#${idx + 1} ${p.replace(/(命中当前页)/g, '')}`; label.title = p; const btnDel = document.createElement('button'); btnDel.textContent = '✕'; btnDel.title = '删除这条规则'; btnDel.style.cssText = 'font-size:10px; padding:1px 6px; border-radius:3px; border:1px solid #e53935; background:#4e342e; color:#ffab91; cursor:pointer; flex-shrink:0;'; btnDel.addEventListener('click', () => { settings.excludeList.splice(idx, 1); saveSettings(settings); syncExcludedIndicator(); renderExcludeList(); }); row.appendChild(label); row.appendChild(btnDel); listBox.appendChild(row); }); } excludeListPanel.appendChild(listBox); // 底部提示 const hint = document.createElement('div'); hint.style.cssText = 'font-size:10px; opacity:0.7; line-height:1.4; padding-top:4px; border-top:1px dashed rgba(255,255,255,0.15);'; hint.innerHTML = '规则写法:
• 通配符: *.bilibili.com, music.example.com/*, example.com/path/*
• 正则: /^https?:\/\/(music|cdn)\./'; excludeListPanel.appendChild(hint); } settingsPanel = document.createElement('div'); settingsPanel.id = 'settings-panel'; settingsPanel.style.cssText = 'background: rgba(0,0,0,0.8); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: #fff; font-family: Arial, sans-serif; font-size: 12px; padding: 10px; border-radius: 8px; box-shadow: 0 0 0 1px rgba(255,255,255,0.9), 0 0 4px 2px rgba(255,255,255,0.6), 0 0 8px 4px rgba(255,255,255,0.3), 0 4px 12px rgba(0,0,0,0.4); display: none; flex-direction: column; gap: 6px; max-width: 280px; max-height: 70vh; overflow-y: auto;'; expandedPanel.appendChild(settingsPanel); settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); settingsVisible = !settingsVisible; settingsPanel.style.display = settingsVisible ? 'flex' : 'none'; // 互斥:展开当前则折叠其余 if (settingsVisible) { if (sourceListVisible) { sourceListVisible = false; sourceListPanel.style.display = 'none'; updateSourceBtnStyle(); } if (excludeListVisible) { excludeListVisible = false; excludeListPanel.style.display = 'none'; if (typeof window.__syncExcludedIndicator === 'function') window.__syncExcludedIndicator(); } } updateSettingsBtnStyle(); }); function createSliderRow(labelText, min, max, step, initialValue, unit, onChange, tooltip) { const row = document.createElement('div'); row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '6px'; const label = document.createElement('span'); label.textContent = labelText; label.style.minWidth = '60px'; row.appendChild(label); const slider = document.createElement('input'); slider.type = 'range'; slider.min = min; slider.max = max; slider.step = step; slider.value = initialValue; slider.style.flex = '1'; row.appendChild(slider); const valueDisplay = document.createElement('span'); valueDisplay.style.minWidth = '40px'; valueDisplay.style.textAlign = 'right'; const precision = step < 0.001 ? 4 : step < 0.01 ? 3 : step < 0.1 ? 2 : 1; const format = (v) => Number(v).toFixed(precision) + (unit || ''); valueDisplay.textContent = format(initialValue); row.appendChild(valueDisplay); if (tooltip) { row.title = tooltip; label.title = tooltip; slider.title = tooltip; valueDisplay.title = tooltip; } slider.addEventListener('input', () => { const val = parseFloat(slider.value); valueDisplay.textContent = format(val); if (onChange) onChange(val); }); return { row, slider, valueDisplay }; } agcParams = Object.assign(agcParams, { targetDBFS: settings.targetDBFS, maxGainDB: settings.maxGainDB, gainUpStep: settings.gainUpStep, gainDownStep: settings.gainDownStep, noiseGate: settings.noiseGate, }); const compressorTitle = document.createElement('div'); compressorTitle.textContent = '压缩器'; compressorTitle.style.fontWeight = 'bold'; compressorTitle.style.borderBottom = '1px solid rgba(255,255,255,0.3)'; settingsPanel.appendChild(compressorTitle); const addSlider = (name, min, max, step, val, unit, onChange, tooltip) => { const row = createSliderRow(name, min, max, step, val, unit, onChange, tooltip); settingsPanel.appendChild(row.row); return row; }; sliderRefs.threshold = addSlider('阈值', -100, 0, 0.1, settings.threshold, ' dB', (v) => { if (compressor) compressor.threshold.value = v; settings.threshold = v; saveSettings(settings); }, '声音超过此电平值时压缩器开始动作。数值越低,较小的声音也会触发压缩,整体动态被平滑;数值越高,只有较大的瞬态峰值才会被压缩,保留更多原始动态。建议 -30 至 -20 dB。'); sliderRefs.knee = addSlider('拐点', 0, 40, 0.1, settings.knee, ' dB', (v) => { if (compressor) compressor.knee.value = v; settings.knee = v; saveSettings(settings); }, '决定压缩器从“无压缩”过渡到“全额压缩”的曲线平滑程度。数值越大,过渡区域越宽,压缩介入过程更自然、不易察觉;数值越小,过渡越陡峭,压缩痕迹较明显。建议 20 至 40 dB。'); sliderRefs.ratio = addSlider('压缩比', 1, 20, 0.1, settings.ratio, ':1', (v) => { if (compressor) compressor.ratio.value = v; settings.ratio = v; saveSettings(settings); }, '表示输入信号超出阈值后,输出电平被压缩的倍数。数值低时压缩轻微,动态保留充分;数值高时压缩剧烈,音量趋近恒定,但声音会显得紧实而缺乏爆发力。人声建议 4 至 8:1,音乐可降低至 2 至 4:1。'); sliderRefs.attack = addSlider('启动', 0, 0.1, 0.001, settings.attack, ' s', (v) => { if (compressor) compressor.attack.value = v; settings.attack = v; saveSettings(settings); }, '从信号超过阈值到压缩器开始起效的延迟时长。数值越小,反应越快,能有效压制瞬时尖峰,但会削去音头的冲击感;数值越大,保留瞬态细节,但突发的大声可能会短暂通过。建议 0.002 至 0.01 秒。'); sliderRefs.release = addSlider('释放', 0, 1, 0.01, settings.release, ' s', (v) => { if (compressor) compressor.release.value = v; settings.release = v; saveSettings(settings); }, '信号回落至阈值以下后,压缩器恢复至非压缩状态的时长。数值越小,音量恢复越快,适合节奏紧凑的语音,但可能出现“抽吸”呼吸声;数值越大,过渡更平缓,但可能过度压低后续弱音。建议 0.1 至 0.5 秒。'); const agcTitle = document.createElement('div'); agcTitle.textContent = '自动增益 (AGC)'; agcTitle.style.fontWeight = 'bold'; agcTitle.style.borderBottom = '1px solid rgba(255,255,255,0.3)'; agcTitle.style.marginTop = '4px'; settingsPanel.appendChild(agcTitle); sliderRefs.targetDBFS = addSlider('目标电平', -60, 0, 0.1, settings.targetDBFS, ' dB', (v) => { agcParams.targetDBFS = v; settings.targetDBFS = v; saveSettings(settings); }, '自动增益所期望维持的输出响度目标值。数值越高,整体输出更响亮,但可能损失动态并放大底噪;数值越低,输出更保守、余量充足,适合安静环境。建议 -23 至 -18 dB。'); sliderRefs.maxGainDB = addSlider('最大增益', 0, 30, 0.1, settings.maxGainDB, ' dB', (v) => { agcParams.maxGainDB = v; settings.maxGainDB = v; saveSettings(settings); }, '允许自动增益对微弱信号进行放大的上限幅度。数值较小,底噪控制优秀,但微弱声音可能仍不够清晰;数值较大,极小细节也能被放大,但环境底噪随之提升。建议 6 至 18 dB。'); sliderRefs.gainUpStep = addSlider('提升速度', 0.1, 30, 0.1, settings.gainUpStep, ' dB/s', (v) => { agcParams.gainUpStep = v; settings.gainUpStep = v; saveSettings(settings); }, '当输入电平低于目标值时,增益向上调整的速率。数值较小,变化平缓,听感稳定,但安静段落响应慢;数值较大,快速补偿音量,无明显静默,但容易产生可闻的起伏。建议 3 至 12 dB/s。'); sliderRefs.gainDownStep = addSlider('下降速度', 0.1, 100, 0.1, settings.gainDownStep, ' dB/s', (v) => { agcParams.gainDownStep = v; settings.gainDownStep = v; saveSettings(settings); }, '当输入电平突然升高时,增益向下回落的速率。数值较小,回落平滑,但大声段落持续时间长;数值较大,反应迅速,能有效防止突发大音量刺激听觉。建议 30 至 80 dB/s。'); sliderRefs.noiseGate = addSlider('噪声门阈值', -80, 0, 0.1, settings.noiseGate, ' dB', (v) => { agcParams.noiseGate = v; settings.noiseGate = v; saveSettings(settings); }, '低于此电平的信号将被视为环境噪声,自动增益不会对其进行放大。数值越低,极微弱的声音也会参与增益提升,可能放大背景沙沙声;数值越高,仅明显的人声或音乐才会被放大,但微弱细节可能被切除。建议 -60 至 -35 dB。'); sliderRefs.outputLimit = addSlider('输出限制', -12, 0, 0.1, settings.outputLimit, ' dB', (v) => { if (limiter) limiter.threshold.value = v; settings.outputLimit = v; saveSettings(settings); }, '最终输出信号允许达到的绝对峰值电平,超过此值将被硬限制。数值越低,留有较大安全余量,基本无失真,但整体响度偏低;数值越高,接近数字满幅,响度最大化,但失真风险升高。建议 -1 至 -0.3 dB。'); const weightTitle = document.createElement('div'); weightTitle.textContent = '响度加权(人耳等响度)'; weightTitle.style.fontWeight = 'bold'; weightTitle.style.borderBottom = '1px solid rgba(255,255,255,0.3)'; weightTitle.style.marginTop = '4px'; settingsPanel.appendChild(weightTitle); const weightRow = document.createElement('div'); weightRow.style.cssText = 'display:flex; align-items:center; gap:6px;'; const weightLabel = document.createElement('span'); weightLabel.textContent = '加权模式'; weightLabel.style.minWidth = '60px'; const weightTooltip = '选择响度测量时采用的频率响应曲线。A加权:模拟低声压下人耳对中频敏感的响应,压制低频和高频,突出人声频段,是最通用的选择。C加权:模拟高声压下的响应,保留更多低频成分,适合重低音节目。关闭:使用原始线性 RMS,不考虑频响差异。推荐日常使用 A加权。'; weightLabel.title = weightTooltip; const weightSelect = document.createElement('select'); weightSelect.title = weightTooltip; weightSelect.style.cssText = 'flex:1; background:#333; color:#fff; border:1px solid #666; border-radius:3px; padding:2px; font-size:12px;'; const weightOptions = [ { v: 'A', t: 'A加权 (dBA, 推荐)' }, { v: 'C', t: 'C加权 (dBC, 大音量)' }, { v: 'Off', t: '关闭 (线性 dBFS)' }, ]; weightOptions.forEach(o => { const opt = document.createElement('option'); opt.value = o.v; opt.textContent = o.t; if (o.v === settings.weightingMode) opt.selected = true; weightSelect.appendChild(opt); }); weightRow.appendChild(weightLabel); weightRow.appendChild(weightSelect); settingsPanel.appendChild(weightRow); const weightHint = document.createElement('div'); weightHint.style.cssText = 'font-size:11px; opacity:0.75; line-height:1.4; padding-left:66px; margin-top:2px;'; weightHint.textContent = 'A加权 = 压低高低频,模拟安静时人耳;低音BGM多可选C加权;传统模式选关闭。'; settingsPanel.appendChild(weightHint); weightSelect.addEventListener('change', () => { settings.weightingMode = weightSelect.value; saveSettings(settings); }); sliderRefs.fftSmoothing = addSlider('频域平滑', 0, 0.95, 0.05, settings.fftSmoothing, '', (v) => { settings.fftSmoothing = v; saveSettings(settings); if (analyser) analyser.smoothingTimeConstant = v; }, '控制频域数据和电平表的平滑程度。数值低时追踪快速瞬变,示数跳动明显,适合观察瞬时峰值;数值高时结果稳定平滑,仅反映平均响度,适合稳定监看。建议 0.5 至 0.8。'); sliderRefs.windowCompensation = addSlider('窗补偿', -6, 6, 0.1, settings.windowCompensation, ' dB', (v) => { settings.windowCompensation = v; saveSettings(settings); }, 'FFT 窗函数造成的能量损失补偿,默认 +3dB 对应 Hann 窗。数值偏低会导致读数偏低,从而过度增益;数值偏高则读数偏高,造成增益不足。通常保持默认即可,仅在实测校准有显著偏差时微调。'); sliderRefs.weightingIntensity = addSlider('加权强度', 0, 100, 1, settings.weightingIntensity, '%', (v) => { settings.weightingIntensity = v; saveSettings(settings); }, '设置加权曲线参与计算的比重,即人耳感知与物理电平的混合比例。强度较低时完全依赖原始电平,低频能量会显著影响音量判断,可能导致人声被压低;强度较高时完全按人耳听感调节,人声清晰度优先,但低频体感减弱。建议 50% 至 80%。'); sliderRefs.minFreq = addSlider('频率下限', 0, 1000, 1, settings.minFreq, ' Hz', (v) => { settings.minFreq = v; saveSettings(settings); }, '参与响度计算的最低频率。数值较低时包含极低频,易受低频噪声干扰;数值较高时滤除这些干扰,使测量更集中于人声频段。建议 30 至 80 Hz。'); sliderRefs.maxFreq = addSlider('频率上限', 2000, 20000, 100, settings.maxFreq, ' Hz', (v) => { settings.maxFreq = v; saveSettings(settings); }, '参与响度计算的最高频率。数值较低时忽略齿音和高频嘶声,避免其影响音量判断;数值较高时全频段计入,高频细节会影响整体决策。建议 10000 至 16000 Hz。'); // 存储模式切换 const storageTitle = document.createElement('div'); storageTitle.textContent = '设置存储'; storageTitle.style.fontWeight = 'bold'; storageTitle.style.borderBottom = '1px solid rgba(255,255,255,0.3)'; storageTitle.style.marginTop = '4px'; settingsPanel.appendChild(storageTitle); const storageRow = document.createElement('div'); storageRow.style.cssText = 'display:flex; align-items:center; gap:6px;'; const storageLabel = document.createElement('span'); storageLabel.textContent = '存储位置'; storageLabel.style.minWidth = '60px'; storageLabel.title = '全局:所有网站共享同一份设置(跨域)。本域名:仅当前网站生效,互不影响。'; const storageSelect = document.createElement('select'); storageSelect.style.cssText = 'flex:1; background:#333; color:#fff; border:1px solid #666; border-radius:3px; padding:2px; font-size:12px;'; const storageOptions = [ { v: 'global', t: '全局(跨域共享,推荐)' }, { v: 'local', t: '本域名(仅当前网站)' }, ]; storageOptions.forEach(o => { const op = document.createElement('option'); op.value = o.v; op.textContent = o.t; if (o.v === settings.storageMode) op.selected = true; storageSelect.appendChild(op); }); storageRow.appendChild(storageLabel); storageRow.appendChild(storageSelect); settingsPanel.appendChild(storageRow); const storageHint = document.createElement('div'); storageHint.style.cssText = 'font-size:10px; opacity:0.7; line-height:1.4; padding-left:66px; margin-top:2px;'; const updateStorageHint = (mode) => { if (mode === 'local') { let host = '当前域名'; try { host = location.host || host; } catch (e) {} storageHint.innerHTML = `设置仅保存到 ${host},其他网站互不影响。切换到全局会自动把当前设置复制过去。`; } else { storageHint.innerHTML = '设置跨所有网站共享。切换到本域名会自动把当前设置复制到当前网站。'; } }; updateStorageHint(settings.storageMode); settingsPanel.appendChild(storageHint); storageSelect.addEventListener('change', () => { const newMode = storageSelect.value; switchStorageMode(newMode); settings.storageMode = newMode; updateStorageHint(newMode); // 提示用户刷新 const tip = document.createElement('div'); tip.style.cssText = 'font-size:11px; color:#ffc107; padding:4px 0;'; tip.textContent = '✓ 已切换并复制设置。建议刷新页面使新存储完全生效。'; settingsPanel.insertBefore(tip, storageHint); setTimeout(() => { try { settingsPanel.removeChild(tip); } catch (e) {} }, 4000); }); // 一键恢复设置 const resetRow = document.createElement('div'); resetRow.style.cssText = 'display:flex; align-items:center; gap:6px; margin-top:6px;'; const resetBtn = document.createElement('button'); resetBtn.textContent = '恢复设置'; resetBtn.title = '将所有参数、排除列表、EQ 设置、存储模式与悬浮球位置恢复为默认值,并刷新页面。此操作不可撤销。'; resetBtn.style.cssText = 'flex:1; padding:5px 0; border:1px solid #e05555; border-radius:4px; background:rgba(224,85,85,0.15); color:#ff8a8a; cursor:pointer; font-size:12px;'; resetBtn.addEventListener('click', () => { if (!confirm('确定要恢复设置吗?\n\n所有参数、排除列表、EQ 设置与悬浮球位置将被重置为默认值,此操作不可撤销。')) return; factoryReset(); }); resetRow.appendChild(resetBtn); settingsPanel.appendChild(resetRow); // 拖动与收起 let collapseTimer = null; let isTouchDragging = false; function expandUI() { if (collapseTimer) { clearTimeout(collapseTimer); collapseTimer = null; } collapsedBar.style.display = 'none'; expandedPanel.style.display = 'flex'; } function collapseUI() { collapsedBar.style.display = 'inline-block'; expandedPanel.style.display = 'none'; if (settingsVisible) { settingsVisible = false; settingsPanel.style.display = 'none'; updateSettingsBtnStyle(); } if (sourceListVisible) { sourceListVisible = false; sourceListPanel.style.display = 'none'; updateSourceBtnStyle(); } if (excludeListVisible) { excludeListVisible = false; excludeListPanel.style.display = 'none'; if (typeof window.__syncExcludedIndicator === 'function') window.__syncExcludedIndicator(); } } function resetCollapseTimer() { if (collapseTimer) clearTimeout(collapseTimer); // 有面板打开时不自动收起 if (settingsVisible || sourceListVisible || excludeListVisible) return; collapseTimer = setTimeout(() => { collapseUI(); collapseTimer = null; }, 200); } // 鼠标拖动 function onMouseDown(e) { if (e.target.closest('button, input, select, textarea')) return; if (e.target.closest('#settings-panel')) return; isDragging = false; const startX = e.clientX, startY = e.clientY; const origLeft = wrapper.offsetLeft, origTop = wrapper.offsetTop; function onMouseMove(e) { const dx = e.clientX - startX, dy = e.clientY - startY; if (!isDragging && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) { isDragging = true; if (collapseTimer) { clearTimeout(collapseTimer); collapseTimer = null; } } if (isDragging) { wrapper.style.left = Math.max(0, Math.min(window.innerWidth - wrapper.offsetWidth, origLeft + dx)) + 'px'; wrapper.style.top = Math.max(0, Math.min(window.innerHeight - wrapper.offsetHeight, origTop + dy)) + 'px'; } } function onMouseUp() { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); if (isDragging) saveWrapperPosition(wrapper.offsetTop, wrapper.offsetLeft); isDragging = false; resetCollapseTimer(); } window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); e.preventDefault(); } wrapper.addEventListener('mousedown', onMouseDown); // 触摸拖动 let touchStartX = 0, touchStartY = 0; let touchOrigLeft = 0, touchOrigTop = 0; wrapper.addEventListener('touchstart', (e) => { if (e.target.closest('button, input, select, textarea')) return; if (e.target.closest('#settings-panel')) return; const touch = e.touches[0]; touchStartX = touch.clientX; touchStartY = touch.clientY; touchOrigLeft = wrapper.offsetLeft; touchOrigTop = wrapper.offsetTop; isTouchDragging = false; expandUI(); }, { passive: true }); wrapper.addEventListener('touchmove', (e) => { if (e.target.closest('button, input, select, textarea')) return; if (e.target.closest('#settings-panel')) return; const touch = e.touches[0]; const dx = touch.clientX - touchStartX; const dy = touch.clientY - touchStartY; if (!isTouchDragging && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) { isTouchDragging = true; if (collapseTimer) { clearTimeout(collapseTimer); collapseTimer = null; } } if (isTouchDragging) { e.preventDefault(); const newLeft = Math.max(0, Math.min(window.innerWidth - wrapper.offsetWidth, touchOrigLeft + dx)); const newTop = Math.max(0, Math.min(window.innerHeight - wrapper.offsetHeight, touchOrigTop + dy)); wrapper.style.left = newLeft + 'px'; wrapper.style.top = newTop + 'px'; } }, { passive: false }); wrapper.addEventListener('touchend', () => { if (isTouchDragging) { saveWrapperPosition(wrapper.offsetTop, wrapper.offsetLeft); isTouchDragging = false; } }); // 鼠标悬停展开 wrapper.addEventListener('mouseenter', expandUI); wrapper.addEventListener('mouseleave', () => { if (isDragging) return; resetCollapseTimer(); }); // 点击外部关闭面板 document.addEventListener('click', (e) => { // 点击目标已被移除(面板内部重新渲染导致),不处理,避免误判为失焦 if (!document.documentElement.contains(e.target)) return; // 如果点击在 wrapper 外部,关闭所有面板并收起 if (!wrapper.contains(e.target)) { if (settingsVisible) { settingsVisible = false; settingsPanel.style.display = 'none'; updateSettingsBtnStyle(); } if (sourceListVisible) { sourceListVisible = false; sourceListPanel.style.display = 'none'; updateSourceBtnStyle(); } if (excludeListVisible) { excludeListVisible = false; excludeListPanel.style.display = 'none'; if (typeof window.__syncExcludedIndicator === 'function') window.__syncExcludedIndicator(); } collapseUI(); return; } // 如果点击在 wrapper 内,但不在某个面板内,则关闭该面板 if (settingsVisible && !e.target.closest('#settings-panel') && !settingsBtn.contains(e.target)) { settingsVisible = false; settingsPanel.style.display = 'none'; updateSettingsBtnStyle(); } if (sourceListVisible && !e.target.closest('#source-list-panel') && !sourceBtn.contains(e.target)) { sourceListVisible = false; sourceListPanel.style.display = 'none'; updateSourceBtnStyle(); } if (excludeListVisible && !e.target.closest('#exclude-list-panel') && !excludeBtn.contains(e.target)) { excludeListVisible = false; excludeListPanel.style.display = 'none'; if (typeof window.__syncExcludedIndicator === 'function') window.__syncExcludedIndicator(); } }); wrapper.appendChild(collapsedBar); wrapper.appendChild(expandedPanel); document.body.appendChild(wrapper); updateToggleStyle(); updateSettingsBtnStyle(); updateSourceBtnStyle(); updateEQBtnStyle(); // 初始化排除状态指示 if (typeof window.__syncExcludedIndicator === 'function') window.__syncExcludedIndicator(); // 窗口尺寸变化时,确保悬浮球始终可见在屏幕内 window.addEventListener('resize', () => { if (!wrapper) return; const w = wrapper.offsetWidth || 120; const h = wrapper.offsetHeight || 30; let l = wrapper.offsetLeft, t = wrapper.offsetTop; let changed = false; if (l + w > window.innerWidth) { l = Math.max(0, window.innerWidth - w); changed = true; } if (t + h > window.innerHeight) { t = Math.max(0, window.innerHeight - h); changed = true; } if (l < 0) { l = 0; changed = true; } if (t < 0) { t = 0; changed = true; } if (changed) { wrapper.style.left = l + 'px'; wrapper.style.top = t + 'px'; saveWrapperPosition(t, l); } }); collapseUI(); } // ---------- 启动(通用策略)---------- function boot() { if (!document.body) { setTimeout(boot, 100); return; } // 注册GM菜单命令:打开设置 if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('打开响度平衡设置', () => { if (!uiCreated) createUI(); if (wrapper && wrapper.style.display === 'none') { wrapper.style.display = 'block'; } if (settingsBtn) { settingsBtn.click(); } }); } initialScanAndBind(); // 无论排除与否都会创建UI if (isNormalizerActive()) { startGlobalObserver(); startUrlObserver(); startHealthCheck(); } // 第一次打开:提示接管音频的风险(全局仅提示一次) if (!hasShownFirstRunNotice()) { showFirstRunNotice(); markFirstRunNoticeShown(); } } // 规格化开启且站点未被排除时才挂钩媒体 API,避免关闭时插入任何监听 if (isNormalizerActive()) installMediaPatches(); boot(); })();