Files
ArtPlayer/packages/artplayer-plugin-danmuku/src/danmuku.ts
T

522 lines
14 KiB
TypeScript

import type { DanmuInput, DanmuItem, DanmukuArt, DanmukuInput, DanmukuOption, DanmuState, NormalizedDanmu, NormalizedOption } from './types'
import { defaultOption, normalizeOption, optionChanged, optionScheme } from './config'
import { beginInput, cancelInputs, inputActive, readInput } from './input'
import { filterState, readyItems, setItemState } from './queue'
import Renderer from './renderer'
import Scheduler from './scheduler'
import WorkerClient from './worker-client'
import DanmuWorker from './worker.ts?worker&inline'
export default class Danmuku {
declare ['constructor']: typeof Danmuku
declare art: DanmukuArt
declare utils: DanmukuArt['constructor']['utils']
declare validator: DanmukuArt['constructor']['validator']
declare $danmuku: HTMLDivElement
declare $player: HTMLDivElement
declare queue: DanmuItem[]
declare $refs: HTMLDivElement[]
declare isStop: boolean
declare isHide: boolean
declare timer: number | null
declare index: number
declare worker: Worker | null
declare workerClient: WorkerClient | null
declare renderer: Renderer
declare scheduler: Scheduler
declare option: NormalizedOption
declare states: Record<DanmuState, DanmuItem[]>
constructor(art: DanmukuArt, option: DanmukuOption) {
const { constructor, template } = art
this.utils = constructor.utils // 工具库
this.validator = constructor.validator // 配置校验器
this.$danmuku = template.$danmuku // 弹幕层容器
this.$player = template.$player // 播放器容器
this.art = art
this.queue = [] // 实际弹幕队列
this.$refs = [] // 弹幕DOM节点池
this.isStop = false // 是否停止
this.isHide = false // 是否隐藏
this.timer = null // 定时器
this.index = 0 // 弹幕索引
this.worker = null
this.workerClient = null
this.renderer = new Renderer(this)
this.scheduler = new Scheduler(this)
// 格式化后的配置项
this.option = Danmuku.option
// 弹幕状态池
this.states = { wait: [], ready: [], emit: [], stop: [] }
// 绑定公用事件
this.start = this.start.bind(this)
this.stop = this.stop.bind(this)
this.reset = this.reset.bind(this)
this.resize = this.resize.bind(this)
this.destroy = this.destroy.bind(this)
this.seek = this.seek.bind(this)
// 配置事件可同步销毁播放器,先建立输入和销毁边界。
inputActive(this)
art.on('destroy', this.destroy)
try {
this.config(option, true)
if (!inputActive(this))
return
// 创建 Web Worker, 用于计算弹幕的 top 值
this.createWorker()
}
catch (error) {
art.off('destroy', this.destroy)
cancelInputs(this)
this.scheduler.destroy()
throw error
}
// 监听事件
art.on('video:play', this.start)
art.on('video:playing', this.start)
art.on('video:pause', this.stop)
art.on('video:waiting', this.stop)
art.on('resize', this.resize)
art.on('video:seeking', this.seek)
// 开始加载弹幕
this.load().catch(error => console.warn('Failed to load initial danmuku:', error))
}
// 默认配置
static get option() {
return defaultOption()
}
// 配置校验
static get scheme() {
return optionScheme()
}
// 初始弹幕样式
static get cssText() {
return `
user-select: none;
position: absolute;
white-space: pre;
pointer-events: none;
perspective: 500px;
display: inline-block;
will-change: transform;
font-weight: normal;
line-height: 1.125;
visibility: hidden;
font-family: SimHei, "Microsoft JhengHei", Arial, Helvetica, sans-serif;
text-shadow: rgb(0, 0, 0) 1px 0px 1px, rgb(0, 0, 0) 0px 1px 1px, rgb(0, 0, 0) 0px -1px 1px, rgb(0, 0, 0) -1px 0px 1px;
`
}
// 是否在移动端使用了自动旋屏,会影响弹幕的left和top值
get isRotate(): boolean | undefined {
return (this.art.plugins?.autoOrientation as { state?: boolean } | undefined)?.state
}
// 计算上空白边距
get marginTop() {
const { clamp } = this.utils
const value = this.option.margin[0]
const { clientHeight } = this.$player
if (typeof value === 'number') {
return clamp(value, 0, clientHeight)
}
if (typeof value === 'string' && value.endsWith('%')) {
const ratio = Number.parseFloat(value) / 100
return clamp(clientHeight * ratio, 0, clientHeight)
}
return Danmuku.option.margin[0]
}
// 计算下空白边距
get marginBottom() {
const { clamp } = this.utils
const value = this.option.margin[1]
const { clientHeight } = this.$player
if (typeof value === 'number') {
return clamp(value, 0, clientHeight)
}
if (typeof value === 'string' && value.endsWith('%')) {
const ratio = Number.parseFloat(value) / 100
return clamp(clientHeight * ratio, 0, clientHeight)
}
return Danmuku.option.margin[1]
}
// 计算弹幕字体大小
get fontSize() {
const { clamp } = this.utils
const { clientHeight } = this.$player
const fontSize = this.option.fontSize
if (typeof fontSize === 'number') {
return Math.round(clamp(fontSize, 12, clientHeight))
}
if (typeof fontSize === 'string' && fontSize.endsWith('%')) {
const ratio = Number.parseFloat(fontSize) / 100
return Math.round(clamp(clientHeight * ratio, 12, clientHeight))
}
return Danmuku.option.fontSize
}
// 获取弹幕DOM节点
get $ref() {
return this.renderer.acquire()
}
// 获取准备好发送的弹幕
get readys() {
return readyItems(this)
}
// 可见的弹幕的数据,用于计算下一个弹幕的top值
get visibles() {
return this.renderer.visibles
}
// 计算弹幕速度
get speed() {
return this.option.synchronousPlayback && this.art.playbackRate
? this.option.speed / Number(this.art.playbackRate)
: this.option.speed
}
// 加载弹幕
async load(danmuku?: DanmukuInput) {
const { errorHandle } = this.utils
const task = beginInput(this, danmuku === undefined)
try {
if (!task.active())
return this
const target = danmuku || this.option.danmuku
const input = readInput(target, task)
// 数组在第一次 await 前提交,空数组的 loaded 保持同步。
const danmus = input.asynchronous ? await task.wait(input.value) : input.value
if (!task.active())
return this
errorHandle(Array.isArray(danmus), 'Danmuku need return an array as result')
// Active input and the runtime validator exclude the cancellation sentinel.
const rows = danmus as DanmuInput[]
// 假如没有传入弹幕参数,则清空弹幕,否则追加弹幕
if (danmuku === undefined) {
this.reset() // 重置弹幕
if (!task.active())
return this
this.queue = [] // 清空弹幕队列
this.states = { wait: [], ready: [], emit: [], stop: [] } // 清空弹幕状态池
this.renderer.clear()
}
// 逐个验证原始弹幕并转换到弹幕队列
for (let index = 0; index < rows.length; index++) {
if (!task.active())
return this
const danmu = rows[index]!
await task.emit(danmu)
}
if (task.active())
this.art.emit('artplayerPluginDanmuku:loaded', this.queue)
}
catch (error) {
if (!task.active())
return this
this.art.emit('artplayerPluginDanmuku:error', error)
throw error
}
finally {
task.finish()
}
return this
}
// 把原始弹幕转换到弹幕队列
async emit(danmu: DanmuInput) {
const { clamp } = this.utils
if (!inputActive(this))
return this
this.validator(danmu, {
id: '?string', // 弹幕唯一标识
text: 'string', // 弹幕文本
mode: '?number', // 弹幕模式: 0: 滚动,1: 顶部,2: 底部
color: '?string', // 弹幕颜色
time: '?number', // 弹幕时间
border: '?boolean', // 弹幕是否有边框
style: '?object', // 弹幕额外样式
})
// 弹幕文本为空则直接忽略
if (!danmu.text.trim())
return this
// 设置弹幕时间,如果没有则默认为当前时间加 0.5 秒
if (danmu.time || danmu.time === 0) {
danmu.time = clamp(danmu.time, 0, Infinity)
}
else {
danmu.time = this.art.currentTime + 0.5
}
// 设置弹幕模式,如果没有则默认为全局配置
if (danmu.mode === undefined) {
danmu.mode = this.option.mode
}
// 设置弹幕单独样式,如果没有则默认为空对象
if (danmu.style === undefined) {
danmu.style = {}
}
// 设置弹幕颜色,如果没有则默认为全局配置
if (danmu.color === undefined) {
danmu.color = this.option.color
}
// 弹幕模式只能是 0, 1, 2
if (![0, 1, 2].includes(danmu.mode))
return this
// 自定义弹幕过滤函数
// Validation/defaulting above fills these fields before the public callback.
if (!this.option.filter(danmu as NormalizedDanmu))
return this
if (!inputActive(this))
return this
// 添加自定义属性
const item = {
...danmu,
$state: 'wait' as const, // 弹幕初始状态
$index: this.index++, // 弹幕索引
$ref: null, // 弹幕 DOM 节点
$restTime: 0, // 弹幕剩余时间
$lastStartTime: 0, // 弹幕上次开始时间
}
// 转换为wait状态
this.setState(item as DanmuItem, 'wait')
// 添加到实际弹幕队列
this.queue.push(item as DanmuItem)
// 弹幕有四个状态:
// - wait: 弹幕还未开始显示,没有被添加到 DOM 中
// - ready: 弹幕准备好显示,没有被添加到 DOM 中
// - emit: 弹幕正在显示,已经被添加到 DOM 中
// - stop: 弹幕正在停止显示,已经被添加到 DOM 中
return this
}
// 动态配置
config(option: DanmukuOption, isInit = false) {
const { clamp } = this.utils
const { $controlsCenter } = this.art.template
// 判断配置项是否有变化
const changed = optionChanged(this.option, option)
// 没有变化则直接返回
if (!changed && !isInit)
return this
// 更新配置项
const next = normalizeOption(this.option, option, {
defaults: Danmuku.option,
validate: value => this.validator(value, Danmuku.scheme),
clamp,
mount: $controlsCenter,
})
if (!inputActive(this))
return this
const beforeVisibleChanged = this.option.beforeVisible !== next.beforeVisible
this.option = next
if (beforeVisibleChanged) {
this.scheduler.invalidate()
this.scheduler.schedule()
}
// 动态配置有字体大小,需要重新渲染
if (option.fontSize) {
this.reset()
if (!inputActive(this))
return this
}
// 通过配置项控制弹幕的显示和隐藏
if (this.option.visible) {
this.show()
}
else {
this.hide()
}
if (inputActive(this))
this.art.emit('artplayerPluginDanmuku:config', this.option)
return this
}
// 计算DOM的left值,受到旋屏影响
getLeft($ref: HTMLElement): number {
return this.renderer.left($ref)
}
// 复杂运算交给 Web Worker 处理
// Keep the historical empty default; unknown message types receive no reply.
postMessage(message: Parameters<WorkerClient['request']>[0] = {} as Parameters<WorkerClient['request']>[0]) {
return this.workerClient!.request(message)
}
createWorker() {
this.workerClient = new WorkerClient(() => new DanmuWorker(), error => this.scheduler.fail(error))
this.worker = this.workerClient.worker
}
// 根据状态获取弹幕
filter(state: DanmuState, callback: (danmu: DanmuItem) => void) {
return filterState(this, state, callback)
}
// 设置弹幕状态
setState(danmu: DanmuItem, state: DanmuState) {
setItemState(this, danmu, state)
}
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu: DanmuItem) {
this.renderer.makeWait(danmu)
}
// 实时更新弹幕
update() {
this.scheduler.running = true
this.scheduler.schedule()
return this
}
// 重置正在显示的弹幕: stop/emit 状态的弹幕
resize() {
this.renderer.resize()
}
// 继续弹幕
continue() {
this.renderer.continue()
return this
}
// 暂停弹幕
suspend() {
this.renderer.suspend()
return this
}
stop() {
this.isStop = true
this.scheduler.running = false
this.scheduler.invalidate()
this.suspend()
this.art.emit('artplayerPluginDanmuku:stop')
return this
}
start() {
if (this.scheduler.closed || this.art.isDestroy)
return this
const start = ++this.scheduler.starts
const generation = this.scheduler.generation
this.isStop = false
this.scheduler.recover()
const obsolete = this.scheduler.closed || this.art.isDestroy || this.isStop || this.scheduler.fault
|| generation !== this.scheduler.generation || start !== this.scheduler.starts
if (obsolete)
return this
this.continue()
this.update()
this.art.emit('artplayerPluginDanmuku:start')
return this
}
reset() {
this.scheduler.invalidate()
this.queue.forEach(danmu => this.makeWait(danmu))
this.art.emit('artplayerPluginDanmuku:reset')
this.scheduler.recover()
this.scheduler.schedule()
return this
}
seek() {
this.scheduler.invalidate()
this.scheduler.schedule()
}
show() {
this.isHide = false
this.$danmuku.style.opacity = 1 as unknown as string
this.option.visible = true
this.art.emit('artplayerPluginDanmuku:show')
return this
}
hide() {
this.isHide = true
this.scheduler.invalidate()
this.$danmuku.style.opacity = 0 as unknown as string
this.option.visible = false
this.art.emit('artplayerPluginDanmuku:hide')
this.scheduler.schedule()
return this
}
destroy() {
cancelInputs(this)
this.scheduler.destroy()
try {
try {
this.stop()
}
finally {
this.workerClient?.dispose()
}
}
finally {
this.renderer.destroy()
this.art.off('video:play', this.start)
this.art.off('video:playing', this.start)
this.art.off('video:pause', this.stop)
this.art.off('video:waiting', this.stop)
this.art.off('resize', this.resize)
this.art.off('video:seeking', this.seek)
this.art.off('destroy', this.destroy)
this.art.emit('artplayerPluginDanmuku:destroy')
}
}
}