@oplayer/danmuku
Version:
Danmuku plugin for oplayer
360 lines (318 loc) • 10.2 kB
text/typescript
import { bilibiliDanmuParseFromUrl } from './bilibili-parse'
import getDanmuTop from './top'
import Player, { $ } from '@oplayer/core'
import DanmukuWorker from './danmuku.worker?worker&inline'
import type { ActiveDanmukuRect, DanmukuItem, Options, QueueItem, _Options } from './types'
const danmukuItemCls = $.css`
position: absolute;
white-space: pre;
pointer-events: none;
perspective: 500px;
will-change: transform, top;
line-height: 1.125;
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;
`
export default class Danmuku {
$player: HTMLDivElement
$danmuku: HTMLDivElement
options: _Options
isStop: boolean = false
isHide: boolean = false
timer: number | null = null
queue: QueueItem[] = []
$refs: HTMLDivElement[] = []
worker: Worker
constructor(public player: Player, options: Options) {
this.$player = player.$root as HTMLDivElement
this.$danmuku = $.create(
`div.${$.css`width: 100%; height: 100%; position: absolute; left: 0; top: 0; pointer-events: none;`}`
)
this.options = Object.assign(
{
speed: 5,
color: '#fff',
mode: 0,
margin: [2, 2],
antiOverlap: true,
useWorker: true,
synchronousPlayback: true
},
options
)
if (options.useWorker) {
this.worker = new DanmukuWorker()
this.worker.addEventListener('error', (error) => {
player.emit('notice', 'danmuku-worker:' + error.message)
})
}
player.on(['play', 'playing'], this.start.bind(this))
player.on(['pause', 'waiting'], this.stop.bind(this))
player.on(['fullscreen', 'webfullscreen', 'seeking'], this.reset.bind(this))
player.on('destroy', this.destroy.bind(this))
this.fetch()
$.render(this.$danmuku, this.$player)
}
async fetch() {
try {
let danmukus: DanmukuItem[] = []
if (typeof this.options.source === 'function') {
danmukus = await this.options.source()
} else if (typeof this.options.source === 'string') {
danmukus = await bilibiliDanmuParseFromUrl(this.options.source)
} else {
danmukus = this.options.source
}
this.player.emit('loadeddanmuku', danmukus)
this.load(danmukus)
} catch (error) {
this.player.emit('notice', { text: 'danmuku: ' + (<Error>error).message })
throw error
}
}
load(danmukus: DanmukuItem[]) {
this.queue = []
this.$danmuku.innerHTML = ''
danmukus
.sort((a, b) => a.time - b.time)
.forEach((danmuku) => {
if (this.options?.filter && this.options.filter(danmuku)) return
this.queue.push({
color: this.options.color,
status: 'wait',
$ref: null,
restTime: 0,
lastTime: 0,
...danmuku
})
})
}
start() {
this.isStop = false
this.continue()
this.update()
this.player.emit('danmuku:start')
}
update() {
this.timer = window.requestAnimationFrame(async () => {
if (this.player.isPlaying && !this.isHide && this.queue.length) {
this.mapping('emit', (danmu) => {
danmu.restTime -= (Date.now() - danmu.lastTime) / 1000
danmu.lastTime = Date.now()
if (danmu.restTime <= 0) {
this.makeWait(danmu)
}
})
const readys = this.getReady()
const { clientWidth, clientHeight } = this.$player
for (let index = 0; index < readys.length; index++) {
const danmu = readys[index]!
danmu.$ref = this.createItem({
text: danmu.text,
cssText: `left: ${clientWidth}px;
${this.options.opacity ? `opacity: ${this.options.opacity};` : ''}
${this.options.fontSize ? `font-size: ${this.options.fontSize}px;` : ''}
${danmu.color ? `color: ${danmu.color};` : ''},
${this.options.fontSize ? `font-size: ${this.options.fontSize}px;` : ''}
${
danmu.border
? `border: 1px solid ${danmu.color}; background-color: rgb(0 0 0 / 50%);`
: ''
}`
})
this.$danmuku.appendChild(danmu.$ref)
danmu.lastTime = Date.now()
danmu.restTime =
this.options.synchronousPlayback && this.player.playbackRate
? this.options.speed / this.player.playbackRate
: this.options.speed
const rect = this.getActiveDanmukusBoundingClientRect()
const target = {
mode: danmu.mode,
height: danmu.$ref.clientHeight,
speed: (clientWidth + danmu.$ref.clientWidth) / danmu.restTime
}
await this.postMessage({
target,
emits: rect,
clientWidth,
clientHeight,
antiOverlap: this.options.antiOverlap,
marginTop: this.options.margin?.[0] || 0,
marginBottom: this.options.margin?.[1] || 50
}).then(({ top }) => {
if (!this.isStop && top != -1) {
danmu.status = 'emit'
danmu.$ref!.style.opacity = '1'
danmu.$ref!.style.top = `${top}px`
switch (danmu.mode) {
case 0: {
const translateX = clientWidth + danmu.$ref!.clientWidth
danmu.$ref!.style.transform = `translate3d(${-translateX}px, 0, 0)`
danmu.$ref!.style.transition = `transform ${danmu.restTime}s linear 0s`
break
}
case 1:
danmu.$ref!.style.left = '50%'
danmu.$ref!.style.transform = 'translate3d(-50%, 0, 0)'
break
default:
break
}
} else {
danmu.status = 'ready'
this.$refs.push(danmu.$ref!)
danmu.$ref = null
}
})
}
if (!this.isStop) this.update()
}
})
}
continue() {
const { clientWidth } = this.$player
this.mapping('stop', (danmu) => {
danmu.status = 'emit'
danmu.lastTime = Date.now()
switch (danmu.mode) {
case 0: {
const translateX = clientWidth + danmu.$ref!.clientWidth
danmu.$ref!.style.transform = `translate3d(${-translateX}px, 0, 0)`
danmu.$ref!.style.transition = `transform ${danmu.restTime}s linear 0s`
break
}
default:
break
}
})
}
suspend() {
const { clientWidth } = this.$player
this.mapping('emit', (danmu) => {
danmu.status = 'stop'
switch (danmu.mode) {
case 0: {
const translateX = clientWidth - (this.getLeft(danmu.$ref!) - this.getLeft(this.$player))
danmu.$ref!.style.transform = `translate3d(${-translateX}px, 0, 0)`
danmu.$ref!.style.transition = 'transform 0s linear 0s'
break
}
default:
break
}
})
}
mapping(status: string, callback: (d: QueueItem) => void) {
this.queue.forEach((danmu) => danmu.status === status && callback(danmu))
}
getLeft($ref: HTMLElement) {
return $ref.getBoundingClientRect().left
}
createItem({ text, cssText }: { text: string; cssText: string }): HTMLDivElement {
const $cache = this.$refs.pop()
if ($cache) return $cache
const $ref = document.createElement('div')
$ref.className = danmukuItemCls
$ref.innerText = text
$ref.style.cssText = cssText
return $ref as HTMLDivElement
}
getReady() {
const { currentTime } = this.player
return this.queue.filter((danmu) => {
return (
danmu.status === 'ready' ||
(danmu.status === 'wait' &&
currentTime + 0.1 >= danmu.time &&
danmu.time >= currentTime - 0.1)
)
})
}
getActiveDanmukusBoundingClientRect() {
const result: ActiveDanmukuRect[] = []
const { clientWidth } = this.$player
const clientLeft = this.getLeft(this.$player)
this.mapping('emit', (danmu) => {
const top = danmu.$ref!.offsetTop
const left = this.getLeft(danmu.$ref!) - clientLeft
const height = danmu.$ref!.clientHeight
const width = danmu.$ref!.clientWidth
const distance = left + width
const right = clientWidth - distance
const speed = distance / danmu.restTime
result.push({
top,
left,
height,
width,
right,
speed,
distance,
time: danmu.restTime,
mode: danmu.mode
})
})
return result
}
postMessage(message = {} as any): Promise<{ top: number }> {
return new Promise((resolve) => {
if (this.options.useWorker && this.worker && this.worker.postMessage) {
message.id = Date.now()
this.worker.onmessage = ({ data }) => {
if (data.id === message.id) resolve(data)
}
this.worker.postMessage(message)
} else {
const top = getDanmuTop(message)
resolve({ top })
}
})
}
makeWait(danmu: QueueItem) {
danmu.status = 'wait'
if (danmu.$ref) {
danmu.$ref.style.opacity = '0'
danmu.$ref.style.transform = 'translate3d(0, 0, 0)'
danmu.$ref.style.transition = 'transform 0s linear 0s'
this.$refs.push(danmu.$ref)
danmu.$ref = null
}
}
reset() {
this.queue.forEach((danmu) => this.makeWait(danmu))
}
emit(danmu: DanmukuItem) {
this.queue.push({
...danmu,
status: 'wait',
$ref: null,
restTime: 0,
lastTime: 0
})
}
stop() {
this.isStop = true
this.suspend()
window.cancelAnimationFrame(this.timer!)
this.player.emit('danmuku:stop')
}
show() {
this.isHide = false
this.start()
this.$danmuku.style.display = 'block'
this.player.emit('danmuku:show')
}
hide() {
this.isHide = true
this.stop()
this.queue.forEach((item) => this.makeWait(item))
this.$danmuku.style.display = 'none'
this.player.emit('danmuku:hide')
}
destroy() {
this.stop()
this.worker?.terminate?.()
this.$danmuku.remove()
this.player.emit('danmuku:destroy')
}
}