抖音直播漂屏弹幕显示用户名称
netnr 2024-12-20
// ==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(); // 取消注释以停止
登录写评论