// ==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();
})();