// ==UserScript==
// @name FlowComments
// @namespace https://midra.me
// @version 0.0.7
// @description コメントをニコニコ風に流すやつ
// @author Midra
// @license MIT
// @grant none
// @compatible chrome >=84
// @compatible safari >=15
// ==/UserScript==
// @ts-check
/* jshint esversion: 6 */
'use strict'
/**
* `FlowCommentItem`のオプション
* @typedef {object} FlowCommentItemOption
* @property {string} [fontFamily] フォント
* @property {string} [color] フォントカラー
* @property {number} [fontScale] 拡大率
* @property {string} [position] 表示位置
* @property {number} [speed] 速度
* @property {number} [opacity] 透明度
*/
/**
* `FlowComments`のオプション
* @typedef {object} FlowCommentsOption
* @property {number} [resolution] 解像度
* @property {number} [lines] 行数
* @property {number} [limit] 画面内に表示するコメントの最大数
* @property {boolean} [autoResize] サイズ(比率)を自動で調整
* @property {boolean} [autoAdjRes] 解像度を自動で調整
*/
/****************************************
* @classdesc 流すコメント
* @example
* // idを指定する場合
* const fcItem1 = new FlowCommentItem('1518633760656605184', 'ウルトラソウッ')
* // idを指定しない場合
* const fcItem2 = new FlowCommentItem(Symbol(), 'うんち!')
*/
class FlowCommentItem {
/**
* 表示時間
* @type {number}
*/
static #lifetime = 6000
/**
* コメントID
* @type {string | number | symbol}
*/
#id
/**
* コメント本文
* @type {string}
*/
#text
/**
* X座標
* @type {number}
*/
x = 0
/**
* X座標(割合)
* @type {number}
*/
xp = 0
/**
* Y座標
* @type {number}
*/
y = 0
/**
* コメントの幅
* @type {number}
*/
width = 0
/**
* コメントの高さ
* @type {number}
*/
height = 0
/**
* 実際に流すときの距離
* @type {number}
*/
scrollWidth = 0
/**
* 行番号
* @type {number}
*/
line = 0
/**
* コメントを流し始めた時間
* @type {number}
*/
startTime = null
/**
* 実際の表示時間
* @type {number}
*/
#actualLifetime
/**
* オプション
* @type {FlowCommentItemOption}
*/
#option = {
fontFamily: FlowComments.fontFamily,
color: '#fff',
fontScale: 1,
position: 'flow',
speed: 1,
opacity: 0,
}
/****************************************
* コンストラクタ
* @param {string | number | symbol} id コメントID
* @param {string} text コメント本文
* @param {FlowCommentItemOption} [option] オプション
*/
constructor(id, text, option = null) {
this.#id = id
this.#text = text
this.#option = { ...this.#option, ...option }
this.#actualLifetime = FlowCommentItem.#lifetime * (this.#option.position === 'flow' ? 1.5 : 1)
}
get id() { return this.#id }
get text() { return this.#text }
get lifetime() { return FlowCommentItem.#lifetime / this.#option.speed }
get actualLifetime() { return this.#actualLifetime }
get option() { return this.#option }
get top() { return this.y }
get bottom() { return this.y + this.height }
get left() { return this.x }
get right() { return this.x + this.width }
}
/****************************************
* @classdesc コメントを流すやつ
* @example
* // 準備
* const fc = new FlowComments()
* document.body.appendChild(fc.canvas)
* fc.start()
*
* // コメントを流す(追加する)
* fc.pushComment(new FlowCommentItem(Symbol(), 'Hello, world!'))
*/
class FlowComments {
/**
* インスタンスに割り当てられるIDのカウント用
* @type {number}
*/
static #id_cnt = 0
/**
* デフォルトのフォント
* @type {string}
*/
static fontFamily = 'Arial,"MS Pゴシック","MS PGothic",MSPGothic,MS-PGothic,sans-serif,-apple-system,Gulim,"黑体",SimHei'
/**
* インスタンスに割り当てられるID
* @type {number}
*/
#id
/**
* `requestAnimationFrame`の`requestID`
* @type {number}
*/
#animReqId = null
/**
* Canvas
* @type {HTMLCanvasElement}
*/
#canvas
/**
* CanvasRenderingContext2D
* @type {CanvasRenderingContext2D}
*/
#context2d
/**
* 現在表示中のコメント
* @type {Array<FlowCommentItem>}
*/
#comments
/**
* オプション
* @type {FlowCommentsOption}
*/
#option = {
resolution: 720,
lines: 11,
limit: 0,
autoResize: true,
autoAdjRes: true,
}
/**
* @type {ResizeObserver}
*/
#resizeObs
/****************************************
* コンストラクタ
* @param {FlowCommentsOption} [option] オプション
*/
constructor(option) {
// ID割り当て
this.#id = ++FlowComments.#id_cnt
// Canvas生成
this.#canvas = document.createElement('canvas')
this.#canvas.classList.add('mid-FlowComments')
this.#canvas.dataset.fcid = this.#id.toString()
// サイズ変更を監視
this.#resizeObs = new ResizeObserver(entries => {
const { width, height } = entries[0].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.autoAdjRes) {
if (height <= 240) {
this.changeResolution(240)
} else if (height <= 480) {
this.changeResolution(480)
} else {
this.changeResolution(720)
}
}
})
this.#resizeObs.observe(this.#canvas)
// CanvasRenderingContext2D
this.#context2d = this.#canvas.getContext('2d')
// 初期化
this.initialize(option)
}
get id() { return this.#id }
get option() { return 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 + 0.5) }
get lineSpace() { return this.lineHeight * 0.5 }
get fontSize() { return this.lineHeight - this.lineSpace * 0.5 }
get isStarted() { return this.#animReqId !== null }
/****************************************
* 初期化(インスタンス生成時には不要)
* @param {FlowCommentsOption} [option] オプション
*/
initialize(option) {
this.stop()
this.#option = { ...this.#option, ...option }
this.#comments = []
this.#animReqId = null
this.initializeCanvas()
}
/****************************************
* Canvasの解像度を変更
* @param {number} resolution 解像度
*/
changeResolution(resolution) {
if (Number.isFinite(resolution) && this.#option.resolution !== resolution) {
this.#option.resolution = resolution
this.initializeCanvas()
}
}
/****************************************
* CanvasRenderingContext2Dを初期化
*/
initializeCanvas() {
this.#resizeCanvas()
this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)
}
/****************************************
* CanvasRenderingContext2Dをリサイズ
*/
#resizeCanvas() {
// Canvasをリサイズ
const { width, height } = this.#canvas.getBoundingClientRect()
const ratio = (width === 0 && height === 0) ? (16 / 9) : (width / height)
this.#canvas.width = ratio * this.#option.resolution
this.#canvas.height = this.#option.resolution
// Canvasのスタイルをリセット
this.#resetCanvasStyle()
// コメントの各プロパティを再計算
this.#comments.forEach(this.#calcCommentProperty.bind(this))
}
/****************************************
* Canvasのスタイルをリセット
*/
#resetCanvasStyle() {
this.#context2d.font = `600 ${this.fontSize}px ${FlowComments.fontFamily}`
this.#context2d.lineJoin = 'round'
this.#context2d.fillStyle = '#fff'
this.#context2d.shadowColor = '#000'
this.#context2d.shadowBlur = this.#option.resolution / 200
}
/****************************************
* コメントの各プロパティを計算する
* @param {FlowCommentItem} comment コメント
*/
#calcCommentProperty(comment) {
comment.width = this.#context2d.measureText(comment.text).width
comment.scrollWidth = this.#canvas.width + comment.width
comment.x = this.#canvas.width - comment.scrollWidth * comment.xp
comment.y = this.lineHeight * comment.line
}
/****************************************
* コメントを追加(流す)
* @param {FlowCommentItem} comment コメント
*/
pushComment(comment) {
if (this.#animReqId === null) return
//----------------------------------------
// 画面内に表示するコメントを制限
//----------------------------------------
if (0 < this.#option.limit && this.#option.limit <= this.#comments.length) {
this.#comments.splice(0, 1)
}
//----------------------------------------
// コメントの各プロパティを計算
//----------------------------------------
this.#calcCommentProperty(comment)
//----------------------------------------
// コメント表示行を計算
//----------------------------------------
const spd_pushCmt = comment.scrollWidth / comment.lifetime
// [[1, 2], [2, 1], ~ , [11, 1]] ([line, cnt])
const lines_over = [...Array(this.#option.lines)].map((_, i) => [i + 1, 0])
this.#comments.forEach(val => {
// 残り表示時間
const leftTime = val.lifetime * (1 - val.xp)
// コメント追加時に重なる or 重なる予定かどうか
const isOver =
comment.left - spd_pushCmt * leftTime <= 0 ||
comment.left <= val.right
if (isOver) {
lines_over[val.line - 1][1]++
}
})
// 重なった頻度を元に昇順で並べ替える
const lines_sort = lines_over.sort(([, cntA], [, cntB]) => cntA - cntB)
comment.line = lines_sort[0][0]
comment.y = this.lineHeight * comment.line
//----------------------------------------
// コメントを追加
//----------------------------------------
this.#comments.push(comment)
}
/****************************************
* テキストを描画
* @param {FlowCommentItem} comment コメント
*/
#renderComment(comment) {
this.#context2d.fillText(comment.text, ~~comment.x, ~~comment.y)
}
/****************************************
* ループ中に実行される処理
* @param {number} time 時間
*/
#update(time) {
// Canvasをリセット
this.#context2d.clearRect(0, 0, this.#canvas.width, this.#canvas.height)
this.#comments.forEach((comment, idx, ary) => {
// コメントを流し始めた時間
if (comment.startTime === null) {
comment.startTime = time
}
// コメントを流し始めて経過した時間
const elapsedTime = time - comment.startTime
if (elapsedTime <= comment.actualLifetime) {
// コメントの座標を更新(流すコメント)
if (comment.option.position === 'flow') {
comment.xp = elapsedTime / comment.lifetime
comment.x = this.#canvas.width - comment.scrollWidth * comment.xp
}
// コメントを描画
this.#renderComment(comment)
} else {
// 表示時間を超えたら消す
ary.splice(idx, 1)
}
})
}
/****************************************
* ループ処理
* @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.initialize()
this.#canvas.remove()
this.#canvas = null
}
}