YT 彈幕

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

目前為 2025-02-27 提交的版本,檢視 最新版本

// ==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 });
    }
})();