YT 彈幕

在直播串流時將聊天室的訊息轉換成彈幕發送

当前为 2025-02-27 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YT 彈幕
// @namespace    http://tampermonkey.net/
// @version      0.0.3
// @description  在直播串流時將聊天室的訊息轉換成彈幕發送
// @author       JayHuang
// @match        https://www.youtube.com/*
// @icon         https://www.youtube.com/s/desktop/b5305900/img/favicon.ico
// @grant        none
// @license      MIT
// ==/UserScript==

const canCtrlConfig = {
    showUser: false, // 是否顯示使用者
    fontFamily: "Arial", // 字型
    speed: 2, // 每幀移動 px 量
    bufferDistance: 20, // 開始位置增量(px),防止突兀的出現
    size: 36, // 字體大小
    weight: 800, // 字體粗細 (normal | bold | bolder | lighter | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900)
    alive: 5, // 存活秒數(若 `fixed` 為 `true` 時才會生效)
};
const defaultConfig = Object.assign({
    showUser: false, // 是否顯示使用者
    color: "white", // 字體顏色
    fontFamily: "Arial", // 字型
    size: 30, // 字體大小
    weight: 800, // 字體粗細
    at: "top", // 上下半部( "top" | "bottom" )
    from: "right", // 從左到右 或 從右到左 ( "right" | "left" )
    speed: 2, // 每幀移動 px 量
    bufferDistance: 20, // 開始位置增量(px),防止突兀的出現
    fixed: false, // 是否固度位置
    alive: 5, // 存活秒數(若 `fixed` 為 `true` 時才會生效),
}, canCtrlConfig);
var SceneInitSatae;
(function (SceneInitSatae) {
    SceneInitSatae[SceneInitSatae["\u670D\u52D9\u5DF2\u7D50\u675F"] = 0] = "\u670D\u52D9\u5DF2\u7D50\u675F";
    SceneInitSatae[SceneInitSatae["\u670D\u52D9\u521D\u59CB\u5316"] = 1] = "\u670D\u52D9\u521D\u59CB\u5316";
    SceneInitSatae[SceneInitSatae["\u670D\u52D9\u5DF2\u555F\u52D5"] = 2] = "\u670D\u52D9\u5DF2\u555F\u52D5";
})(SceneInitSatae || (SceneInitSatae = {}));
function theLog(...message) {
    console.log("[danmu]::", ...message);
}
function sleep(millisecond = 400) {
    return new Promise((resolve) => {
        setTimeout(resolve, millisecond);
    });
}
class DanMuManager {
    get ratio() {
        return window.devicePixelRatio;
    }
    get isFreeze() {
        return document.visibilityState === "hidden";
    }
    get isActive() {
        return !this.isFreeze;
    }
    constructor(elementRef) {
        this.elementRef = elementRef;
        theLog("DanMuManager Constructor");
        this.canvas = document.createElement("canvas");
        const ctx = this.canvas.getContext("2d");
        if (ctx === null) {
            throw new Error("Not Support Canvas Context");
        }
        else {
            this.ctx = ctx;
            this.danmuSet = new Set();
            this.__injectCanvas();
            this.loop = this.loop.bind(this);
            this.loodID = requestAnimationFrame(this.loop);
        }
    }
    loop() {
        this.__setCanvas();
        const width = this.canvas.width;
        const height = this.canvas.height;
        this.ctx.clearRect(0, 0, width, height);
        const willDelete = [];
        this.danmuSet.forEach((danmu) => {
            const obj = danmu.update();
            if (obj) {
                const { size, color, weight, message, x, y } = obj;
                this.ctx.font = `${weight} ${size}px Arial`;
                this.ctx.fillStyle = color;
                this.ctx.fillText(message, x, y);
            }
            else {
                willDelete.push(danmu);
            }
        });
        willDelete.forEach((danmu) => {
            this.danmuSet.delete(danmu);
        });
        this.loodID = requestAnimationFrame(this.loop);
    }
    addDanmu(message, user, option) {
        if (this.isActive) {
            const width = this.canvas.width;
            const height = this.canvas.height;
            const danmu = new DanMu(message, user, { width, height }, this, option);
            this.danmuSet.add(danmu);
        }
    }
    onDestroy() {
        this.canvas.remove();
        this.danmuSet.clear();
        cancelAnimationFrame(this.loodID);
        theLog("DanMuManager onDestroy");
    }
    __injectCanvas() {
        var _a;
        (_a = this.elementRef.parentElement) === null || _a === void 0 ? void 0 : _a.append(this.canvas);
    }
    __setCanvas() {
        this.canvas.style.position = "absolute";
        this.canvas.style.width = this.elementRef.style.width;
        this.canvas.style.height = this.elementRef.style.height;
        this.canvas.style.top = this.elementRef.style.top;
        this.canvas.style.left = this.elementRef.style.left;
        this.canvas.width = this.elementRef.clientWidth * this.ratio;
        this.canvas.height = this.elementRef.clientHeight * this.ratio;
    }
}
class DanMu {
    get width() {
        return this.elementRef.width;
    }
    get height() {
        return this.elementRef.height;
    }
    constructor(message, user, elementRef, manager, config) {
        this.message = message;
        this.user = user;
        this.elementRef = elementRef;
        this.manager = manager;
        const { showUser, color, size, weight, fixed, at, from, bufferDistance, speed, alive, fontFamily, } = Object.assign(Object.assign({}, defaultConfig), config);
        this.config = {
            showUser,
            color,
            size,
            weight,
            fontFamily,
            fixed,
            at,
            from,
            speed: fixed ? 0 : from === "left" ? speed : speed * -1,
            alive: alive * 1000,
            bufferDistance,
        };
        this.textWidth = this.__computerWidth();
        const ref_x = (this.textWidth + bufferDistance) * 1.5;
        this.rangeX = [ref_x * -1, this.width + ref_x];
        this.locate = this.__getInitLoate();
        this.currXaxis = this.locate.x;
        this.isEnd = false;
        this.time = Date.now();
    }
    update() {
        if (this.isEnd) {
            return null;
        }
        else {
            const { size, weight, color, showUser, speed, alive, fixed } = this.config;
            const message = showUser
                ? `${this.user} 說: ${this.message}`
                : this.message;
            this.currXaxis += speed;
            if (fixed) {
                const timeGap = Date.now() - this.time;
                if (timeGap >= alive) {
                    this.isEnd = true;
                }
            }
            else if (this.currXaxis < this.rangeX[0] ||
                this.currXaxis > this.rangeX[1]) {
                this.isEnd = true;
            }
            return {
                size,
                weight,
                color,
                message,
                x: this.currXaxis,
                y: this.locate.y,
            };
        }
    }
    __computerWidth() {
        const { weight, size } = this.config;
        this.manager.ctx.font = `${weight} ${size}px Arial`;
        const textMetrics = this.manager.ctx.measureText(this.message);
        return textMetrics.width;
    }
    __getInitLoate() {
        const x = this.__getXaxis();
        const y = this.__getYaxis();
        return { x, y };
    }
    __getXaxis() {
        const { fixed, from } = this.config;
        if (fixed) {
            return (this.width - this.textWidth) / 2;
        }
        else if (from === "left") {
            return this.rangeX[0];
        }
        else {
            return this.rangeX[1];
        }
    }
    __getYaxis() {
        const { at } = this.config;
        const padding = 20;
        const rangeY = [padding, this.height - padding];
        if (at === "bottom") {
            rangeY[0] = this.height / 2;
        }
        else if (at === "top") {
            rangeY[1] = this.height / 2;
        }
        return Math.floor(Math.random() * (rangeY[1] - rangeY[0] + 1)) + rangeY[0];
    }
}
class ChatObserver {
    get ChatListElement() {
        var _a, _b;
        return (_b = (_a = this.iframeBody) === null || _a === void 0 ? void 0 : _a.querySelector("#items")) !== null && _b !== void 0 ? _b : null;
    }
    constructor(containerEl, manager) {
        this.manager = manager;
        theLog("ChatObserver Constructor");
        if (containerEl instanceof HTMLIFrameElement) {
            this.containerEl = containerEl;
            containerEl.onload = () => {
                var _a;
                this.iframeBody = (_a = containerEl.contentDocument) === null || _a === void 0 ? void 0 : _a.body;
                this.__addObs();
            };
        }
        else {
            throw new Error("ChatObserver Error");
        }
    }
    __addObs() {
        if (this.iframeBody && this.iframeBody.children.length > 0) {
            const targetEl = this.ChatListElement;
            if (targetEl && this.stateObs === undefined) {
                theLog("add observer");
                const stateObs = new MutationObserver((entire) => {
                    entire.forEach((record) => {
                        record.addedNodes.forEach((node) => {
                            var _a, _b;
                            const element = node;
                            const authorEl = element.querySelector("#author-name");
                            const isMember = Array.from((_a = authorEl === null || authorEl === void 0 ? void 0 : authorEl.classList) !== null && _a !== void 0 ? _a : []).includes("member");
                            const userName = authorEl === null || authorEl === void 0 ? void 0 : authorEl.textContent;
                            const message = (_b = element.querySelector("#message")) === null || _b === void 0 ? void 0 : _b.textContent;
                            if (userName && message) {
                                this.manager.addDanmu(message, userName, isMember ? { color: "red", size: 36, fixed: true } : {});
                            }
                        });
                    });
                });
                stateObs.observe(targetEl, { childList: true });
            }
        }
    }
    __removeObs() {
        var _a;
        (_a = this.stateObs) === null || _a === void 0 ? void 0 : _a.disconnect();
        this.stateObs = undefined;
        theLog("remove observer");
    }
    onDestroy() {
        this.__removeObs();
        theLog("ChatObserver onDestroy");
    }
}
class Scene {
    constructor() {
        theLog("Scene Constructor");
        this.prevUrl = "";
        this.destroy = () => { };
        this.state = SceneInitSatae.服務已結束;
    }
    changeScene(url) {
        var _a;
        if (this.prevUrl !== url) {
            this.prevUrl = url;
            const isVideo = Scene.videoRegExp.test(url);
            if (isVideo) {
                switch (this.state) {
                    case SceneInitSatae.服務已結束: {
                        theLog("啟動服務");
                        this.__onInit();
                        break;
                    }
                    case SceneInitSatae.服務初始化:
                        theLog("啟動中.....");
                        break;
                    case SceneInitSatae.服務已啟動:
                        theLog("關閉服務並重新啟動");
                        this.__onDesroy().then(() => {
                            this.__onInit();
                        });
                        break;
                }
            }
            else {
                (_a = this.controller) === null || _a === void 0 ? void 0 : _a.abort();
                this.__onDesroy();
            }
        }
    }
    async __onInit() {
        try {
            this.state = SceneInitSatae.服務初始化;
            this.controller = new AbortController();
            const signal = this.controller.signal;
            const [video, container] = await Promise.all([
                this.__getElement(".video-stream.html5-main-video", { signal }),
                this.__getElement("#chat-container #chatframe", { signal }),
            ]);
            theLog({ video, container });
            const manager = new DanMuManager(video);
            const obs = new ChatObserver(container, manager);
            this.controller = undefined;
            this.state = SceneInitSatae.服務已啟動;
            this.destroy = () => {
                manager.onDestroy();
                obs.onDestroy();
            };
        }
        catch (e) {
            theLog("Error occur", e);
            this.state = SceneInitSatae.服務已結束;
            this.destroy = () => { };
        }
    }
    async __onDesroy() {
        return new Promise((resolve) => {
            this.destroy();
            this.state = SceneInitSatae.服務已結束;
            resolve();
        });
    }
    async __getElement(selectors, option = {}) {
        var _a, _b;
        let element = null;
        const signal = (_a = option.signal) !== null && _a !== void 0 ? _a : { aborted: false };
        let limitRetry = (_b = option.limitRetry) !== null && _b !== void 0 ? _b : 400;
        while (element === null && limitRetry >= 0) {
            if (signal.aborted) {
                throw new Error(`Search aborted for selector: ${selectors} and Reason: ${signal.reason}`);
            }
            element = document.querySelector(selectors);
            limitRetry -= 1;
            await sleep();
        }
        if (element) {
            return element;
        }
        else {
            throw new Error("Not Found Element: " + selectors);
        }
    }
}
Scene.videoRegExp = new RegExp(/^https:\/\/www.youtube.com\/watch\?v=(\S+)/);
(function () {
    "use strict";
    theLog("Loaded Script");
    const scene = new Scene();
    const observer = new MutationObserver(() => {
        scene.changeScene(window.location.href);
    });
    const titleElement = document.querySelector("title");
    if (titleElement) {
        observer.observe(titleElement, { childList: true });
    }
})();