mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-30 12:01:20 -08:00
refactor(danmuku): [PKG-DANMUKU-06] migrate runtime and preserve public types
This commit is contained in:
1 parent
abc8008da7
commit
24b883d862
64 files changed
+14020
-633
No files matched your search
@@ -0,0 +1,521 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user