// ==UserScript== // @name B站弹幕显示发送次数与点赞数 // @namespace https://tampermonkey.net/ // @description 在弹幕旁边显示发送次数(合并同文本弹幕)与点赞数。 // @version 1.0.0 // @author ZBpine // @icon https://www.bilibili.com/favicon.ico // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/bangumi/play/* // @match https://www.bilibili.com/list/watchlater* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect api.bilibili.com // @run-at document-start // @license MIT // ==/UserScript== /******/ (() => { // webpackBootstrap /******/ "use strict"; ;// ./src/ui.css const ui_namespaceObject = "/* 弹幕角标 */\r\n.tm-danmaku-badge {\r\n position: absolute;\r\n left: 100%;\r\n top: 50%;\r\n transform: translateY(-50%);\r\n margin-left: .28em;\r\n font-size: .86em;\r\n pointer-events: none;\r\n user-select: none;\r\n white-space: nowrap;\r\n display: inline-flex;\r\n align-items: center;\r\n gap: .18em;\r\n}\r\n\r\n.tm-danmaku-badge svg {\r\n width: 1em;\r\n height: 1em;\r\n flex: 0 0 auto;\r\n}\r\n\r\n.tm-danmaku-merged-hide {\r\n opacity: 0 !important;\r\n visibility: hidden !important;\r\n pointer-events: none !important;\r\n}\r\n\r\n/* 可折叠面板外壳 */\r\n.tm-dm-adapt-panel {\r\n width: 100%;\r\n box-sizing: border-box;\r\n flex: 0 0 100%;\r\n margin: 8px 0 6px;\r\n}\r\n\r\n/* 面板头 */\r\n.tm-dm-adapt-panel-header {\r\n width: 100%;\r\n box-sizing: border-box;\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 10px;\r\n cursor: pointer;\r\n user-select: none;\r\n padding: 6px;\r\n}\r\n\r\n.tm-dm-adapt-panel-title {\r\n font-size: 14px;\r\n line-height: 20px;\r\n color: var(--text1, #18191C);\r\n}\r\n\r\n/* 小箭头 */\r\n.tm-dm-adapt-panel-caret {\r\n width: 6px;\r\n height: 6px;\r\n flex: 0 0 auto;\r\n opacity: .75;\r\n border-right: 2px solid currentColor;\r\n border-bottom: 2px solid currentColor;\r\n transform: rotate(45deg);\r\n /* 向下 */\r\n transition: transform .18s ease;\r\n margin-right: 2px;\r\n}\r\n\r\n.tm-dm-adapt-panel.closed .tm-dm-adapt-panel-caret {\r\n transform: rotate(-45deg);\r\n}\r\n\r\n/* 面板体:用 max-height 做动画 */\r\n.tm-dm-adapt-panel-body {\r\n overflow: hidden;\r\n max-height: 999px;\r\n transform-origin: top;\r\n transform: scaleY(1);\r\n opacity: 1;\r\n transition: max-height .22s ease, transform .18s ease, opacity .18s ease;\r\n will-change: max-height, transform, opacity;\r\n}\r\n\r\n.tm-dm-adapt-panel.closed .tm-dm-adapt-panel-body {\r\n max-height: 0;\r\n transform: scaleY(0);\r\n opacity: 0;\r\n}\r\n\r\n/* 控制栏容器:强制竖向 + 占满宽度,避免被父级 flex 横排影响 */\r\n.tm-dm-adapt-controls {\r\n width: 100%;\r\n box-sizing: border-box;\r\n flex: 0 0 100%;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 10px;\r\n padding: 10px 0 6px 10px;\r\n}\r\n\r\n/* 每一行:左文字右开关 */\r\n.tm-dm-adapt-toggle {\r\n width: 100%;\r\n box-sizing: border-box;\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 12px;\r\n cursor: pointer;\r\n user-select: none;\r\n}\r\n\r\n.tm-dm-adapt-toggle-txt {\r\n font-size: 14px;\r\n line-height: 20px;\r\n color: var(--text2, #61666D);\r\n}\r\n\r\n/* 自画开关(接近B站自动连播) */\r\n.tm-dm-adapt-switch {\r\n width: 30px;\r\n height: 20px;\r\n border-radius: 999px;\r\n background: var(--graph_bg_thick, #E3E5E7);\r\n /* off 灰 */\r\n position: relative;\r\n flex: 0 0 auto;\r\n transition: background .18s ease;\r\n}\r\n\r\n.tm-dm-adapt-switch.on {\r\n background: var(--brand_blue, #00AEEC);\r\n /* on 蓝(B站主蓝) */\r\n}\r\n\r\n.tm-dm-adapt-switch-block {\r\n width: 16px;\r\n height: 16px;\r\n border-radius: 999px;\r\n background: #fff;\r\n position: absolute;\r\n top: 2px;\r\n left: 2px;\r\n transition: transform .18s ease;\r\n box-shadow: 0 1px 4px rgba(0, 0, 0, .18);\r\n /* 小圆点阴影 */\r\n}\r\n\r\n.tm-dm-adapt-switch.on .tm-dm-adapt-switch-block {\r\n transform: translateX(10px);\r\n}\r\n\r\n.tm-dm-adapt-settings {\r\n padding-left: 10px;\r\n display: grid;\r\n grid-template-columns: 1fr;\r\n gap: 6px;\r\n color: var(--text2, #61666D);\r\n font-size: 12px;\r\n}\r\n\r\n.tm-dm-adapt-tip {\r\n color: var(--text3, #9499A0);\r\n font-size: 12px;\r\n line-height: 16px;\r\n}\r\n\r\n\r\n.tm-dm-adapt-like-row {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n}\r\n\r\n.tm-dm-adapt-like-row label {\r\n min-width: 84px;\r\n color: var(--text2, #61666D);\r\n}\r\n\r\n.tm-dm-adapt-like-row select,\r\n.tm-dm-adapt-like-row input {\r\n flex: 1 1 auto;\r\n height: 28px;\r\n border-radius: 6px;\r\n border: 1px solid var(--line_regular, rgba(0, 0, 0, .08));\r\n background: var(--bg1, #fff);\r\n padding: 0 8px;\r\n outline: none;\r\n}"; ;// ./src/ui.js // src/ui.js const LIKE_SVG = ` `; const SEND_SVG = ` `; function injectStyles() { // 避免重复注入 if (document.getElementById("tm-dm-adapt-style")) return; const style = document.createElement("style"); style.id = "tm-dm-adapt-style"; style.textContent = ui_namespaceObject; // document-start 可能 head 还没出来,兜底到 documentElement document.documentElement.appendChild(style); } function createUI({ config, saveConfig, rebuildAll, refreshAll, castToInteger }) { /*********************** * UI 面板:页面开关注入(弹幕列表下方) ***********************/ let uiMounted = false; let uiPanel = null; let mergeSwitchBtn = null; let mergeSettingsWrap = null; let mergeWindowInput = null; let likesSwitchBtn = null; let likesSettingsWrap = null; let likeScopeSel = null; let likeMinInput = null; function setSwitchOn(swEl, on) { if (!swEl) return; swEl.classList.toggle("on", !!on); } function createSettingsWrap(innerHTML, extraClass) { const wrap = document.createElement("div"); wrap.className = "tm-dm-adapt-settings" + (extraClass ? ` ${extraClass}` : ""); wrap.innerHTML = innerHTML; return wrap; } function setWrapVisible(wrap, visible) { if (!wrap) return; wrap.style.display = visible ? "" : "none"; } function applyCfgToUi() { setSwitchOn(mergeSwitchBtn, config.mergeSame); setSwitchOn(likesSwitchBtn, config.showLikes); if (mergeWindowInput) mergeWindowInput.value = String(config.mergeWindowSec ?? 0); if (likeScopeSel) likeScopeSel.value = config.likeScope; if (likeMinInput) likeMinInput.value = String(config.likeMin); setWrapVisible(mergeSettingsWrap, config.mergeSame); setWrapVisible(likesSettingsWrap, config.showLikes); } function findDanmakuArea() { const areas = Array.from(document.querySelectorAll(".bui-area")); for (const a of areas) { const header = a.querySelector(".bui-collapse-header"); if (header && /弹幕/.test(header.textContent || "")) return a; } return areas.find((a) => a.querySelector(".bui-collapse-wrap")) || null; } function setPanelOpen(open) { if (!uiPanel) return; uiPanel.classList.toggle("closed", !open); } function mountUI() { if (uiMounted && uiPanel && uiPanel.isConnected) return true; const area = findDanmakuArea(); if (!area) return false; const collapse = area.querySelector(":scope > .bui-collapse-wrap") || area.querySelector(".bui-collapse-wrap"); if (!collapse) return false; uiMounted = true; // ====== 外层:可折叠面板 ====== uiPanel = document.createElement("div"); uiPanel.className = "tm-dm-adapt-panel"; const header = document.createElement("div"); header.className = "tm-dm-adapt-panel-header"; header.setAttribute("role", "button"); header.tabIndex = 0; header.innerHTML = `
弹幕显示设置
`; const panelBodyWrap = document.createElement("div"); panelBodyWrap.className = "tm-dm-adapt-panel-body"; const panelControls = document.createElement("div"); panelControls.className = "tm-dm-adapt-controls"; function makeToggleRow(label, getOn, setOn) { const row = document.createElement("div"); row.className = "tm-dm-adapt-toggle"; row.setAttribute("role", "button"); row.tabIndex = 0; row.innerHTML = `
`; row.querySelector(".tm-dm-adapt-toggle-txt").textContent = label; const sw = row.querySelector(".tm-dm-adapt-switch"); const render = () => sw.classList.toggle("on", !!getOn()); const toggle = () => { setOn(!getOn()); render(); }; row.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); toggle(); }); row.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }); render(); return { row, sw, render }; } // 合并弹幕 const mergeUI = makeToggleRow("合并弹幕", () => config.mergeSame, (v) => { config.mergeSame = v; saveConfig(); applyCfgToUi(); rebuildAll("toggle-merge"); }); // 显示点赞数 const likesUI = makeToggleRow("显示点赞数", () => config.showLikes, (v) => { config.showLikes = v; saveConfig(); applyCfgToUi(); refreshAll("toggle-likes"); }); // 记录引用,applyCfgToUi 里用 mergeSwitchBtn = mergeUI.sw; likesSwitchBtn = likesUI.sw; panelControls.appendChild(mergeUI.row); // 合并时间阈值(秒,支持小数;0=不限制) mergeSettingsWrap = createSettingsWrap(`
`, "tm-dm-adapt-merge-settings"); mergeWindowInput = mergeSettingsWrap.querySelector(".tm-dm-adapt-merge-window"); let mergeWinTimer = null; mergeWindowInput.addEventListener("input", () => { if (mergeWinTimer) clearTimeout(mergeWinTimer); mergeWinTimer = setTimeout(() => { const n = Number(mergeWindowInput.value); let v = Number.isFinite(n) ? n : 0; if (v < 0) v = 0; // 最多保留 3 位小数,避免存储噪声 v = Math.round(v * 1000) / 1000; config.mergeWindowSec = v; mergeWindowInput.value = String(config.mergeWindowSec); saveConfig(); rebuildAll("merge-window changed"); }, 200); }); panelControls.appendChild(mergeSettingsWrap); panelControls.appendChild(likesUI.row); likesSettingsWrap = createSettingsWrap(`
`, "tm-dm-adapt-like-settings"); likeScopeSel = likesSettingsWrap.querySelector(".tm-dm-adapt-like-scope"); likeMinInput = likesSettingsWrap.querySelector(".tm-dm-adapt-like-min"); likeScopeSel.addEventListener("change", () => { config.likeScope = likeScopeSel.value || "all"; saveConfig(); refreshAll("like-scope"); }); let likeMinTimer = null; likeMinInput.addEventListener("input", () => { if (likeMinTimer) clearTimeout(likeMinTimer); likeMinTimer = setTimeout(() => { config.likeMin = castToInteger(likeMinInput.value); likeMinInput.value = String(config.likeMin); saveConfig(); refreshAll("like-min"); }, 200); }); panelControls.appendChild(likesSettingsWrap); // 组装面板 panelBodyWrap.appendChild(panelControls); uiPanel.appendChild(header); uiPanel.appendChild(panelBodyWrap); // ✅插入到弹幕列表下面:放在 bui-collapse-body 后面 const body = collapse.querySelector(":scope > .bui-collapse-body") || collapse.querySelector(".bui-collapse-body"); if (body) body.insertAdjacentElement("afterend", uiPanel); else collapse.appendChild(uiPanel); // 面板头点击展开/收起(可持久化) header.addEventListener("click", () => { config.panelOpen = !config.panelOpen; saveConfig(); setPanelOpen(config.panelOpen); }); header.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); config.panelOpen = !config.panelOpen; saveConfig(); setPanelOpen(config.panelOpen); } }); applyCfgToUi(); // 初始展开状态(没配过就默认 true) if (typeof config.panelOpen !== "boolean") config.panelOpen = true; setPanelOpen(config.panelOpen); return true; } function bootUI() { const tryMount = () => mountUI(); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", tryMount, { once: true }); } else { tryMount(); } // B站页面经常局部重建:兜底轮询 setInterval(() => { if (!uiPanel || !uiPanel.isConnected) mountUI(); }, 1500); } return { bootUI }; } ;// ./src/index.js // src/index.js "use strict"; /** * ========================= * 0) 小工具:日志 / 配置 * ========================= */ const console = new Proxy(window.console, { get(target, prop) { const original = target[prop]; if (typeof original === "function" && (prop === "log" || prop === "error" || prop === "warn")) { return (...args) => original.call( target, "%cDanmaku Adapt", { log: "background:#01a1d6;", warn: "background:#d6a001;", error: "background:#d63601;", }[prop] + "color:#fff;padding:2px 6px;border-radius:3px;font-weight:bold;", ...args ); } return original; }, }); const STORAGE_KEY = "tm_dm_adapt_cfg_v1"; const DEFAULT_CFG = { mergeSame: true, // 合并同文同模式弹幕 mergeWindowSec: 3, // 合并时间阈值(秒;0=不限制) showLikes: true, // badge 显示点赞 likeScope: "all", // all | high likeMin: 0, // 仅展示 likesSum > likeMin panelOpen: true, }; function loadConfig() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULT_CFG }; return { ...DEFAULT_CFG, ...(JSON.parse(raw) || {}) }; } catch { return { ...DEFAULT_CFG }; } } const config = loadConfig(); function saveConfig() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); } catch { } } injectStyles(); /** * ========================= * 1) Hook:在页面上下文中拦截 render,把 textData 写到 data-* * 目的:拿到 dmid/oid/mode/text/color/isHighLike/likes/stime * ========================= */ function injectToPageContext(fn) { const s = document.createElement("script"); s.textContent = `(${fn.toString()})();`; (document.head || document.documentElement).appendChild(s); s.remove(); } injectToPageContext(function () { if (window.__DM_ADAPT_HOOK_INSTALLED__) return; window.__DM_ADAPT_HOOK_INSTALLED__ = true; const FINGERPRINT = ["aria-live", "role", "comment", "--fontSize"]; // render源码指纹 const define = Object.defineProperty; const orig = Object.getOwnPropertyDescriptor(Object.prototype, "render"); function fnStr(fn) { try { return Function.prototype.toString.call(fn); } catch { return ""; } } function looksLikeDanmakuRender(fn) { if (typeof fn !== "function") return false; const s = fnStr(fn); let hit = 0; for (const k of FINGERPRINT) if (s.includes(k)) hit++; return hit >= 2; } function toHexColor(c) { try { if (typeof c === "number" && Number.isFinite(c)) { const v = (c >>> 0) & 0xffffff; return "#" + v.toString(16).padStart(6, "0"); } if (typeof c === "string") { const s = c.trim(); if (s.startsWith("#") && (s.length === 7 || s.length === 4)) { if (s.length === 4) return ("#" + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]).toLowerCase(); return s.toLowerCase(); } if (/^\d+$/.test(s)) { const n = Number(s); if (Number.isFinite(n)) return toHexColor(n); } } } catch { } return null; } function wrapRender(fn) { if (fn.__DM_ADAPT_WRAPPED__) return fn; const wrapped = function (...args) { const ret = fn.apply(this, args); try { const el = this?.element; const td = this?.textData; if (!(el instanceof HTMLElement) || !td) return ret; const dmid = td.dmid ?? td.id_str; const oid = td.oid ?? td.cid; const mode = td.rawMode ?? td.mode; const stime = td.stime; const colorHex = toHexColor(td.color); if (dmid != null) el.dataset.dmid = String(dmid); if (oid != null) el.dataset.oid = String(oid); if (mode != null) el.dataset.mode = String(mode); // 弹幕时间(秒;支持小数) if (typeof stime === "number" && Number.isFinite(stime)) el.dataset.stime = String(stime); // 合并文本 key 用 textData.text,比 textContent 稳(emoji / prefix / suffix) if (td.text != null) el.dataset.dmText = String(td.text); if (colorHex) el.dataset.color = colorHex; // 有时 textData 自带 likes if (typeof td.likes === "number") el.dataset.likes = String(td.likes); if (typeof td.isHighLike === "boolean") el.dataset.isHighLike = td.isHighLike ? "1" : "0"; } catch { } return ret; }; wrapped.__DM_ADAPT_WRAPPED__ = true; return wrapped; } function restore() { try { if (orig) define(Object.prototype, "render", orig); else delete Object.prototype.render; } catch { } } define(Object.prototype, "render", { configurable: true, enumerable: false, get() { return undefined; }, set(v) { if (looksLikeDanmakuRender(v)) { define(this, "render", { value: wrapRender(v), writable: true, enumerable: true, configurable: true }); console.log("Danmaku render hooked."); restore(); // 命中一次就恢复 return; } define(this, "render", { value: v, writable: true, enumerable: true, configurable: true }); }, }); }); /** * ========================= * 2) DOM / 文本 / 颜色 / badge 工具 * ========================= */ const DM_CONTAINER_SELECTOR = ".bpx-player-row-dm-wrap"; const DM_SELECTOR = ".bili-danmaku-x-dm"; const DM_SHOW_CLASS = "bili-danmaku-x-show"; function isShowing(el) { return !!(el?.classList?.contains(DM_SHOW_CLASS)); } function normalizeText(s) { return String(s || "").replace(/\s+/g, " ").trim(); } function formatCount(n) { const num = Number(n) || 0; if (num >= 1e8) return (num / 1e8).toFixed(1).replace(/\.0$/, "") + "亿"; if (num >= 1e4) return (num / 1e4).toFixed(1).replace(/\.0$/, "") + "万"; return String(num); } function parseHexToRgb(hex) { const m = /^#([0-9a-f]{6})$/i.exec(hex || ""); if (!m) return null; const v = parseInt(m[1], 16); return [(v >> 16) & 255, (v >> 8) & 255, v & 255]; } function rgbToHex(r, g, b) { const to2 = (x) => Math.max(0, Math.min(255, x | 0)).toString(16).padStart(2, "0"); return `#${to2(r)}${to2(g)}${to2(b)}`; } function ensureBadge(el) { if (getComputedStyle(el).position === "static") el.style.position = "relative"; let b = el.querySelector(".tm-danmaku-badge"); if (!b) { b = document.createElement("span"); b.className = "tm-danmaku-badge"; el.appendChild(b); } return b; } function removeBadge(el) { const b = el?.querySelector?.(".tm-danmaku-badge"); if (b) b.remove(); } function hideMerged(el) { el.classList.add("tm-danmaku-merged-hide"); el.dataset.merged = "1"; } function showMerged(el) { el.classList.remove("tm-danmaku-merged-hide"); delete el.dataset.merged; } function dmType(el) { if (el.classList.contains("bili-danmaku-x-high") || el.classList.contains("bili-danmaku-x-high-top") || el.dataset.isHighLike === "1") return "high"; if (el.classList.contains("bili-danmaku-x-center")) return "center"; if (el.classList.contains("bili-danmaku-x-roll")) return "roll"; return "other"; } function shouldShowLikes(el) { if (!config.showLikes) return false; if (config.likeScope === "high") return dmType(el) === "high"; return true; } function castToInteger(v) { const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : 0; } /** * ========================= * 3) 核心状态:Group / ElementMeta * * Group = “可被合并的一组弹幕” * - mergeSame=true:同 oid + 同 mode + 同文本 * - mergeSame=false:退化成每条一个 group(用 oid+dmid) * * - 只允许 stime >= firstStime * - 且 stime - firstStime <= mergeWindowSec 才能并进该组 * - 超出则新开组(新的 firstStime) * ========================= */ let groupMap = new Map(); // groupId -> group let elMeta = new WeakMap(); // el -> { groupId, memberId, isMaster } let baseKeyGroups = new Map(); // baseKey -> Set function getMergeWindowSec() { const n = Number(config.mergeWindowSec); return Number.isFinite(n) && n > 0 ? n : 0; } function memberIdOf(oid, dmid) { return `${oid}:${dmid}`; } function baseKeyOf(oid, mode, text) { return `${oid}|${mode}|${text}`; } function allocGroupId(baseKey, stime) { // stime 是 seconds(float),用尽量稳定的字符串,避免科学计数法 const t = Number.isFinite(stime) ? stime.toFixed(6).replace(/0+$/, "").replace(/\.$/, "") : String(Date.now()); return `${baseKey}|t${t}`; } function registerGroupId(baseKey, gid) { if (!baseKey || !gid) return; let set = baseKeyGroups.get(baseKey); if (!set) baseKeyGroups.set(baseKey, (set = new Set())); set.add(gid); } function unregisterGroupId(baseKey, gid) { if (!baseKey || !gid) return; const set = baseKeyGroups.get(baseKey); if (!set) return; set.delete(gid); if (set.size === 0) baseKeyGroups.delete(baseKey); } function dropGroup(g) { if (!g || !groupMap.has(g.id)) return; groupMap.delete(g.id); if (g.baseKey) unregisterGroupId(g.baseKey, g.id); } function chooseGroupId(oid, mode, text, dmid, stime) { // 不合并:每条一个 group if (!config.mergeSame) return `${oid}|${dmid}`; const baseKey = baseKeyOf(oid, mode, text); const win = getMergeWindowSec(); // 0=不限制 或 拿不到 stime:退化成旧逻辑(同文同模式全并) if (win <= 0 || !Number.isFinite(stime)) return baseKey; // ✅ 单向固定窗口:在同 baseKey 的现存组里,找 firstStime 距离最近且在阈值内的组 const set = baseKeyGroups.get(baseKey); if (set && set.size) { let bestGid = null; let bestDiff = Infinity; // Set 保留插入顺序,转数组后倒序遍历,更容易命中“新建”的组 const arr = Array.from(set); for (let i = arr.length - 1; i >= 0; i--) { const gid = arr[i]; const g = groupMap.get(gid); if (!g || g.members.size === 0) continue; if (!Number.isFinite(g.firstStime)) continue; const diff = stime - g.firstStime; if (diff < 0) continue; if (diff <= win && diff < bestDiff) { bestDiff = diff; bestGid = gid; if (bestDiff === 0) break; } } if (bestGid) return bestGid; } // 新开一个组(该组 firstStime = 当前 stime) return allocGroupId(baseKey, stime); } /** * ========================= * 4) 点赞请求:按 oid 分桶批量请求 / 回填 * ========================= */ const likesCache = new Map(); // memberId -> likes const likesPending = new Map(); // oid -> Set let likesFlushTimer = null; let likesInflight = false; function queueLikes(oid, dmid) { if (!oid || !dmid) return; const mid = memberIdOf(oid, dmid); if (likesCache.has(mid)) return; let set = likesPending.get(oid); if (!set) likesPending.set(oid, (set = new Set())); set.add(String(dmid)); if (!likesFlushTimer) likesFlushTimer = setTimeout(flushLikes, 200); } function requestThumbupStats(oid, ids) { const url = "https://api.bilibili.com/x/v2/dm/thumbup/stats" + `?oid=${encodeURIComponent(oid)}` + `&ids=${encodeURIComponent(ids.join(","))}`; const headers = { "User-Agent": navigator.userAgent, "Referer": "https://www.bilibili.com/", }; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url, headers, responseType: "json", withCredentials: true, timeout: 8000, onload: (res) => { try { const json = res.response || JSON.parse(res.responseText); if (!json || json.code !== 0) return reject(new Error("bad resp")); resolve(json.data || {}); } catch (e) { reject(e); } }, onerror: reject, ontimeout: () => reject(new Error("timeout")), }); }); } async function flushLikes() { likesFlushTimer = null; if (likesInflight || likesPending.size === 0) return; likesInflight = true; try { for (const [oid, set] of likesPending) { while (set.size) { const chunk = Array.from(set).slice(0, 50); chunk.forEach((x) => set.delete(x)); const data = await requestThumbupStats(oid, chunk); // 回填 cache for (const [dmid, info] of Object.entries(data || {})) { const likes = typeof info === "number" ? info : Number(info?.likes ?? 0); const mid = memberIdOf(oid, dmid); likesCache.set(mid, likes); } // 回填到 group member,并标记 group dirty for (const g of groupMap.values()) { let touched = false; for (const dmid of chunk) { const mid = memberIdOf(oid, dmid); const rec = g.members.get(mid); if (rec && !rec.likesKnown && likesCache.has(mid)) { rec.likesKnown = true; rec.likes = likesCache.get(mid); touched = true; } } if (touched) scheduleGroupUpdate(g); } } if (set.size === 0) likesPending.delete(oid); } } catch (e) { console.warn("flushLikes failed, retry later", String(e)); if (likesPending.size) likesFlushTimer = setTimeout(flushLikes, 1200); } finally { likesInflight = false; } } /** * ========================= * 5) 核心流程:onStart/onUpdate/onEnd + group render(合并 & badge) * ========================= */ function cleanupElForReuse(el) { removeBadge(el); showMerged(el); delete el.dataset.dmAdaptProcessed; } function scheduleGroupUpdate(g) { if (g._scheduled) return; g._scheduled = true; requestAnimationFrame(() => { g._scheduled = false; if (!groupMap.has(g.id)) return; const master = g.masterEl; if (!(master instanceof HTMLElement) || !master.isConnected) { // master 不在了:尽量从仍在显示的成员里挑一个顶上 g.masterEl = null; for (const el of g.memberEls.values()) { if (!(el instanceof HTMLElement)) continue; if (!el.isConnected) continue; if (!isShowing(el)) continue; g.masterEl = el; break; } if (!g.masterEl) { dropGroup(g); return; } } // 重新聚合(count/likes/颜色) let count = 0; let likesSum = 0; let pending = 0; let rSum = 0, gSum = 0, bSum = 0, colorCount = 0; for (const rec of g.members.values()) { count++; if (rec.likesKnown) likesSum += rec.likes; else pending++; if (rec.rgb) { rSum += rec.rgb[0]; gSum += rec.rgb[1]; bSum += rec.rgb[2]; colorCount++; } } g.count = count; g.likesSum = likesSum; g.pending = pending; g.colorCount = colorCount; g.rSum = rSum; g.gSum = gSum; g.bSum = bSum; // master 的 DOM 更新 const el = g.masterEl; if (!(el instanceof HTMLElement) || !el.isConnected) return; // master 永远保持可见 showMerged(el); // 合并模式下:颜色平均混合 if (config.mergeSame && colorCount > 0) { const r = Math.round(rSum / colorCount); const gg = Math.round(gSum / colorCount); const b = Math.round(bSum / colorCount); el.style.setProperty("--color", rgbToHex(r, gg, b)); } // badge:likes + count const threshold = castToInteger(config.likeMin); let likePart = ""; if (shouldShowLikes(el)) { const likeText = pending > 0 ? (likesSum > 0 ? `${formatCount(likesSum)}+` : "…") : `${formatCount(likesSum)}`; const pass = pending > 0 ? (threshold <= 0 || likesSum > threshold) : (likesSum > threshold); if (pass) likePart = `${LIKE_SVG}${likeText}`; } const sendPart = (config.mergeSame && count > 1) ? `${SEND_SVG}${count}` : ""; if (!likePart && !sendPart) { removeBadge(el); } else { const badge = ensureBadge(el); badge.innerHTML = `${sendPart}${likePart}`; } }); } function onDanmakuStart(el) { if (!(el instanceof HTMLElement)) return; if (!el.classList.contains("bili-danmaku-x-dm")) return; if (!isShowing(el)) return; // ✅ 只处理 show 状态,避免对象池旧值 const oid = el.dataset.oid; const dmid = el.dataset.dmid; const mode = el.dataset.mode; const stimeRaw = Number(el.dataset.stime); const stime = Number.isFinite(stimeRaw) ? stimeRaw : NaN; if (!oid || !dmid) return; if (config.mergeSame && mode == null) return; const mid = memberIdOf(oid, dmid); // DOM 复用:同一个 el 之前绑定过别的 member -> 先结束旧的 const prev = elMeta.get(el); if (prev && prev.memberId !== mid) { onDanmakuEnd(el, "reuse"); } // 幂等:同一 member 只处理一次(避免属性震荡反复进组) if (el.dataset.dmAdaptProcessed === mid) return; el.dataset.dmAdaptProcessed = mid; const text = normalizeText(el.dataset.dmText ?? el.textContent ?? ""); if (!text) return; const gid = chooseGroupId(oid, mode, text, dmid, stime); const baseKey = config.mergeSame ? baseKeyOf(oid, mode, text) : null; const win = getMergeWindowSec(); // 建/取 group let g = groupMap.get(gid); if (!g) { g = { id: gid, baseKey, oid: String(oid), mode: mode == null ? null : String(mode), text, firstStime: Number.isFinite(stime) ? stime : NaN, masterEl: null, members: new Map(), // mid -> { likesKnown, likes, rgb, stime } memberEls: new Map(), // mid -> el(便于迁移 master & 清理) count: 0, likesSum: 0, pending: 0, colorCount: 0, rSum: 0, gSum: 0, bSum: 0, _scheduled: false, lastSeen: Date.now(), }; groupMap.set(gid, g); } // 仅在“合并时间阈值”开启且 gid != baseKey 时登记(允许同 baseKey 多组并存) if (config.mergeSame && win > 0 && gid !== baseKey) registerGroupId(baseKey, gid); g.lastSeen = Date.now(); // 注册 member 记录 if (!g.members.has(mid)) { const rec = { likesKnown: false, likes: 0, rgb: null, stime }; const rgb = parseHexToRgb(el.dataset.color); if (rgb) rec.rgb = rgb; if (el.dataset.likes != null && el.dataset.likes !== "") { rec.likesKnown = true; rec.likes = Number(el.dataset.likes) || 0; } else if (likesCache.has(mid)) { rec.likesKnown = true; rec.likes = likesCache.get(mid); } else { queueLikes(oid, dmid); } g.members.set(mid, rec); } // 记录 member -> el g.memberEls.set(mid, el); // 选 master:优先第一个出现/当前 master let isMaster = false; if (!g.masterEl || g.masterEl === el || !g.masterEl.isConnected) { g.masterEl = el; isMaster = true; } // 合并:隐藏非 master if (config.mergeSame && !isMaster) hideMerged(el); else showMerged(el); elMeta.set(el, { groupId: gid, memberId: mid, isMaster }); // 更新(合并渲染 + badge) scheduleGroupUpdate(g); } function onDanmakuEnd(el, reason) { if (!(el instanceof HTMLElement)) return; const meta = elMeta.get(el); if (!meta) { cleanupElForReuse(el); return; } const g = groupMap.get(meta.groupId); elMeta.delete(el); // 清理这个 el 的 UI/标记,避免 DOM 复用残留 cleanupElForReuse(el); if (!g) return; // 从组里移除 member g.members.delete(meta.memberId); g.memberEls.delete(meta.memberId); // 组空了:删组 if (g.members.size === 0) { dropGroup(g); return; } // 如果结束的是 master:把 master 迁移到仍在显示的成员 if (meta.isMaster && g.masterEl === el) { g.masterEl = null; } g.lastSeen = Date.now(); scheduleGroupUpdate(g); if (reason) void reason; // 预留 } function startShowingDanmaku(els) { //先按发送时间排序在处理 const arr = Array.from(els || []); arr.sort((a, b) => { const sa = Number(a?.dataset?.stime); const sb = Number(b?.dataset?.stime); const na = Number.isFinite(sa) ? sa : Number.POSITIVE_INFINITY; const nb = Number.isFinite(sb) ? sb : Number.POSITIVE_INFINITY; return na - nb; }); for (const el of arr) onDanmakuStart(el); } /** * ========================= * 6) Observer:以 show class 的“出现/消失”作为生命周期 * ========================= */ function collectDanmakuEls(node) { const out = []; if (!(node instanceof HTMLElement)) return out; if (node.matches?.(DM_SELECTOR)) out.push(node); node.querySelectorAll?.(DM_SELECTOR)?.forEach((el) => out.push(el)); return out; } function startObserverOn(container) { if (!container || container.__dmAdaptObserverInstalled) return false; container.__dmAdaptObserverInstalled = true; const obs = new MutationObserver((records) => { const startSet = new Set(); //收集所有需要start/update的弹幕元素 for (const m of records) { if (m.type === "childList") { // 新增:只要是 show,就处理 for (const n of m.addedNodes) { for (const el of collectDanmakuEls(n)) startSet.add(el); } // 移除:真 remove 也算结束(少数情况) for (const n of m.removedNodes) { for (const el of collectDanmakuEls(n)) onDanmakuEnd(el, "removed"); } continue; } if (m.type === "attributes") { const el = m.target; if (!(el instanceof HTMLElement)) continue; if (!el.classList.contains("bili-danmaku-x-dm")) continue; // ✅ 关键:class 变化判断 show 的出现/消失 if (m.attributeName === "class") { const oldCls = String(m.oldValue || ""); const oldHadShow = oldCls.split(/\s+/).includes(DM_SHOW_CLASS); const nowHasShow = isShowing(el); if (oldHadShow && !nowHasShow) { onDanmakuEnd(el, "hide"); continue; } if (!oldHadShow && nowHasShow) { // 新开始 startSet.add(el); continue; } // show 中的其它 class 抖动:当作 update if (nowHasShow) startSet.add(el); continue; } // data-* 变化:只在 show 时更新(避免对象池旧值干扰) if (isShowing(el)) startSet.add(el); } } startShowingDanmaku(startSet); }); obs.observe(container, { subtree: true, childList: true, attributes: true, attributeOldValue: true, attributeFilter: [ "class", "data-dmid", "data-oid", "data-mode", "data-stime", "data-likes", "data-dm-text", "data-color", "data-is-high-like", ], }); // 初次:只扫 show 的(避免对象池内的旧弹幕) startShowingDanmaku(container.querySelectorAll?.(`${DM_SELECTOR}.${DM_SHOW_CLASS}`)); return true; } function bootObserver() { const container = document.querySelector(DM_CONTAINER_SELECTOR); if (container) return startObserverOn(container); // 容器可能晚出现/被重建:轮询 + body 兜底 const timer = setInterval(() => { const c = document.querySelector(DM_CONTAINER_SELECTOR); if (c && startObserverOn(c)) clearInterval(timer); }, 300); const bodyObs = new MutationObserver(() => { const c = document.querySelector(DM_CONTAINER_SELECTOR); if (c) startObserverOn(c); }); const startBody = () => bodyObs.observe(document.body, { childList: true, subtree: true }); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", startBody, { once: true }); else startBody(); return false; } bootObserver(); /** * ========================= * 7) 配置变化:刷新/重建(给 UI 用) * ========================= */ function refreshAll(reason) { for (const g of groupMap.values()) scheduleGroupUpdate(g); console.log("refresh", reason); } function rebuildAll(reason) { const container = document.querySelector(DM_CONTAINER_SELECTOR); // 先清理 DOM 残留 container?.querySelectorAll?.(DM_SELECTOR)?.forEach((el) => { cleanupElForReuse(el); }); // 清空状态 groupMap = new Map(); elMeta = new WeakMap(); baseKeyGroups = new Map(); // 只重扫 show startShowingDanmaku(container.querySelectorAll?.(`${DM_SELECTOR}.${DM_SHOW_CLASS}`)); console.log("rebuild", reason); } /** * ========================= * 8) 防内存增长:长期不活跃 group 清理 * ========================= setInterval(() => { const now = Date.now(); for (const [gid, g] of groupMap) { if (now - (g.lastSeen || 0) > 60_000) dropGroup(g); } }, 10_000); */ /** * ========================= * 9) UI * ========================= */ const { bootUI } = createUI({ config, saveConfig, rebuildAll, refreshAll, castToInteger, }); bootUI(); // === debug expose === const debugAPI = { get groupMap() { return groupMap; }, get elMeta() { return elMeta; }, get baseKeyGroups() { return baseKeyGroups; }, // 辅助:把 WeakMap 里某个元素的 meta 取出来 metaOf(el) { return elMeta.get(el); }, // 辅助:按 baseKey 看有哪些 gid gidsOfBaseKey(baseKey) { return baseKeyGroups.get(baseKey); }, // 辅助:按 gid 取 group groupOf(gid) { return groupMap.get(gid); }, }; try { // eslint-disable-next-line no-undef unsafeWindow.__DM_ADAPT__ = debugAPI; } catch { window.__DM_ADAPT__ = debugAPI; } /******/ })() ;