refactor(danmuku): [PKG-DANMUKU-05] own renderer settings and heatmap resources

This commit is contained in:
Harvey Zhao committed 2026-09-13 20:45:16 +08:00
1 parent 12332df221
commit 053d211fb0
38 files changed
+65630 -2420

No files matched your search

+11 -109
View File
@@ -1,6 +1,7 @@
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'
@@ -23,6 +24,7 @@ export default class Danmuku {
this.index = 0 // 弹幕索引
this.worker = null
this.workerClient = null
this.renderer = new Renderer(this)
this.scheduler = new Scheduler(this)
// 格式化后的配置项
@@ -158,12 +160,7 @@ export default class Danmuku {
// 获取弹幕DOM节点
get $ref() {
const $ref = this.$refs.pop() || document.createElement('div')
$ref.style.cssText = Danmuku.cssText
$ref.dataset.mode = ''
$ref.dataset.id = ''
$ref.className = ''
return $ref
return this.renderer.acquire()
}
// 获取准备好发送的弹幕
@@ -173,34 +170,7 @@ export default class Danmuku {
// 可见的弹幕的数据,用于计算下一个弹幕的top值
get visibles() {
const result = []
const { clientWidth } = this.$player
const clientLeft = this.getLeft(this.$player)
this.filter('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
const emit = {}
emit.top = top
emit.left = left
emit.height = height
emit.width = width
emit.right = right
emit.speed = speed
emit.distance = distance
emit.time = danmu.$restTime
emit.mode = danmu.mode
result.push(emit)
})
return result
return this.renderer.visibles
}
// 计算弹幕速度
@@ -234,8 +204,7 @@ export default class Danmuku {
return this
this.queue = [] // 清空弹幕队列
this.states = { wait: [], ready: [], emit: [], stop: [] } // 清空弹幕状态池
this.$refs = [] // 清空弹幕DOM节点池
this.$danmuku.textContent = '' // 清空弹幕层
this.renderer.clear()
}
// 逐个验证原始弹幕并转换到弹幕队列
@@ -391,8 +360,7 @@ export default class Danmuku {
// 计算DOM的left值,受到旋屏影响
getLeft($ref) {
const rect = $ref.getBoundingClientRect()
return this.isRotate ? rect.top : rect.left
return this.renderer.left($ref)
}
// 复杂运算交给 Web Worker 处理
@@ -417,16 +385,7 @@ export default class Danmuku {
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu) {
this.setState(danmu, 'wait')
if (danmu.$ref) {
danmu.$ref.style.cssText = Danmuku.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.$refs.push(danmu.$ref)
danmu.$ref = null
}
this.renderer.makeWait(danmu)
}
// 实时更新弹幕
@@ -438,76 +397,18 @@ export default class Danmuku {
// 重置正在显示的弹幕: stop/emit 状态的弹幕
resize() {
const { clientWidth } = this.$player
this.filter('stop', (danmu) => {
switch (danmu.mode) {
// 滚动的弹幕
case 0:
danmu.$ref.style.left = `${clientWidth}px`
break
default:
break
}
})
this.filter('emit', (danmu) => {
danmu.$lastStartTime = Date.now()
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`
break
}
default:
break
}
})
this.renderer.resize()
}
// 继续弹幕
continue() {
const { clientWidth } = this.$player
this.filter('stop', (danmu) => {
this.setState(danmu, 'emit') // 转换为emit状态
danmu.$lastStartTime = Date.now()
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`
break
}
default:
break
}
})
this.renderer.continue()
return this
}
// 暂停弹幕
suspend() {
const { clientWidth } = this.$player
this.filter('emit', (danmu) => {
this.setState(danmu, 'stop') // 转换为stop状态
switch (danmu.mode) {
// 停止滚动的弹幕
case 0: {
const translateX = clientWidth - (this.getLeft(danmu.$ref) - this.getLeft(this.$player))
danmu.$ref.style.transform = `translateX(${-translateX}px)`
danmu.$ref.style.transition = 'transform 0s linear 0s'
break
}
default:
break
}
})
this.renderer.suspend()
return this
}
@@ -581,6 +482,7 @@ export default class Danmuku {
}
}
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)
@@ -0,0 +1,78 @@
import { MAX_HEATMAP_POINTS, sampleHeatmap } from './heatmap-sampling'
function map(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
}
function line(pointA, pointB) {
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 = [] }) {
if (![width, height, duration].every(value => Number.isFinite(value) && value > 0))
return null
const defaults = {
xMin: 0,
xMax: width,
yMin: 0,
yMax: 128,
scale: 0.25,
opacity: 0.2,
minHeight: Math.floor(height * 0.05),
sampling: Math.max(1, Math.floor(width / 100)),
smoothing: 0.2,
flattening: 0.2,
}
const options = Object.assign({}, defaults, typeof option === 'object' ? option : {})
for (const key of Object.keys(defaults)) {
if (!Number.isFinite(options[key]))
options[key] = defaults[key]
}
if (options.sampling <= 0)
options.sampling = defaults.sampling
if (options.xMin === options.xMax || options.yMin === options.yMax)
return null
const points = Array.isArray(input) && input.length ? [...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]
if (lastPoint[0] !== width) {
if (points.length === MAX_HEATMAP_POINTS)
return null
points.push([width, lastPoint[1]])
}
let yMin = Infinity
let yMax = -Infinity
for (const point of points) {
yMin = Math.min(yMin, point[1])
yMax = Math.max(yMax, point[1])
}
const yMid = (yMin + yMax) / 2
// Historical points events copy only the outer array: callers observe these y writes.
for (const point of points) {
const y = point[1]
point[1] = y * (y > yMid ? 1 + options.scale : 1 - options.scale) + options.minHeight
}
const controlPoint = (current, previous, next, reverse) => {
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)
const length = geometry.length * options.smoothing
return [current[0] + Math.cos(angle) * length, current[1] + Math.sin(angle) * length]
}
const positions = points.map(point => [map(point[0], options.xMin, options.xMax, 0, width), 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)
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(' ')
if (/NaN|Infinity/u.test(path))
return null
return { path, opacity: options.opacity, width, height }
}
@@ -0,0 +1,32 @@
export const MAX_HEATMAP_POINTS = 0xFFFFFFFF
function upperBound(values, value) {
let start = 0
let end = values.length
while (start < end) {
const middle = Math.floor((start + end) / 2)
if (values[middle] <= value)
start = middle + 1
else end = middle
}
return start
}
export function sampleHeatmap(queue, width, duration, sampling) {
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 gap = duration / width
if (!Number.isFinite(gap))
return []
// Keep the old floating-point x progression and strict-left/inclusive-right bins.
for (let x = 0; x <= width; x += sampling) {
if (points.length >= MAX_HEATMAP_POINTS || x + sampling === x)
return []
const left = x * gap
const right = (x + sampling) * gap
points.push([x, upperBound(times, right) - upperBound(times, left)])
}
return points
}
+137 -177
View File
@@ -1,186 +1,146 @@
const lib = {
map(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
},
range(start, end, tick) {
const s = Math.round(start / tick) * tick
return Array.from(
{
length: Math.floor((end - start) / tick),
},
(v, k) => {
return k * tick + s
},
)
},
}
import { heatmapGeometry } from './heatmap-geometry'
function line(pointA, pointB) {
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),
}
}
let nextGradient = 0
export default function heatmap(art, danmuku, option) {
if (art.isDestroy)
return
const { query } = art.constructor.utils
let gradient
const subscriptions = []
let element
let start
let stop
let closed = false
art.controls.add({
name: 'heatmap',
position: 'top',
html: '',
style: {
position: 'absolute',
top: '-100px',
left: '0px',
right: '0px',
height: '100px',
width: '100%',
pointerEvents: 'none',
},
mounted($heatmap) {
let $start = null
let $stop = null
function update(arg = []) {
$start = null
$stop = null
$heatmap.innerHTML = ''
if (!art.duration || art.option.isLive)
return
const svg = {
w: $heatmap.offsetWidth,
h: $heatmap.offsetHeight,
}
const options = {
xMin: 0,
xMax: svg.w,
yMin: 0,
yMax: 128,
scale: 0.25,
opacity: 0.2,
minHeight: Math.floor(svg.h * 0.05),
sampling: Math.floor(svg.w / 100),
smoothing: 0.2,
flattening: 0.2,
}
if (typeof option === 'object') {
Object.assign(options, option)
}
let points = []
if (Array.isArray(arg) && arg.length) {
points = [...arg]
}
else {
const gap = art.duration / svg.w
for (let x = 0; x <= svg.w; x += options.sampling) {
const y = danmuku.queue.filter(
({ time }) => time > x * gap && time <= (x + options.sampling) * gap,
).length
points.push([x, y])
}
}
if (points.length === 0)
return
const lastPoint = points[points.length - 1]
const lastX = lastPoint[0]
const lastY = lastPoint[1]
if (lastX !== svg.w) {
points.push([svg.w, lastY])
}
const yPoints = points.map(point => point[1])
const yMin = Math.min(...yPoints)
const yMax = Math.max(...yPoints)
const yMid = (yMin + yMax) / 2
for (let i = 0; i < points.length; i++) {
const point = points[i]
const y = point[1]
point[1] = y * (y > yMid ? 1 + options.scale : 1 - options.scale) + options.minHeight
}
const controlPoint = (current, previous, next, reverse) => {
const p = previous || current
const n = next || current
const o = line(p, n)
const flat = lib.map(Math.cos(o.angle) * options.flattening, 0, 1, 1, 0)
const angle = o.angle * flat + (reverse ? Math.PI : 0)
const length = o.length * options.smoothing
const x = current[0] + Math.cos(angle) * length
const y = current[1] + Math.sin(angle) * length
return [x, y]
}
const bezierCommand = (point, i, a) => {
const cps = controlPoint(a[i - 1], a[i - 2], point)
const cpe = controlPoint(point, a[i - 1], a[i + 1], true)
const close = i === a.length - 1 ? ' z' : ''
return `C ${cps[0]},${cps[1]} ${cpe[0]},${cpe[1]} ${point[0]},${point[1]}${close}`
}
const pointsPositions = points.map((e) => {
const x = lib.map(e[0], options.xMin, options.xMax, 0, svg.w)
const y = lib.map(e[1], options.yMin, options.yMax, svg.h, 0)
return [x, y]
})
const pathD = pointsPositions.reduce(
(acc, e, i, a) =>
i === 0
? `M ${a[a.length - 1][0]},${svg.h} L ${e[0]},${svg.h} L ${e[0]},${e[1]}`
: `${acc} ${bezierCommand(e, i, a)}`,
'',
)
$heatmap.innerHTML = `
<svg viewBox="0 0 ${svg.w} ${svg.h}">
<defs>
<linearGradient id="heatmap-solids" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${options.opacity}" />
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${options.opacity}" id="heatmap-start" />
<stop offset="0%" style="stop-color:var(--art-progress-color);stop-opacity:1" id="heatmap-stop" />
<stop offset="100%" style="stop-color:var(--art-progress-color);stop-opacity:1" />
</linearGradient>
</defs>
<path fill="url(#heatmap-solids)" d="${pathD}"></path>
</svg>
`
$start = query('#heatmap-start', $heatmap)
$stop = query('#heatmap-stop', $heatmap)
$start.setAttribute('offset', `${art.played * 100}%`)
$stop.setAttribute('offset', `${art.played * 100}%`)
const active = () => !closed && !art.isDestroy
function listen(name, callback) {
subscriptions.push([name, callback])
art.on(name, callback)
}
function dispose(removeControl) {
if (closed)
return
closed = true
start = null
stop = null
let failure
let failed = false
const attempt = (callback) => {
try {
callback()
}
art.on('video:timeupdate', () => {
if ($start && $stop) {
$start.setAttribute('offset', `${art.played * 100}%`)
$stop.setAttribute('offset', `${art.played * 100}%`)
catch (error) {
if (!failed)
failure = error
failed = true
}
}
for (const [name, callback] of subscriptions.splice(0))
attempt(() => art.off(name, callback))
if (removeControl && element && art.controls.heatmap === element)
attempt(() => art.controls.remove('heatmap'))
element = undefined
if (failed)
throw failure
}
function progress(value) {
if (active() && start && stop) {
start.setAttribute('offset', `${value * 100}%`)
stop.setAttribute('offset', `${value * 100}%`)
}
}
function draw(points = []) {
if (!active())
return
start = null
stop = null
element.innerHTML = ''
if (art.option.isLive)
return
const shape = heatmapGeometry({ width: element.offsetWidth, height: element.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points })
if (!shape || !active())
return
const document = element.ownerDocument
if (!gradient)
gradient = document ? 'heatmap-solids' : `heatmap-solids-${++nextGradient}`
while (document?.getElementById(gradient))
gradient = `heatmap-solids-${++nextGradient}`
element.innerHTML = `
<svg viewBox="0 0 ${shape.width} ${shape.height}">
<defs>
<linearGradient id="${gradient}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${shape.opacity}" />
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${shape.opacity}" id="heatmap-start" />
<stop offset="0%" style="stop-color:var(--art-progress-color);stop-opacity:1" id="heatmap-stop" />
<stop offset="100%" style="stop-color:var(--art-progress-color);stop-opacity:1" />
</linearGradient>
</defs>
<path fill="url(#${gradient})" d="${shape.path}"></path>
</svg>
`
start = query('#heatmap-start', element)
stop = query('#heatmap-stop', element)
progress(art.played)
}
function update(points) {
try {
draw(points)
}
catch (error) {
try {
dispose(true)
}
catch {}
throw error
}
}
listen('destroy', () => dispose(true))
try {
art.controls.add({
name: 'heatmap',
position: 'top',
html: '',
style: {
position: 'absolute',
top: '-100px',
left: '0px',
right: '0px',
height: '100px',
width: '100%',
pointerEvents: 'none',
},
beforeUnmount() { dispose(false) },
mounted(value) {
element = value
if (closed) {
if (art.controls.heatmap === value)
art.controls.remove('heatmap')
element = undefined
return
}
})
art.on('setBar', (type, percentage) => {
if ($start && $stop && type === 'played') {
$start.setAttribute('offset', `${percentage * 100}%`)
$stop.setAttribute('offset', `${percentage * 100}%`)
if (!active()) {
dispose(true)
return
}
})
art.on('ready', () => update())
art.on('resize', () => update())
art.on('artplayerPluginDanmuku:loaded', () => update())
art.on('artplayerPluginDanmuku:points', points => update(points))
},
})
listen('video:timeupdate', () => progress(art.played))
listen('setBar', (type, percentage) => {
if (type === 'played')
progress(percentage)
})
listen('ready', () => update())
listen('resize', () => update())
listen('artplayerPluginDanmuku:loaded', () => update())
listen('artplayerPluginDanmuku:points', points => update(points))
},
})
}
catch (error) {
try {
dispose(true)
}
catch {}
throw error
}
return () => dispose(true)
}
+17 -4
View File
@@ -5,10 +5,23 @@ import Setting from './setting'
export default function artplayerPluginDanmuku(option) {
return (art) => {
const danmuku = new Danmuku(art, option)
const setting = art.isDestroy ? undefined : new Setting(art, danmuku)
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
let setting
try {
setting = art.isDestroy ? undefined : new Setting(art, danmuku)
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
}
}
catch (error) {
try {
setting?.destroy()
}
catch {}
try {
danmuku.destroy()
}
catch {}
throw error
}
return {
@@ -0,0 +1,203 @@
export default class Renderer {
constructor(owner) {
this.owner = owner
this.nodes = new Set()
}
acquire() {
const $ref = this.owner.$refs.pop() || document.createElement('div')
$ref.style.cssText = this.owner.constructor.cssText
$ref.dataset.mode = ''
$ref.dataset.id = ''
$ref.className = ''
this.nodes.add($ref)
return $ref
}
get visibles() {
const result = []
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 distance = left + width
const right = clientWidth - distance
const speed = distance / danmu.$restTime
const emit = {}
emit.top = top
emit.left = left
emit.height = height
emit.width = width
emit.right = right
emit.speed = speed
emit.distance = distance
emit.time = danmu.$restTime
emit.mode = danmu.mode
result.push(emit)
})
return result
}
left($ref) {
const rect = $ref.getBoundingClientRect()
return this.owner.isRotate ? rect.top : rect.left
}
makeWait(danmu) {
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'
this.owner.$refs.push(danmu.$ref)
danmu.$ref = null
}
}
resize() {
const { clientWidth } = this.owner.$player
this.owner.filter('stop', (danmu) => {
switch (danmu.mode) {
// 滚动的弹幕
case 0:
danmu.$ref.style.left = `${clientWidth}px`
break
default:
break
}
})
this.owner.filter('emit', (danmu) => {
danmu.$lastStartTime = Date.now()
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`
break
}
default:
break
}
})
}
continue() {
const { clientWidth } = this.owner.$player
this.owner.filter('stop', (danmu) => {
this.owner.setState(danmu, 'emit') // 转换为emit状态
danmu.$lastStartTime = Date.now()
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`
break
}
default:
break
}
})
return this
}
suspend() {
const { clientWidth } = this.owner.$player
this.owner.filter('emit', (danmu) => {
this.owner.setState(danmu, 'stop') // 转换为stop状态
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'
break
}
default:
break
}
})
return this
}
prepare(danmu, operation) {
const owner = this.owner
const ref = owner.$ref
operation.danmu = danmu
operation.ref = ref
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
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
owner.utils.setStyles(ref, danmu.style)
return ref
}
place(danmu, ref, top, distance, clientWidth) {
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`
ref.style.marginLeft = '0px'
ref.style.transform = `translateX(${-distance}px)`
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
case 1:
case 2:
ref.style.left = '50%'
ref.style.marginLeft = `-${ref.clientWidth / 2}px`
break
default:
break
}
}
release(operation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
}
operation.ref = null
}
clear() {
this.destroy()
this.owner.$refs = []
this.owner.$danmuku.textContent = ''
}
destroy() {
for (const node of this.nodes) {
if (node.parentElement)
node.parentElement.removeChild(node)
}
for (const danmu of this.owner.queue) danmu.$ref = null
this.owner.$refs.length = 0
this.nodes.clear()
}
}
@@ -20,14 +20,7 @@ export default class Scheduler {
}
release(operation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
}
operation.ref = null
this.owner.renderer.release(operation)
}
invalidate() {
@@ -108,7 +101,6 @@ export default class Scheduler {
async run(operation) {
const owner = this.owner
const { setStyles } = owner.utils
if (!owner.art.playing || !this.active(operation))
return
owner.filter('emit', (danmu) => {
@@ -141,18 +133,7 @@ export default class Scheduler {
continue
const { clientWidth, clientHeight } = owner.$player
const ref = owner.$ref
operation.danmu = danmu
operation.ref = ref
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
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
setStyles(ref, danmu.style)
const ref = owner.renderer.prepare(danmu, operation)
if (!this.active(operation))
return
danmu.$lastStartTime = Date.now()
@@ -172,25 +153,7 @@ export default class Scheduler {
return
if (top !== undefined) {
owner.setState(danmu, 'emit')
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`
ref.style.marginLeft = '0px'
ref.style.transform = `translateX(${-distance}px)`
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
case 1:
case 2:
ref.style.left = '50%'
ref.style.marginLeft = `-${ref.clientWidth / 2}px`
break
default:
break
}
owner.renderer.place(danmu, ref, top, distance, clientWidth)
operation.ref = null
owner.art.emit('artplayerPluginDanmuku:visible', danmu)
}
@@ -0,0 +1,154 @@
const properties = new WeakMap()
export default class SettingLifecycle {
constructor(art) {
this.art = art
this.closed = false
this.disposers = new Set()
this.cancelled = new Promise(resolve => this.cancel = resolve)
}
get active() {
return !this.closed && !this.art.isDestroy
}
own(dispose) {
if (this.closed)
dispose()
else this.disposers.add(dispose)
}
write(target, key, value, apply = value => target[key] = value) {
if (!this.active)
return
const priority = () => typeof target.getPropertyPriority === 'function' ? target.getPropertyPriority(key) : undefined
let keys = properties.get(target)
if (!keys) {
keys = new Map()
properties.set(target, keys)
}
let entry = keys.get(key)
if (!entry) {
entry = {
original: target[key],
present: key in target || target[key] !== undefined,
priority: priority(),
owners: new Map(),
apply,
}
keys.set(key, entry)
}
if (!entry.owners.has(this)) {
this.own(() => {
const owners = [...entry.owners.keys()]
const current = owners[owners.length - 1] === this
entry.owners.delete(this)
// A later user write belongs to the host, not this settings instance.
if (current && target[key] === entry.last && priority() === entry.lastPriority) {
const values = [...entry.owners.values()]
if (values.length) {
entry.apply(values[values.length - 1])
entry.last = target[key]
entry.lastPriority = priority()
}
else if (entry.priority !== undefined) {
target.setProperty(key, entry.original, entry.priority)
}
else if (entry.present) {
entry.apply(entry.original)
}
else {
delete target[key]
}
}
if (!entry.owners.size) {
keys.delete(key)
if (!keys.size)
properties.delete(target)
}
})
}
entry.owners.delete(this)
entry.owners.set(this, value)
entry.apply(value)
entry.last = target[key]
entry.lastPriority = priority()
}
on(name, callback) {
if (!this.active)
return
const lifecycle = this
function listener(...args) {
if (lifecycle.active)
return callback.apply(this, args)
}
const dispose = () => this.art.off(name, listener)
this.own(dispose)
try {
this.art.on(name, listener)
if (!this.active)
dispose()
}
catch (error) {
this.release(dispose)
throw error
}
}
proxy(target, name, callback) {
if (!this.active)
return
const lifecycle = this
function listener(...args) {
if (lifecycle.active)
return callback.apply(this, args)
}
let remove
const dispose = () => {
if (typeof remove === 'function') {
const registry = this.art.events
if (typeof registry?.remove === 'function' && registry.destroyEvents?.has(remove))
registry.remove(remove)
else remove()
}
else {
target.removeEventListener?.(name, listener)
}
}
this.own(dispose)
try {
remove = this.art.proxy(target, name, listener)
if (!this.active)
dispose()
}
catch (error) {
this.release(dispose)
throw error
}
}
wait(value) {
return Promise.race([value, this.cancelled])
}
release(dispose) {
try {
dispose()
}
catch (error) {
console.warn('Failed to dispose danmuku setting resource:', error)
}
}
close() {
if (this.closed)
return
this.closed = true
this.cancel()
for (const dispose of this.disposers) {
this.release(dispose)
}
this.disposers.clear()
}
}
@@ -0,0 +1,79 @@
export async function emitSetting(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 = {
text,
mode: setting.option.mode,
color: setting.option.color,
time: setting.art.currentTime,
}
const report = (error) => {
if (lifecycle.active)
console.error('Error emitting danmuku:', error)
}
try {
setting.emitting = true
const state = await lifecycle.wait(setting.option.beforeEmit(danmu))
if (!lifecycle.active)
return
setting.emitting = false
if (state !== true)
return
danmu.border = true
delete danmu.time
// emit inserts synchronously; observe its Promise without delaying the UI.
Promise.resolve(setting.danmuku.emit(danmu)).catch(report)
if (!lifecycle.active)
return
$input.value = ''
setting.lock()
}
catch (error) {
report(error)
if (lifecycle.active)
setting.emitting = false
}
}
export function lockSetting(setting) {
if (!setting.lifecycle.active)
return
const { addClass } = setting.utils
const { $send } = setting.template
clearTimeout(setting.timer)
setting.isLock = true
let time = setting.option.lockTime
$send.textContent = time
addClass($send, 'apd-lock')
const loop = () => {
setting.timer = setTimeout(() => {
if (!setting.lifecycle.active)
return
if (time === 0) {
setting.unlock()
}
else {
time -= 1
$send.textContent = time
loop()
}
}, 1000)
}
loop()
}
export function unlockSetting(setting) {
clearTimeout(setting.timer)
setting.timer = null
setting.isLock = false
if (!setting.lifecycle.active)
return
const { removeClass } = setting.utils
const { $send } = setting.template
$send.textContent = '发送'
removeClass($send, 'apd-lock')
}
@@ -0,0 +1,80 @@
export default function createSlider({ min, max, container, findIndex, onChange, steps = [] }) {
const { lifecycle } = this
if (!lifecycle.active)
return { reset() {} }
const { query, clamp, setStyle } = this.utils
setStyle(container, 'touch-action', 'none')
container.innerHTML = `
<div class="apd-slider-line">
<div class="apd-slider-points">
${steps.map(() => `<div class="apd-slider-point"></div>`).join('')}
</div>
<div class="apd-slider-progress"></div>
</div>
<div class="apd-slider-dot"></div>
<div class="apd-slider-steps">
${steps.map(step => (step.hide ? '' : `<div class="apd-slider-step">${step.name}</div>`)).join('')}
</div>
`
const $dot = query('.apd-slider-dot', container)
const $progress = query('.apd-slider-progress', container)
let isDroging = false
lifecycle.own(() => isDroging = false)
function reset(index) {
if (!lifecycle.active)
return
if (index === undefined)
index = findIndex()
if (index < min || index > max)
return
const percentage = (index - min) / (max - min)
$dot.style.left = `${percentage * 100}%`
if (steps.length === 0) {
$progress.style.width = $dot.style.left
}
onChange(index)
}
function updateLeft(event) {
const { top, height, left, width } = container.getBoundingClientRect()
if (this.art.isRotate) {
const value = clamp(event.clientY - top, 0, height)
const index = Math.round((value / height) * (max - min) + min)
reset(index)
}
else {
const value = clamp(event.clientX - left, 0, width)
const index = Math.round((value / width) * (max - min) + min)
reset(index)
}
}
lifecycle.proxy(container, 'click', (event) => {
updateLeft.call(this, event)
})
lifecycle.proxy(container, 'pointerdown', (event) => {
isDroging = event.button === 0
})
lifecycle.on('document:pointermove', (event) => {
if (isDroging) {
updateLeft.call(this, event)
}
})
lifecycle.on('document:pointerup', (event) => {
if (isDroging) {
isDroging = false
updateLeft.call(this, event)
}
})
return { reset }
}
@@ -0,0 +1,29 @@
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]
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document[pending] = $style
const ready = () => {
document.removeEventListener('DOMContentLoaded', ready)
if (document[pending] === $style)
delete document[pending]
const existing = document.getElementById(id)
if (existing)
existing.textContent = $style.textContent
else
(document.head || document.documentElement).appendChild($style)
}
document.addEventListener('DOMContentLoaded', ready, { once: true })
}
else {
(document.head || document.documentElement).appendChild($style)
}
}
$style.textContent = style
}
@@ -0,0 +1,126 @@
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'
import $mode_0_off from './img/mode_0_off.svg?raw'
import $mode_0_on from './img/mode_0_on.svg?raw'
import $mode_1_off from './img/mode_1_off.svg?raw'
import $mode_1_on from './img/mode_1_on.svg?raw'
import $mode_2_off from './img/mode_2_off.svg?raw'
import $mode_2_on from './img/mode_2_on.svg?raw'
import $off from './img/off.svg?raw'
import $on from './img/on.svg?raw'
import $style from './img/style.svg?raw'
export function settingIcons() {
return {
$on,
$off,
$config,
$style,
$mode_0_off,
$mode_0_on,
$mode_1_off,
$mode_1_on,
$mode_2_off,
$mode_2_on,
$check_on,
$check_off,
}
}
export function settingTemplate() {
const { option } = this
return `
<div class="apd-toggle">
${$on}${$off}
</div>
<div class="apd-config">
${$config}
<div class="apd-config-panel">
<div class="apd-config-panel-inner">
<div class="apd-config-mode">
按类型屏蔽
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_off}${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_off}${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_off}${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-config-other">
<div class="apd-other apd-anti-overlap">
${$check_on}${$check_off}
防止弹幕重叠
</div>
<div class="apd-other apd-sync-video">
${$check_on}${$check_off}
同步视频速度
</div>
</div>
<div class="apd-config-slider apd-config-opacity">
不透明度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-margin">
显示区域
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-fontSize">
弹幕字号
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-speed">
弹幕速度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
</div>
</div>
</div>
<div class="apd-emitter">
<div class="apd-style">
${$style}
<div class="apd-style-panel">
<div class="apd-style-panel-inner">
<div class="apd-style-mode">
模式
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-style-color">
颜色
<div class="apd-colors">
${this.COLOR.map(color => `<div data-color="${color}" class="apd-color" style="background-color: ${color}"></div>`).join('')}
</div>
</div>
</div>
</div>
</div>
<input class="apd-input" placeholder="发个友善的弹幕见证当下" autocomplete="off" maxLength="${option.maxLength}" />
<div class="apd-send">发送</div>
</div>
`
}
+99 -307
View File
@@ -1,16 +1,8 @@
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'
import $mode_0_off from './img/mode_0_off.svg?raw'
import $mode_0_on from './img/mode_0_on.svg?raw'
import $mode_1_off from './img/mode_1_off.svg?raw'
import $mode_1_on from './img/mode_1_on.svg?raw'
import $mode_2_off from './img/mode_2_off.svg?raw'
import $mode_2_on from './img/mode_2_on.svg?raw'
import $off from './img/off.svg?raw'
import $on from './img/on.svg?raw'
import $style from './img/style.svg?raw'
import style from './style.less?inline'
import SettingLifecycle from './setting-lifecycle'
import { emitSetting, lockSetting, unlockSetting } from './setting-send'
import createSlider from './setting-slider'
import { settingIcons, settingTemplate } from './setting-template'
import './setting-style'
export default class Setting {
constructor(art, danmuku) {
@@ -20,7 +12,6 @@ export default class Setting {
const { setStyle } = this.utils
const { $controlsCenter } = art.template
setStyle($controlsCenter, 'display', 'flex')
this.template = {
$controlsCenter,
@@ -57,46 +48,59 @@ export default class Setting {
this.isLock = false
this.timer = null
this.createTemplate()
this.createSliders()
this.createEvents()
this.lifecycle = new SettingLifecycle(art)
this.destroy = this.destroy.bind(this)
this.lifecycle.own(() => art.off('destroy', this.destroy))
this.mount(this.option.mount)
try {
art.on('destroy', this.destroy)
if (!this.lifecycle.active)
return
this.lifecycle.write($controlsCenter.style, 'display', 'flex', value => setStyle($controlsCenter, 'display', value))
art.on('resize', () => this.resize())
art.on('fullscreen', state => this.onFullscreen(state))
art.on('fullscreenWeb', state => this.onFullscreen(state))
this.createTemplate()
if (!this.lifecycle.active)
return
this.createSliders()
if (!this.lifecycle.active)
return
this.createEvents()
art.proxy(this.template.$config, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$config,
$panel: this.template.$configPanel,
this.mount(this.option.mount)
if (!this.lifecycle.active)
return
this.lifecycle.on('resize', () => this.resize())
this.lifecycle.on('fullscreen', state => this.onFullscreen(state))
this.lifecycle.on('fullscreenWeb', state => this.onFullscreen(state))
this.lifecycle.proxy(this.template.$config, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$config,
$panel: this.template.$configPanel,
})
})
})
art.proxy(this.template.$style, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$style,
$panel: this.template.$stylePanel,
this.lifecycle.proxy(this.template.$style, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$style,
$panel: this.template.$stylePanel,
})
})
})
}
catch (error) {
try {
this.destroy()
}
catch (cleanupError) {
console.warn('Failed to roll back danmuku setting:', cleanupError)
}
throw error
}
}
static get icons() {
return {
$on,
$off,
$config,
$style,
$mode_0_off,
$mode_0_on,
$mode_1_off,
$mode_1_on,
$mode_2_off,
$mode_2_on,
$check_on,
$check_off,
}
return settingIcons()
}
get option() {
@@ -108,100 +112,7 @@ export default class Setting {
}
get TEMPLATE() {
const { option } = this
return `
<div class="apd-toggle">
${$on}${$off}
</div>
<div class="apd-config">
${$config}
<div class="apd-config-panel">
<div class="apd-config-panel-inner">
<div class="apd-config-mode">
按类型屏蔽
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_off}${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_off}${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_off}${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-config-other">
<div class="apd-other apd-anti-overlap">
${$check_on}${$check_off}
防止弹幕重叠
</div>
<div class="apd-other apd-sync-video">
${$check_on}${$check_off}
同步视频速度
</div>
</div>
<div class="apd-config-slider apd-config-opacity">
不透明度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-margin">
显示区域
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-fontSize">
弹幕字号
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-speed">
弹幕速度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
</div>
</div>
</div>
<div class="apd-emitter">
<div class="apd-style">
${$style}
<div class="apd-style-panel">
<div class="apd-style-panel-inner">
<div class="apd-style-mode">
模式
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-style-color">
颜色
<div class="apd-colors">
${this.COLOR.map(color => `<div data-color="${color}" class="apd-color" style="background-color: ${color}"></div>`).join('')}
</div>
</div>
</div>
</div>
</div>
<input class="apd-input" placeholder="发个友善的弹幕见证当下" autocomplete="off" maxLength="${option.maxLength}" />
<div class="apd-send">发送</div>
</div>
`
return settingTemplate.call(this)
}
get OPACITY() {
@@ -308,6 +219,8 @@ export default class Setting {
}
append(el, target) {
if (!this.lifecycle.active)
return
const { append } = this.utils
const children = [...el.children]
if (children.includes(target))
@@ -316,11 +229,13 @@ export default class Setting {
}
setData(key, value) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
const { $mount } = this.template
$player.dataset[key] = value
this.lifecycle.write($player.dataset, key, value)
if (this.outside) {
$mount.dataset[key] = value
this.lifecycle.write($mount.dataset, key, value)
}
}
@@ -328,10 +243,10 @@ export default class Setting {
const { createElement, tooltip } = this.utils
const $danmuku = createElement('div')
this.template.$danmuku = $danmuku
$danmuku.className = 'artplayer-plugin-danmuku'
$danmuku.innerHTML = this.TEMPLATE
this.template.$danmuku = $danmuku
this.template.$toggle = this.query('.apd-toggle')
this.template.$config = this.query('.apd-config')
this.template.$configPanel = this.query('.apd-config-panel')
@@ -355,11 +270,11 @@ export default class Setting {
const { $toggle } = this.template
this.art.on('artplayerPluginDanmuku:show', () => {
this.lifecycle.on('artplayerPluginDanmuku:show', () => {
tooltip($toggle, '关闭弹幕')
})
this.art.on('artplayerPluginDanmuku:hide', () => {
this.lifecycle.on('artplayerPluginDanmuku:hide', () => {
tooltip($toggle, '打开弹幕')
})
}
@@ -367,14 +282,14 @@ export default class Setting {
createEvents() {
const { $toggle, $configModes, $styleModes, $colors, $antiOverlap, $syncVideo, $send, $input } = this.template
this.art.proxy($toggle, 'click', () => {
this.lifecycle.proxy($toggle, 'click', () => {
this.danmuku.config({
visible: !this.option.visible,
})
this.reset()
})
this.art.proxy($configModes, 'click', (event) => {
this.lifecycle.proxy($configModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
if (!$mode)
return
@@ -392,21 +307,21 @@ export default class Setting {
this.reset()
})
this.art.proxy($antiOverlap, 'click', () => {
this.lifecycle.proxy($antiOverlap, 'click', () => {
this.danmuku.config({
antiOverlap: !this.option.antiOverlap,
})
this.reset()
})
this.art.proxy($syncVideo, 'click', () => {
this.lifecycle.proxy($syncVideo, 'click', () => {
this.danmuku.config({
synchronousPlayback: !this.option.synchronousPlayback,
})
this.reset()
})
this.art.proxy($styleModes, 'click', (event) => {
this.lifecycle.proxy($styleModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
if (!$mode)
return
@@ -417,7 +332,7 @@ export default class Setting {
this.reset()
})
this.art.proxy($colors, 'click', (event) => {
this.lifecycle.proxy($colors, 'click', (event) => {
const $color = event.target.closest('.apd-color')
if (!$color)
return
@@ -427,9 +342,9 @@ export default class Setting {
this.reset()
})
this.art.proxy($send, 'click', () => this.emit())
this.lifecycle.proxy($send, 'click', () => this.emit())
this.art.proxy($input, 'keypress', (event) => {
this.lifecycle.proxy($input, 'keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
this.emit()
@@ -509,79 +424,13 @@ export default class Setting {
})
}
createSlider({ min, max, container, findIndex, onChange, steps = [] }) {
const { query, clamp, setStyle } = this.utils
setStyle(container, 'touch-action', 'none')
container.innerHTML = `
<div class="apd-slider-line">
<div class="apd-slider-points">
${steps.map(() => `<div class="apd-slider-point"></div>`).join('')}
</div>
<div class="apd-slider-progress"></div>
</div>
<div class="apd-slider-dot"></div>
<div class="apd-slider-steps">
${steps.map(step => (step.hide ? '' : `<div class="apd-slider-step">${step.name}</div>`)).join('')}
</div>
`
const $dot = query('.apd-slider-dot', container)
const $progress = query('.apd-slider-progress', container)
let isDroging = false
function reset(index = findIndex()) {
if (index < min || index > max)
return
const percentage = (index - min) / (max - min)
$dot.style.left = `${percentage * 100}%`
if (steps.length === 0) {
$progress.style.width = $dot.style.left
}
onChange(index)
}
function updateLeft(event) {
const { top, height, left, width } = container.getBoundingClientRect()
if (this.art.isRotate) {
const value = clamp(event.clientY - top, 0, height)
const index = Math.round((value / height) * (max - min) + min)
reset(index)
}
else {
const value = clamp(event.clientX - left, 0, width)
const index = Math.round((value / width) * (max - min) + min)
reset(index)
}
}
this.art.proxy(container, 'click', (event) => {
updateLeft.call(this, event)
})
this.art.proxy(container, 'pointerdown', (event) => {
isDroging = event.button === 0
})
this.art.on('document:pointermove', (event) => {
if (isDroging) {
updateLeft.call(this, event)
}
})
this.art.on('document:pointerup', (event) => {
if (isDroging) {
isDroging = false
updateLeft.call(this, event)
}
})
return { reset }
createSlider(option) {
return createSlider.call(this, option)
}
onFullscreen(state) {
if (!this.lifecycle.active)
return
const { $danmuku, $controlsCenter, $mount } = this.template
if (this.outside) {
if (state) {
@@ -597,6 +446,8 @@ export default class Setting {
}
onMouseEnter({ $control, $panel }) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
const controlRect = $control.getBoundingClientRect()
const panelRect = $panel.getBoundingClientRect()
@@ -617,80 +468,21 @@ export default class Setting {
}
}
async emit() {
const { $input } = this.template
const text = $input.value.trim()
if (!text.length)
return
if (this.isLock)
return
if (this.emitting)
return
const danmu = {
text,
mode: this.option.mode,
color: this.option.color,
time: this.art.currentTime,
}
try {
this.emitting = true
const state = await this.option.beforeEmit(danmu)
this.emitting = false
if (state !== true)
return
danmu.border = true
delete danmu.time
this.danmuku.emit(danmu)
$input.value = ''
this.lock()
}
catch (error) {
console.error('Error emitting danmuku:', error)
this.emitting = false
}
emit() {
return emitSetting(this)
}
lock() {
const { addClass } = this.utils
const { $send } = this.template
this.isLock = true
let time = this.option.lockTime
$send.textContent = time
addClass($send, 'apd-lock')
const loop = () => {
this.timer = setTimeout(() => {
if (time === 0) {
this.unlock()
}
else {
time -= 1
$send.textContent = time
loop()
}
}, 1000)
}
loop()
lockSetting(this)
}
unlock() {
const { removeClass } = this.utils
const { $send } = this.template
clearTimeout(this.timer)
this.isLock = false
$send.textContent = '发送'
removeClass($send, 'apd-lock')
unlockSetting(this)
}
resize() {
if (!this.lifecycle.active)
return
if (this.outside)
return
if (this.art.fullscreen)
@@ -708,13 +500,16 @@ export default class Setting {
}
reset() {
if (!this.lifecycle.active)
return
const { inverseClass, tooltip } = this.utils
const { $toggle, $colors } = this.template
this.slider.opacity.reset()
this.slider.margin.reset()
this.slider.fontSize.reset()
this.slider.speed.reset()
for (const slider of Object.values(this.slider)) {
slider.reset()
if (!this.lifecycle.active)
return
}
this.setData('danmukuVisible', this.option.visible)
this.setData('danmukuMode', this.option.mode)
@@ -737,6 +532,8 @@ export default class Setting {
}
mount(target) {
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}`)
@@ -744,22 +541,17 @@ export default class Setting {
this.template.$mount = $el
this.reset()
}
}
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-danmuku'
let $style = document.getElementById(id)
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild($style)
})
}
else {
(document.head || document.documentElement).appendChild($style)
}
destroy() {
if (this.lifecycle.closed)
return
this.lifecycle.close()
clearTimeout(this.timer)
this.timer = null
this.emitting = false
this.isLock = false
const $danmuku = this.template.$danmuku
if ($danmuku?.parentElement)
$danmuku.parentElement.removeChild($danmuku)
}
$style.textContent = style
}