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