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).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') } }