YT 彈幕

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YT 彈幕
// @namespace    http://tampermonkey.net/
// @version      0.0.7
// @description  在直播串流時將聊天室的訊息轉換成彈幕發送
// @author       JayHuang
// @match        https://www.youtube.com/*
// @icon         https://www.youtube.com/s/desktop/b5305900/img/favicon.ico
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==
const showUser = false; // 是否顯示使用者
const fontFamily = "Arial"; // 字型
const speed = 2; // 每幀移動 px 量
const bufferDistance = 20; // 開始位置增量(px),防止突兀的出現
const size = 36; // 字體大小
const weight = 800; // 字體粗細 (normal | bold | bolder | lighter | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900)
const alive = 5; // 存活秒數(若 `fixed` 為 `true` 時才會生效)
const at = "full"; // 上下半部( "top" | "bottom" | "full" )
const from = "right"; // 從左到右 或 從右到左 ( "right" | "left" )
// ----------以上可調整--------------
const defaultConfig = {
    showUser,
    color: "white", // 字體顏色
    fontFamily,
    size,
    fontSizeRadio: false, // 字體大小是否跟隨影片比例調整
    weight,
    at,
    from,
    speed,
    bufferDistance,
    fixed: false, // 是否固度位置
    alive, // 若 `fixed` 為 `true` 時才會生效
};
var SceneInitState;
(function (SceneInitState) {
    SceneInitState[SceneInitState["\u670D\u52D9\u5DF2\u7D50\u675F"] = 0] = "\u670D\u52D9\u5DF2\u7D50\u675F";
    SceneInitState[SceneInitState["\u670D\u52D9\u521D\u59CB\u5316"] = 1] = "\u670D\u52D9\u521D\u59CB\u5316";
    SceneInitState[SceneInitState["\u670D\u52D9\u5DF2\u555F\u52D5"] = 2] = "\u670D\u52D9\u5DF2\u555F\u52D5";
})(SceneInitState || (SceneInitState = {}));
const danmuState = "danmuState";
const danmuManualCtrl = "danmuCtrl";
function theLog(...message) {
    console.log("[danmu]::", ...message);
}
function sleep(millisecond = 400) {
    return new Promise((resolve) => {
        setTimeout(resolve, millisecond);
    });
}
function debounceWrapper(func, wait = 1000) {
    let timeout = null;
    return function (...args) {
        if (timeout) {
            theLog("Too Fast!!");
            clearTimeout(timeout);
        }
        timeout = setTimeout(() => {
            func(...args);
        }, wait);
    };
}
async function 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;
    const delaytime = option.delaytime;
    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(delaytime);
    }
    if (element) {
        return element;
    }
    else {
        throw new Error("Not Found Element: " + selectors);
    }
}
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.strokeStyle = "black";
                this.ctx.lineWidth = 1;
                this.ctx.fillText(message, x, y);
                this.ctx.strokeText(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, fontSizeRadio, } = 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,
            fontSizeRadio,
        };
        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) {
        var _a;
        this.manager = manager;
        theLog("ChatObserver Constructor");
        if (containerEl instanceof HTMLIFrameElement) {
            this.containerEl = containerEl;
            this.iframeBody = (_a = containerEl.contentDocument) === null || _a === void 0 ? void 0 : _a.body;
            this.__addObs();
            if (!this.iframeBody) {
                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) => {
                            this.decodeElement(node)
                                .then(() => { })
                                .catch(() => { });
                        });
                    });
                });
                stateObs.observe(targetEl, { childList: true });
            }
        }
    }
    async decodeElement(el) {
        return new Promise((resolve, reject) => {
            var _a, _b;
            const authorEl = el.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 = el.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 } : {});
                resolve();
            }
            else {
                reject();
            }
        });
    }
    __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 InsertKit {
    constructor() {
        this.rootElement = document.createElement("div");
        this.switchElement = document.createElement("label");
        this.fontSizeElement = document.createElement("div");
        this.__setRootElement();
        this.__setSwitchElement();
        this.rootElement.append(this.switchElement, this.fontSizeElement);
    }
    onInsert() {
        theLog("onInsert");
        getElement("#below.style-scope.ytd-watch-flexy")
            .then((ref) => {
            if (ref.parentElement) {
                ref.parentElement.insertBefore(this.rootElement, ref);
            }
        })
            .catch((e) => {
            theLog("找不到主畫面");
        });
    }
    onDesroy() {
        this.rootElement.remove();
    }
    __setRootElement() {
        this.rootElement.style.display = "flex";
        this.rootElement.style.flexDirection = "row";
        this.rootElement.style.alignItems = "center";
        this.rootElement.style.justifyContent = "center";
        this.rootElement.style.padding = "4px 16px";
        this.rootElement.style.margin = "8px";
        this.rootElement.style.borderRadius = "8px";
        this.rootElement.style.backgroundColor = "rgba(30,30,30, 0.5)";
        this.rootElement.style.width = "max-content";
    }
    __setSwitchElement() {
        this.switchElement.id = "tp-switch-btn";
        this.switchElement.style.color = "var(--yt-live-chat-primary-text-color)";
        const inputEl = document.createElement("input");
        inputEl.type = "checkbox";
        inputEl.name = "toggle";
        inputEl.hidden = true;
        inputEl.disabled = true;
        inputEl.classList.add("Toggle__input");
        const spanEl = document.createElement("span");
        spanEl.style.marginLeft = "8px";
        spanEl.classList.add("Toggle__display");
        spanEl.innerHTML = `<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" class="Toggle__icon Toggle__icon--checkmark">
<path d="M6.08471 10.6237L2.29164 6.83059L1 8.11313L6.08471 13.1978L17 2.28255L15.7175 1L6.08471 10.6237Z" fill="currentcolor" stroke="currentcolor"></path>
</svg>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" class="Toggle__icon Toggle__icon--cross">
<path d="M11.167 0L6.5 4.667L1.833 0L0 1.833L4.667 6.5L0 11.167L1.833 13L6.5 8.333L11.167 13L13 11.167L8.333 6.5L13 1.833L11.167 0Z" fill="currentcolor"></path>
</svg>`;
        inputEl.onclick = () => {
            document.dispatchEvent(new CustomEvent(danmuManualCtrl, {
                detail: !inputEl.checked,
            }));
        };
        document.addEventListener(danmuState, (event) => {
            const { detail } = event;
            switch (detail) {
                case SceneInitState.服務初始化:
                    inputEl.disabled = true;
                    break;
                case SceneInitState.服務已啟動:
                    inputEl.disabled = false;
                    inputEl.checked = true;
                    break;
                case SceneInitState.服務已結束:
                    inputEl.disabled = false;
                    inputEl.checked = false;
                    break;
                default:
                    break;
            }
        });
        this.switchElement.append("彈幕開關", inputEl, spanEl);
    }
}
class Scene {
    get state() {
        return this.__state;
    }
    set state(v) {
        if (this.__state !== v) {
            this.__state = v;
            document.dispatchEvent(new CustomEvent(danmuState, {
                detail: v,
            }));
        }
    }
    constructor() {
        theLog("Scene Constructor");
        this.prevUrl = "";
        this.destroy = () => { };
        this.__state = SceneInitState.服務已結束;
        document.addEventListener(danmuManualCtrl, (event) => {
            const { detail } = event;
            if (detail) {
                this.onManualClose();
            }
            else {
                this.onManualOpen();
            }
        });
    }
    changeScene(url) {
        var _a;
        theLog("changeScene");
        if (this.prevUrl !== url) {
            this.prevUrl = url;
            const isVideo = Scene.videoRegExp.test(url);
            if (isVideo) {
                switch (this.state) {
                    case SceneInitState.服務已結束: {
                        theLog("啟動服務");
                        this.__onInit();
                        break;
                    }
                    case SceneInitState.服務初始化:
                        theLog("啟動中.....");
                        break;
                    case SceneInitState.服務已啟動:
                        theLog("關閉服務並重新啟動");
                        this.__onDesroy().then(() => {
                            this.__onInit();
                        });
                        break;
                }
            }
            else {
                (_a = this.controller) === null || _a === void 0 ? void 0 : _a.abort();
                this.__onDesroy();
            }
        }
    }
    onManualOpen() {
        theLog("手動開啟服務");
        this.__onDesroy().then(() => {
            this.__onInit();
        });
    }
    onManualClose() {
        theLog("手動關閉服務");
        this.__onDesroy();
    }
    async __onInit() {
        try {
            this.state = SceneInitState.服務初始化;
            this.controller = new AbortController();
            const signal = this.controller.signal;
            const [video, container] = await Promise.all([
                getElement(".video-stream.html5-main-video", { signal }),
                getElement("#chat-container #chatframe", { signal }),
            ]);
            theLog({ video, container });
            const manager = new DanMuManager(video);
            const obs = new ChatObserver(container, manager);
            this.controller = undefined;
            this.state = SceneInitState.服務已啟動;
            this.destroy = () => {
                manager.onDestroy();
                obs.onDestroy();
            };
        }
        catch (e) {
            theLog("Error occur", e);
            this.state = SceneInitState.服務已結束;
            this.destroy = () => { };
        }
    }
    async __onDesroy() {
        return new Promise((resolve) => {
            this.destroy();
            this.state = SceneInitState.服務已結束;
            setTimeout(resolve, 200);
        });
    }
}
Scene.videoRegExp = new RegExp(/^https:\/\/www.youtube.com\/watch\?v=(\S+)/);
(function () {
    "use strict";
    theLog("Loaded Script");
    // @ts-ignore
    GM_addStyle(`.Toggle__display {
  --offset: 0.25em;
  --diameter: 1.8em;
  
  display: inline-flex;
  align-items: center;
  justify-content: space-around;
  width: calc(var(--diameter) * 2 + var(--offset) * 2);
  height: calc(var(--diameter) + var(--offset) * 2);
  position: relative;
  border-radius: 100vw;
  background-color: #fbe4e2;
  transition: 250ms;
  margin-right: 1ch;
}

.Toggle__display::before {
  content: '';
  z-index: 2;
  position: absolute;
  top: 50%;
  left: var(--offset);
  width: var(--diameter);
  height: var(--diameter);
  border-radius: 50%;
  background-color: white;
  transform: translate(0, -50%);
  will-change: transform;
  transition: inherit;
}

.Toggle__input:focus + .Toggle__display {
  outline: 1px dotted #212121;
  outline: 1px auto -webkit-focus-ring-color;
  outline-offset: 2px;
}

.Toggle__input:focus:not(:focus-visible) + .Toggle__display {
  outline: 0;
}

.Toggle__input:checked + .Toggle__display {
  background-color: #e3f5eb;
}

.Toggle__input:checked + .Toggle__display::before {
  transform: translate(100%, -50%);
}

.Toggle__input:disabled + .Toggle__display {
  opacity: 0.6;
  filter: grayscale(40%);
  cursor: not-allowed;
}

.Toggle__icon {
  display: inline-block;
  width: 1em;
  height: 1em;
  color: inherit;
  fill: currentcolor;
  vertical-align: middle;
  overflow: hidden;
}

.Toggle__icon--cross {
  color: #e74c3c;
  font-size: 85%;
}

.Toggle__icon--checkmark {
  color: #1fb978;
}`);
    const scene = new Scene();
    const kit = new InsertKit();
    const changeScene = debounceWrapper(scene.changeScene.bind(scene));
    const insertElement = debounceWrapper(kit.onInsert.bind(kit));
    const observer = new MutationObserver(() => {
        changeScene(window.location.href);
        insertElement();
    });
    const titleElement = document.querySelector("title");
    if (titleElement) {
        observer.observe(titleElement, { childList: true });
    }
})();