コメントを流すやつ
สคริปต์นี้ไม่ควรถูกติดตั้งโดยตรง มันเป็นคลังสำหรับสคริปต์อื่น ๆ เพื่อบรรจุด้วยคำสั่งเมทา // @require https://update.greasyfork.org/scripts/444119/1061612/FlowComments.js
// ==UserScript== // @name FlowComments // @namespace https://midra.me // @version 1.0.6 // @description コメントを流すやつ // @author Midra // @license MIT // @grant none // @compatible chrome >= 84 // @compatible safari >= 15 // @compatible firefox >= 90 // ==/UserScript== // @ts-check 'use strict' /** * `FlowComments`のスタイル * @typedef {object} FlowCommentsStyle * @property {string} [fontFamily] フォント * @property {string} [fontWeight] フォントの太さ * @property {number} [fontScale] 拡大率 * @property {string} [color] フォントカラー * @property {string} [shadowColor] シャドウの色 * @property {number} [shadowBlur] シャドウのぼかし * @property {number} [opacity] 透明度 */ /** * `FlowCommentsItem`のオプション * @typedef {object} FlowCommentsItemOption * @property {number} [position] 表示位置 * @property {number} [duration] 表示時間 */ /** * `FlowComments`のオプション * @typedef {object} FlowCommentsOption * @property {number} [resolution] 解像度 * @property {number} [lines] 行数 * @property {number} [limit] 画面内に表示するコメントの最大数 * @property {boolean} [autoResize] サイズ(比率)を自動で調整 * @property {boolean} [autoResolution] 解像度を自動で調整 * @property {boolean} [smoothRender] カクつきを抑える(負荷高いかも) */ /**************************************** * デフォルト値 */ const FLOWCMT_CONFIG = Object.freeze({ /** フォントファミリー */ FONT_FAMILY: [ 'Arial', '"ヒラギノ角ゴシック"', '"Hiragino Sans"', '"游ゴシック体"', 'YuGothic', '"游ゴシック"', '"Yu Gothic"', 'Gulim', '"Malgun Gothic"', '"黑体"', 'SimHei', 'system-ui', '-apple-system', 'sans-serif', ].join(), /** フォントの太さ */ FONT_WEIGHT: /Android/.test(window.navigator.userAgent) ? '700' : '600', /** フォントの拡大率 */ FONT_SCALE: 0.7, /** フォントのY軸のオフセット */ FONT_OFFSET_Y: 0.15, /** テキストの色 */ TEXT_COLOR: '#fff', /** テキストシャドウの色 */ TEXT_SHADOW_COLOR: '#000', /** テキストシャドウのぼかし */ TEXT_SHADOW_BLUR: 1, /** テキスト間の余白(配列形式の場合) */ TEXT_MARGIN: 0.2, /** Canvasのクラス名 */ CANVAS_CLASSNAME: 'mid-FlowComments', /** Canvasの比率 */ CANVAS_RATIO: 16 / 9, /** Canvasの解像度 */ CANVAS_RESOLUTION: 720, /** 解像度のリスト */ RESOLUTION_LIST: [240, 360, 480, 720], /** コメントの表示時間 */ CMT_DISPLAY_DURATION: 6000, /** コメントの最大数(0は無制限) */ CMT_LIMIT: 0, /** 行数 */ LINES: 11, /** 比率の自動調整 */ AUTO_RESIZE: true, /** 解像度の自動調整 */ AUTO_RESOLUTION: true, }) /**************************************** * コメントの種類 */ const FLOWCMT_TYPE = Object.freeze({ // 流す FLOW: 0, // 上部に固定 TOP: 1, // 下部に固定 BOTTOM: 2, }) /**************************************** * @type {FlowCommentsItemOption} */ const FLOWCMTITEM_DEFAULT_OPTION = Object.freeze({ position: FLOWCMT_TYPE.FLOW, duration: FLOWCMT_CONFIG.CMT_DISPLAY_DURATION, }) /**************************************** * @type {FlowCommentsOption} */ const FLOWCMT_DEFAULT_OPTION = Object.freeze({ resolution: FLOWCMT_CONFIG.CANVAS_RESOLUTION, lines: FLOWCMT_CONFIG.LINES, limit: FLOWCMT_CONFIG.CMT_LIMIT, autoResize: FLOWCMT_CONFIG.AUTO_RESIZE, autoResolution: FLOWCMT_CONFIG.AUTO_RESOLUTION, smoothRender: false, }) /**************************************** * @type {FlowCommentsStyle} */ const FLOWCMT_DEFAULT_STYLE = Object.freeze({ fontFamily: FLOWCMT_CONFIG.FONT_FAMILY, fontWeight: FLOWCMT_CONFIG.FONT_WEIGHT, fontScale: 1, color: FLOWCMT_CONFIG.TEXT_COLOR, shadowColor: FLOWCMT_CONFIG.TEXT_SHADOW_COLOR, shadowBlur: FLOWCMT_CONFIG.TEXT_SHADOW_BLUR, opacity: 1, }) /**************************************** * @classdesc ユーティリティ */ class FlowCommentsUtil { /**************************************** * オブジェクトのプロパティからnullとundefinedを除去 * @param {object} obj オブジェクト */ static filterObject(obj) { if (typeof obj === 'object' && !Array.isArray(obj) && obj !== undefined && obj !== null) { Object.keys(obj).forEach(key => { if (obj[key] === undefined || obj[key] === null) { delete obj[key] } else { this.filterObject(obj[key]) } }) } } /**************************************** * Canvasにスタイルを適用 * @param {CanvasRenderingContext2D} ctx CanvasRenderingContext2D * @param {FlowCommentsStyle} style スタイル * @param {number} resolution 解像度 * @param {number} fontSize フォントサイズ */ static setStyleToCanvas(ctx, style, resolution, fontSize) { ctx.textBaseline = 'middle' ctx.lineJoin = 'round' ctx.font = `${style.fontWeight} ${fontSize * style.fontScale}px ${style.fontFamily}` ctx.fillStyle = style.color ctx.shadowColor = style.shadowColor ctx.shadowBlur = resolution / 400 * style.shadowBlur ctx.globalAlpha = style.opacity } } /**************************************** * @classdesc 画像キャッシュ管理用 */ class FlowCommentsImageCache { /** * オプション(デフォルト値) */ static #OPTION = { maxSize: 50, } /** * キャッシュ * @type {{ [url: string]: { img: HTMLImageElement; lastUsed: number; }; }} */ static #cache = {} /**************************************** * キャッシュ追加 * @param {string} url URL * @param {HTMLImageElement} img 画像 */ static add(url, img) { // 削除 if (this.#OPTION.maxSize < Object.keys(this.#cache).length) { let delCacheUrl Object.keys(this.#cache).forEach(key => { if ( delCacheUrl === undefined || this.#cache[key].lastUsed < this.#cache[delCacheUrl].lastUsed ) { delCacheUrl = key } }) this.dispose(delCacheUrl) } // 追加 this.#cache[url] = { img: img, lastUsed: Date.now(), } } /**************************************** * 画像が存在するか * @param {string} url URL */ static has(url) { return this.#cache.hasOwnProperty(url) } /**************************************** * 画像を取得 * @param {string} url URL * @returns {Promise<HTMLImageElement>} 画像 */ static async get(url) { return new Promise(async (resolve, reject) => { if (this.has(url)) { this.#cache[url].lastUsed = Date.now() resolve(this.#cache[url].img) } else { try { let img = new Image() img.addEventListener('load', ({ target }) => { if (target instanceof HTMLImageElement) { this.add(target.src, target) resolve(this.#cache[target.src].img) } else { reject() } }) img.addEventListener('error', reject) img.src = url img = null } catch (e) { reject(e) } } }) } /**************************************** * 画像を解放 * @param {string} url URL */ static dispose(url) { if (this.has(url)) { this.#cache[url].img.remove() delete this.#cache[url] } } } /**************************************** * @classdesc `FlowCommentsItem`用の画像クラス */ class FlowCommentsImage { /** * URL * @type {string} */ #url /** * 代替テキスト * @type {string} */ #alt /**************************************** * コンストラクタ * @param {string} url URL * @param {string} [alt] 代替テキスト */ constructor(url, alt) { this.#url = url this.#alt = alt } get url() { return this.#url } get alt() { return this.#alt } /**************************************** * 画像を取得 * @returns {Promise<HTMLImageElement | string>} */ async get() { try { return (await FlowCommentsImageCache.get(this.#url)) } catch (e) { return this.#alt } } } /**************************************** * @classdesc 流すコメント * @example * // idを指定する場合 * const fcItem1 = new FlowCommentsItem('1518633760656605184', 'ウルトラソウッ') * // idを指定しない場合 * const fcItem2 = new FlowCommentsItem(Symbol(), 'みどらんかわいい!') */ class FlowCommentsItem { /** * コメントID * @type {string | number | symbol} */ #id /** * コメント本文 * @type {Array<string | FlowCommentsImage>} */ #content /** * オプション * @type {FlowCommentsItemOption} */ #option /** * スタイル * @type {FlowCommentsStyle} */ #style /** * 実際の表示時間 * @type {number} */ #actualDuration /** * コメント単体を描画したCanvas * @type {HTMLCanvasElement} */ #canvas /** * 座標 * @type {{ x: number; y: number; xp: number; offsetY: number; }} */ position = { x: 0, y: 0, xp: 0, offsetY: 0, } /** * 描画サイズ * @type {{ width: number; height: number; }} */ size = { width: 0, height: 0, } /** * 実際に流すときの距離 * @type {number} */ scrollWidth = 0 /** * 行番号 * @type {number} */ line = 0 /** * コメントを流し始めた時間 * @type {number} */ startTime = null /**************************************** * コンストラクタ * @param {string | number | symbol} id コメントID * @param {Array<string | FlowCommentsImage>} content コメント本文 * @param {FlowCommentsItemOption} [option] オプション * @param {FlowCommentsStyle} [style] スタイル */ constructor(id, content, option, style) { FlowCommentsUtil.filterObject(option) FlowCommentsUtil.filterObject(style) this.#id = id this.#content = Array.isArray(content) ? content.filter(v => v) : content this.#style = style this.#option = { ...FLOWCMTITEM_DEFAULT_OPTION, ...option } if (this.#option.position === FLOWCMT_TYPE.FLOW) { this.#actualDuration = this.#option.duration * 1.5 } this.#canvas = document.createElement('canvas') } get id() { return this.#id } get content() { return this.#content } get style() { return this.#style } get option() { return this.#option } get actualDuration() { return this.#actualDuration } get canvas() { return this.#canvas } get top() { return this.position.y } get bottom() { return this.position.y + this.size.height } get left() { return this.position.x } get right() { return this.position.x + this.size.width } get rect() { return { width: this.size.width, height: this.size.height, top: this.top, bottom: this.bottom, left: this.left, right: this.right, } } dispose() { this.#canvas.remove() this.#id = null this.#content = null this.#style = null this.#option = null this.#actualDuration = null this.#canvas = null Object.keys(this).forEach(k => delete this[k]) } } /**************************************** * @classdesc コメントを流すやつ * @example * // 準備 * const fc = new FlowComments() * document.body.appendChild(fc.canvas) * fc.start() * * // コメントを流す(追加する) * fc.pushComment(new FlowCommentsItem(Symbol(), 'Hello world!')) */ class FlowComments { /** * インスタンスに割り当てられるIDのカウント用 * @type {number} */ static #id_cnt = 0 /** * インスタンスに割り当てられるID * @type {number} */ #id /** * `requestAnimationFrame`の`requestID` * @type {number} */ #animReqId = null /** * Canvas * @type {HTMLCanvasElement} */ #canvas /** * CanvasRenderingContext2D * @type {CanvasRenderingContext2D} */ #context2d /** * 現在表示中のコメント * @type {Array<FlowCommentsItem>} */ #comments /** * オプション * @type {FlowCommentsOption} */ #option /** * スタイル * @type {FlowCommentsStyle} */ #style /** * @type {ResizeObserver} */ #resizeObs /**************************************** * コンストラクタ * @param {FlowCommentsOption} [option] オプション * @param {FlowCommentsStyle} [style] スタイル */ constructor(option, style) { // 初期化 this.initialize(option, style) } get id() { return this.#id } get style() { return { ...FLOWCMT_DEFAULT_STYLE, ...this.#style } } get option() { return { ...FLOWCMT_DEFAULT_OPTION, ...this.#option } } get canvas() { return this.#canvas } get context2d() { return this.#context2d } get comments() { return this.#comments } get lineHeight() { return this.#canvas.height / this.option.lines } get fontSize() { return this.lineHeight * FLOWCMT_CONFIG.FONT_SCALE } get isStarted() { return this.#animReqId !== null } /**************************************** * 初期化(インスタンス生成時には不要) * @param {FlowCommentsOption} [option] オプション * @param {FlowCommentsStyle} [style] スタイル */ initialize(option, style) { this.dispose() // ID割り当て this.#id = ++FlowComments.#id_cnt // Canvas生成 this.#canvas = document.createElement('canvas') this.#canvas.classList.add(FLOWCMT_CONFIG.CANVAS_CLASSNAME) this.#canvas.dataset.fcid = this.#id.toString() // CanvasRenderingContext2D this.#context2d = this.#canvas.getContext('2d') // コメント一覧 this.#comments = [] // サイズ変更を監視 this.#resizeObs = new ResizeObserver(entries => { entries.forEach(entry => { const { width, height } = entry.contentRect // Canvasのサイズ(比率)を自動で調整 if (this.option.autoResize) { const rect_before = this.#canvas.width / this.#canvas.height const rect_resized = width / height if (0.01 < Math.abs(rect_before - rect_resized)) { this.resizeCanvas() } } // Canvasの解像度を自動で調整 if (this.option.autoResolution) { const resolution = FLOWCMT_CONFIG.RESOLUTION_LIST.find(v => height <= v) if (Number.isFinite(resolution) && this.option.resolution !== resolution) { this.changeOption({ resolution: resolution }) } } }) }) this.#resizeObs.observe(this.#canvas) // オプションをセット this.changeOption(option) // スタイルをセット this.changeStyle(style) } /**************************************** * オプションを変更 * @param {FlowCommentsOption} option オプション */ changeOption(option) { FlowCommentsUtil.filterObject(option) this.#option = { ...this.#option, ...option } if (option !== undefined && option !== null) { this.resizeCanvas() } } /**************************************** * スタイルを変更 * @param {FlowCommentsStyle} [style] スタイル */ changeStyle(style) { FlowCommentsUtil.filterObject(style) this.#style = { ...this.#style, ...style } if (style !== undefined && style !== null) { this.#updateCanvasStyle() } } /**************************************** * Canvasをリサイズ */ resizeCanvas() { // Canvasをリサイズ const { width, height } = this.#canvas.getBoundingClientRect() const { resolution } = this.option const ratio = (width === 0 && height === 0) ? FLOWCMT_CONFIG.CANVAS_RATIO : (width / height) this.#canvas.width = resolution * ratio this.#canvas.height = resolution // Canvasのスタイルをリセット this.#updateCanvasStyle() } /**************************************** * Canvasのスタイルを更新 */ #updateCanvasStyle() { // スタイルを適用 FlowCommentsUtil.setStyleToCanvas( this.#context2d, this.style, this.option.resolution, this.fontSize ) // Canvasをリセット this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height) // コメントの各プロパティを再計算・描画 this.#comments.forEach(cmt => { this.#generateCommentsItemCanvas(cmt) this.#renderComment(cmt) }) } /**************************************** * Canvasのスタイルをリセット */ resetCanvasStyle() { this.changeStyle(FLOWCMT_DEFAULT_STYLE) } /**************************************** * 端数処理 * @param {number} num */ #floor(num) { return this.#option.smoothRender ? num : (num | 0) } /**************************************** * コメントの単体のCanvasを生成 * @param {FlowCommentsItem} comment コメント */ async #generateCommentsItemCanvas(comment) { const ctx = comment.canvas.getContext('2d') ctx.clearRect(0, 0, comment.canvas.width, comment.canvas.height) const style = { ...this.style, ...comment.style } const drawFontSize = this.fontSize * style.fontScale const margin = drawFontSize * FLOWCMT_CONFIG.TEXT_MARGIN // スタイルを適用 FlowCommentsUtil.setStyleToCanvas( ctx, style, this.option.resolution, this.fontSize ) /** @type {Array<number>} */ const aryWidth = [] //---------------------------------------- // サイズを計算 //---------------------------------------- for (const cont of comment.content) { // 文字列 if (typeof cont === 'string') { aryWidth.push(ctx.measureText(cont).width) } // 画像 else if (cont instanceof FlowCommentsImage) { const img = await cont.get() if (img instanceof HTMLImageElement) { const ratio = img.width / img.height aryWidth.push(drawFontSize * ratio) } else if (img !== undefined) { aryWidth.push(ctx.measureText(img).width) } else { aryWidth.push(1) } } } // コメントの各プロパティを計算 comment.size.width = aryWidth.reduce((a, b) => a + b) comment.size.width += margin * (aryWidth.length - 1) comment.size.height = this.lineHeight comment.scrollWidth = this.#canvas.width + comment.size.width comment.position.x = this.#canvas.width - comment.scrollWidth * comment.position.xp comment.position.y = this.lineHeight * comment.line comment.position.offsetY = this.lineHeight / 2 * (1 + FLOWCMT_CONFIG.FONT_OFFSET_Y) // Canvasのサイズを設定 comment.canvas.width = comment.size.width comment.canvas.height = comment.size.height // スタイルを再適用(上でリセットされる) FlowCommentsUtil.setStyleToCanvas( ctx, style, this.option.resolution, this.fontSize ) //---------------------------------------- // コメントを描画 //---------------------------------------- let dx = 0 for (let idx = 0; idx < comment.content.length; idx++) { if (0 < idx) { dx += margin } const cont = comment.content[idx] // 文字列 if (typeof cont === 'string') { ctx.fillText( cont, this.#floor(dx), this.#floor(comment.position.offsetY) ) } // 画像 else if (cont instanceof FlowCommentsImage) { const img = await cont.get() if (img instanceof HTMLImageElement) { ctx.drawImage( img, this.#floor(dx), this.#floor((comment.size.height - drawFontSize) / 2), this.#floor(aryWidth[idx]), this.#floor(drawFontSize) ) } else if (img !== undefined) { ctx.fillText( img, this.#floor(dx), this.#floor(comment.position.offsetY) ) } else { ctx.fillText( '', this.#floor(dx), this.#floor(comment.position.offsetY) ) } } dx += aryWidth[idx] } } /**************************************** * コメントを追加(流す) * @param {FlowCommentsItem} comment コメント */ async pushComment(comment) { if (this.#animReqId === null || document.visibilityState === 'hidden') return //---------------------------------------- // 画面内に表示するコメントを制限 //---------------------------------------- if (0 < this.option.limit && this.option.limit <= this.#comments.length) { this.#comments.splice(0, this.#comments.length - this.option.limit)[0] } //---------------------------------------- // コメントの各プロパティを計算 //---------------------------------------- await this.#generateCommentsItemCanvas(comment) //---------------------------------------- // コメント表示行を計算 //---------------------------------------- const spd_pushCmt = comment.scrollWidth / comment.option.duration // [[0, 0], [1, 0], ~ , [10, 0]] ([line, cnt]) const lines_over = [...Array(this.option.lines)].map((_, i) => [i, 0]) this.#comments.forEach(cmt => { // 残り表示時間 const leftTime = cmt.option.duration * (1 - cmt.position.xp) // コメント追加時に重なる or 重なる予定かどうか const isOver = comment.left - spd_pushCmt * leftTime <= 0 || comment.left <= cmt.right if (isOver && cmt.line < this.option.lines) { lines_over[cmt.line][1]++ } }) // 重なった頻度を元に昇順で並べ替える const lines_sort = lines_over.sort(([, cntA], [, cntB]) => cntA - cntB) comment.line = lines_sort[0][0] comment.position.y = this.lineHeight * comment.line //---------------------------------------- // コメントを追加 //---------------------------------------- this.#comments.push(comment) } /**************************************** * テキストを描画 * @param {FlowCommentsItem} comment コメント */ #renderComment(comment) { this.#context2d.drawImage( comment.canvas, this.#floor(comment.position.x), this.#floor(comment.position.y) ) } /**************************************** * ループ中に実行される処理 * @param {number} time 時間 */ #update(time) { // Canvasをリセット this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height) this.#comments.forEach((cmt, idx, ary) => { // コメントを流し始めた時間 if (cmt.startTime === null) { cmt.startTime = time } // コメントを流し始めて経過した時間 const elapsedTime = time - cmt.startTime if (elapsedTime <= cmt.actualDuration) { // コメントの座標を更新(流すコメント) if (cmt.option.position === FLOWCMT_TYPE.FLOW) { cmt.position.xp = elapsedTime / cmt.option.duration cmt.position.x = this.#canvas.width - cmt.scrollWidth * cmt.position.xp } // コメントを描画 this.#renderComment(cmt) } else { // 表示時間を超えたら消す cmt.dispose() ary.splice(idx, 1)[0] } }) } /**************************************** * ループ処理 * @param {number} time 時間 */ #loop(time) { this.#update(time) if (this.#animReqId !== null) { this.#animReqId = window.requestAnimationFrame(this.#loop.bind(this)) } } /**************************************** * コメント流しを開始 */ start() { if (this.#animReqId === null) { this.#animReqId = window.requestAnimationFrame(this.#loop.bind(this)) } } /**************************************** * コメント流しを停止 */ stop() { if (this.#animReqId !== null) { window.cancelAnimationFrame(this.#animReqId) this.#animReqId = null } } /**************************************** * 解放(初期化してCanvasを削除) */ dispose() { this.stop() this.#canvas?.remove() this.#resizeObs?.disconnect() this.#id = null this.#animReqId = null this.#canvas = null this.#context2d = null this.#comments = null this.#style = null this.#option = null this.#resizeObs = null Object.keys(this).forEach(k => delete this[k]) } }