// ==UserScript== // @name 虎牙弹幕工具箱(大小自适应 / 行数自定义 / 禁止重叠) // @namespace https://www.huya.com/ // @version 4.0 // @description :①弹幕大小自适应(窗口越小字号越小)②弹幕行数自定义(按播放器高度自动减行)③禁止弹幕重叠(提前算出会追尾的弹幕,趁它还没露脸就拦下)。快捷键:Alt+Shift+D 打开设置面板;Alt+= / Alt+- 弹幕行数 +1 / -1;Alt+1/2/3 分别切换三个功能;Alt+0 重置。 // @author hanyicheng // @match *://*.huya.com/* // @run-at document-idle // @grant GM_registerMenuCommand // ==/UserScript== (function () { 'use strict'; /* ============ 三个功能开关(互不影响,关掉即回到虎牙默认) ============ */ var DEF = { /* 1. 弹幕大小自适应 */ fitScale: true, baseH: 900, // 参考高度:播放器高度 >= 此值时字号不缩(全屏/剧场模式就是 1.0) minScale: 0.70, // 最小缩放比例(0.70 = 24px 缩到约 17px,再小就看不清了) maxScale: 1.0, /* 2. 弹幕行数自定义 */ limitRows: true, maxRatio: 0.26, // 弹幕区最多占播放器高度的比例(26%;嫌挡调 0.20,想多看调 0.32) maxRows: 5, // 最多保留几行 minRows: 2, // 最少保留几行 /* 3. 禁止弹幕重叠 */ noOverlap: true, antiOverlap: false, // 画面内兜底去重(真的看到两条糊在一起才撤下)。默认关,容易误杀 minOverlap: 10, // 宽容度:算出来只会擦碰 10px 以内就放行(调大=弹幕更多) fadeMs: 320, // 兜底撤下时淡出时长(0 = 瞬间消失) /* 固定开启,面板里不可关闭 */ uniformFS: true, // 统一字号:虎牙普通/贵族/礼物弹幕原始字号不同,统一成最常见的一档 /* ---- 内部参数 ---- */ headRatio: 1.458, // 首行 top / 字号(虎牙实测 35/24,仅作兜底) gapRatio: 1.917, // 轨道间距 / 字号(虎牙实测 46/24,仅作兜底) lineRatio: 1.5, // line-height / 字号(虎牙实测 36/24) itemHRatio: 1.8, // 弹幕实际盒高 / 字号(测不到时兜底) compressTop: false, // true = 行距也跟着缩(会动 top,风险略高) gapPad: 6, // compressTop 为 true 时相邻两行至少留的空隙 overlapX: 0.30, // antiOverlap 打开时:横向重叠超过较窄那条 30% 才算真叠 overlapY: 0.45, // antiOverlap 打开时:纵向重叠超过较矮那条 45% 才算真叠 sweepMs: 300, // antiOverlap 的扫描间隔 speedMargin: 0.03, // 预判安全余量(假设新弹幕快 3%、前车慢 3%),防估算误差漏判 safeGuard: true // 自检:发现弹幕被异常全部隐藏时自动恢复 }; var LS_KEY = 'huyaDanmuFitCfgV4'; var cfg = {}; for (var k in DEF) cfg[k] = DEF[k]; try { var saved = localStorage.getItem(LS_KEY); if (saved) { var o = JSON.parse(saved); for (var j in o) if (j in cfg && typeof o[j] === typeof cfg[j]) cfg[j] = o[j]; } } catch (e) {} cfg.uniformFS = true; // 固定开启,不受存档影响 function save() { try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch (e) {} } /* ============ 状态 ============ */ var wrap = null, stage = null, uiHost = null, layers = [], ro = null, mo = null; var curScale = 1, curRows = 5, maxTopOrig = Infinity, ready = false; var lastH = 0, lastW = 0, prevSig = ''; var origHead = 35, origGap = 46, newGap = 46, topRatio = 1, itemH = 42; var uniFS = 24; // 统一基准字号(当前弹幕里最常见的那一档) var hideCount = 0, dropCount = 0, guardCd = 0; var fallbackT = 0; // 读不到 transition 时长时用的兜底周期 function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); } function el(id) { return document.getElementById(id); } function log(m) { if (window.console) console.log('[弹幕工具箱] ' + m); } /* 页面顶部的一次性提示 */ var tipTimer = null; function tip(text, ms) { var box = el('__hydm_tip'); if (!box) { box = document.createElement('div'); box.id = '__hydm_tip'; box.style.cssText = 'position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:999999;' + 'background:rgba(0,0,0,.78);color:#fff;font:13px/1.7 "Microsoft YaHei",sans-serif;' + 'padding:8px 18px;border-radius:6px;pointer-events:none;white-space:pre-line;text-align:center;'; document.body.appendChild(box); } box.textContent = text; box.style.display = 'block'; clearTimeout(tipTimer); tipTimer = setTimeout(function () { box.style.display = 'none'; }, ms || 2600); } /* ============ 取值:优先用缓存在元素上的"虎牙原始值" ============ */ function origFS(node) { var v = node.dataset ? parseFloat(node.dataset.hydmFs) : NaN; if (!isNaN(v) && v > 0) return v; return parseFloat(node.style.fontSize) || parseFloat(getComputedStyle(node).fontSize) || 24; } function origTop(node) { var v = node.dataset ? parseFloat(node.dataset.hydmTop) : NaN; if (!isNaN(v)) return v; var t = parseFloat(node.style.top); if (!isNaN(t)) return t; var m = /top:\s*([\d.]+)%/.exec(node.getAttribute('style') || ''); if (m) return parseFloat(m[1]) / 100 * (stage ? stage.clientHeight : 0); return NaN; } function baseFontSize() { for (var i = 0; i < layers.length; i++) { var n = layers[i].querySelector('.danmu-item'); if (n) return origFS(n); } return 24; } function origLH(node) { var v = node.dataset ? parseFloat(node.dataset.hydmLh) : NaN; if (!isNaN(v) && v > 0) return v; var cs = parseFloat(getComputedStyle(node).lineHeight); if (!isNaN(cs) && cs > 0) return cs; return origFS(node) * cfg.lineRatio; } /* 虎牙不同类型的弹幕原始字号不一样(普通 / 贵族 / 礼物),取出现最多的那档作为统一基准 */ function learnUniformFS() { var counts = {}, best = 0, bestC = 0; for (var i = 0; i < layers.length; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length; j++) { var n = kids[j]; if (n.nodeType !== 1) continue; if (!n.dataset.hydmFs) { // 先固化原始值,免得读到我们已经改过的数 n.dataset.hydmFs = String(parseFloat(n.style.fontSize) || parseFloat(getComputedStyle(n).fontSize) || 24); } var fs = Math.round(parseFloat(n.dataset.hydmFs)); if (!(fs > 0)) continue; counts[fs] = (counts[fs] || 0) + 1; if (counts[fs] > bestC) { bestC = counts[fs]; best = fs; } } } return { fs: best || 24, dist: counts }; } /* 从真实弹幕的 top 反推轨道参数,比写死比例可靠 */ function learnTracks() { var seen = {}, arr = []; for (var i = 0; i < layers.length; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length; j++) { var t = origTop(kids[j]); if (isNaN(t)) continue; t = Math.round(t); if (!seen[t]) { seen[t] = 1; arr.push(t); } } } if (arr.length < 3) return null; arr.sort(function (a, b) { return a - b; }); var fs = baseFontSize(); var minGap = fs * 0.8; var counts = {}; for (var k = 1; k < arr.length; k++) { var d = Math.round(arr[k] - arr[k - 1]); if (d < minGap || d > 120) continue; counts[d] = (counts[d] || 0) + 1; } var want = fs * cfg.gapRatio; var best = 0, bestC = 0; for (var key in counts) { var v = +key; if (counts[key] > bestC || (counts[key] === bestC && Math.abs(v - want) < Math.abs(best - want))) { bestC = counts[key]; best = v; } } if (!bestC || !best) return null; var pcount = {}; for (var p = 0; p < arr.length; p++) { var ph = ((arr[p] % best) + best) % best; pcount[ph] = (pcount[ph] || 0) + 1; } var bestPh = -1, bestPhC = 0; for (var q in pcount) { if (pcount[q] > bestPhC) { bestPhC = pcount[q]; bestPh = +q; } } var head = arr[0]; for (var m = 0; m < arr.length; m++) { if (((arr[m] % best) + best) % best === bestPh) { head = arr[m]; break; } } return { head: head, gap: best }; } function compute() { var H = stage.clientHeight || Math.round(stage.getBoundingClientRect().height); var W = stage.clientWidth || Math.round(stage.getBoundingClientRect().width); lastH = H; lastW = W; var uni = learnUniformFS(); uniFS = uni.fs; var fs = cfg.uniformFS ? uniFS : baseFontSize(); var head = fs * cfg.headRatio; var gap = fs * cfg.gapRatio; var learned = learnTracks(); if (learned) { head = learned.head; gap = learned.gap; } origHead = head; origGap = gap; // 功能①:大小自适应。关掉就是 1.0(虎牙原始字号) curScale = cfg.fitScale ? clamp(H / cfg.baseH, cfg.minScale, cfg.maxScale) : 1; itemH = measureItemH() || (fs * cfg.itemHRatio * curScale); var budget = H * cfg.maxRatio; var lo = Math.min(cfg.minRows, cfg.maxRows); var hi = Math.max(cfg.minRows, cfg.maxRows); if (cfg.compressTop) { newGap = Math.max(gap * curScale, itemH + cfg.gapPad); topRatio = newGap / gap; curRows = clamp(Math.floor((budget - itemH - head * topRatio) / newGap) + 1, lo, hi); } else { newGap = gap; topRatio = 1; curRows = clamp(Math.floor((budget - itemH - head) / gap) + 1, lo, hi); } // 功能②:行数自定义。关掉就是 Infinity(虎牙原始 5 行全部放行) maxTopOrig = cfg.limitRows ? head + gap * (curRows - 1) + Math.max(1, gap * 0.25) : Infinity; } function measureItemH() { var list = []; for (var i = 0; i < layers.length && list.length < 60; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length && list.length < 60; j++) { var n = kids[j]; if (n.nodeType !== 1) continue; styleText(n); list.push(n); } } var maxH = 0; for (var m = 0; m < list.length; m++) { var h = list[m].offsetHeight; if (h > maxH) maxH = h; } return maxH; } /* 只改文字相关样式;top 只有 compressTop 打开时才动 */ function styleText(node) { var fs = origFS(node); var lh = origLH(node); if (node.dataset) { if (!node.dataset.hydmFs) node.dataset.hydmFs = String(fs); if (!node.dataset.hydmLh) node.dataset.hydmLh = String(lh); } // 字号统一固定开启:所有弹幕都用同一个基准(众数),行高按统一字号的比例算 var useFS = cfg.uniformFS ? uniFS : fs; var useLH = cfg.uniformFS ? (uniFS * cfg.lineRatio) : lh; node.style.setProperty('font-size', (useFS * curScale).toFixed(2) + 'px', 'important'); node.style.setProperty('line-height', (useLH * curScale).toFixed(2) + 'px', 'important'); var top = origTop(node); if (!isNaN(top) && node.dataset && !node.dataset.hydmTop) node.dataset.hydmTop = String(top); if (cfg.compressTop && topRatio !== 1 && !isNaN(top)) { node.style.setProperty('top', (top * topRatio).toFixed(2) + 'px', 'important'); } } /* ================= 功能③:追尾预判 ================= * 虎牙每条弹幕都是 CSS transition 滚动,duration 写在元素上(实测 8.7~9.6s,逐条随机)。 * 位移 = 舞台宽 + 弹幕宽,所以 速度 = (舞台宽 + 弹幕宽) / duration: * 105px 宽 -> 约 96 px/s 393px 宽 -> 约 126 px/s * 宽弹幕快三成,从后面追上窄弹幕 -> 重叠。 * duration 能直接读到,所以可以在弹幕还藏在画面右侧外面时精确算出它会不会撞上前车。 */ /* 舞台坐标系下的位置:l = 左边缘距舞台左边的距离,w = 宽度 */ function boxOf(node) { var r = node.getBoundingClientRect(); var sr = stage.getBoundingClientRect(); return { l: r.left - sr.left, w: r.width, t: r.top, b: r.bottom }; } function stageW() { return stage.clientWidth || stage.getBoundingClientRect().width || lastW || 0; } /* 首次见到这条弹幕时记下原始宽度和滚动时长,算出速度(px/s)。 * 必须在 styleText 之前调用 —— 改了字号宽度就变了,算出来的速度会偏。 */ function captureMetrics(node) { if (!node.dataset) return; if (node.dataset.hydmSpd) return; var w = node.getBoundingClientRect().width; if (!(w > 0)) return; node.dataset.hydmW0 = w.toFixed(1); var T = parseFloat(getComputedStyle(node).transitionDuration); // "9.50408s" if (isNaN(T) || !(T > 0.3)) T = fallbackT; if (!(T > 0.3)) return; var v = (stageW() + w) / T; if (!(v > 1) || v > 5000) return; node.dataset.hydmSpd = v.toFixed(2); if (!fallbackT) fallbackT = T; } function speedOf(node) { if (!node.dataset) return NaN; var v = parseFloat(node.dataset.hydmSpd); if (!isNaN(v) && v > 1) return v; var w = parseFloat(node.dataset.hydmW0); var T = parseFloat(getComputedStyle(node).transitionDuration); if (isNaN(T) || !(T > 0.3)) T = fallbackT; if (isNaN(w) || !(w > 0) || !(T > 0.3)) return NaN; return (stageW() + w) / T; } /* * 这条弹幕滚出去之前,会不会撞上同一行前面的某条弹幕? * 设 新弹幕左边缘 = x新 - v新·t,前车右边缘 = x前尾 - v前·t,重叠深度 * L(t) = (x前尾 - x新) + (v新 - v前)·t * 只有 v新 > v前 时 L 才变大。重叠"看得见"需同时满足:新弹幕已进画面 且 前车未滚出左边。 */ function willCollide(node) { if (!stage) return false; var vN = speedOf(node); var topN = origTop(node); if (isNaN(vN) || isNaN(topN)) return false; var bN = boxOf(node), W = stageW(); var xN = bN.l; var mg = cfg.speedMargin || 0; var vFast = vN * (1 + mg); var thr = cfg.minOverlap || 0; for (var i = 0; i < layers.length; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length; j++) { var p = kids[j]; if (p.nodeType !== 1 || p === node) continue; if (p.dataset && p.dataset.hydmHide === '1') continue; if (p.style.visibility === 'hidden') continue; if (String(p.className).indexOf('danmu-item') < 0) continue; var tp = origTop(p); if (isNaN(tp) || Math.abs(tp - topN) > 6) continue; // 不在同一行 var bp = boxOf(p); var tail = bp.l + bp.w; if (tail <= 0) continue; // 前车已整个滚出左边 // 不能跳过"还在画面右外侧"的前车 —— 两条常前后脚出生,等滚进来才叠上 if (bp.l > W * 1.5) continue; var vP = speedOf(p); if (isNaN(vP)) continue; var vSlow = vP * (1 - mg); var dv = vFast - vSlow; if (dv <= 0.5) continue; // 不比它快,永远追不上 var gap = xN - tail; var tThr = (gap + thr) / dv; var tEnter = (xN - W) / vFast; if (tEnter < 0) tEnter = 0; var tExit = tail / vSlow; var tStart = tThr > tEnter ? tThr : tEnter; if (tStart < tExit) return true; } } return false; } /* 画面里的兜底撤下:给个淡出,别"啪"一下没了 */ function injectKeyframes() { if (el('__hydm_style')) return; var s = document.createElement('style'); s.id = '__hydm_style'; // 只动 opacity,不碰 transform —— 虎牙靠 transition 控制位移,不能覆盖它的 transition s.textContent = '@keyframes hydmFadeOut{from{opacity:1}to{opacity:0}}'; (document.head || document.documentElement).appendChild(s); } function fadeHide(node) { if (node.dataset && node.dataset.hydmHide !== '1') { node.dataset.hydmHide = '1'; hideCount++; } if (cfg.fadeMs > 0 && !offscreen(node)) { injectKeyframes(); node.style.setProperty('animation', 'hydmFadeOut ' + cfg.fadeMs + 'ms linear forwards', 'important'); } else { node.style.setProperty('visibility', 'hidden', 'important'); } } /* 这条弹幕是不是还没从右边露脸?(只有"没露脸"时才允许改它的显示状态) */ function offscreen(node) { var r = node.getBoundingClientRect(); var sr = stage.getBoundingClientRect(); if (!r.width || !r.height) return true; return r.left >= sr.right - 8; } function setHide(node) { if (node.dataset && node.dataset.hydmHide !== '1') { node.dataset.hydmHide = '1'; hideCount++; } node.style.setProperty('visibility', 'hidden', 'important'); } function setShow(node) { if (node.dataset) node.dataset.hydmHide = '0'; var v = node.dataset ? node.dataset.hydmVis : 'none'; if (v && v !== 'none') node.style.setProperty('visibility', v); else node.style.removeProperty('visibility'); } /* * 核心规则:一条弹幕的显示/隐藏,只允许在它"还没从右边露脸"的时候切换。 * - 已经滚进画面的一律不翻案 -> 既不会凭空冒出,也不会滚到一半突然消失 * - 窗口变小时,超出的行里还没进场的不放行,已进场的让它滚完自然走 -> 平滑过渡 * - 窗口变大时,还没进场的补放行,已进场的不补 -> 新行是慢慢滚出来填满的 */ function applyTo(node, isNew) { if (!node || node.nodeType !== 1) return; if (node.className && String(node.className).indexOf('danmu-item') < 0) return; if (node.dataset && node.dataset.hydmVis === undefined) { node.dataset.hydmVis = node.style.visibility || 'none'; } // 虎牙复用 DOM 元素时会重设 top —— 按"新的一条"处理,清掉上一条的标记 if (isNew && node.dataset && node.dataset.hydmTop) { var ct = parseFloat(node.style.top); if (!isNaN(ct) && Math.abs(ct - parseFloat(node.dataset.hydmTop)) > 0.5) { node.dataset.hydmTop = String(ct); delete node.dataset.hydmHide; delete node.dataset.hydmSpd; delete node.dataset.hydmW0; delete node.dataset.hydmJudged; node.style.removeProperty('animation'); node.style.removeProperty('opacity'); } } captureMetrics(node); // 必须在 styleText 之前 styleText(node); var top = origTop(node); if (isNaN(top)) return; // 功能②:超出预算行数 var wantHide = cfg.limitRows && top > maxTopOrig; // 功能③:追尾预判(每条只在第一次出现、且还没露脸时算一次) if (!wantHide && cfg.noOverlap && node.dataset && !node.dataset.hydmJudged) { node.dataset.hydmJudged = '1'; if (willCollide(node)) { wantHide = true; dropCount++; } } var isHidden = node.style.visibility === 'hidden' || (node.dataset && node.dataset.hydmHide === '1'); if (wantHide === isHidden) return; // 状态已经对了,别碰它 if (!offscreen(node)) return; // 已经露脸 -> 不翻案,让它自然滚完 if (wantHide) setHide(node); else setShow(node); } /* 功能③的兜底:量真实矩形,只有真压在一起才撤下后面那条 */ function sweep() { if (!ready || !cfg.noOverlap || !cfg.antiOverlap) return; var items = []; for (var i = 0; i < layers.length; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length; j++) { var n = kids[j]; if (n.nodeType !== 1) continue; if (String(n.className).indexOf('danmu-item') < 0) continue; if (n.style.visibility === 'hidden') continue; if (n.dataset && n.dataset.hydmHide === '1') continue; var r = n.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue; if (r.right < 0 || r.left > (window.innerWidth + 50)) continue; items.push({ n: n, l: r.left, r: r.right, t: r.top, b: r.bottom }); } } if (items.length < 2) return; items.sort(function (a, b) { return a.l - b.l; }); // 越靠左越早出现,保留早的 var kept = []; for (var k = 0; k < items.length; k++) { var c = items[k], hit = false; for (var m = 0; m < kept.length; m++) { var p = kept[m]; var ox = Math.min(c.r, p.r) - Math.max(c.l, p.l); var oy = Math.min(c.b, p.b) - Math.max(c.t, p.t); if (ox <= 0 || oy <= 0) continue; if (ox / Math.min(c.r - c.l, p.r - p.l) >= cfg.overlapX && oy / Math.min(c.b - c.t, p.b - p.t) >= cfg.overlapY) { hit = true; break; } } if (hit) fadeHide(c.n); else kept.push(c); } } /* 自检:页面上一堆弹幕却一条都不显示 = 出问题了,自动清掉状态重来 */ function safeguard() { if (!cfg.safeGuard || !ready) return; if (!cfg.limitRows && !cfg.noOverlap) return; // 没开隐藏类功能就不可能全灭 var now = Date.now(); if (now < guardCd) return; var total = 0, alive = 0; for (var i = 0; i < layers.length; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length; j++) { var n = kids[j]; if (n.nodeType !== 1) continue; if (String(n.className).indexOf('danmu-item') < 0) continue; total++; if (n.style.visibility !== 'hidden') alive++; } } if (total >= 6 && alive === 0) { forceReset(); guardCd = now + 10000; log('自检:弹幕被全部隐藏,已强制恢复'); tip('弹幕工具箱自检:弹幕被异常全隐藏,已自动恢复', 3200); } } /* 急救:把所有弹幕还原成虎牙原生状态,缓存的基准值也一并清掉 */ function forceReset() { reset(); for (var i = 0; i < layers.length; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length; j++) { var n = kids[j]; if (!n.dataset) continue; delete n.dataset.hydmFs; delete n.dataset.hydmLh; delete n.dataset.hydmTop; delete n.dataset.hydmVis; delete n.dataset.hydmHide; // 速度不重算:它是用"虎牙原始宽度"量出来的,改过字号后再量会偏小 delete n.dataset.hydmJudged; n.style.removeProperty('animation'); n.style.removeProperty('opacity'); } } hideCount = 0; dropCount = 0; } function rejudge() { for (var i = 0; i < layers.length; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length; j++) applyTo(kids[j]); } } function apply(silent) { if (!ready) return; compute(); rejudge(); try { window.__hydmDebug = { playerH: lastH, playerW: lastW, fitScale: cfg.fitScale, limitRows: cfg.limitRows, noOverlap: cfg.noOverlap, scale: +curScale.toFixed(3), rows: curRows, baseFS: +uniFS.toFixed(1), nowFS: +parseFloat(uniFS * curScale).toFixed(1), itemH: +itemH.toFixed(1), fsDist: learnUniformFS().dist, origHead: +origHead.toFixed(1), origGap: +origGap.toFixed(1), newGap: +newGap.toFixed(1), topRatio: +topRatio.toFixed(3), maxTop: (maxTopOrig === Infinity ? 'Infinity' : +maxTopOrig.toFixed(1)), maxRows: cfg.maxRows, minRows: cfg.minRows, hidden: hideCount, dropped: dropCount, antiOverlap: cfg.antiOverlap, learned: learnTracks(), fallbackT: +fallbackT.toFixed(2) }; } catch (e) {} var sig = lastH + '|' + curScale.toFixed(2) + '|' + curRows + '|' + cfg.maxRatio + '|' + cfg.maxRows + '|' + cfg.compressTop + '|' + cfg.fitScale + '|' + cfg.limitRows; if (sig !== prevSig) { prevSig = sig; log('播放器 ' + lastW + 'x' + lastH + ' | 缩放 ' + curScale.toFixed(2) + ' | 保留 ' + curRows + ' 行 | 行距 ' + newGap.toFixed(1) + 'px(行高 ' + itemH.toFixed(1) + ')' + ' | 上限 ' + Math.round(cfg.maxRatio * 100) + '%'); } } function reset() { if (!layers.length) return; for (var i = 0; i < layers.length; i++) { var kids = layers[i].children; for (var j = 0; j < kids.length; j++) { var n = kids[j]; // 只有我们设过的(带 !important)才需要还原;虎牙自己设的样式一律不碰 if (n.style.getPropertyPriority('font-size') === 'important') { if (n.dataset && n.dataset.hydmFs) n.style.setProperty('font-size', n.dataset.hydmFs + 'px'); else n.style.removeProperty('font-size'); } if (n.style.getPropertyPriority('line-height') === 'important') { if (n.dataset && n.dataset.hydmLh) n.style.setProperty('line-height', n.dataset.hydmLh + 'px'); else n.style.removeProperty('line-height'); } if (n.dataset && n.dataset.hydmTop) n.style.setProperty('top', n.dataset.hydmTop + 'px'); var v = n.dataset ? n.dataset.hydmVis : ''; if (v && v !== 'none') n.style.setProperty('visibility', v); else n.style.removeProperty('visibility'); if (n.style.getPropertyPriority('animation') === 'important') n.style.removeProperty('animation'); if (n.style.getPropertyPriority('opacity') === 'important') n.style.removeProperty('opacity'); if (n.dataset) { n.dataset.hydmHide = '0'; delete n.dataset.hydmJudged; } } } hideCount = 0; } /* ============================================================ * 设置面板 * ============================================================ */ var FEATS = [ { key: 'fitScale', name: '弹幕大小自适应', desc: '窗口越小字号越小' }, { key: 'limitRows', name: '弹幕行数自定义', desc: '按播放器高度自动减少弹幕行数' }, { key: 'noOverlap', name: '禁止弹幕重叠', desc: '提前算出会追尾的弹幕,未露脸就拦下' } ]; var PANEL_CSS = [ '#hydmFab{position:absolute;right:10px;bottom:52px;z-index:2147483000;padding:4px 11px;', 'border-radius:13px;background:rgba(0,0,0,.55);color:#fff;opacity:.28;cursor:pointer;', 'font:12px/1.5 -apple-system,"Microsoft YaHei",sans-serif;user-select:none;transition:opacity .18s;', 'box-shadow:0 2px 8px rgba(0,0,0,.35);white-space:nowrap;}', '#hydmFab:hover{opacity:1;}', '#hydmPanel{position:absolute;right:10px;bottom:88px;width:240px;max-height:78%;overflow:auto;', 'z-index:2147483001;background:#181c25;color:#e9ecf1;border:1px solid #2f3542;border-radius:10px;', 'font:13px/1.6 -apple-system,"Microsoft YaHei",sans-serif;', 'box-shadow:0 12px 34px rgba(0,0,0,.55);padding:12px 14px 11px;user-select:none;display:none;}', '#hydmPanel *{box-sizing:border-box;font-family:inherit;}', '.hydmHd{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;}', '.hydmTt{font-size:14px;font-weight:600;color:#fff;}', '.hydmX{cursor:pointer;color:#8b93a5;font-size:16px;line-height:1;padding:0 2px;}', '.hydmX:hover{color:#fff;}', '.hydmRow{display:flex;align-items:flex-start;gap:10px;padding:8px 0;border-top:1px solid #262c38;}', '.hydmMain{flex:1 1 auto;min-width:0;}', '.hydmNm{color:#fff;font-size:13px;}', '.hydmDs{color:#8b93a5;font-size:11px;line-height:1.5;margin-top:2px;}', '.hydmSw{flex:0 0 auto;width:38px;height:20px;border-radius:10px;background:#39404f;', 'position:relative;cursor:pointer;transition:background .18s;margin-top:2px;}', '.hydmSw>span{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;', 'background:#fff;transition:left .18s;}', '.hydmSw.on{background:#ff8a00;}', '.hydmSw.on>span{left:20px;}', '.hydmBt{margin-top:12px;width:100%;padding:7px 0;text-align:center;border-radius:6px;', 'background:#2a3040;color:#dfe4ec;cursor:pointer;font-size:12px;}', '.hydmBt:hover{background:#333a4c;}', '.hydmFt{margin-top:8px;color:#6b7385;font-size:10.5px;line-height:1.7;white-space:pre-line;}' ].join(''); var panelEl = null, fabEl = null, panelOpen = false; function injectPanelCSS() { if (el('hydmPanelCss')) return; var s = document.createElement('style'); s.id = 'hydmPanelCss'; s.textContent = PANEL_CSS; (document.head || document.documentElement).appendChild(s); } function mk(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt !== undefined) e.textContent = txt; return e; } function buildPanel() { var p = mk('div'); p.id = 'hydmPanel'; var hd = mk('div', 'hydmHd'); hd.appendChild(mk('div', 'hydmTt', '弹幕设置')); var x = mk('div', 'hydmX', '×'); x.onclick = function (e) { e.stopPropagation(); togglePanel(false); }; hd.appendChild(x); p.appendChild(hd); /* 三个功能开关 */ FEATS.forEach(function (f) { var row = mk('div', 'hydmRow'); var main = mk('div', 'hydmMain'); main.appendChild(mk('div', 'hydmNm', f.name)); main.appendChild(mk('div', 'hydmDs', f.desc)); row.appendChild(main); var sw = mk('div', 'hydmSw'); sw.appendChild(mk('span')); sw.dataset.key = f.key; function sync() { sw.classList.toggle('on', !!cfg[f.key]); } sync(); sw._sync = sync; sw.onclick = function (e) { e.stopPropagation(); cfg[f.key] = !cfg[f.key]; save(); sync(); syncUI(); switchFeature(f.key); }; row.appendChild(sw); p.appendChild(row); }); /* 重置按钮 */ var bt = mk('div', 'hydmBt', '重置弹幕状态'); bt.style.marginTop = '12px'; bt.onclick = function (e) { e.stopPropagation(); forceReset(); apply(true); tip('已重置弹幕状态', 1600); }; p.appendChild(bt); p.appendChild(mk('div', 'hydmFt', 'Alt+Shift+D 打开/关闭本面板\nAlt+= / Alt+- 弹幕行数 +1 / -1\n' + 'Alt+1/2/3 分别切换三个功能 · Alt+0 重置')); p.onclick = function (e) { e.stopPropagation(); }; p.onmousedown = function (e) { e.stopPropagation(); }; return p; } function syncUI() { if (!panelEl) return; var sws = panelEl.querySelectorAll('.hydmSw'); for (var i = 0; i < sws.length; i++) { var key = sws[i].dataset.key; sws[i].classList.toggle('on', !!cfg[key]); } } function togglePanel(show) { if (!panelEl) return; var want = (show === undefined) ? !panelOpen : show; panelOpen = want; panelEl.style.display = want ? 'block' : 'none'; if (want) syncUI(); } function enableDrag(node) { var dragging = false, moved = false, sx = 0, sy = 0, sr = 0, sb = 0; node.addEventListener('mousedown', function (e) { if (e.button !== 0) return; var pr = uiHost.getBoundingClientRect(), nr = node.getBoundingClientRect(); sr = pr.right - nr.right; sb = pr.bottom - nr.bottom; sx = e.clientX; sy = e.clientY; dragging = true; moved = false; e.preventDefault(); e.stopPropagation(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; if (Math.abs(e.clientX - sx) + Math.abs(e.clientY - sy) > 3) moved = true; var pr = uiHost.getBoundingClientRect(), nr = node.getBoundingClientRect(); var r = clamp(sr - (e.clientX - sx), 0, Math.max(0, pr.width - nr.width)); var b = clamp(sb - (e.clientY - sy), 0, Math.max(0, pr.height - nr.height)); node.style.right = r + 'px'; node.style.bottom = b + 'px'; node.style.left = 'auto'; node.style.top = 'auto'; }); document.addEventListener('mouseup', function () { dragging = false; }); node.addEventListener('click', function (e) { e.stopPropagation(); if (moved) return; togglePanel(); }); } function ensureUI() { if (!uiHost) return; injectPanelCSS(); var cs = getComputedStyle(uiHost); if (cs.position === 'static') uiHost.style.setProperty('position', 'relative', 'important'); if (!fabEl || !fabEl.isConnected) { fabEl = mk('div', null, '弹幕设置'); fabEl.id = 'hydmFab'; fabEl.title = '点击打开设置 · 可拖动'; uiHost.appendChild(fabEl); enableDrag(fabEl); } if (!panelEl || !panelEl.isConnected) { panelEl = buildPanel(); uiHost.appendChild(panelEl); } } /* 切换功能后:清掉判定缓存重新算,已露脸的弹幕不会被翻案 */ function switchFeature(key) { if (key === 'fitScale') { apply(true); } else if (key === 'limitRows') { apply(true); } else if (key === 'noOverlap') { if (cfg.noOverlap) { forceReset(); apply(true); } else { reset(); apply(true); } } tip(FEATS.filter(function (f) { return f.key === key; })[0].name + ':' + (cfg[key] ? '开' : '关(虎牙默认)'), 1800); } /* ============ 初始化 ============ */ function init() { wrap = el('danmuwrap'); if (!wrap) return false; stage = el('player-video') || wrap.parentElement; if (!stage) return false; uiHost = wrap.parentElement || stage; layers = []; var d1 = el('danmudiv'), d2 = el('danmudiv2'); if (d1) layers.push(d1); if (d2) layers.push(d2); if (!layers.length) return false; if (window.MutationObserver && !mo) { mo = new MutationObserver(function (records) { for (var i = 0; i < records.length; i++) { var added = records[i].addedNodes; for (var j = 0; j < added.length; j++) { var n = added[j]; applyTo(n, true); if (n.querySelectorAll) { var sub = n.querySelectorAll('.danmu-item'); for (var m = 0; m < sub.length; m++) applyTo(sub[m], true); } } } }); for (var i = 0; i < layers.length; i++) mo.observe(layers[i], { childList: true }); } var t = null; function onResize() { clearTimeout(t); t = setTimeout(function () { apply(true); }, 200); } if (window.ResizeObserver && !ro) { ro = new ResizeObserver(onResize); ro.observe(stage); } window.addEventListener('resize', onResize); document.addEventListener('fullscreenchange', onResize); ready = true; apply(true); ensureUI(); if (!window.__hydmBooted) { window.__hydmBooted = true; tip('弹幕工具箱 v4.0\n大小自适应 ' + (cfg.fitScale ? '开' : '关') + ' · 行数自定义 ' + (cfg.limitRows ? '开' : '关') + ' · 禁止重叠 ' + (cfg.noOverlap ? '开' : '关') + '\nAlt+Shift+D 打开设置面板', 4200); } return true; } /* 脚本管理器菜单:打开设置面板 */ function openSettings() { if (!ready) init(); ensureUI(); togglePanel(true); } window.openHydmSettings = openSettings; if (typeof GM_registerMenuCommand === 'function') { try { GM_registerMenuCommand('打开设置', openSettings); } catch (e) {} } /* ============ 快捷键 ============ */ document.addEventListener('keydown', function (e) { if (!e.altKey) return; var tag = (e.target && e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea') return; // Alt+Shift+D:打开/关闭设置面板 if (e.shiftKey && (e.key === 'D' || e.key === 'd')) { ensureUI(); togglePanel(); e.preventDefault(); return; } // Alt+0:急救重置 if (e.code === 'Digit0' || e.key === '0') { forceReset(); apply(true); tip('已重置弹幕状态\n' + curRows + ' 行 · 缩放 ' + curScale.toFixed(2)); e.preventDefault(); return; } // Alt+1/2/3:分别切换三个功能 var map = { Digit1: 'fitScale', Digit2: 'limitRows', Digit3: 'noOverlap', Numpad1: 'fitScale', Numpad2: 'limitRows', Numpad3: 'noOverlap' }; if (map[e.code]) { var key = map[e.code]; cfg[key] = !cfg[key]; save(); syncUI(); switchFeature(key); e.preventDefault(); return; } if (e.code === 'Minus' || e.code === 'Equal') { cfg.maxRows = clamp(cfg.maxRows + (e.code === 'Equal' ? 1 : -1), cfg.minRows, 12); save(); apply(true); syncUI(); tip('行数上限 ' + cfg.maxRows + ' 行 | 当前 ' + curRows + ' 行'); e.preventDefault(); return; } if (e.key === '[' || e.key === ']') { cfg.maxRatio = clamp(+(cfg.maxRatio + (e.key === ']' ? 0.02 : -0.02)).toFixed(3), 0.08, 0.60); save(); apply(true); syncUI(); tip('占比上限 ' + Math.round(cfg.maxRatio * 100) + '% | 当前 ' + curRows + ' 行'); e.preventDefault(); } }, true); /* ============ 启动 / 巡检 ============ */ var bootTimer = setInterval(function () { if (init()) clearInterval(bootTimer); }, 500); setTimeout(function () { clearInterval(bootTimer); }, 30000); setInterval(function () { var w = el('danmuwrap'); if (!w || !w.isConnected || w !== wrap) { ready = false; if (mo) { mo.disconnect(); mo = null; } if (ro) { ro.disconnect(); ro = null; } init(); } else if (ready) { apply(true); safeguard(); ensureUI(); } }, 3000); setInterval(sweep, cfg.sweepMs); })();