refactor(danmuku): [PKG-DANMUKU-06] migrate runtime and preserve public types

This commit is contained in:
Harvey Zhao committed 2026-09-13 22:06:00 +08:00
1 parent abc8008da7
commit 24b883d862
64 files changed
+14020 -633

No files matched your search

@@ -1,4 +1,6 @@
export function getMode(key) {
import type { ParsedDanmu, ParserReply, ParserRequest } from './parser-types'
export function getMode(key: number): number {
switch (key) {
case 1:
case 2:
@@ -13,16 +15,16 @@ export function getMode(key) {
}
}
export function bilibiliDanmuParseFromXml(xmlString) {
export function bilibiliDanmuParseFromXml(xmlString: unknown): ParsedDanmu[] {
if (typeof xmlString !== 'string')
return []
const reg = /<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs
const matches = xmlString.matchAll(reg)
return Array.from(matches)
.map((match) => {
const attr = match.groups.p.split(',')
const attr = match.groups!.p!.split(',')
if (attr.length >= 8) {
const text = match.groups.text
const text = match.groups!.text!
.trim()
.replaceAll('&quot;', '"')
.replaceAll('&apos;', '\'')
@@ -38,7 +40,7 @@ export function bilibiliDanmuParseFromXml(xmlString) {
color: `#${Number(attr[3]).toString(16)}`,
timestamp: Number(attr[4]),
pool: Number(attr[5]),
userID: attr[6],
userID: attr[6]!,
rowID: Number(attr[7]),
}
}
@@ -46,11 +48,11 @@ export function bilibiliDanmuParseFromXml(xmlString) {
return null
}
})
.filter(Boolean)
.filter(Boolean) as ParsedDanmu[]
}
export function onmessage({ data }) {
export function onmessage({ data }: MessageEvent<ParserRequest>) {
const { xml, id } = data
const danmus = bilibiliDanmuParseFromXml(xml)
globalThis.postMessage({ danmus, id })
globalThis.postMessage({ danmus, id } satisfies ParserReply)
}
@@ -1,8 +1,9 @@
import type { ParsedDanmu, ParserCancellation, ParserReply, ParserRequest } from './parser-types'
import { bilibiliDanmuParseFromXml, getMode, onmessage } from './bilibili-parser'
let nextRequest = 0
function abortError(signal) {
function abortError(signal?: AbortSignal): unknown {
if (signal?.reason !== undefined)
return signal.reason
const error = new Error('Bilibili Danmu loading cancelled')
@@ -10,17 +11,17 @@ function abortError(signal) {
return error
}
export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
export function bilibiliDanmuParseFromUrl(url: string, { signal, onCancel }: ParserCancellation = {}): Promise<ParsedDanmu[]> {
return new Promise((resolve, reject) => {
let settled = false
let worker
let workerUrl
let releaseCancel
let worker: Worker | undefined
let workerUrl: string | undefined
let releaseCancel: (() => void) | undefined
const id = ++nextRequest
const cancel = () => finish(undefined, abortError(signal))
function releaseWorker() {
let error
let error: unknown
let failed = false
try {
if (worker) {
@@ -52,7 +53,9 @@ export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
throw error
}
function finish(value, error) {
function finish(value: ParsedDanmu[]): void
function finish(value: undefined, error: unknown): void
function finish(value: ParsedDanmu[] | undefined, error?: unknown) {
if (settled)
return
settled = true
@@ -69,10 +72,10 @@ export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
}
if (failed)
reject(error)
else resolve(value)
else resolve(value!)
}
function fallback(xml, error) {
function fallback(xml: string, error: unknown) {
if (settled)
return
console.error('Error parsing Bilibili Danmu:', error)
@@ -107,7 +110,7 @@ export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
releaseWorker()
return
}
worker.onmessage = (event) => {
worker.onmessage = (event: MessageEvent<ParserReply>) => {
if (settled || event.data?.id !== id)
return
const { danmus } = event.data
@@ -121,8 +124,8 @@ export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
event.preventDefault?.()
fallback(xml, event.error || event)
}
worker.onmessageerror = event => fallback(xml, event.error || event)
worker.postMessage({ xml, id })
worker.onmessageerror = event => fallback(xml, (event as MessageEvent & { error?: unknown }).error || event)
worker.postMessage({ xml, id } satisfies ParserRequest)
}
catch (error) {
fallback(xml, error)
@@ -1,4 +1,6 @@
export function defaultOption() {
import type { DanmukuOption, NormalizedOption } from './types'
export function defaultOption(): NormalizedOption & { fontSize: number, margin: [number, string] } {
return {
danmuku: [],
speed: 5,
@@ -62,21 +64,21 @@ export function optionScheme() {
}
}
export function isPromiseInput(value) {
export function isPromiseInput(value: unknown): value is Promise<unknown> {
return value instanceof Promise || Object.prototype.toString.call(value) === '[object Promise]'
}
export function optionChanged(previous, update) {
export function optionChanged(previous: NormalizedOption, update: DanmukuOption) {
return Object.keys(update).some((key) => {
const before = previous[key]
const after = update[key]
const before = previous[key as keyof NormalizedOption]
const after = update[key as keyof NormalizedOption]
if (typeof before === 'function' || typeof after === 'function' || isPromiseInput(before) || isPromiseInput(after))
return before !== after
return JSON.stringify(before) !== JSON.stringify(after)
})
}
export function normalizeOption(previous, update, { defaults, validate, clamp, mount }) {
export function normalizeOption(previous: NormalizedOption, update: DanmukuOption, { defaults, validate, clamp, mount }: { defaults: NormalizedOption, validate: (value: NormalizedOption) => unknown, clamp: (value: number, min: number, max: number) => number, mount: HTMLDivElement }): NormalizedOption {
const next = Object.assign({}, defaults, previous, update)
validate(next)
next.mode = clamp(next.mode, 0, 2)
@@ -1,13 +1,34 @@
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.js?worker&inline'
import DanmuWorker from './worker.ts?worker&inline'
export default class Danmuku {
constructor(art, option) {
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 // 工具库
@@ -99,8 +120,8 @@ export default class Danmuku {
}
// 是否在移动端使用了自动旋屏,会影响弹幕的left和top值
get isRotate() {
return this.art.plugins?.autoOrientation?.state
get isRotate(): boolean | undefined {
return (this.art.plugins?.autoOrientation as { state?: boolean } | undefined)?.state
}
// 计算上空白边距
@@ -181,7 +202,7 @@ export default class Danmuku {
}
// 加载弹幕
async load(danmuku) {
async load(danmuku?: DanmukuInput) {
const { errorHandle } = this.utils
const task = beginInput(this, danmuku === undefined)
@@ -196,6 +217,8 @@ export default class Danmuku {
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) {
@@ -208,10 +231,10 @@ export default class Danmuku {
}
// 逐个验证原始弹幕并转换到弹幕队列
for (let index = 0; index < danmus.length; index++) {
for (let index = 0; index < rows.length; index++) {
if (!task.active())
return this
const danmu = danmus[index]
const danmu = rows[index]!
await task.emit(danmu)
}
@@ -232,7 +255,7 @@ export default class Danmuku {
}
// 把原始弹幕转换到弹幕队列
async emit(danmu) {
async emit(danmu: DanmuInput) {
const { clamp } = this.utils
if (!inputActive(this))
return this
@@ -279,7 +302,8 @@ export default class Danmuku {
return this
// 自定义弹幕过滤函数
if (!this.option.filter(danmu))
// Validation/defaulting above fills these fields before the public callback.
if (!this.option.filter(danmu as NormalizedDanmu))
return this
if (!inputActive(this))
return this
@@ -287,7 +311,7 @@ export default class Danmuku {
// 添加自定义属性
const item = {
...danmu,
$state: 'wait', // 弹幕初始状态
$state: 'wait' as const, // 弹幕初始状态
$index: this.index++, // 弹幕索引
$ref: null, // 弹幕 DOM 节点
$restTime: 0, // 弹幕剩余时间
@@ -295,10 +319,10 @@ export default class Danmuku {
}
// 转换为wait状态
this.setState(item, 'wait')
this.setState(item as DanmuItem, 'wait')
// 添加到实际弹幕队列
this.queue.push(item)
this.queue.push(item as DanmuItem)
// 弹幕有四个状态:
// - wait: 弹幕还未开始显示,没有被添加到 DOM 中
@@ -310,7 +334,7 @@ export default class Danmuku {
}
// 动态配置
config(option, isInit = false) {
config(option: DanmukuOption, isInit = false) {
const { clamp } = this.utils
const { $controlsCenter } = this.art.template
@@ -359,13 +383,14 @@ export default class Danmuku {
}
// 计算DOM的left值,受到旋屏影响
getLeft($ref) {
getLeft($ref: HTMLElement): number {
return this.renderer.left($ref)
}
// 复杂运算交给 Web Worker 处理
postMessage(message = {}) {
return this.workerClient.request(message)
// 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() {
@@ -374,17 +399,17 @@ export default class Danmuku {
}
// 根据状态获取弹幕
filter(state, callback) {
filter(state: DanmuState, callback: (danmu: DanmuItem) => void) {
return filterState(this, state, callback)
}
// 设置弹幕状态
setState(danmu, state) {
setState(danmu: DanmuItem, state: DanmuState) {
setItemState(this, danmu, state)
}
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu) {
makeWait(danmu: DanmuItem) {
this.renderer.makeWait(danmu)
}
@@ -454,7 +479,7 @@ export default class Danmuku {
show() {
this.isHide = false
this.$danmuku.style.opacity = 1
this.$danmuku.style.opacity = 1 as unknown as string
this.option.visible = true
this.art.emit('artplayerPluginDanmuku:show')
return this
@@ -463,7 +488,7 @@ export default class Danmuku {
hide() {
this.isHide = true
this.scheduler.invalidate()
this.$danmuku.style.opacity = 0
this.$danmuku.style.opacity = 0 as unknown as string
this.option.visible = false
this.art.emit('artplayerPluginDanmuku:hide')
this.scheduler.schedule()
@@ -1,19 +1,36 @@
import type { DanmuItem, HeatmapOptions, HeatmapPoint } from './types'
import { MAX_HEATMAP_POINTS, sampleHeatmap } from './heatmap-sampling'
function map(value, inMin, inMax, outMin, outMax) {
function map(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
}
function line(pointA, pointB) {
function line(pointA: HeatmapPoint, pointB: HeatmapPoint) {
const lengthX = pointB[0] - pointA[0]
const lengthY = pointB[1] - pointA[1]
return { length: Math.sqrt(lengthX ** 2 + lengthY ** 2), angle: Math.atan2(lengthY, lengthX) }
}
export function heatmapGeometry({ width, height, duration, queue, option, points: input = [] }) {
interface GeometryInput {
width: number
height: number
duration: number
queue: readonly Pick<DanmuItem, 'time'>[]
option: boolean | HeatmapOptions
points?: HeatmapPoint[]
}
export interface HeatmapGeometry {
path: string
opacity: number
width: number
height: number
}
export function heatmapGeometry({ width, height, duration, queue, option, points: input = [] }: GeometryInput): HeatmapGeometry | null {
if (![width, height, duration].every(value => Number.isFinite(value) && value > 0))
return null
const defaults = {
const defaults: Required<HeatmapOptions> = {
xMin: 0,
xMax: width,
yMin: 0,
@@ -25,9 +42,9 @@ export function heatmapGeometry({ width, height, duration, queue, option, points
smoothing: 0.2,
flattening: 0.2,
}
const configured = Object.assign({}, typeof option === 'object' ? option : {})
const configured: HeatmapOptions = Object.assign({}, typeof option === 'object' ? option : {})
const options = Object.assign({}, defaults, configured)
for (const key of Object.keys(defaults)) {
for (const key of Object.keys(defaults) as (keyof HeatmapOptions)[]) {
if (!Number.isFinite(options[key]))
options[key] = defaults[key]
}
@@ -39,7 +56,8 @@ export function heatmapGeometry({ width, height, duration, queue, option, points
const points = hasCustomPoints ? [...input] : sampleHeatmap(queue, width, duration, options.sampling)
if (!points.length
|| !points.every(point => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]))) { return null }
const lastPoint = points[points.length - 1]
// The nonempty check above guarantees both endpoints used below.
const lastPoint = points[points.length - 1]!
if (lastPoint[0] !== width) {
if (points.length === MAX_HEATMAP_POINTS)
return null
@@ -65,8 +83,8 @@ export function heatmapGeometry({ width, height, duration, queue, option, points
const fitted = automatic && peak > defaults.yMax / 4
if (fitted)
options.yMax = peak * 4
const boundY = value => fitted ? Math.min(height, Math.max(height * 0.75, value)) : value
const controlPoint = (current, previous, next, reverse) => {
const boundY = (value: number) => fitted ? Math.min(height, Math.max(height * 0.75, value)) : value
const controlPoint = (current: HeatmapPoint, previous: HeatmapPoint | undefined, next: HeatmapPoint | undefined, reverse?: boolean): HeatmapPoint => {
const geometry = line(previous || current, next || current)
const flat = map(Math.cos(geometry.angle) * options.flattening, 0, 1, 1, 0)
const angle = geometry.angle * flat + (reverse ? Math.PI : 0)
@@ -74,13 +92,14 @@ export function heatmapGeometry({ width, height, duration, queue, option, points
// A cubic stays inside the convex hull of its endpoints and controls.
return [current[0] + Math.cos(angle) * length, boundY(current[1] + Math.sin(angle) * length)]
}
const positions = points.map(point => [map(point[0], options.xMin, options.xMax, 0, width), boundY(map(point[1], options.yMin, options.yMax, height, 0))])
const positions = points.map<HeatmapPoint>(point => [map(point[0], options.xMin, options.xMax, 0, width), boundY(map(point[1], options.yMin, options.yMax, height, 0))])
if (!positions.every(point => point.every(Number.isFinite)))
return null
const path = positions.map((point, index, all) => {
if (index === 0)
return `M ${all[all.length - 1][0]},${height} L ${point[0]},${height} L ${point[0]},${point[1]}`
const start = controlPoint(all[index - 1], all[index - 2], point)
return `M ${all[all.length - 1]![0]},${height} L ${point[0]},${height} L ${point[0]},${point[1]}`
// Every noninitial point has its immediately preceding control point.
const start = controlPoint(all[index - 1]!, all[index - 2], point)
const end = controlPoint(point, all[index - 1], all[index + 1], true)
return `C ${start[0]},${start[1]} ${end[0]},${end[1]} ${point[0]},${point[1]}${index === all.length - 1 ? ' z' : ''}`
}).join(' ')
@@ -1,22 +1,25 @@
import type { DanmuItem, HeatmapPoint } from './types'
export const MAX_HEATMAP_POINTS = 0xFFFFFFFF
function upperBound(values, value) {
function upperBound(values: readonly number[], value: number): number {
let start = 0
let end = values.length
while (start < end) {
const middle = Math.floor((start + end) / 2)
if (values[middle] <= value)
// The binary-search interval guarantees middle is an existing index.
if (values[middle]! <= value)
start = middle + 1
else end = middle
}
return start
}
export function sampleHeatmap(queue, width, duration, sampling) {
export function sampleHeatmap(queue: readonly Pick<DanmuItem, 'time'>[], width: number, duration: number, sampling: number): HeatmapPoint[] {
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(duration) || duration <= 0
|| !Number.isFinite(sampling) || sampling <= 0 || Math.floor(width / sampling) + 1 > MAX_HEATMAP_POINTS) { return [] }
const times = queue.map(item => item.time).filter(time => typeof time === 'number' && !Number.isNaN(time)).sort((a, b) => a - b)
const points = []
const points: HeatmapPoint[] = []
const gap = duration / width
if (!Number.isFinite(gap))
return []
@@ -1,32 +1,47 @@
import type { DanmuItem, DanmukuArt, HeatmapOptions, HeatmapPoint } from './types'
import { heatmapGeometry } from './heatmap-geometry'
let nextGradient = 0
export default function heatmap(art, danmuku, option) {
type HeatmapArt = Pick<DanmukuArt, 'isDestroy' | 'constructor' | 'on' | 'off' | 'controls' | 'option' | 'duration' | 'played'>
interface HeatmapEvents {
'destroy': []
'video:timeupdate': []
'setBar': [type: string, percentage: number]
'ready': []
'resize': []
'artplayerPluginDanmuku:loaded': []
'artplayerPluginDanmuku:points': [points: HeatmapPoint[]]
}
type HeatmapCallback = (...args: unknown[]) => unknown
export default function heatmap(art: HeatmapArt, danmuku: { queue: readonly Pick<DanmuItem, 'time'>[] }, option: boolean | HeatmapOptions): (() => void) | undefined {
if (art.isDestroy)
return
const { query } = art.constructor.utils
let gradient
const subscriptions = []
let element
let start
let stop
let gradient: string | undefined
const subscriptions: [keyof HeatmapEvents, HeatmapCallback][] = []
let element: HTMLElement | undefined
let start: Element | null | undefined
let stop: Element | null | undefined
let closed = false
const active = () => !closed && !art.isDestroy
function listen(name, callback) {
subscriptions.push([name, callback])
art.on(name, callback)
function listen<Name extends keyof HeatmapEvents>(name: Name, callback: (...args: HeatmapEvents[Name]) => unknown) {
// Core's string-event overload erases payloads; this local map checks each subscription.
const listener = callback as HeatmapCallback
subscriptions.push([name, listener])
art.on(name, listener)
}
function dispose(removeControl) {
function dispose(removeControl: boolean) {
if (closed)
return
closed = true
start = null
stop = null
let failure
let failure: unknown
let failed = false
const attempt = (callback) => {
const attempt = (callback: () => unknown) => {
try {
callback()
}
@@ -44,29 +59,31 @@ export default function heatmap(art, danmuku, option) {
if (failed)
throw failure
}
function progress(value) {
function progress(value: number) {
if (active() && start && stop) {
start.setAttribute('offset', `${value * 100}%`)
stop.setAttribute('offset', `${value * 100}%`)
}
}
function draw(points = []) {
function draw(points: HeatmapPoint[] = []) {
if (!active())
return
start = null
stop = null
element.innerHTML = ''
// Drawing listeners are registered only after mounted assigns element; dispose closes first.
const target = element!
target.innerHTML = ''
if (art.option.isLive)
return
const shape = heatmapGeometry({ width: element.offsetWidth, height: element.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points })
const shape = heatmapGeometry({ width: target.offsetWidth, height: target.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points })
if (!shape || !active())
return
const document = element.ownerDocument
const document = target.ownerDocument
if (!gradient)
gradient = document ? 'heatmap-solids' : `heatmap-solids-${++nextGradient}`
while (document?.getElementById(gradient))
gradient = `heatmap-solids-${++nextGradient}`
element.innerHTML = `
target.innerHTML = `
<svg viewBox="0 0 ${shape.width} ${shape.height}">
<defs>
<linearGradient id="${gradient}" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -79,11 +96,11 @@ export default function heatmap(art, danmuku, option) {
<path fill="url(#${gradient})" d="${shape.path}"></path>
</svg>
`
start = query('#heatmap-start', element)
stop = query('#heatmap-stop', element)
start = query('#heatmap-start', target)
stop = query('#heatmap-stop', target)
progress(art.played)
}
function update(points) {
function update(points?: HeatmapPoint[]) {
try {
draw(points)
}
@@ -1,15 +1,19 @@
import type Artplayer from 'artplayer'
import type { DanmukuArt, DanmukuOption } from './types'
import Danmuku from './danmuku'
import heatmap from './heatmap'
import Setting from './setting'
export default function artplayerPluginDanmuku(option) {
return (art) => {
const danmuku = new Danmuku(art, option)
let setting
export default function artplayerPluginDanmuku(option: DanmukuOption) {
return (art: Artplayer) => {
// Old declarations type instance.constructor as Function; both supported cores expose these statics.
const danmuku = new Danmuku(art as DanmukuArt, option)
let setting: Setting | undefined
try {
setting = art.isDestroy ? undefined : new Setting(art, danmuku)
setting = art.isDestroy ? undefined : new Setting(art as DanmukuArt, danmuku)
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
heatmap(art as DanmukuArt, danmuku, danmuku.option.heatmap)
}
}
catch (error) {
@@ -25,7 +29,7 @@ export default function artplayerPluginDanmuku(option) {
}
return {
name: 'artplayerPluginDanmuku',
name: 'artplayerPluginDanmuku' as const,
emit: danmuku.emit.bind(danmuku),
load: danmuku.load.bind(danmuku),
config: danmuku.config.bind(danmuku),
@@ -1,22 +1,45 @@
import type { DanmuInput, DanmukuInput } from './types'
import { bilibiliDanmuParseFromUrl } from './bilibili'
import { isPromiseInput } from './config'
const inputs = new WeakMap()
const cancelled = Symbol('cancelled danmuku input')
function stateFor(owner) {
if (!inputs.has(owner))
inputs.set(owner, { closed: false, tasks: new Set(), emitting: [] })
return inputs.get(owner)
interface InputOwner {
art: { isDestroy: boolean }
emit: (danmu: DanmuInput) => unknown
}
export function inputActive(owner) {
export interface InputTask {
replace: boolean
signal: AbortSignal | undefined
active: () => boolean
onCancel: (callback: () => void) => () => void
cancel: () => void
wait: <T>(value: T) => Promise<Awaited<T> | typeof cancelled>
emit: (danmu: DanmuInput) => unknown
finish: () => void
}
interface InputState {
closed: boolean
tasks: Set<InputTask>
emitting: InputTask[]
}
const inputs = new WeakMap<InputOwner, InputState>()
export const cancelled = Symbol('cancelled danmuku input')
function stateFor(owner: InputOwner): InputState {
if (!inputs.has(owner))
inputs.set(owner, { closed: false, tasks: new Set(), emitting: [] })
return inputs.get(owner)!
}
export function inputActive(owner: InputOwner) {
const state = stateFor(owner)
const task = state.emitting[state.emitting.length - 1]
return !state.closed && !owner.art.isDestroy && (!task || task.active())
}
export function beginInput(owner, replace) {
export function beginInput(owner: InputOwner, replace: boolean): InputTask {
const state = stateFor(owner)
if (replace) {
for (const task of [...state.tasks]) {
@@ -25,11 +48,11 @@ export function beginInput(owner, replace) {
}
}
let stopped = state.closed || owner.art.isDestroy
let resolveCancel
const cancellation = new Promise(resolve => resolveCancel = resolve)
const handlers = new Set()
let resolveCancel!: (value: typeof cancelled) => void
const cancellation = new Promise<typeof cancelled>(resolve => resolveCancel = resolve)
const handlers = new Set<() => void>()
const controller = typeof AbortController === 'function' ? new AbortController() : undefined
const task = {
const task: InputTask = {
replace,
signal: controller?.signal,
active: () => !stopped && !state.closed && !owner.art.isDestroy,
@@ -71,13 +94,15 @@ export function beginInput(owner, replace) {
return task
}
export function cancelInputs(owner) {
export function cancelInputs(owner: InputOwner) {
const state = stateFor(owner)
state.closed = true
for (const task of [...state.tasks]) task.cancel()
}
export function readInput(target, task) {
export function readInput(target: DanmukuInput, task: InputTask):
| { asynchronous: true, value: DanmuInput[] | PromiseLike<DanmuInput[]> }
| { asynchronous: false, value: DanmuInput[] } {
if (typeof target === 'function')
return { asynchronous: true, value: target() }
if (isPromiseInput(target))
@@ -0,0 +1,20 @@
import type { DanmuInput } from './types'
export interface ParsedDanmu extends DanmuInput {
time: number
mode: number
fontSize: number
color: string
timestamp: number
pool: number
userID: string
rowID: number
}
export interface ParserRequest { xml: unknown, id: number }
export interface ParserReply { danmus: ParsedDanmu[], id: number }
export interface ParserCancellation {
signal?: AbortSignal
onCancel?: (callback: () => void) => () => void
}
@@ -1,12 +1,16 @@
export function filterState(owner, state, callback) {
import type { DanmuItem, DanmuState } from './types'
interface QueueOwner { states: Record<DanmuState, DanmuItem[]>, art: { currentTime: number } }
export function filterState(owner: QueueOwner, state: DanmuState, callback: (danmu: DanmuItem) => void) {
const danmus = owner.states[state] || []
for (let index = 0; index < danmus.length; index++) callback(danmus[index])
for (let index = 0; index < danmus.length; index++) callback(danmus[index]!)
return danmus
}
export function readyItems(owner) {
export function readyItems(owner: QueueOwner) {
const { currentTime } = owner.art
const result = []
const result: DanmuItem[] = []
filterState(owner, 'ready', danmu => result.push(danmu))
filterState(owner, 'wait', (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1)
@@ -15,7 +19,7 @@ export function readyItems(owner) {
return result
}
export function setItemState(owner, danmu, state) {
export function setItemState(owner: QueueOwner, danmu: DanmuItem, state: DanmuState) {
owner.states[danmu.$state] = owner.states[danmu.$state].filter(item => item !== danmu)
danmu.$state = state
if (danmu.$ref)
@@ -1,5 +1,12 @@
import type Danmuku from './danmuku'
import type { FrameOperation } from './scheduler'
import type { DanmuItem } from './types'
export default class Renderer {
constructor(owner) {
declare owner: Danmuku
declare nodes: Set<HTMLDivElement>
constructor(owner: Danmuku) {
this.owner = owner
this.nodes = new Set()
}
@@ -14,21 +21,21 @@ export default class Renderer {
return $ref
}
get visibles() {
const result = []
get visibles(): { top: number, left: number, height: number, width: number, right: number, speed: number, distance: number, time: number, mode: number }[] {
const result: Renderer['visibles'] = []
const { clientWidth } = this.owner.$player
const clientLeft = this.owner.getLeft(this.owner.$player)
this.owner.filter('emit', (danmu) => {
const top = danmu.$ref.offsetTop
const left = this.owner.getLeft(danmu.$ref) - clientLeft
const height = danmu.$ref.clientHeight
const width = danmu.$ref.clientWidth
const top = danmu.$ref!.offsetTop
const left = this.owner.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
const emit = {}
const emit = {} as Renderer['visibles'][number]
emit.top = top
emit.left = left
emit.height = height
@@ -45,21 +52,21 @@ export default class Renderer {
return result
}
left($ref) {
left($ref: HTMLElement): number {
const rect = $ref.getBoundingClientRect()
return this.owner.isRotate ? rect.top : rect.left
}
makeWait(danmu) {
makeWait(danmu: DanmuItem) {
this.owner.setState(danmu, 'wait')
if (danmu.$ref) {
danmu.$ref.style.cssText = this.owner.constructor.cssText
danmu.$ref.style.visibility = 'hidden'
danmu.$ref.style.marginLeft = '0px'
danmu.$ref.style.transform = 'translateX(0px)'
danmu.$ref.style.transition = 'transform 0s linear 0s'
danmu.$ref!.style.cssText = this.owner.constructor.cssText
danmu.$ref!.style.visibility = 'hidden'
danmu.$ref!.style.marginLeft = '0px'
danmu.$ref!.style.transform = 'translateX(0px)'
danmu.$ref!.style.transition = 'transform 0s linear 0s'
this.owner.$refs.push(danmu.$ref)
danmu.$ref = null
danmu!.$ref = null
}
}
@@ -70,7 +77,7 @@ export default class Renderer {
switch (danmu.mode) {
// 滚动的弹幕
case 0:
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref!.style.left = `${clientWidth}px`
break
default:
break
@@ -82,10 +89,10 @@ export default class Renderer {
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
const distance = clientWidth + danmu.$ref!.clientWidth
danmu.$ref!.style.left = `${clientWidth}px`
danmu.$ref!.style.transform = `translateX(${-distance}px)`
danmu.$ref!.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
@@ -102,9 +109,9 @@ export default class Renderer {
switch (danmu.mode) {
// 继续滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
const distance = clientWidth + danmu.$ref!.clientWidth
danmu.$ref!.style.transform = `translateX(${-distance}px)`
danmu.$ref!.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
@@ -122,9 +129,9 @@ export default class Renderer {
switch (danmu.mode) {
// 停止滚动的弹幕
case 0: {
const translateX = clientWidth - (this.owner.getLeft(danmu.$ref) - this.owner.getLeft(this.owner.$player))
danmu.$ref.style.transform = `translateX(${-translateX}px)`
danmu.$ref.style.transition = 'transform 0s linear 0s'
const translateX = clientWidth - (this.owner.getLeft(danmu.$ref!) - this.owner.getLeft(this.owner.$player))
danmu.$ref!.style.transform = `translateX(${-translateX}px)`
danmu.$ref!.style.transition = 'transform 0s linear 0s'
break
}
default:
@@ -135,7 +142,7 @@ export default class Renderer {
return this
}
prepare(danmu, operation) {
prepare(danmu: DanmuItem, operation: FrameOperation) {
const owner = this.owner
const ref = owner.$ref
operation.danmu = danmu
@@ -143,19 +150,19 @@ export default class Renderer {
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
ref.style.opacity = owner.option.opacity as unknown as string
ref.style.fontSize = `${owner.fontSize}px`
ref.style.color = danmu.color
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null as unknown as string
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null as unknown as string
owner.utils.setStyles(ref, danmu.style)
return ref
}
place(danmu, ref, top, distance, clientWidth) {
place(danmu: DanmuItem, ref: HTMLDivElement, top: number, distance: number, clientWidth: number) {
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.mode = danmu.mode as unknown as string
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
@@ -174,13 +181,13 @@ export default class Renderer {
}
}
release(operation) {
release(operation: FrameOperation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
if (ref && danmu!.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
danmu!.$ref = null
}
operation.ref = null
}
@@ -1,7 +1,28 @@
import type Danmuku from './danmuku'
import type { DanmuItem } from './types'
const cancelled = Symbol('cancelled danmuku frame')
export interface FrameOperation {
generation: number
cancel: () => void
wait: <T>(value: T | PromiseLike<T>) => Promise<T | typeof cancelled>
danmu?: DanmuItem
ref: HTMLDivElement | null
}
export default class Scheduler {
constructor(owner) {
declare owner: Danmuku
declare generation: number
declare starts: number
declare frame: number | null
declare operation: FrameOperation | null
declare closed: boolean
declare running: boolean
declare fault: boolean
declare failedItems: Set<DanmuItem>
constructor(owner: Danmuku) {
this.owner = owner
this.generation = 0
this.starts = 0
@@ -13,13 +34,13 @@ export default class Scheduler {
this.failedItems = new Set()
}
active(operation) {
active(operation: FrameOperation) {
const owner = this.owner
return !this.closed && !this.fault && !owner.art.isDestroy && !owner.isStop
&& !owner.isHide && operation.generation === this.generation
}
release(operation) {
release(operation: FrameOperation) {
this.owner.renderer.release(operation)
}
@@ -39,7 +60,7 @@ export default class Scheduler {
this.failedItems.clear()
}
report(error) {
report(error: unknown) {
try {
this.owner.art.emit('artplayerPluginDanmuku:error', error)
}
@@ -48,7 +69,7 @@ export default class Scheduler {
}
}
fail(error) {
fail(error: unknown) {
if (this.closed || this.fault)
return
this.fault = true
@@ -81,9 +102,9 @@ export default class Scheduler {
owner.timer = null
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return
let cancel
const cancellation = new Promise(resolve => cancel = () => resolve(cancelled))
const operation = { generation: this.generation, cancel, wait: value => Promise.race([value, cancellation]), ref: null }
let cancel!: () => void
const cancellation = new Promise<typeof cancelled>(resolve => cancel = () => resolve(cancelled))
const operation: FrameOperation = { generation: this.generation, cancel, wait: value => Promise.race([value, cancellation]), ref: null }
this.operation = operation
return this.run(operation).catch((error) => {
if (this.active(operation))
@@ -99,7 +120,7 @@ export default class Scheduler {
owner.timer = this.frame
}
async run(operation) {
async run(operation: FrameOperation) {
const owner = this.owner
if (!owner.art.playing || !this.active(operation))
return
@@ -139,16 +160,18 @@ export default class Scheduler {
danmu.$lastStartTime = Date.now()
danmu.$restTime = owner.speed
const distance = clientWidth + ref.clientWidth
const { result: top } = await operation.wait(owner.postMessage({
const reply = await operation.wait(owner.postMessage({
type: 'getDanmuTop',
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
antiOverlap: owner.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: owner.marginBottom,
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom as number,
marginTop: owner.marginTop,
}))
const top = typeof reply === 'symbol' ? undefined : reply.result
if (!this.active(operation) || danmu.$ref !== ref)
return
if (top !== undefined) {
@@ -1,7 +1,35 @@
const properties = new WeakMap()
import type { DanmukuArt } from './types'
type Disposer = () => unknown
type PropertyValue = string | number | boolean | undefined
interface PropertyEntry {
original: PropertyValue
present: boolean
priority: string | undefined
owners: Map<SettingLifecycle, PropertyValue>
apply: (value: PropertyValue) => unknown
last?: PropertyValue
lastPriority?: string
}
interface SettingEvents {
'resize': []
'fullscreen': [state: boolean]
'fullscreenWeb': [state: boolean]
'artplayerPluginDanmuku:show': []
'artplayerPluginDanmuku:hide': []
'document:pointermove': [event: PointerEvent]
'document:pointerup': [event: PointerEvent]
}
const properties = new WeakMap<object, Map<string, PropertyEntry>>()
export default class SettingLifecycle {
constructor(art) {
declare art: DanmukuArt
declare closed: boolean
declare disposers: Set<Disposer>
declare cancelled: Promise<void>
declare cancel: () => void
constructor(art: DanmukuArt) {
this.art = art
this.closed = false
this.disposers = new Set()
@@ -12,13 +40,15 @@ export default class SettingLifecycle {
return !this.closed && !this.art.isDestroy
}
own(dispose) {
own(dispose: Disposer) {
if (this.closed)
dispose()
else this.disposers.add(dispose)
}
write(target, key, value, apply = value => target[key] = value) {
write(host: DOMStringMap | CSSStyleDeclaration, key: string, value: PropertyValue, apply: (value: PropertyValue) => unknown = value => (host as unknown as Record<string, PropertyValue>)[key] = value) {
// This adapter retains native dataset/style assignment and its WebIDL coercion.
const target = host as unknown as Record<string, PropertyValue> & Partial<Pick<CSSStyleDeclaration, 'getPropertyPriority' | 'setProperty'>>
if (!this.active)
return
const priority = () => typeof target.getPropertyPriority === 'function' ? target.getPropertyPriority(key) : undefined
@@ -52,7 +82,7 @@ export default class SettingLifecycle {
entry.lastPriority = priority()
}
else if (entry.priority !== undefined) {
target.setProperty(key, entry.original, entry.priority)
target.setProperty!(key, entry.original as string, entry.priority)
}
else if (entry.present) {
entry.apply(entry.original)
@@ -75,13 +105,15 @@ export default class SettingLifecycle {
entry.lastPriority = priority()
}
on(name, callback) {
on<Name extends keyof SettingEvents>(name: Name, callback: (this: unknown, ...args: SettingEvents[Name]) => unknown) {
if (!this.active)
return
// Keep the emitter/native callback receiver while consulting this owner.
// eslint-disable-next-line ts/no-this-alias
const lifecycle = this
function listener(...args) {
function listener(this: unknown, ...args: unknown[]) {
if (lifecycle.active)
return callback.apply(this, args)
return callback.apply(this, args as SettingEvents[Name])
}
const dispose = () => this.art.off(name, listener)
this.own(dispose)
@@ -96,18 +128,20 @@ export default class SettingLifecycle {
}
}
proxy(target, name, callback) {
proxy<Name extends keyof HTMLElementEventMap>(target: HTMLElement, name: Name, callback: (this: unknown, event: HTMLElementEventMap[Name]) => unknown) {
if (!this.active)
return
// Keep the emitter/native callback receiver while consulting this owner.
// eslint-disable-next-line ts/no-this-alias
const lifecycle = this
function listener(...args) {
function listener(this: unknown, ...args: unknown[]) {
if (lifecycle.active)
return callback.apply(this, args)
return callback.apply(this, args as [HTMLElementEventMap[Name]])
}
let remove
let remove: (() => void) | undefined
const dispose = () => {
if (typeof remove === 'function') {
const registry = this.art.events
const registry = this.art.events as DanmukuArt['events'] & { destroyEvents?: Set<() => void> }
if (typeof registry?.remove === 'function' && registry.destroyEvents?.has(remove))
registry.remove(remove)
else remove()
@@ -128,11 +162,11 @@ export default class SettingLifecycle {
}
}
wait(value) {
wait<Value>(value: Value | PromiseLike<Value>) {
return Promise.race([value, this.cancelled])
}
release(dispose) {
release(dispose: Disposer) {
try {
dispose()
}
@@ -1,17 +1,20 @@
export async function emitSetting(setting) {
import type Setting from './setting'
import type { DanmuInput } from './types'
export async function emitSetting(setting: Setting) {
const { lifecycle, template: { $input } } = setting
if (!lifecycle.active)
return
const text = $input.value.trim()
if (!text.length || setting.isLock || setting.emitting)
return
const danmu = {
const danmu: DanmuInput = {
text,
mode: setting.option.mode,
color: setting.option.color,
time: setting.art.currentTime,
}
const report = (error) => {
const report = (error: unknown) => {
if (lifecycle.active)
console.error('Error emitting danmuku:', error)
}
@@ -39,15 +42,15 @@ export async function emitSetting(setting) {
}
}
export function lockSetting(setting) {
export function lockSetting(setting: Setting) {
if (!setting.lifecycle.active)
return
const { addClass } = setting.utils
const { $send } = setting.template
clearTimeout(setting.timer)
clearTimeout(setting.timer as number | undefined)
setting.isLock = true
let time = setting.option.lockTime
$send.textContent = time
$send.textContent = time as unknown as string
addClass($send, 'apd-lock')
const loop = () => {
setting.timer = setTimeout(() => {
@@ -58,7 +61,7 @@ export function lockSetting(setting) {
}
else {
time -= 1
$send.textContent = time
$send.textContent = time as unknown as string
loop()
}
}, 1000)
@@ -66,8 +69,8 @@ export function lockSetting(setting) {
loop()
}
export function unlockSetting(setting) {
clearTimeout(setting.timer)
export function unlockSetting(setting: Setting) {
clearTimeout(setting.timer as number | undefined)
setting.timer = null
setting.isLock = false
if (!setting.lifecycle.active)
@@ -1,10 +1,13 @@
export default function createSlider({ min, max, container, findIndex, onChange, steps = [] }) {
import type Setting from './setting'
import type { SliderConfig } from './setting-types'
export default function createSlider(this: Setting, { min, max, container, findIndex, onChange, steps = [] }: SliderConfig) {
const { lifecycle } = this
if (!lifecycle.active)
return { reset() {} }
const { query, clamp, setStyle } = this.utils
setStyle(container, 'touch-action', 'none')
setStyle(container, 'touch-action' as keyof CSSStyleDeclaration, 'none')
container.innerHTML = `
<div class="apd-slider-line">
@@ -19,14 +22,14 @@ export default function createSlider({ min, max, container, findIndex, onChange,
</div>
`
const $dot = query('.apd-slider-dot', container)
const $progress = query('.apd-slider-progress', container)
const $dot = query<HTMLDivElement>('.apd-slider-dot', container)!
const $progress = query<HTMLDivElement>('.apd-slider-progress', container)!
let isDroging = false
lifecycle.own(() => isDroging = false)
function reset(index) {
function reset(index?: number) {
if (!lifecycle.active)
return
if (index === undefined)
@@ -41,7 +44,7 @@ export default function createSlider({ min, max, container, findIndex, onChange,
onChange(index)
}
function updateLeft(event) {
function updateLeft(this: Setting, event: MouseEvent) {
const { top, height, left, width } = container.getBoundingClientRect()
if (this.art.isRotate) {
const value = clamp(event.clientY - top, 0, height)
@@ -3,26 +3,27 @@ import style from './style.less?inline'
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-danmuku'
const pending = Symbol.for('artplayer-plugin-danmuku.pending-style')
let $style = document.getElementById(id) || document[pending]
const styleDocument = document as Document & { [key: symbol]: HTMLElement | undefined }
let $style = document.getElementById(id) || styleDocument[pending]
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document[pending] = $style
styleDocument[pending] = $style
const ready = () => {
document.removeEventListener('DOMContentLoaded', ready)
if (document[pending] === $style)
delete document[pending]
if (styleDocument[pending] === $style)
delete styleDocument[pending]
const existing = document.getElementById(id)
if (existing)
existing.textContent = $style.textContent
existing.textContent = $style!.textContent
else
(document.head || document.documentElement).appendChild($style)
(document.head || document.documentElement).appendChild($style!)
}
document.addEventListener('DOMContentLoaded', ready, { once: true })
}
else {
(document.head || document.documentElement).appendChild($style)
(document.head || document.documentElement).appendChild($style!)
}
}
$style.textContent = style
@@ -1,3 +1,4 @@
import type Setting from './setting'
import $check_off from './img/check_off.svg?raw'
import $check_on from './img/check_on.svg?raw'
import $config from './img/config.svg?raw'
@@ -28,7 +29,7 @@ export function settingIcons() {
}
}
export function settingTemplate() {
export function settingTemplate(this: Setting) {
const { option } = this
return `
<div class="apd-toggle">
@@ -0,0 +1,39 @@
import type { SliderStep } from './types'
// createTemplate/createSliders fill these slots before registering active UI callbacks.
// The initial null values and absent antiOverlap/syncVideo keys are kept for compatibility.
export interface SettingTemplate {
$controlsCenter: HTMLDivElement
$mount: HTMLElement
$danmuku: HTMLDivElement
$toggle: HTMLDivElement
$config: HTMLDivElement
$configPanel: HTMLDivElement
$configModes: HTMLDivElement
$style: HTMLDivElement
$stylePanel: HTMLDivElement
$styleModes: HTMLDivElement
$colors: HTMLDivElement
$antiOverlap?: HTMLDivElement
$syncVideo?: HTMLDivElement
$opacitySlider: HTMLDivElement
$opacityValue: HTMLDivElement
$marginSlider: HTMLDivElement
$marginValue: HTMLDivElement
$fontSizeSlider: HTMLDivElement
$fontSizeValue: HTMLDivElement
$speedSlider: HTMLDivElement
$speedValue: HTMLDivElement
$input: HTMLInputElement
$send: HTMLDivElement
}
export interface SettingSlider { reset: (index?: number) => void }
export interface SliderConfig {
min: number
max: number
container: HTMLElement
findIndex: () => number
onChange: (index: number) => void
steps?: SliderStep<unknown>[]
}
@@ -1,3 +1,6 @@
import type Danmuku from './danmuku'
import type { SettingSlider, SettingTemplate, SliderConfig } from './setting-types'
import type { DanmukuArt, NormalizedOption, SliderOption } from './types'
import SettingLifecycle from './setting-lifecycle'
import { emitSetting, lockSetting, unlockSetting } from './setting-send'
import createSlider from './setting-slider'
@@ -5,7 +8,17 @@ import { settingIcons, settingTemplate } from './setting-template'
import './setting-style'
export default class Setting {
constructor(art, danmuku) {
declare art: DanmukuArt
declare danmuku: Danmuku
declare utils: DanmukuArt['constructor']['utils']
declare template: SettingTemplate
declare slider: Record<'opacity' | 'margin' | 'fontSize' | 'speed', SettingSlider>
declare emitting: boolean
declare isLock: boolean
declare timer: ReturnType<typeof setTimeout> | null
declare lifecycle: SettingLifecycle
constructor(art: DanmukuArt, danmuku: Danmuku) {
this.art = art
this.danmuku = danmuku
this.utils = art.constructor.utils
@@ -16,32 +29,32 @@ export default class Setting {
this.template = {
$controlsCenter,
$mount: $controlsCenter,
$danmuku: null,
$toggle: null,
$config: null,
$configPanel: null,
$configModes: null,
$style: null,
$stylePanel: null,
$styleModes: null,
$colors: null,
$opacitySlider: null,
$opacityValue: null,
$marginSlider: null,
$marginValue: null,
$fontSizeSlider: null,
$fontSizeValue: null,
$speedSlider: null,
$speedValue: null,
$input: null,
$send: null,
$danmuku: null!,
$toggle: null!,
$config: null!,
$configPanel: null!,
$configModes: null!,
$style: null!,
$stylePanel: null!,
$styleModes: null!,
$colors: null!,
$opacitySlider: null!,
$opacityValue: null!,
$marginSlider: null!,
$marginValue: null!,
$fontSizeSlider: null!,
$fontSizeValue: null!,
$speedSlider: null!,
$speedValue: null!,
$input: null!,
$send: null!,
}
this.slider = {
opacity: null,
margin: null,
fontSize: null,
speed: null,
opacity: null!,
margin: null!,
fontSize: null!,
speed: null!,
}
this.emitting = false
@@ -56,7 +69,7 @@ export default class Setting {
art.on('destroy', this.destroy)
if (!this.lifecycle.active)
return
this.lifecycle.write($controlsCenter.style, 'display', 'flex', value => setStyle($controlsCenter, 'display', value))
this.lifecycle.write($controlsCenter.style, 'display', 'flex', value => setStyle($controlsCenter, 'display', value as string))
this.createTemplate()
if (!this.lifecycle.active)
@@ -103,7 +116,7 @@ export default class Setting {
return settingIcons()
}
get option() {
get option(): NormalizedOption {
return this.danmuku.option
}
@@ -115,7 +128,7 @@ export default class Setting {
return settingTemplate.call(this)
}
get OPACITY() {
get OPACITY(): Required<SliderOption> {
return {
min: 0,
max: 100,
@@ -124,7 +137,7 @@ export default class Setting {
}
}
get FONT_SIZE() {
get FONT_SIZE(): Required<SliderOption> {
return {
min: 12,
max: 120,
@@ -133,7 +146,7 @@ export default class Setting {
}
}
get MARGIN() {
get MARGIN(): Required<SliderOption<NormalizedOption['margin']>> {
return {
min: 0,
max: 3,
@@ -159,7 +172,7 @@ export default class Setting {
}
}
get SPEED() {
get SPEED(): Required<SliderOption> {
return {
min: 0,
max: 4,
@@ -212,13 +225,13 @@ export default class Setting {
]
}
query(selector) {
query<T extends HTMLElement = HTMLDivElement>(selector: string): T {
const { query } = this.utils
const { $danmuku } = this.template
return query(selector, $danmuku)
return query(selector, $danmuku) as T
}
append(el, target) {
append(el: HTMLElement, target: HTMLElement) {
if (!this.lifecycle.active)
return
const { append } = this.utils
@@ -228,7 +241,7 @@ export default class Setting {
append(el, target)
}
setData(key, value) {
setData(key: string, value: string | number | boolean) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
@@ -265,7 +278,7 @@ export default class Setting {
this.template.$fontSizeValue = this.query('.apd-config-fontSize .apd-value')
this.template.$speedSlider = this.query('.apd-config-speed .apd-slider')
this.template.$speedValue = this.query('.apd-config-speed .apd-value')
this.template.$input = this.query('.apd-input')
this.template.$input = this.query<HTMLInputElement>('.apd-input')
this.template.$send = this.query('.apd-send')
const { $toggle } = this.template
@@ -290,7 +303,7 @@ export default class Setting {
})
this.lifecycle.proxy($configModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
const $mode = (event.target as HTMLElement).closest<HTMLElement>('.apd-mode')
if (!$mode)
return
const mode = Number($mode.dataset.mode)
@@ -307,14 +320,14 @@ export default class Setting {
this.reset()
})
this.lifecycle.proxy($antiOverlap, 'click', () => {
this.lifecycle.proxy($antiOverlap!, 'click', () => {
this.danmuku.config({
antiOverlap: !this.option.antiOverlap,
})
this.reset()
})
this.lifecycle.proxy($syncVideo, 'click', () => {
this.lifecycle.proxy($syncVideo!, 'click', () => {
this.danmuku.config({
synchronousPlayback: !this.option.synchronousPlayback,
})
@@ -322,7 +335,7 @@ export default class Setting {
})
this.lifecycle.proxy($styleModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
const $mode = (event.target as HTMLElement).closest<HTMLElement>('.apd-mode')
if (!$mode)
return
const mode = Number($mode.dataset.mode)
@@ -333,7 +346,7 @@ export default class Setting {
})
this.lifecycle.proxy($colors, 'click', (event) => {
const $color = event.target.closest('.apd-color')
const $color = (event.target as HTMLElement).closest<HTMLElement>('.apd-color')
if (!$color)
return
this.danmuku.config({
@@ -373,7 +386,7 @@ export default class Setting {
container: this.template.$marginSlider,
findIndex: () => {
return this.MARGIN.steps.findIndex(
item => item.value[0] === this.option.margin[0] && item.value[1] === this.option.margin[1],
item => item.value![0] === this.option.margin[0] && item.value![1] === this.option.margin[1],
)
},
onChange: (index) => {
@@ -381,7 +394,7 @@ export default class Setting {
if (!margin)
return
const { $marginValue } = this.template
$marginValue.textContent = margin.name
$marginValue.textContent = margin.name as string
this.danmuku.config({
margin: margin.value,
})
@@ -416,7 +429,7 @@ export default class Setting {
if (!speed)
return
const { $speedValue } = this.template
$speedValue.textContent = speed.name
$speedValue.textContent = speed.name as string
this.danmuku.config({
speed: speed.value,
})
@@ -424,11 +437,11 @@ export default class Setting {
})
}
createSlider(option) {
createSlider(option: SliderConfig) {
return createSlider.call(this, option)
}
onFullscreen(state) {
onFullscreen(state: boolean) {
if (!this.lifecycle.active)
return
const { $danmuku, $controlsCenter, $mount } = this.template
@@ -445,7 +458,7 @@ export default class Setting {
}
}
onMouseEnter({ $control, $panel }) {
onMouseEnter({ $control, $panel }: { $control: HTMLElement, $panel: HTMLElement }) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
@@ -522,7 +535,7 @@ export default class Setting {
this.setData('danmukuTheme', this.option.theme)
this.setData('danmukuEmitter', this.option.emitter)
const colors = $colors.children
const colors = $colors.children as HTMLCollectionOf<HTMLElement>
const $color = Array.from(colors).find(item => item.dataset.color === this.option.color.toUpperCase())
$color && inverseClass($color, 'apd-active')
@@ -531,14 +544,14 @@ export default class Setting {
this.resize()
}
mount(target) {
mount(target: NormalizedOption['mount']) {
if (!this.lifecycle.active)
return
const { errorHandle } = this.utils
const $el = typeof target === 'string' ? document.querySelector(target) : target
errorHandle($el, `Can not find the mount point: ${target}`)
this.append($el, this.template.$danmuku)
this.template.$mount = $el
const $el = typeof target === 'string' ? document.querySelector<HTMLElement>(target) : target
errorHandle($el as unknown as boolean, `Can not find the mount point: ${target}`)
this.append($el!, this.template.$danmuku)
this.template.$mount = $el!
this.reset()
}
@@ -546,7 +559,7 @@ export default class Setting {
if (this.lifecycle.closed)
return
this.lifecycle.close()
clearTimeout(this.timer)
clearTimeout(this.timer as number | undefined)
this.timer = null
this.emitting = false
this.isLock = false
@@ -0,0 +1,19 @@
import type Artplayer from 'artplayer'
// Public runtime data has one declaration source; host and Worker adapters stay internal.
export type {
Danmu as DanmuInput,
Item as DanmuItem,
Input as DanmukuInput,
RuntimeOption as DanmukuOption,
State as DanmuState,
Heatmap as HeatmapOptions,
Point as HeatmapPoint,
NormalizedDanmu,
NormalizedOption,
Slider as SliderOption,
SliderStep,
} from '../types/runtime-shared'
export type DanmukuArt = Artplayer & { constructor: typeof Artplayer, isRotate?: boolean }
export type DanmuStyle = Partial<CSSStyleDeclaration>
@@ -0,0 +1,4 @@
declare module '*?worker&inline' {
const WorkerConstructor: new (options?: WorkerOptions) => Worker
export default WorkerConstructor
}
@@ -1,14 +1,22 @@
import type { PlacementReply, PlacementRequest } from './worker-types'
let nextRequest = 0
export default class WorkerClient {
constructor(createWorker, onFailure) {
declare pending: Map<number, { resolve: (value: PlacementReply) => void, reject: (error: unknown) => void }>
declare closed: boolean
declare failed: boolean
declare onFailure: (error: unknown) => void
declare worker: Worker
constructor(createWorker: () => Worker, onFailure: (error: unknown) => void) {
this.pending = new Map()
this.closed = false
this.failed = false
this.onFailure = onFailure
this.worker = createWorker()
try {
this.worker.onmessage = (event) => {
this.worker.onmessage = (event: MessageEvent<PlacementReply>) => {
const request = this.pending.get(event.data?.id)
if (!request)
return
@@ -19,7 +27,7 @@ export default class WorkerClient {
event.preventDefault?.()
this.fail(event.error || event)
}
this.worker.onmessageerror = event => this.fail(event.error || event)
this.worker.onmessageerror = event => this.fail((event as MessageEvent & { error?: unknown }).error || event)
}
catch (error) {
try {
@@ -30,7 +38,7 @@ export default class WorkerClient {
}
}
request(message) {
request(message: PlacementRequest): Promise<PlacementReply> {
message.id = ++nextRequest
const { id } = message
if (this.closed || this.failed)
@@ -46,7 +54,7 @@ export default class WorkerClient {
})
}
fail(error) {
fail(error: unknown) {
if (this.closed || this.failed)
return
this.failed = true
@@ -70,8 +78,8 @@ export default class WorkerClient {
this.closed = true
this.cancel()
let failed = false
let error
const attempt = (callback) => {
let error: unknown
const attempt = (callback: () => void) => {
try {
callback()
}
@@ -81,7 +89,7 @@ export default class WorkerClient {
failed = true
}
}
for (const name of ['onmessage', 'onerror', 'onmessageerror']) {
for (const name of ['onmessage', 'onerror', 'onmessageerror'] as const) {
attempt(() => this.worker[name] = null)
}
attempt(() => this.worker.terminate())
@@ -0,0 +1,32 @@
export interface VisibleDanmu {
mode: number
top: number
left: number
right: number
height: number
width: number
speed: number
distance: number
time: number
}
export interface PlacementRequest {
id?: number
type: 'getDanmuTop'
target: { mode: number, height: number, speed: number }
visibles: VisibleDanmu[]
clientWidth: number
clientHeight: number
marginBottom: number
marginTop: number
antiOverlap: boolean
}
export interface PlacementMessage extends PlacementRequest { id: number }
export interface PlacementReply { id: number, result: number | undefined }
export interface PlacementBoundary extends Omit<VisibleDanmu, 'mode' | 'time'> {
type: 'top' | 'bottom'
}
export type PlacementRow = VisibleDanmu | PlacementBoundary
@@ -1,9 +1,11 @@
function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }) {
import type { PlacementMessage, PlacementReply, PlacementRequest, PlacementRow, VisibleDanmu } from './worker-types'
function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }: PlacementRequest): number | undefined {
// 弹幕最大高度
const maxTop = clientHeight - marginBottom
// 过滤同模式的弹幕,即每种模式各不影响
const danmus = visibles
const danmus: PlacementRow[] = visibles
.filter(item => item.mode === target.mode && item.top <= maxTop)
.sort((prev, next) => prev.top - next.top)
@@ -44,8 +46,8 @@ function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom
if (target.mode === 2) {
// 倒序查找
for (let index = danmus.length - 2; index >= 0; index -= 1) {
const item = danmus[index]
const prev = danmus[index + 1]
const item = danmus[index]!
const prev = danmus[index + 1]!
const itemBottom = item.top + item.height
const diff = prev.top - itemBottom
if (diff >= target.height) {
@@ -56,8 +58,8 @@ function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom
else {
// 顺序查找
for (let index = 1; index < danmus.length; index += 1) {
const item = danmus[index]
const prev = danmus[index - 1]
const item = danmus[index]!
const prev = danmus[index - 1]!
const prevBottom = prev.top + prev.height
const diff = item.top - prevBottom
if (diff >= target.height) {
@@ -66,12 +68,13 @@ function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom
}
}
const topMap = []
const topMap: VisibleDanmu[][] = []
for (let index = 1; index < danmus.length - 1; index += 1) {
const item = danmus[index]
// The loop excludes the two virtual boundary rows inserted above.
const item = danmus[index] as VisibleDanmu
if (topMap.length) {
const last = topMap[topMap.length - 1]
if (last[0].top === item.top) {
const last = topMap[topMap.length - 1]!
if (last[0]!.top === item.top) {
last.push(item)
}
else {
@@ -131,11 +134,11 @@ function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom
break
}
return topMap[0][0].top
return topMap[0]![0]!.top
}
}
onmessage = (event) => {
onmessage = (event: MessageEvent<PlacementMessage>) => {
const { data } = event
if (!data.id || !data.type)
return
@@ -147,5 +150,5 @@ onmessage = (event) => {
globalThis.postMessage({
result,
id: data.id,
})
} satisfies PlacementReply)
}