// ==UserScript== // @name douyin_barrage // @namespace https://github.com/netnr // @icon https://netnr.github.io/favicon.svg // @version 2026.06.18.1944 // @description 抖音直播漂屏弹幕(从聊天列表 clone 节点漂屏) // @author netnr // @license MIT // @match https://live.douyin.com/* // ==/UserScript== let nrBarrage = { // 漂屏容器 layoutEl: null, // 聊天列表观察目标 listEl: null, // MutationObserver observer: null, // 已处理的 data-index 集合 processedSet: new Set(), // 漂屏轨道(行高、行数) rowHeight: 72, rowCount: 0, trackLastUsed: [], // 首次加载弹幕条数 initCount: 5, // 每条弹幕漂屏时长(ms) duration: 12000, // 同屏弹幕上限(超出自动清除最旧的) maxOnScreen: 20, // 启动延迟(ms),等待页面充分加载 initDelay: 4000, sleep: (ms) => new Promise(r => setTimeout(r, ms || 1000)), /** 注入漂屏容器样式 */ injectStyle: () => { if (document.getElementById("nr-barrage-style")) return; var style = document.createElement("style"); style.id = "nr-barrage-style"; style.textContent = ` #DanmakuLayout { position: fixed; top: 0; left: 0; width: 100vw; height: 30vh; pointer-events: none; overflow: hidden; z-index: 99; } #DanmakuLayout .nr-danmaku { position: absolute; white-space: nowrap; will-change: transform; animation: nr-danmaku-fly var(--nr-dur) linear forwards; font-size: 30px; line-height: 1.3; text-shadow: 2px 2px 4px rgba(0,0,0,.7); padding: 2px 10px; border-radius: 6px; background: rgba(0,0,0,.3); } #DanmakuLayout .nr-danmaku * { margin: 0 !important; padding: 0 !important; font-size: inherit !important; line-height: inherit !important; letter-spacing: normal !important; vertical-align: middle !important; } #DanmakuLayout .nr-danmaku img { height: 1em; width: auto; } @keyframes nr-danmaku-fly { 0% { transform: translateX(0); } 100% { transform: translateX(calc(-100% - 100vw)); } } `; document.head.appendChild(style); }, /** 创建或获取漂屏容器 */ ensureLayout: () => { if (!nrBarrage.layoutEl) { nrBarrage.layoutEl = document.getElementById("DanmakuLayout"); } if (!nrBarrage.layoutEl) { nrBarrage.layoutEl = document.createElement("div"); nrBarrage.layoutEl.id = "DanmakuLayout"; document.body.appendChild(nrBarrage.layoutEl); } nrBarrage.rowCount = Math.floor(window.innerHeight * 0.5 / nrBarrage.rowHeight); nrBarrage.trackLastUsed = new Array(nrBarrage.rowCount).fill(0); }, /** 获取空闲最久的轨道(防重叠) */ getTrackTop: () => { var now = Date.now(); var bestIdx = 0; var bestTime = Infinity; for (var i = 0; i < nrBarrage.rowCount; i++) { if (nrBarrage.trackLastUsed[i] < bestTime) { bestTime = nrBarrage.trackLastUsed[i]; bestIdx = i; } } nrBarrage.trackLastUsed[bestIdx] = now; return bestIdx * nrBarrage.rowHeight; }, /** 强制清除最旧的在屏弹幕 */ evictOldest: () => { var items = nrBarrage.layoutEl.querySelectorAll(".nr-danmaku"); if (items.length >= nrBarrage.maxOnScreen) { items[0].remove(); } }, /** 从聊天列表项中提取内容节点并漂屏 */ flyItem: (itemWrapper) => { if (!itemWrapper) return; // clone 内容节点 var clone = itemWrapper.cloneNode(true); // 构建漂屏弹幕元素 var el = document.createElement("div"); el.className = "nr-danmaku"; el.style.setProperty("--nr-dur", nrBarrage.duration + "ms"); el.style.top = nrBarrage.getTrackTop() + "px"; // 从右侧屏幕外开始 el.style.left = "100vw"; // 取 wrapper 内第一个子元素(内容区),不依赖哈希类名 var inner = clone.firstElementChild; if (inner) { // 找到昵称节点(第一个有文字的叶子 span),删除其前面的所有徽章节点 var found = false; Array.from(inner.children).forEach(child => { if (found) return; // 叶子 span 且有文字内容 → 昵称节点,从此处开始保留 if (child.tagName === "SPAN" && child.children.length === 0 && child.textContent.trim()) { found = true; return; } // 昵称节点之前的全是徽章,移除 child.remove(); }); el.appendChild(inner); } else { el.innerHTML = clone.innerHTML; } nrBarrage.evictOldest(); nrBarrage.layoutEl.appendChild(el); // 动画结束后移除 el.addEventListener("animationend", () => el.remove()); }, /** 扫描现有节点(首次仅取最新 N 条) */ scanExisting: () => { var all = nrBarrage.listEl.querySelectorAll("div[data-index]"); var items = Array.from(all).slice(-nrBarrage.initCount); items.forEach(item => { var idx = item.dataset.index; if (!nrBarrage.processedSet.has(idx)) { nrBarrage.processedSet.add(idx); var wrapper = item.querySelector('[class*="webcast-chatroom___item-wrapper"]'); if (wrapper && wrapper.firstElementChild) { nrBarrage.flyItem(wrapper); } } }); // 将全部已有节点标记为已处理,避免后续 observer 重复推送 all.forEach(item => nrBarrage.processedSet.add(item.dataset.index)); }, /** 启动观察 */ startObserve: () => { nrBarrage.observer = new MutationObserver((mutations) => { for (var mutation of mutations) { mutation.addedNodes.forEach(node => { if (node.nodeType !== 1) return; // 直接是 data-index 节点 var indexEl = node.dataset && node.dataset.index != null ? node : node.querySelector && node.querySelector("div[data-index]"); if (indexEl) { var idx = indexEl.dataset.index; if (!nrBarrage.processedSet.has(idx)) { nrBarrage.processedSet.add(idx); var wrapper = indexEl.querySelector('[class*="webcast-chatroom___item-wrapper"]'); if (wrapper && wrapper.firstElementChild) { nrBarrage.flyItem(wrapper); } } } }); } }); nrBarrage.observer.observe(nrBarrage.listEl, { childList: true, subtree: true, }); }, /** 定期清理已处理集合,防止内存泄漏 */ startCleanup: () => { setInterval(() => { if (nrBarrage.processedSet.size > 2000) { var arr = Array.from(nrBarrage.processedSet); nrBarrage.processedSet = new Set(arr.slice(-500)); } }, 60000); }, /** 初始化 */ init: async () => { // 等待页面充分加载后再初始化 await nrBarrage.sleep(nrBarrage.initDelay); nrBarrage.injectStyle(); nrBarrage.ensureLayout(); // 等待聊天列表就绪 for (var i = 0; i < 15; i++) { nrBarrage.listEl = document.querySelector('[class*="webcast-chatroom___list"]'); if (nrBarrage.listEl) break; await nrBarrage.sleep(); } if (!nrBarrage.listEl) { console.warn("[nrBarrage] 未找到 webcast-chatroom___list,退出"); return; } // 清空容器 nrBarrage.layoutEl.innerHTML = ""; // 扫描已有弹幕 nrBarrage.scanExisting(); // 观察新增弹幕 nrBarrage.startObserve(); // 启动清理 nrBarrage.startCleanup(); console.debug("[nrBarrage] 已开启漂屏弹幕,观察 webcast-chatroom___list → #DanmakuLayout"); }, /** 销毁 */ destroy: () => { if (nrBarrage.observer) { nrBarrage.observer.disconnect(); nrBarrage.observer = null; } if (nrBarrage.layoutEl) { nrBarrage.layoutEl.innerHTML = ""; } nrBarrage.processedSet.clear(); console.debug("[nrBarrage] 已销毁"); } }; nrBarrage.init(); // nrBarrage.destroy(); // 取消注释以停止