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

+209 -244
View File
@@ -1,114 +1,96 @@
export type Mode = 0 | 1 | 2
export type Danmuku
= | Danmu[]
| string // URL
| (() => Promise<Danmu[]>)
| Promise<Danmu[]>
export interface Slider {
min?: number
max?: number
steps?: {
name?: string
value?: number | string
show?: boolean
}[]
}
export interface Danmu {
/**
* 弹幕文本
*/
text: string
/**
* 弹幕发送模式: 0: 滚动,1: 顶部,2: 底部
*/
mode?: Mode
/**
* 弹幕颜色
*/
color?: string
/**
* 弹幕出现的时间,单位为秒
*/
time?: number
/**
* 弹幕是否有描边, 默认为 false
*/
border?: boolean
/**
* 弹幕自定义样式
*/
style?: Partial<CSSStyleDeclaration>
}
export interface Option {
/**
* 弹幕数据: 函数,数组,Promise,URL
*/
danmuku: Danmuku
/**
* 弹幕持续时间,范围在[1 ~ 10]
*/
speed?: number
/**
* 弹幕上下边距,支持像素数字和百分比
*/
margin?: [number | `${number}%`, number | `${number}%`]
/**
* 弹幕透明度,范围在[0 ~ 1]
*/
opacity?: number
/**
* 默认弹幕颜色,可以被单独弹幕项覆盖
*/
color?: string
/**
* 弹幕模式: 0: 滚动,1: 顶部,2: 底部
*/
mode?: Mode
/**
* 弹幕可见的模式
*/
modes?: Mode[]
/**
* 弹幕字体大小,支持像素数字和百分比
*/
fontSize?: number | `${number}%`
/**
* 弹幕是否防重叠
*/
antiOverlap?: boolean
/**
* 是否同步播放速度
*/
synchronousPlayback?: boolean
/**
* 弹幕发射器挂载点, 默认为播放器控制栏中部
*/
mount?: HTMLDivElement | string
/**
* 是否开启弹幕热度图
*/
heatmap?:
| boolean
| {
// Generated from the package public declaration by yarn build:ts. Do not edit.
/* eslint-disable ts/no-redeclare -- Callable and public type namespace intentionally merge. */
declare namespace artplayerPluginDanmukuDefinitions {
export type Mode = 0 | 1 | 2
export type Danmuku = Danmu[] | string // URL
| (() => Promise<Danmu[]>) | Promise<Danmu[]>
export interface Slider {
min?: number
max?: number
steps?: {
name?: string
value?: number | string
show?: boolean
}[]
}
export interface Danmu {
/**
* 弹幕文本
*/
text: string
/**
* 弹幕发送模式: 0: 滚动,1: 顶部,2: 底部
*/
mode?: Mode
/**
* 弹幕颜色
*/
color?: string
/**
* 弹幕出现的时间,单位为秒
*/
time?: number
/**
* 弹幕是否有描边, 默认为 false
*/
border?: boolean
/**
* 弹幕自定义样式
*/
style?: Partial<CSSStyleDeclaration>
}
export interface Option {
/**
* 弹幕数据: 函数,数组,Promise,URL
*/
danmuku: Danmuku
/**
* 弹幕持续时间,范围在[1 ~ 10]
*/
speed?: number
/**
* 弹幕上下边距,支持像素数字和百分比
*/
margin?: [
number | `${number}%`,
number | `${number}%`,
]
/**
* 弹幕透明度,范围在[0 ~ 1]
*/
opacity?: number
/**
* 默认弹幕颜色,可以被单独弹幕项覆盖
*/
color?: string
/**
* 弹幕模式: 0: 滚动,1: 顶部,2: 底部
*/
mode?: Mode
/**
* 弹幕可见的模式
*/
modes?: Mode[]
/**
* 弹幕字体大小,支持像素数字和百分比
*/
fontSize?: number | `${number}%`
/**
* 弹幕是否防重叠
*/
antiOverlap?: boolean
/**
* 是否同步播放速度
*/
synchronousPlayback?: boolean
/**
* 弹幕发射器挂载点, 默认为播放器控制栏中部
*/
mount?: HTMLDivElement | string
/**
* 是否开启弹幕热度图
*/
heatmap?: boolean | {
xMin?: number
xMax?: number
yMin?: number
@@ -120,140 +102,123 @@ export interface Option {
smoothing?: number
flattening?: number
}
/**
* 当播放器宽度小于此值时,弹幕发射器置于播放器底部
*/
width?: number
/**
* 热力图数据
*/
points?: { time: number, value: number }[]
/**
* 弹幕载入前的过滤器,只支持返回布尔值
*/
filter?: (danmu: Danmu) => boolean
/**
* 弹幕发送前的过滤器,支持返回 Promise
*/
beforeEmit?: (danmu: Danmu) => boolean | Promise<boolean>
/**
* 弹幕显示前的过滤器,支持返回 Promise
*/
beforeVisible?: (danmu: Danmu) => boolean | Promise<boolean>
/**
* 弹幕是否可见
*/
visible?: boolean
/**
* 是否开启弹幕发射器
*/
emitter?: boolean
/**
* 弹幕输入框最大长度, 范围在[1 ~ 1000]
*/
maxLength?: number
/**
* 输入框锁定时间,范围在[1 ~ 60]
*/
lockTime?: number
/**
* 弹幕主题,只在自定义挂载时生效
*/
theme?: 'light' | 'dark'
/**
* 不透明度配置项
*/
OPACITY?: Slider
/**
* 弹幕速度配置项
*/
SPEED?: Slider
/**
* 显示区域配置项
*/
MARGIN?: Slider
/**
* 弹幕字号配置项
*/
FONT_SIZE?: Slider
/**
* 颜色列表配置项
*/
COLOR?: string[]
/**
* 当播放器宽度小于此值时,弹幕发射器置于播放器底部
*/
width?: number
/**
* 热力图数据
*/
points?: {
time: number
value: number
}[]
/**
* 弹幕载入前的过滤器,只支持返回布尔值
*/
filter?: (danmu: Danmu) => boolean
/**
* 弹幕发送前的过滤器,支持返回 Promise
*/
beforeEmit?: (danmu: Danmu) => boolean | Promise<boolean>
/**
* 弹幕显示前的过滤器,支持返回 Promise
*/
beforeVisible?: (danmu: Danmu) => boolean | Promise<boolean>
/**
* 弹幕是否可见
*/
visible?: boolean
/**
* 是否开启弹幕发射器
*/
emitter?: boolean
/**
* 弹幕输入框最大长度, 范围在[1 ~ 1000]
*/
maxLength?: number
/**
* 输入框锁定时间,范围在[1 ~ 60]
*/
lockTime?: number
/**
* 弹幕主题,只在自定义挂载时生效
*/
theme?: 'light' | 'dark'
/**
* 不透明度配置项
*/
OPACITY?: Slider
/**
* 弹幕速度配置项
*/
SPEED?: Slider
/**
* 显示区域配置项
*/
MARGIN?: Slider
/**
* 弹幕字号配置项
*/
FONT_SIZE?: Slider
/**
* 颜色列表配置项
*/
COLOR?: string[]
}
export interface Result {
name: 'artplayerPluginDanmuku'
/**
* 发送一条实时弹幕
*/
emit: (danmu: Danmu) => Result
/**
* 重载弹幕源,或者切换新弹幕
*/
load: (danmuku?: Danmuku) => Promise<Result>
/**
* 实时改变弹幕配置
*/
config: (option: Option) => Result
/**
* 隐藏弹幕层
*/
hide: () => Result
/**
* 显示弹幕层
*/
show: () => Result
/**
* 挂载弹幕输入框
*/
mount: (el?: HTMLDivElement | string) => void
/**
* 重置弹幕
*/
reset: () => Result
/**
* 弹幕配置
*/
option: Option
/**
* 是否隐藏弹幕层
*/
isHide: boolean
/**
* 是否弹幕层停止状态
*/
isStop: boolean
}
export const artplayerPluginDanmuku: (option: Option) => (art: Artplayer) => Result
}
export interface Result {
name: 'artplayerPluginDanmuku'
/**
* 发送一条实时弹幕
*/
emit: (danmu: Danmu) => Result
/**
* 重载弹幕源,或者切换新弹幕
*/
load: (danmuku?: Danmuku) => Promise<Result>
/**
* 实时改变弹幕配置
*/
config: (option: Option) => Result
/**
* 隐藏弹幕层
*/
hide: () => Result
/**
* 显示弹幕层
*/
show: () => Result
/**
* 挂载弹幕输入框
*/
mount: (el?: HTMLDivElement | string) => void
/**
* 重置弹幕
*/
reset: () => Result
/**
* 弹幕配置
*/
option: Option
/**
* 是否隐藏弹幕层
*/
isHide: boolean
/**
* 是否弹幕层停止状态
*/
isStop: boolean
declare const artplayerPluginDanmuku: typeof artplayerPluginDanmukuDefinitions.artplayerPluginDanmuku
declare namespace artplayerPluginDanmuku {
export type Mode = artplayerPluginDanmukuDefinitions.Mode
export type Danmuku = artplayerPluginDanmukuDefinitions.Danmuku
export type Slider = artplayerPluginDanmukuDefinitions.Slider
export type Danmu = artplayerPluginDanmukuDefinitions.Danmu
export type Option = artplayerPluginDanmukuDefinitions.Option
export type Result = artplayerPluginDanmukuDefinitions.Result
}
declare const artplayerPluginDanmuku: (option: Option) => (art: Artplayer) => Result
export default artplayerPluginDanmuku
export = artplayerPluginDanmuku
export as namespace artplayerPluginDanmuku;
+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
+24 -16
View File
@@ -665,16 +665,18 @@ 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,
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
const top = typeof reply === "symbol" ? void 0 : reply.result;
if (!this.active(operation) || danmu.$ref !== ref)
return;
if (top !== void 0) {
@@ -948,6 +950,7 @@ class Danmuku {
if (!task.active())
return this;
errorHandle(Array.isArray(danmus), "Danmuku need return an array as result");
const rows = danmus;
if (danmuku === void 0) {
this.reset();
if (!task.active())
@@ -956,10 +959,10 @@ class Danmuku {
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.renderer.clear();
}
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);
}
if (task.active())
@@ -1074,6 +1077,7 @@ class Danmuku {
return this.renderer.left($ref);
}
// 复杂运算交给 Web Worker 处理
// Keep the historical empty default; unknown message types receive no reply.
postMessage(message = {}) {
return this.workerClient.request(message);
}
@@ -1312,8 +1316,9 @@ function heatmap(art, danmuku, option) {
let closed = false;
const active = () => !closed && !art.isDestroy;
function listen(name, callback) {
subscriptions.push([name, callback]);
art.on(name, callback);
const listener = callback;
subscriptions.push([name, listener]);
art.on(name, listener);
}
function dispose(removeControl) {
if (closed)
@@ -1351,18 +1356,19 @@ function heatmap(art, danmuku, option) {
return;
start = null;
stop = null;
element.innerHTML = "";
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 document2 = element.ownerDocument;
const document2 = target.ownerDocument;
if (!gradient)
gradient = document2 ? "heatmap-solids" : `heatmap-solids-${++nextGradient}`;
while (document2?.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%">
@@ -1375,8 +1381,8 @@ 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) {
@@ -1456,7 +1462,8 @@ class SettingLifecycle {
dispose();
else this.disposers.add(dispose);
}
write(target, key, value, apply = (value2) => target[key] = value2) {
write(host, key, value, apply = (value2) => host[key] = value2) {
const target = host;
if (!this.active)
return;
const priority = () => typeof target.getPropertyPriority === "function" ? target.getPropertyPriority(key) : void 0;
@@ -1847,16 +1854,17 @@ const style = ".artplayer-plugin-danmuku {\n display: flex;\n position: relati
if (typeof document !== "undefined") {
const id = "artplayer-plugin-danmuku";
const pending = /* @__PURE__ */ Symbol.for("artplayer-plugin-danmuku.pending-style");
let $style2 = document.getElementById(id) || document[pending];
const styleDocument = document;
let $style2 = document.getElementById(id) || styleDocument[pending];
if (!$style2) {
$style2 = document.createElement("style");
$style2.id = id;
if (document.readyState === "loading") {
document[pending] = $style2;
styleDocument[pending] = $style2;
const ready = () => {
document.removeEventListener("DOMContentLoaded", ready);
if (document[pending] === $style2)
delete document[pending];
if (styleDocument[pending] === $style2)
delete styleDocument[pending];
const existing = document.getElementById(id);
if (existing)
existing.textContent = $style2.textContent;
@@ -661,16 +661,18 @@ var artplayerPluginDanmuku = (function() {
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,
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
const top = typeof reply === "symbol" ? void 0 : reply.result;
if (!this.active(operation) || danmu.$ref !== ref)
return;
if (top !== void 0) {
@@ -944,6 +946,7 @@ var artplayerPluginDanmuku = (function() {
if (!task.active())
return this;
errorHandle(Array.isArray(danmus), "Danmuku need return an array as result");
const rows = danmus;
if (danmuku === void 0) {
this.reset();
if (!task.active())
@@ -952,10 +955,10 @@ var artplayerPluginDanmuku = (function() {
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.renderer.clear();
}
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);
}
if (task.active())
@@ -1070,6 +1073,7 @@ var artplayerPluginDanmuku = (function() {
return this.renderer.left($ref);
}
// 复杂运算交给 Web Worker 处理
// Keep the historical empty default; unknown message types receive no reply.
postMessage(message = {}) {
return this.workerClient.request(message);
}
@@ -1235,7 +1239,8 @@ var artplayerPluginDanmuku = (function() {
smoothing: 0.2,
flattening: 0.2
};
const options = Object.assign({}, defaults, typeof option === "object" ? option : {});
const configured = Object.assign({}, typeof option === "object" ? option : {});
const options = Object.assign({}, defaults, configured);
for (const key of Object.keys(defaults)) {
if (!Number.isFinite(options[key]))
options[key] = defaults[key];
@@ -1244,7 +1249,8 @@ var artplayerPluginDanmuku = (function() {
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);
const hasCustomPoints = Array.isArray(input) && input.length > 0;
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;
}
@@ -1265,14 +1271,21 @@ var artplayerPluginDanmuku = (function() {
const y = point[1];
point[1] = y * (y > yMid ? 1 + options.scale : 1 - options.scale) + options.minHeight;
}
const automatic = !hasCustomPoints && !Number.isFinite(configured.yMin) && !Number.isFinite(configured.yMax);
let peak = 0;
for (const point of points) peak = Math.max(peak, point[1]);
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 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];
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), map(point[1], options.yMin, options.yMax, height, 0)]);
const positions = points.map((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) => {
@@ -1299,8 +1312,9 @@ var artplayerPluginDanmuku = (function() {
let closed = false;
const active = () => !closed && !art.isDestroy;
function listen(name, callback) {
subscriptions.push([name, callback]);
art.on(name, callback);
const listener = callback;
subscriptions.push([name, listener]);
art.on(name, listener);
}
function dispose(removeControl) {
if (closed)
@@ -1338,18 +1352,19 @@ var artplayerPluginDanmuku = (function() {
return;
start = null;
stop = null;
element.innerHTML = "";
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 document2 = element.ownerDocument;
const document2 = target.ownerDocument;
if (!gradient)
gradient = document2 ? "heatmap-solids" : `heatmap-solids-${++nextGradient}`;
while (document2?.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%">
@@ -1362,8 +1377,8 @@ var artplayerPluginDanmuku = (function() {
<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) {
@@ -1443,7 +1458,8 @@ var artplayerPluginDanmuku = (function() {
dispose();
else this.disposers.add(dispose);
}
write(target, key, value, apply = (value2) => target[key] = value2) {
write(host, key, value, apply = (value2) => host[key] = value2) {
const target = host;
if (!this.active)
return;
const priority = () => typeof target.getPropertyPriority === "function" ? target.getPropertyPriority(key) : void 0;
@@ -1834,16 +1850,17 @@ var artplayerPluginDanmuku = (function() {
if (typeof document !== "undefined") {
const id = "artplayer-plugin-danmuku";
const pending = /* @__PURE__ */ Symbol.for("artplayer-plugin-danmuku.pending-style");
let $style2 = document.getElementById(id) || document[pending];
const styleDocument = document;
let $style2 = document.getElementById(id) || styleDocument[pending];
if (!$style2) {
$style2 = document.createElement("style");
$style2.id = id;
if (document.readyState === "loading") {
document[pending] = $style2;
styleDocument[pending] = $style2;
const ready = () => {
document.removeEventListener("DOMContentLoaded", ready);
if (document[pending] === $style2)
delete document[pending];
if (styleDocument[pending] === $style2)
delete styleDocument[pending];
const existing = document.getElementById(id);
if (existing)
existing.textContent = $style2.textContent;
+3 -1
View File
@@ -100,7 +100,9 @@
"test:chromecast": "node --test test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:chromecast-types-package": "node refactor/scripts/chromecast-package-types.mjs",
"test:danmuku-mask": "node --test test/danmuku-mask-failures.test.js refactor/scripts/danmuku-mask-contract.test.mjs",
"test:danmuku": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js refactor/scripts/danmuku-contract.test.mjs"
"test:danmuku": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js refactor/scripts/danmuku-contract.test.mjs",
"test:danmuku-types": "node --test refactor/scripts/danmuku-types.test.mjs",
"test:danmuku-types-package": "node refactor/scripts/danmuku-package-types.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
+2 -1
View File
@@ -2,4 +2,5 @@ node_modules
.DS_Store
yarn.lock
package-lock.json
src
src
tsconfig.json
+112 -31
View File
@@ -2,40 +2,100 @@
The compatibility reference is the actual npm 5.3.0 package. Its archive,
historical declarations, earlier exports and failure evidence are indexed in
`refactor/baselines/danmuku-release.json` and `danmuku-contract.md` at the
repository root. Do not infer runtime contracts from the old declaration file.
`refactor/baselines/danmuku-release.json` and
`refactor/baselines/danmuku-contract.md` relative to the repository root. Do not infer runtime contracts from the old declaration file.
## Module responsibilities
| Module | Responsibility |
| --- | --- |
| `src/index.js` | Plugin factory, facade getters, bound methods, setting and heatmap composition |
| `src/danmuku.js` | Internal instance, method return identity, configuration/load commits, event and lifecycle wiring |
| `src/config.js` | Fresh defaults, validation schema, configuration comparison and normalization |
| `src/input.js` | Input forms, replacement ownership, independent append operations, cancellation and synchronous callback reentry |
| `src/bilibili-parser.js` | Pure legacy-compatible XML fields, mode mapping and entity decoding |
| `src/bilibili.js` | Fetch and response text, one parser Worker per request, fallback, settlement and Blob URL cleanup |
| `src/scheduler.js` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery |
| `src/renderer.js` | Owned node allocation, preparation, geometry snapshots, placement, pause/resume styling and disposal |
| `src/queue.js` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.js` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
| `src/worker.js` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/setting.js` | Settings coordinator, configuration controls, mount and fullscreen layout |
| `src/setting-template.js` | Existing template HTML and static icon values |
| `src/setting-slider.js` | Slider index, pointer and rotation behavior |
| `src/setting-send.js` | Pending send, original error outlet and lock countdown |
| `src/setting-lifecycle.js` | Exact subscription/proxy disposal, cancellation and conditional host-property restoration |
| `src/setting-style.js` | Shared document style and one pending DOMContentLoaded installation |
| `src/heatmap.js` | Owned control, progress stops, subscriptions and per-instance gradient |
| `src/heatmap-sampling.js` | Sorted-time bin counts with historical numeric boundaries |
| `src/heatmap-geometry.js` | Legacy point normalization, caller-visible point writes and SVG curve calculation |
| `src/index.ts` | Plugin factory, facade getters, bound methods, setting and heatmap composition |
| `src/danmuku.ts` | Internal instance, method return identity, configuration/load commits, event and lifecycle wiring |
| `src/config.ts` | Fresh defaults, validation schema, configuration comparison and normalization |
| `src/input.ts` | Input forms, replacement ownership, independent append operations, cancellation and synchronous callback reentry |
| `src/bilibili-parser.ts` | Pure legacy-compatible XML fields, mode mapping and entity decoding |
| `src/bilibili.ts` | Fetch and response text, one parser Worker per request, fallback, settlement and Blob URL cleanup |
| `src/scheduler.ts` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery |
| `src/renderer.ts` | Owned node allocation, preparation, geometry snapshots, placement, pause/resume styling and disposal |
| `src/queue.ts` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.ts` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
| `src/worker.ts` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/setting.ts` | Settings coordinator, configuration controls, mount and fullscreen layout |
| `src/setting-template.ts` | Existing template HTML and static icon values |
| `src/setting-slider.ts` | Slider index, pointer and rotation behavior |
| `src/setting-send.ts` | Pending send, original error outlet and lock countdown |
| `src/setting-lifecycle.ts` | Exact subscription/proxy disposal, cancellation and conditional host-property restoration |
| `src/setting-style.ts` | Shared document style and one pending DOMContentLoaded installation |
| `src/heatmap.ts` | Owned control, progress stops, subscriptions and per-instance gradient |
| `src/heatmap-sampling.ts` | Sorted-time bin counts with historical numeric boundaries |
| `src/heatmap-geometry.ts` | Legacy point normalization, caller-visible point writes and SVG curve calculation |
The package is undergoing staged migration. PKG-DANMUKU-03 separates input and
configuration responsibilities in JavaScript; PKG-DANMUKU-04 separates scheduling
and track requests; PKG-DANMUKU-05 separates rendering and UI resources;
PKG-DANMUKU-06 converts all owned
modules and Worker messages to TypeScript and validates installed consumers.
This intermediate structure is not the final TypeScript or release acceptance.
All 20 owned runtime modules use TypeScript. Their responsibilities remain
separate from public declaration compatibility and from release acceptance.
The following files contain types only:
| Module | Responsibility |
| --- | --- |
| `src/types.ts` | Reexports public runtime data types and defines the internal Artplayer host adapter |
| `src/worker-types.ts` | Placement requests, replies, visible rows and virtual boundary rows |
| `src/parser-types.ts` | XML parser results and its independent Worker protocol |
| `src/setting-types.ts` | Settings template slots and slider integration contracts |
| `src/worker-assets.d.ts` | Inline placement Worker module declaration |
| `types/runtime-shared.d.ts` | Single source for accurate option, callback, input, queue, facade, owner and event payload types |
`src/types.ts` imports/reexports `types/runtime-shared.d.ts` with type-only
syntax. Keep shared option/data definitions there; do not create a second copy
in production source. Host, DOM and Worker adapter types remain internal. The
factory composes Danmuku, Setting and heatmap. Danmuku owns queue/state pools and
coordinates input, renderer and scheduler. Scheduler/renderer refer back to the
owner with type-only imports; these references add no runtime module cycle.
The XML parser and heatmap sampling/geometry remain independently testable.
## Declaration entrypoints and type boundaries
The root and `/legacy` declarations retain the exact actual npm 5.3.0 type
source, including historical inaccuracies and extraction behavior. Edit neither
to make runtime implementation checks pass. The additive `/runtime` entrypoint
selects the existing ESM or CommonJS factory with accurate declarations. Its
`runtime.d.mts` entry exposes ESM type exports; `runtime.d.cts` and the
`typesVersions` fallback `runtime.d.ts` expose the callable CommonJS factory
and its type namespace. CommonJS returns the function itself; importing it does
not add a `factory.default` property.
The argument object is required, while its fields are optional. The registrar
returns `RuntimeResult` synchronously. Command methods resolve/return `Owner`,
which is a different object; `emit` is asynchronous even though its initial
insertion work is synchronous. The facade retains its three live getters and
requires a valid target for `mount`. Public data includes mutable point tuples,
typed static icons, normalized filter inputs and queue entries for visibility
callbacks. Filters and visibility callbacks accept truthy results; sending
requires strict `true`. Their `this` is the current normalized option. Loader
functions retain their receiver-free call. `EventMap` supplies named payload
tuples explicitly; importing `/runtime` does not augment old core event types.
It contains types only, with no new event bus or runtime export.
Keep these narrow implementation assertions documented when editing them:
- Native style/dataset writes retain their historical numeric and null values.
WebIDL performs the conversion; changing source assignments to satisfy DOM
declarations could change controlled hosts or observable setters.
- Emit/stop state pools own allocated nodes. A frame with a non-null reference
has its associated item, and release checks node identity. Non-null assertions
depend on those invariants; they do not replace lifecycle validation.
- The legacy public core type describes `constructor` as `Function`. The
factory's host adapter narrows it to the existing Artplayer static utilities
and validator. It introduces no new core capability or minimum core version.
- An omitted internal `postMessage` keeps its old empty-message behavior;
unsupported margin strings retain the raw historical fallback at the Worker
boundary. Placement types describe valid scheduler requests, with explicit
compatibility assertions for those exceptional paths. These internal message
helpers are not additional methods promised by the public `Owner` interface.
The strict package config disables JavaScript input. Its `ES2021.String` type
library describes the existing XML `replaceAll` calls; it neither introduces a
new runtime dependency nor raises the existing browser requirement. Modern and
legacy build targets remain es2020 and es2015; syntax lowering does not polyfill
`replaceAll` or other browser APIs.
## Contracts to retain
@@ -125,6 +185,25 @@ Late, unknown and duplicate replies do not settle a different request.
## Verification and remaining work
Run package commands from the repository root with the pinned Node version and
Yarn Classic 1.22.22:
| Command | What it checks or produces |
| --- | --- |
| `yarn test:danmuku` | Historical and candidate input, parser, Worker, scheduling, rendering, settings and heatmap regressions |
| `yarn test:danmuku-types` | Frozen root declarations, current/legacy compiler consumers, negative cases, implementation assignability and editor types |
| `yarn test:danmuku-types-package` | Packed isolated installs, real entrypoints and consumer module/compiler modes; creates local evidence, does not publish |
| `yarn typecheck` | Strict production sources and the repository consumer type matrix |
| `yarn build artplayer-plugin-danmuku` | Normal package outputs and copied docs artifacts, including the inline TS Worker |
| `yarn dev artplayer-plugin-danmuku` | Local demo rebuild for the package |
Edit `src/` for runtime changes, `types/runtime-shared.d.ts` for shared accurate
data contracts and the corresponding `runtime.d.*` entry for module shape.
Rebuild artifacts through the package script. The frozen root declaration and
historical test helpers are compatibility evidence, not generated repair targets.
Use `refactor/fixtures/implementation/danmuku.ts` when a source/declaration change must
prove assignability to the actual implementation.
`yarn test:danmuku` runs frozen historical contracts/failures and candidate input,
parser, scheduler and Worker regressions. The historical helper and archives must remain frozen;
candidate tests use `test/helpers/danmuku-candidate.js` and may select built
@@ -143,9 +222,11 @@ three modes, strict dense geometry, one request per prepared item, native
pause/seek/rate and visible-node reuse. Preserve both geometric and protocol
assertions; unique IDs alone do not prove non-overlapping tracks.
Long-run native load behavior still requires PKG-DANMUKU-07. Check task evidence
and the risk register before changing completion or release status; short native
regressions are not package-wide stability acceptance.
Long-run native load behavior still requires PKG-DANMUKU-07; cross-plugin/core
combination coverage and final distribution acceptance remain PKG-DANMUKU-08/09.
Check task evidence and the risk register before changing completion or release
status. These commands and short native regressions do not establish npm release
readiness; final checks and release reviews remain separate gates.
## Rendering and UI resources
+36 -1
View File
@@ -6,6 +6,38 @@ Danmuku plugin for ArtPlayer
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-danmuku/index.js&example=danmuku)
## TypeScript entrypoints
Existing imports from `artplayer-plugin-danmuku` and `/legacy` retain the npm
5.3.0 declarations. Use `/runtime` for declarations that describe the actual
factory, asynchronous commands and returned internal owner:
```ts
import danmuku from 'artplayer-plugin-danmuku/runtime'
import type { Point, RuntimeOption } from 'artplayer-plugin-danmuku/runtime'
const option: RuntimeOption = { danmuku: [], heatmap: true }
const points: Point[] = [[0, 10], [200, 20]]
```
The option object is required; each field is optional. Registration returns the
plugin facade immediately. `await plugin.emit(row)` and `await plugin.load()`
return the internal owner, as do synchronous `config/hide/show/reset` commands.
That owner differs from the registered facade. `mount(target)` returns undefined
and requires a valid element or selector. The facade's option/state getters
remain live.
The accurate entrypoint also exposes icon types, callback receivers and an
explicit `EventMap` of payload tuples. Filters and visibility callbacks receive
the current option as `this`; `beforeEmit` does too and accepts only strict
`true`. Importing these types does not automatically alter Artplayer's historical
event declarations. Custom points use mutable `[number, number]` tuples and
retain the existing inner-array modifications during rendering.
ESM imports select the existing ESM factory; CommonJS
`require('artplayer-plugin-danmuku/runtime')` returns the existing callable
factory directly. No additional runtime implementation is loaded.
## Loading and configuration
`plugin.load()` reloads the configured input and replaces the queue after input
@@ -66,7 +98,10 @@ finite `heatmap.yMin` or `heatmap.yMax`, and custom points events, retain their
original coordinate mapping; use these when deliberately controlling the scale.
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module ownership, compatibility
constraints, tests and the remaining staged TypeScript migration.
constraints and editing/verification commands. All owned runtime modules now use
TypeScript, with shared public runtime data types maintained in
`types/runtime-shared.d.ts`. Long-duration load tests, combination/distribution
acceptance and release reviews remain separate unfinished stages.
## License
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
@@ -665,16 +665,18 @@ 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,
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
const top = typeof reply === "symbol" ? void 0 : reply.result;
if (!this.active(operation) || danmu.$ref !== ref)
return;
if (top !== void 0) {
@@ -948,6 +950,7 @@ class Danmuku {
if (!task.active())
return this;
errorHandle(Array.isArray(danmus), "Danmuku need return an array as result");
const rows = danmus;
if (danmuku === void 0) {
this.reset();
if (!task.active())
@@ -956,10 +959,10 @@ class Danmuku {
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.renderer.clear();
}
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);
}
if (task.active())
@@ -1074,6 +1077,7 @@ class Danmuku {
return this.renderer.left($ref);
}
// 复杂运算交给 Web Worker 处理
// Keep the historical empty default; unknown message types receive no reply.
postMessage(message = {}) {
return this.workerClient.request(message);
}
@@ -1312,8 +1316,9 @@ function heatmap(art, danmuku, option) {
let closed = false;
const active = () => !closed && !art.isDestroy;
function listen(name, callback) {
subscriptions.push([name, callback]);
art.on(name, callback);
const listener = callback;
subscriptions.push([name, listener]);
art.on(name, listener);
}
function dispose(removeControl) {
if (closed)
@@ -1351,18 +1356,19 @@ function heatmap(art, danmuku, option) {
return;
start = null;
stop = null;
element.innerHTML = "";
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 document2 = element.ownerDocument;
const document2 = target.ownerDocument;
if (!gradient)
gradient = document2 ? "heatmap-solids" : `heatmap-solids-${++nextGradient}`;
while (document2?.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%">
@@ -1375,8 +1381,8 @@ 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) {
@@ -1456,7 +1462,8 @@ class SettingLifecycle {
dispose();
else this.disposers.add(dispose);
}
write(target, key, value, apply = (value2) => target[key] = value2) {
write(host, key, value, apply = (value2) => host[key] = value2) {
const target = host;
if (!this.active)
return;
const priority = () => typeof target.getPropertyPriority === "function" ? target.getPropertyPriority(key) : void 0;
@@ -1847,16 +1854,17 @@ const style = ".artplayer-plugin-danmuku {\n display: flex;\n position: relati
if (typeof document !== "undefined") {
const id = "artplayer-plugin-danmuku";
const pending = /* @__PURE__ */ Symbol.for("artplayer-plugin-danmuku.pending-style");
let $style2 = document.getElementById(id) || document[pending];
const styleDocument = document;
let $style2 = document.getElementById(id) || styleDocument[pending];
if (!$style2) {
$style2 = document.createElement("style");
$style2.id = id;
if (document.readyState === "loading") {
document[pending] = $style2;
styleDocument[pending] = $style2;
const ready = () => {
document.removeEventListener("DOMContentLoaded", ready);
if (document[pending] === $style2)
delete document[pending];
if (styleDocument[pending] === $style2)
delete styleDocument[pending];
const existing = document.getElementById(id);
if (existing)
existing.textContent = $style2.textContent;
+21 -1
View File
@@ -27,11 +27,31 @@
"types": "./types/artplayer-plugin-danmuku.d.ts",
"import": "./dist/artplayer-plugin-danmuku.legacy.js",
"require": "./dist/artplayer-plugin-danmuku.legacy.js"
},
"./runtime": {
"import": {
"types": "./types/runtime.d.mts",
"default": "./dist/artplayer-plugin-danmuku.mjs"
},
"require": {
"types": "./types/runtime.d.cts",
"default": "./dist/artplayer-plugin-danmuku.js"
}
}
},
"main": "./dist/artplayer-plugin-danmuku.js",
"module": "./dist/artplayer-plugin-danmuku.mjs",
"types": "./types/artplayer-plugin-danmuku.d.ts",
"legacy": "./dist/artplayer-plugin-danmuku.legacy.js",
"browserslist": "last 1 Chrome version"
"browserslist": "last 1 Chrome version",
"typesVersions": {
"*": {
"legacy": [
"types/artplayer-plugin-danmuku.d.ts"
],
"runtime": [
"types/runtime.d.ts"
]
}
}
}
@@ -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)
}
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": false,
"lib": ["ES2020", "ES2021.String", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "../../types/assets.d.ts", "../../types/option-validator.d.ts"]
}
@@ -0,0 +1,171 @@
import type Artplayer from 'artplayer'
export type Mode = 0 | 1 | 2
export type State = 'wait' | 'ready' | 'emit' | 'stop'
export type Margin = [number | string, number | string]
/** Mutable tuples: historical custom heatmap rendering adjusts the second coordinate. */
export type Point = [number, number]
export interface Danmu {
text: string
id?: string
mode?: number
color?: string
time?: number
border?: boolean
style?: Partial<CSSStyleDeclaration>
[extension: string]: unknown
}
/** Input fields have been filled before filter runs; queue fields do not exist yet. */
export interface NormalizedDanmu extends Danmu {
mode: number
color: string
time: number
style: Partial<CSSStyleDeclaration>
}
/** The actual mutable queue entry supplied to beforeVisible and visible/loaded events. */
export interface Item extends NormalizedDanmu {
$state: State
$index: number
$ref: HTMLDivElement | null
$restTime: number
$lastStartTime: number
}
/** Functions run without an option receiver. Arrays are also valid synchronous results. */
export type Input = Danmu[] | string | Promise<Danmu[]> | (() => Danmu[] | Promise<Danmu[]>)
export interface SliderStep<Value = number> { name?: string, value?: Value, hide?: boolean, show?: boolean }
export interface Slider<Value = number> { min?: number, max?: number, steps?: SliderStep<Value>[] }
export interface Heatmap {
xMin?: number
xMax?: number
yMin?: number
yMax?: number
scale?: number
opacity?: number
minHeight?: number
sampling?: number
smoothing?: number
flattening?: number
}
export interface NormalizedOption {
danmuku: Input
speed: number
margin: Margin
opacity: number
color: string
/** Numeric inputs are clamped; values other than 0, 1 and 2 are not emitted. */
mode: number
modes: number[]
fontSize: number | string
antiOverlap: boolean
synchronousPlayback: boolean
mount: HTMLDivElement | string | undefined
heatmap: boolean | Heatmap
width: number
/** Stored configuration; send the points event to draw custom points. */
points: Point[]
/** A truthy result accepts the row. */
filter: (this: NormalizedOption, danmu: NormalizedDanmu) => unknown
/** Only a result equal to true sends the row; callbacks may be asynchronous. */
beforeEmit: (this: NormalizedOption, danmu: Danmu) => unknown
/** A truthy result displays the queue entry; callbacks may be asynchronous. */
beforeVisible: (this: NormalizedOption, danmu: Item) => unknown
visible: boolean
emitter: boolean
maxLength: number
lockTime: number
theme: string
OPACITY: Slider
FONT_SIZE: Slider
MARGIN: Slider<Margin>
SPEED: Slider
COLOR: string[]
}
/** The argument object is required; every option inside it is optional. */
export type RuntimeOption = Partial<NormalizedOption>
/** The owner returned by commands is different from the registered plugin facade. */
export interface Owner {
art: Artplayer
option: NormalizedOption
isHide: boolean
isStop: boolean
queue: Item[]
states: Record<State, Item[]>
readonly readys: Item[]
readonly speed: number
readonly fontSize: number
readonly marginTop: number
readonly marginBottom: number | string
readonly isRotate: boolean | undefined
emit: (danmu: Danmu) => Promise<Owner>
/** No argument replaces the configured source; a provided argument appends rows. */
load: (danmuku?: Input) => Promise<Owner>
config: (option: RuntimeOption) => Owner
hide: () => Owner
show: () => Owner
reset: () => Owner
start: () => Owner
stop: () => Owner
continue: () => Owner
suspend: () => Owner
update: () => Owner
resize: () => void
seek: () => void
destroy: () => void
}
export interface RuntimeResult {
name: 'artplayerPluginDanmuku'
emit: (danmu: Danmu) => Promise<Owner>
load: (danmuku?: Input) => Promise<Owner>
config: (option: RuntimeOption) => Owner
hide: () => Owner
show: () => Owner
reset: () => Owner
/** The live setting requires a valid mount; omitting it does not select a default. */
mount: (target: HTMLDivElement | string) => void
readonly option: NormalizedOption
readonly isHide: boolean
readonly isStop: boolean
}
export interface Icons {
$on: string
$off: string
$config: string
$style: string
$mode_0_off: string
$mode_0_on: string
$mode_1_off: string
$mode_1_on: string
$mode_2_off: string
$mode_2_on: string
$check_on: string
$check_off: string
}
export interface RuntimeFactory {
(option: RuntimeOption): (art: Artplayer) => RuntimeResult
icons: Icons
}
/** Explicit payload map; importing /runtime does not augment historical Artplayer events. */
export interface EventMap {
'artplayerPluginDanmuku:points': [points: Point[]]
'artplayerPluginDanmuku:loaded': [queue: Item[]]
'artplayerPluginDanmuku:visible': [item: Item]
'artplayerPluginDanmuku:config': [option: NormalizedOption]
'artplayerPluginDanmuku:error': [error: unknown]
'artplayerPluginDanmuku:show': []
'artplayerPluginDanmuku:hide': []
'artplayerPluginDanmuku:start': []
'artplayerPluginDanmuku:stop': []
'artplayerPluginDanmuku:reset': []
'artplayerPluginDanmuku:destroy': []
}
@@ -0,0 +1,25 @@
import type { RuntimeFactory } from './runtime-shared.js'
declare const artplayerPluginDanmuku: RuntimeFactory
// eslint-disable-next-line ts/no-redeclare -- Ambient merge exposes types on the CommonJS factory.
declare namespace artplayerPluginDanmuku {
type Danmu = import('./runtime-shared.js').Danmu
type EventMap = import('./runtime-shared.js').EventMap
type Heatmap = import('./runtime-shared.js').Heatmap
type Icons = import('./runtime-shared.js').Icons
type Input = import('./runtime-shared.js').Input
type Item = import('./runtime-shared.js').Item
type Margin = import('./runtime-shared.js').Margin
type Mode = import('./runtime-shared.js').Mode
type NormalizedDanmu = import('./runtime-shared.js').NormalizedDanmu
type NormalizedOption = import('./runtime-shared.js').NormalizedOption
type Owner = import('./runtime-shared.js').Owner
type Point = import('./runtime-shared.js').Point
type RuntimeFactory = import('./runtime-shared.js').RuntimeFactory
type RuntimeOption = import('./runtime-shared.js').RuntimeOption
type RuntimeResult = import('./runtime-shared.js').RuntimeResult
type Slider<Value = number> = import('./runtime-shared.js').Slider<Value>
type SliderStep<Value = number> = import('./runtime-shared.js').SliderStep<Value>
type State = import('./runtime-shared.js').State
}
export = artplayerPluginDanmuku
@@ -0,0 +1,5 @@
import type { RuntimeFactory } from './runtime-shared.js'
declare const artplayerPluginDanmuku: RuntimeFactory
export default artplayerPluginDanmuku
export type { Danmu, EventMap, Heatmap, Icons, Input, Item, Margin, Mode, NormalizedDanmu, NormalizedOption, Owner, Point, RuntimeFactory, RuntimeOption, RuntimeResult, Slider, SliderStep, State } from './runtime-shared.js'
+25
View File
@@ -0,0 +1,25 @@
import type { RuntimeFactory } from './runtime-shared.js'
declare const artplayerPluginDanmuku: RuntimeFactory
// eslint-disable-next-line ts/no-redeclare -- Ambient merge exposes types on the CommonJS factory.
declare namespace artplayerPluginDanmuku {
type Danmu = import('./runtime-shared.js').Danmu
type EventMap = import('./runtime-shared.js').EventMap
type Heatmap = import('./runtime-shared.js').Heatmap
type Icons = import('./runtime-shared.js').Icons
type Input = import('./runtime-shared.js').Input
type Item = import('./runtime-shared.js').Item
type Margin = import('./runtime-shared.js').Margin
type Mode = import('./runtime-shared.js').Mode
type NormalizedDanmu = import('./runtime-shared.js').NormalizedDanmu
type NormalizedOption = import('./runtime-shared.js').NormalizedOption
type Owner = import('./runtime-shared.js').Owner
type Point = import('./runtime-shared.js').Point
type RuntimeFactory = import('./runtime-shared.js').RuntimeFactory
type RuntimeOption = import('./runtime-shared.js').RuntimeOption
type RuntimeResult = import('./runtime-shared.js').RuntimeResult
type Slider<Value = number> = import('./runtime-shared.js').Slider<Value>
type SliderStep<Value = number> = import('./runtime-shared.js').SliderStep<Value>
type State = import('./runtime-shared.js').State
}
export = artplayerPluginDanmuku
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,99 @@
# PKG-DANMUKU-06 自有 TypeScript 与兼容声明
本步将前面已拆分的20个自有运行模块迁移为严格TypeScript,明确输入、队列、
Worker协议、UI与生命周期边界。任务完成状态以tasks.json为准。
## 实现与兼容边界
- 入口保持同步插件门面;Danmuku拥有配置与队列,scheduler负责时钟与有效操作,
renderer负责节点,WorkerClient负责请求身份与终止,worker只计算轨道。
input、bilibili及parser保持请求/解析/取消分层;设置和热图继续使用05/10的模块边界。
- 新增内部types、worker-types、parser-types、setting-types及Worker资源声明。
公开运行数据由types/runtime-shared.d.ts单一维护,内部通过type-only转发使用;
DOM宿主与Worker协议留在src。不存在新的运行时模块环或消费者依赖。
- 类字段使用declare,不改变原初始化顺序或新增undefined自有属性。保留同步数组
加载前缀、Promise/owner返回身份、回调receiver、默认参数、事件与原DOM属性写入。
取消符号明确区分异步结果;没有为了通过类型检查改变轨道算法。
- 受控断言用于旧核心constructor声明、已初始化的设置节点、已分配的活动弹幕节点、
DOM WebIDL的原始数值/null赋值,以及历史无效margin字符串回退。它们不替代输入
验证,不引入全局any。旧postMessage空对象默认值仍保留,未知协议不产生回复。
- 分包tsconfig禁用allowJs。ES2021.String仅描述既有parser.replaceAll,不新增该
运行时要求;现代es2020与legacy es2015构建目标不变。本包没有自有遗留JS或vendor。
模块地图、生命周期与维护指引见包内ARCHITECTURE.md。
## 公开类型与消费者
实际npm 5.3.0根声明完整保留,换行归一后SHA256为
`006c3e2d22425b936d6d09bf5138b0ae13c4cfffd0a571d03722c17137dbe99c`。
旧根/legacy的参数、返回提取和普通替代工厂关系不变;没有给工厂增加必填default。
新增可选/runtime路径指向相同分发文件,只提供准确声明:
- 工厂参数对象仍必传,但对象内字段可选。保留弹幕扩展字段;输入可为数组、URL、
Promise或返回数组/Promise的函数,config可以传部分字段。
- emit/load返回Promise<Owner>,同步命令返回Owner,Owner与注册门面分别描述;
门面的option/isHide/isStop只读getter不会使内部配置对象变为深只读。
- filter接收填默认值后的数据,beforeVisible接收队列项;两者按truthy判断,
beforeEmit严格比较true。回调this为实际NormalizedOption。
- mount需要有效目标。icons描述真实可写属性;Slider与margin元组、heatmap坐标和
配置保持实际格式。points配置目前只保存,自定义绘图由points事件触发,保留内层
元组mutation。EventMap提供明确payload,不全局改写旧核心事件声明。
- 旧根的同步emit结果和对象points属于历史声明错误,切换/runtime后需按真实行为
使用await及元组;继续使用旧入口无需因本次迁移修改代码。实际运行并未被旧声明
所描述的行为替换,具体示例见README及type-compatibility-policy.md。
四项类型测试覆盖真实声明归档、正反例、实现赋值和语义编辑器生成。安装测试在
仓库外实际Yarn pack/install并离线冻结重装,逐成员核对字节,检查CJS/ESM身份。
12个编译器/解析模式组合包括TS5.9.3与4.3.5;候选每个适用模式有4个root和14个
runtime负例,逐个去掉expect-error确认相应错误实际出现。
旧根在NodeNext ESM默认namespace方式的7个历史诊断与候选逐代码/行号比较,
显式namespace适配通过;保留失败事实,不把它包装成全部旧TS用法均无错误。
build:ts把Danmuku纳入语义声明生成,更新docs编辑器资产;不手改生成声明。
.npmignore排除实现tsconfig,公开类型仍随包分发。新增test:danmuku-types及
test:danmuku-types-package脚本,前者被既有test:baseline自动发现。本步无新依赖,
唯一yarn.lock不变,版本递增仍由统一版本任务执行。
## 测试与证据
最终机器证据见[验收报告](../baselines/danmuku-types-validation.json)。
完整源码250项单测通过,正式main和legacy各164项联合测试通过;严格类型检查覆盖
全仓390个生产TS文件。源码/main/legacy最终各156项真实浏览器通过,共468项,
每轮2并发、零重试、零跳过;覆盖实际发布5.4.0与候选5.4.1核心以及Chromium、
Firefox和Windows WebKit。正常构建、类型编辑器生成、目标lint、冻结安装及严格
Node24.21.0/Yarn1.22.22工具链检查通过;构建产物与docs复制逐字节相同。
源码单测、正式main/legacy联合测试包含共同内部模块与历史对照,不能把每一项都
计为独立产物测试。Worker加载器会执行选中产物内嵌Blob或data URL的实际字节;
只有源码受控模式才单独编译worker.ts。冻结的历史helper没有修改。
内置浏览器用正常yarn dev打开8082现有danmuku示例,本地视频和XML实际加载。
Chrome/152.0.0.0,视频播放至11.711807秒且解码宽640;发送
`TS migration browser check`后输入清空;暂停在43.744183秒,再次读取保持同值。
真实弹幕可见,页面warn/error记录为空。原生Browser.getVersion不支持,改用受支持
CDP只读navigator.userAgent取版本;之前失效tab的CDP焦点超时不作为页面失败。
这只是开发示例smoke,不替代发布包、Safari真机或07的持续负载验收。
## 失败处置与未完成范围
- 第一版实际pack发现实现tsconfig泄漏,修正.npmignore后重做完整隔离安装。
- 实现赋值fixture最初放在根test/types,导致根配置缺少分包Worker资源和既有
ES2021.String声明;移至refactor/fixtures/implementation,并用分包配置检查。
负例诊断行计算也修正了跨空白行匹配;保留失败日志,不弱化类型断言。
- 最终独立复查更正架构文档仍指向旧位置的实现fixture,并对最终包内容重新执行
隔离打包安装;不使用修正文档前的归档作为最终候选证据。
- 产物Worker测试首次只识别Blob,实际正式构建使用百分号编码data URL。增加精确
data URL解码后执行内嵌算法,保持未知来源断言,不用当前源码冒充产物。
- 一轮浏览器复测提前启动下一轮,共享结果目录被覆盖,导致最后两项trace清理失败;
下一轮同时遇到端口占用。保留日志,改为一个串行命令,上一进程结束后归档再继续。
- 随后source矩阵154通过、2个WebKit uniform场景超时。trace显示published首次
Play客户端13.17秒而浏览器动作仅0.39秒;candidate启动/建页/导航合计16.23秒,
都在插件安装前。16k加载分别0.995秒与2.96秒;published超时后几何断言实际通过,
但整项仍记失败。证据支持宿主或自动化延迟,不能确认更具体的系统原因。
其他重型检查结束后,原断言/20秒超时不变,单worker WebKit诊断6项通过;完整
最终矩阵单独归档,不关闭历史负载疑点,也不以重跑抹去首次失败。
07继续承担持续负载、渲染/资源对比及Mask稳定依赖,08承担完整组合,09承担分发
与文档最终验收。DANMUKU-LOAD-01仍保持待验证;本步不声称npm候选或全部重构完成。
三轮发布复盘、设备/外部SDK证据、全包独立major以及远端CI仍按原计划。
回退:单独revert本任务提交,恢复05/10的JS实现及原类型分发;#958独立修复不回退。
只提交本任务实现、测试、文档和状态,不推送或发布。
@@ -0,0 +1,27 @@
import type Artplayer from 'artplayer'
import type { Danmu, Option, Result } from 'artplayer-plugin-danmuku'
import danmuku from 'artplayer-plugin-danmuku'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Assert<T extends true> = T
type Factory = (option: Option) => (art: Artplayer) => Result
type SameArguments = Assert<Equal<Parameters<typeof danmuku>, [option: Option]>>
type SameResult = Assert<Equal<ReturnType<ReturnType<typeof danmuku>>, Result>>
type SameEmit = Assert<Equal<ReturnType<Result['emit']>, Result>>
type SameLoad = Assert<Equal<ReturnType<Result['load']>, Promise<Result>>>
type SamePoints = Assert<Equal<Option['points'], { time: number, value: number }[] | undefined>>
type SameDanmuKeys = Assert<Equal<keyof Danmu, 'text' | 'mode' | 'color' | 'time' | 'border' | 'style'>>
declare const art: Artplayer
declare const result: Result
const replacement: Factory = (_option: Option) => (_art: Artplayer) => result
const toOld: Factory = danmuku
const fromOld: typeof danmuku = replacement
const option: Option = { danmuku: [], points: [{ time: 1, value: 2 }], margin: [0, '25%'], beforeEmit: async () => true }
const plugin: Result = danmuku(option)(art)
const synchronousOldEmit: Result = plugin.emit({ text: 'old declaration', mode: 1 })
const loaded: Promise<Result> = plugin.load(Promise.resolve([{ text: 'reload' }]))
const configured: Result = plugin.config(option)
const mounted: void = plugin.mount('#external')
void [toOld, fromOld, synchronousOldEmit, loaded, configured, mounted]
export type { SameArguments, SameDanmuKeys, SameEmit, SameLoad, SamePoints, SameResult }
@@ -0,0 +1,6 @@
import type { RuntimeFactory } from 'artplayer-plugin-danmuku/runtime'
import factory from '../../../packages/artplayer-plugin-danmuku/src'
// Compile with the package configuration: the implementation imports Worker assets.
const implementation: RuntimeFactory = factory
export { implementation }
+4 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 229 项,范围 22 个包及工作区/示例。
状态:todo 69 / doing 15 / blocked 0 / done 145 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 68 / doing 15 / blocked 0 / done 146 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -314,8 +314,8 @@
| PKG-DANMUKU-03 | artplayer-plugin-danmuku<br>整理加载、解析与配置 | PKG-DANMUKU-02, CORE-08 | bilibili/input/parser/config 的分层与取消 | 旧格式、回调、过滤、追加/替换语义保持 | M | done |
| PKG-DANMUKU-04 | artplayer-plugin-danmuku<br>整理时钟、队列与轨道调度 | PKG-DANMUKU-03, CORE-10 | danmuku 调度器和确定性时钟测试 | seek/暂停/倍率/长时间运行无顺序和碰撞回归 | H | done |
| PKG-DANMUKU-05 | artplayer-plugin-danmuku<br>整理 DOM 渲染、设置、热力图与 worker | PKG-DANMUKU-04, CORE-14, CORE-18 | renderer/setting/heatmap/worker 职责及资源归属 | mount/icons/设置和 worker 协议保持,销毁无后台工作 | H | done |
| PKG-DANMUKU-06 | artplayer-plugin-danmuku<br>迁移 TS 与公开声明 | PKG-DANMUKU-05, ENG-04, ENG-06 | 自有模块、worker 消息、option/item/result 类型 | 旧参数、扩展字段和事件消费通过,vendored 边界清楚 | H | doing |
| PKG-DANMUKU-07 | artplayer-plugin-danmuku<br>弹幕能力稳定性验收 | PKG-DANMUKU-06, PKG-DANMUKU-10 | 负载/渲染/资源对比及 mask 可依赖的稳定边界 | 在试点核心中旧插件 API 与关键帧路径通过 | H | todo |
| PKG-DANMUKU-06 | artplayer-plugin-danmuku<br>迁移 TS 与公开声明 | PKG-DANMUKU-05, ENG-04, ENG-06 | 自有模块、worker 消息、option/item/result 类型 | 旧参数、扩展字段和事件消费通过,vendored 边界清楚 | H | done |
| PKG-DANMUKU-07 | artplayer-plugin-danmuku<br>弹幕能力稳定性验收 | PKG-DANMUKU-06, PKG-DANMUKU-10 | 负载/渲染/资源对比及 mask 可依赖的稳定边界 | 在试点核心中旧插件 API 与关键帧路径通过 | H | doing |
| PKG-DANMUKU-08 | artplayer-plugin-danmuku<br>完成新旧核心与组合验收 | PKG-DANMUKU-07, CORE-22 | 最终核心/旧核心、mask/fullscreen/PiP 组合报告 | 性能无未解释退化,反复装卸无累计资源 | H | todo |
| PKG-DANMUKU-09 | artplayer-plugin-danmuku<br>完成分发、示例与文档 | PKG-DANMUKU-08, ENG-07 | danmuku.js、README、声明、worker 与产物 | tarball 和静态 icons 等旧调用通过,有独立回退版本 | H | todo |
| PKG-DANMUKU-10 | artplayer-plugin-danmuku<br>修复 issue958 密集热力图过高与曲线裁平 | PKG-DANMUKU-05 | 自动密度缩放与曲线边界、历史自定义坐标兼容及真实浏览器红绿证据 | 16000条三种分布在两核心三引擎和source/main/legacy不遮挡;显式轴和自定义points保持旧解释,有单独commit | M | done |
@@ -559,6 +559,7 @@
- PKG-DANMUKU-03: [记录](changes/2026-09-13-PKG-DANMUKU-03-input.md) [记录](baselines/danmuku-input-validation.json)
- PKG-DANMUKU-04: [记录](changes/2026-09-13-PKG-DANMUKU-04-scheduler.md) [记录](baselines/danmuku-scheduler-validation.json)
- PKG-DANMUKU-05: [记录](changes/2026-09-13-PKG-DANMUKU-05-resources.md) [记录](baselines/danmuku-resources-validation.json)
- PKG-DANMUKU-06: [记录](changes/2026-09-13-PKG-DANMUKU-06-types.md) [记录](baselines/danmuku-types-validation.json)
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md)
+15
View File
@@ -1,5 +1,20 @@
# 进度与证据
## PKG-DANMUKU-06 自有 TS 与公开声明完成
20个自有运行模块完成严格TS,新增内部状态/Worker/parser/UI类型边界;公开运行
数据单一维护。实际npm5.3.0根声明完整保留,新增同实现/runtime准确描述Promise、
owner、getter、回调和数据;语义docs编辑器声明同步生成。包架构和维护命令已更新。
完整源码250项、main/legacy各164项联合单测、4项类型检查、12组仓库外安装消费者
通过;全仓390生产TS严格检查、冻结Yarn安装、工具链、构建和目标lint通过。
三格式/两核心/三引擎最终468项浏览器通过,原断言/超时不变、无重试/跳过;内置
Chrome152完成现有docs示例实际播放、发送与暂停。Worker data URL测试加载器、
先前报告目录冲突和WebKit超时均保留原因与失败证据,未把重跑等同历史从未失败。
关闭DANMUKU-TYPE-01/02;LOAD-01与稳定性/组合/分发及发布复盘继续保留。
见[变更](changes/2026-09-13-PKG-DANMUKU-06-types.md)和[机器证据](baselines/danmuku-types-validation.json)。
229项:146 done、15 doing、68 todo;07进入负载、渲染与资源稳定性验收。
本地独立任务提交,不推送或发布。
## PKG-DANMUKU-10:issue #958 密集热力图修复
用户新增的16000条弹幕热图过高/裁平已独立复现并修复。自动密度拟合到底部四分之一,
+2 -2
View File
@@ -227,11 +227,11 @@
| MASK-BACKEND-01 | open / 已复现 | Mask TensorFlow backend fallback does not establish MediaPipe fallback and failed initialization keeps scheduling | PKG-MASK-02, PKG-MASK-03, PKG-MASK-05 |
| MASK-DOM-01 | open / 已复现 | Mask registration and frame processing assume Danmuku DOM, readable frames and a 2D context | PKG-MASK-02, PKG-MASK-03, PKG-MASK-05 |
| MASK-NOTICE-01 | open / 源码/产物事实 | Published Mask bundles lack a separate third-party notice despite bundled SDK code | PKG-MASK-06 |
| DANMUKU-TYPE-01 | open / 已复现 | Danmuku historical declared results, optional mount and initial Promise input disagree with runtime | PKG-DANMUKU-03, PKG-DANMUKU-06 |
| DANMUKU-TYPE-01 | resolved / 已复现 | Danmuku historical declared results, optional mount and initial Promise input disagree with runtime | PKG-DANMUKU-03, PKG-DANMUKU-06 |
| DANMUKU-INPUT-01 | resolved / 已复现 | Danmuku replaces time zero with current time plus 0.5 and mutates supplied input objects | PKG-DANMUKU-02, PKG-DANMUKU-03 |
| DANMUKU-CONFIG-01 | resolved / 已复现 | Danmuku JSON configuration comparison ignores a replacement filter function | PKG-DANMUKU-02, PKG-DANMUKU-03 |
| DANMUKU-CLEANUP-01 | resolved / 已复现 | Danmuku removes a different resize callback and retains the settings lock timer on destroy | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-TYPE-02 | open / 源码/产物事实 | Danmuku id, icons, Slider steps, heatmap points and partial configuration drift from declarations | PKG-DANMUKU-05, PKG-DANMUKU-06 |
| DANMUKU-TYPE-02 | resolved / 源码/产物事实 | Danmuku id, icons, Slider steps, heatmap points and partial configuration drift from declarations | PKG-DANMUKU-05, PKG-DANMUKU-06 |
| DANMUKU-ASYNC-01 | resolved / 已复现 | Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths | PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05 |
| DANMUKU-HEATMAP-01 | resolved / 已复现 | Danmuku heatmap sampling can become zero and points/instance ownership need browser validation | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-SOURCE-01 | open / 已复现 | Danmuku registry Git associations differ from releases and static icon provenance remains incomplete | PKG-DANMUKU-09 |
+20 -6
View File
@@ -5023,7 +5023,7 @@
"id": "DANMUKU-TYPE-01",
"title": "Danmuku historical declared results, optional mount and initial Promise input disagree with runtime",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-03",
"PKG-DANMUKU-06"
@@ -5033,10 +5033,17 @@
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-03-input.md",
"refactor/baselines/danmuku-input-validation.json"
"refactor/baselines/danmuku-input-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-06-types.md",
"refactor/baselines/danmuku-types-validation.json"
],
"compatibleResolution": "Preserve latest published complete root type contracts and actual legal JS return identities; expose accurate types separately.",
"closureCriteria": "Installed consumers verify latest root assignments and precise async/getter/mount contracts; initial Promise mismatch has an explicit tested resolution."
"closureCriteria": "Installed consumers verify latest root assignments and precise async/getter/mount contracts; initial Promise mismatch has an explicit tested resolution.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-06-types.md",
"refactor/baselines/danmuku-types-validation.json"
],
"resolutionRationale": "Preserved actual npm 5.3.0 complete root/legacy declaration and installed consumer behavior; optional runtime entry describes tested async owner, getter, mount, data, icon and callback contracts. Verified actual implementation assignability and packed/offline-installed current and historical compiler consumers."
},
{
"id": "DANMUKU-INPUT-01",
@@ -5127,7 +5134,7 @@
"id": "DANMUKU-TYPE-02",
"title": "Danmuku id, icons, Slider steps, heatmap points and partial configuration drift from declarations",
"confirmation": "source-observed",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-05",
"PKG-DANMUKU-06"
@@ -5135,10 +5142,17 @@
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
"refactor/baselines/danmuku-release.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-06-types.md",
"refactor/baselines/danmuku-types-validation.json"
],
"compatibleResolution": "Validate actual accepted data and static properties, retain root compatibility and document effective fields.",
"closureCriteria": "Positive and negative installed consumers and source behavior tests cover these fields without widening away old factory contracts."
"closureCriteria": "Positive and negative installed consumers and source behavior tests cover these fields without widening away old factory contracts.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-06-types.md",
"refactor/baselines/danmuku-types-validation.json"
],
"resolutionRationale": "Preserved actual npm 5.3.0 complete root/legacy declaration and installed consumer behavior; optional runtime entry describes tested async owner, getter, mount, data, icon and callback contracts. Verified actual implementation assignability and packed/offline-installed current and historical compiler consumers."
},
{
"id": "DANMUKU-ASYNC-01",
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import ts from 'typescript'
import compat from 'typescript-compat'
import { checkFiles, packedFiles } from '../../scripts/package-check.mjs'
import { consumerDirectory, removeConsumer, run, workspace, writeJson } from '../../scripts/package-consumer.mjs'
import { verifyDanmukuContract } from './danmuku-contract.mjs'
import { hash, readMember } from './releases.mjs'
const publishedFixture = 'refactor/fixtures/consumers/danmuku-published.ts'
const rootFixture = 'test/types/danmuku-root.ts'
const runtimeFixture = 'test/types/danmuku-runtime.ts'
async function main() {
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Use yarn test:danmuku-types-package')
const yarn = process.env.npm_execpath
assert(yarn && fs.existsSync(yarn))
const output = fs.mkdtempSync(path.join(workspace, 'refactor/.cache/danmuku-package-types-'))
console.log(`Danmuku isolated package evidence: ${output}`)
const contract = await verifyDanmukuContract()
assert.equal(contract.baseline.release.version, '5.3.0')
const fixtures = Object.fromEntries([publishedFixture, rootFixture, runtimeFixture].map(file => [file, fs.readFileSync(path.join(workspace, file), 'utf8')]))
const packages = []
for (const name of ['artplayer', 'artplayer-plugin-danmuku']) {
const archive = path.join(output, `${name}.tgz`)
fs.writeFileSync(path.join(output, `${name}-pack.log`), run([yarn, 'pack', '--filename', archive], path.join(workspace, 'packages', name)))
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
const files = packedFiles(archive)
checkFiles(manifest, files, name === contract.baseline.release.name ? Object.keys(contract.baseline.release.files) : [])
packages.push({ name, archive, sha256: hash(fs.readFileSync(archive)), files: Object.fromEntries(files.map(member => [member, hash(readMember(archive, member))])) })
}
const published = { ...contract.baseline.release, archive: contract.archives.get('5.3.0'), label: 'published-5.3.0' }
const declaration = 'package/types/artplayer-plugin-danmuku.d.ts'
assert.equal(readMember(packages[1].archive, declaration).toString().replaceAll('\r\n', '\n'), readMember(published.archive, declaration).toString().replaceAll('\r\n', '\n'), 'Preserve the actual npm 5.3.0 root declaration')
const matrix = []
const historical = new Map()
for (const plugin of [published, { ...packages[1], label: 'candidate' }]) {
const consumer = consumerDirectory()
try {
writeJson(path.join(consumer, 'package.json'), { name: 'danmuku-isolated-consumer', private: true, dependencies: Object.fromEntries([packages[0], plugin].map(pkg => [pkg.name, `file:${pkg.archive.replaceAll('\\', '/')}`])) })
fs.copyFileSync(path.join(workspace, 'yarn.lock'), path.join(consumer, 'yarn.lock'))
fs.writeFileSync(path.join(output, `${plugin.label}-install.log`), run([yarn, 'install', '--offline', '--ignore-scripts', '--non-interactive'], consumer))
const lock = fs.readFileSync(path.join(consumer, 'yarn.lock'))
fs.writeFileSync(path.join(output, `${plugin.label}-frozen-install.log`), run([yarn, 'install', '--offline', '--frozen-lockfile', '--ignore-scripts', '--non-interactive'], consumer))
assert.deepEqual(fs.readFileSync(path.join(consumer, 'yarn.lock')), lock)
fs.writeFileSync(path.join(output, `${plugin.label}-yarn.lock`), lock)
for (const pkg of [packages[0], plugin]) {
const root = path.join(consumer, 'node_modules', pkg.name)
assert.equal(fs.realpathSync(root), root, 'Installed package must not be a workspace link')
for (const [member, expected] of Object.entries(pkg.files))
assert.equal(hash(fs.readFileSync(path.join(root, member.slice(8)))), expected, `Installed bytes differ: ${member}`)
}
const runtimeFile = path.join(consumer, 'exports.cjs')
fs.writeFileSync(runtimeFile, `const assert = require('node:assert/strict');
const names = ${JSON.stringify(plugin.label === 'candidate' ? ['artplayer-plugin-danmuku', 'artplayer-plugin-danmuku/legacy', 'artplayer-plugin-danmuku/runtime'] : ['artplayer-plugin-danmuku', 'artplayer-plugin-danmuku/legacy'])};
for (const name of names) { const factory = require(name); assert.equal(typeof factory, 'function'); assert.equal(typeof factory({danmuku: []}), 'function'); assert.equal(typeof factory.icons, 'object'); }
${plugin.label === 'candidate' ? 'assert.equal(require(\'artplayer-plugin-danmuku/runtime\'), require(\'artplayer-plugin-danmuku\'));' : ''}
Promise.all(names.map(name => import(name))).then(modules => { for (const value of modules) assert.equal(typeof value.default, 'function'); assert.equal(modules[1].default, require(names[1])); ${plugin.label === 'candidate' ? 'assert.equal(modules[2].default, modules[0].default);' : ''} }).catch(error => { console.error(error); process.exitCode = 1; });`)
fs.writeFileSync(path.join(output, `${plugin.label}-runtime.log`), run([runtimeFile], consumer))
const modes = [[ts, 'node10-commonjs'], [compat, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm']]
if (plugin.label === 'candidate')
modes.push([ts, 'node10-commonjs-no-interop'], [compat, 'node10-commonjs-no-interop'])
for (const [compiler, mode] of modes) {
const next = mode.startsWith('nodenext')
const filename = path.join(consumer, `consumer.${next ? mode.endsWith('-cjs') ? 'cts' : 'mts' : 'ts'}`)
const options = { strict: true, noEmit: true, skipLibCheck: false, types: [], esModuleInterop: !mode.endsWith('-no-interop'), target: compiler.ScriptTarget.ES2020, lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'], module: next ? compiler.ModuleKind.NodeNext : mode === 'bundler-esm' ? compiler.ModuleKind.ESNext : compiler.ModuleKind.CommonJS, moduleResolution: next ? compiler.ModuleResolutionKind.NodeNext : mode === 'bundler-esm' ? compiler.ModuleResolutionKind.Bundler : compiler.ModuleResolutionKind.NodeJs }
function compile(source) {
fs.writeFileSync(filename, source)
const program = compiler.createProgram([filename], options)
for (const file of program.getSourceFiles()) {
const actual = fs.realpathSync(file.fileName)
assert(actual.startsWith(fs.realpathSync(consumer) + path.sep) || (program.isSourceFileDefaultLibrary(file) && path.dirname(actual) === fs.realpathSync(path.dirname(compiler.sys.getExecutingFilePath()))), `Types escaped installed consumer: ${actual}`)
}
return compiler.getPreEmitDiagnostics(program).map(item => ({ code: item.code, line: item.file && item.start !== undefined ? item.file.getLineAndCharacterOfPosition(item.start).line + 1 : null, message: compiler.flattenDiagnosticMessageText(item.messageText, '\n').replaceAll(consumer.replaceAll('\\', '/'), '<consumer>') }))
}
function checkedFixture(source, label) {
const diagnostics = compile(source)
assert.deepEqual(diagnostics, [], `${label}: ${compiler.version} ${mode}`)
const lines = []
let line = 1
for (const text of source.split('\n')) {
if (/^[ \t]*\/\/ @ts-expect-error/.test(text))
lines.push(line)
else line++
}
assert.equal(lines.length, label === 'root/legacy' ? 4 : 14, `Keep all negative ${label} fixtures`)
const invalid = compile(source.replaceAll(/^[ \t]*\/\/ @ts-expect-error[^\n]*\n/gm, ''))
assert.deepEqual([...new Set(invalid.map(item => item.line))].sort((a, b) => a - b), lines, `Every ${label} negative statement must fail at its own line`)
return { diagnostics, invalid, negativeStatements: lines.length }
}
const direct = compile(fixtures[publishedFixture])
const key = `${compiler.version}:${mode}`
if (plugin.label === 'published-5.3.0') {
if (mode === 'nodenext-esm') {
assert(direct.length > 0, 'Preserve evidence of the existing NodeNext default namespace mismatch')
assert(direct.every(item => [2322, 2344, 2349].includes(item.code)), 'Only the established callable namespace mismatch is expected')
}
else {
assert.deepEqual(direct, [])
}
historical.set(key, direct.map(({ code, line }) => ({ code, line })))
}
else {
assert.deepEqual(direct.map(({ code, line }) => ({ code, line })), historical.get(key) ?? [], 'Candidate must preserve exact npm root diagnostic codes and positions')
}
const namespace = mode === 'nodenext-esm' ? compile(fixtures[publishedFixture].replace('import danmuku from \'artplayer-plugin-danmuku\'', 'import danmukuModule from \'artplayer-plugin-danmuku\'\nconst danmuku = danmukuModule.default')) : []
assert.deepEqual(namespace, [], 'Explicit published NodeNext namespace remains valid')
let root = null
let runtime = null
let commonjs = []
if (plugin.label === 'candidate') {
const rootSource = mode === 'nodenext-esm'
? fixtures[rootFixture].replace('import danmuku from \'artplayer-plugin-danmuku\'', 'import danmukuModule from \'artplayer-plugin-danmuku\'\nconst danmuku = danmukuModule.default').replace('import legacy from \'artplayer-plugin-danmuku/legacy\'', 'import legacyModule from \'artplayer-plugin-danmuku/legacy\'\nconst legacy = legacyModule.default')
: fixtures[rootFixture]
root = checkedFixture(rootSource, 'root/legacy')
const runtimeSource = mode.endsWith('-no-interop') ? fixtures[runtimeFixture].replace('import runtime from \'artplayer-plugin-danmuku/runtime\'', 'import runtime = require(\'artplayer-plugin-danmuku/runtime\')') : fixtures[runtimeFixture]
runtime = checkedFixture(runtimeSource, 'runtime')
if (mode !== 'nodenext-esm' && mode !== 'bundler-esm') {
commonjs = compile(`import root = require('artplayer-plugin-danmuku'); import legacy = require('artplayer-plugin-danmuku/legacy'); import runtime = require('artplayer-plugin-danmuku/runtime');\nconst replacement: typeof root.default = (_option) => (_art) => { throw new Error('consumer'); }; const other: typeof legacy.default = replacement; root.default({danmuku: []}); legacy.default({danmuku: []}); runtime({danmuku: []}); void other;`)
assert.deepEqual(commonjs, [], 'Installed require declarations preserve old factories and callable runtime')
}
}
matrix.push({ plugin: plugin.label, compiler: compiler.version, mode, direct, namespace, root, runtime, commonjs })
writeJson(path.join(output, 'matrix-progress.json'), matrix)
}
}
finally {
removeConsumer(consumer)
}
}
writeJson(path.join(output, 'report.json'), { suite: 'danmuku-isolated-package-types', introducedBy: 'PKG-DANMUKU-06', scope: 'Actual npm 5.3.0 and packed candidate outside workspace; offline and frozen reinstall, exact installed bytes, unchanged root declaration, full factory replacement and extraction. Old synchronous emit and object points remain root typing only. Runtime types are checked separately. NodeNext root namespace failures are compared by exact codes and line positions. This is not real playback or complete publication acceptance.', fixtures: Object.fromEntries(Object.entries(fixtures).map(([file, source]) => [file, hash(source)])), packages, published: { archive: published.archive, sha256: published.sha256 }, matrix })
console.log(`Danmuku installed types passed: ${matrix.length} cases; ${output}`)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
// eslint-disable-next-line test/no-import-node-test -- Public declaration compatibility uses Node.
import test from 'node:test'
import ts from 'typescript'
import compat from 'typescript-compat'
import { generateCoreEditorDeclaration } from '../../scripts/editor-types.mjs'
import { checkPluginEditorDeclaration, generatePluginEditorDeclaration } from '../../scripts/plugin-editor-types.mjs'
import { checkConsumer } from '../../scripts/typecheck.mjs'
import { ensureArchive, readMember } from './releases.mjs'
const modes = [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]
const read = file => fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n')
const declaration = 'packages/artplayer-plugin-danmuku/types/artplayer-plugin-danmuku.d.ts'
test('Danmuku root declaration retains the exact latest npm 5.3.0 type source', async () => {
const { release } = JSON.parse(read('refactor/baselines/danmuku-release.json'))
const archive = await ensureArchive(release)
assert.equal(read(declaration), readMember(archive, 'package/types/artplayer-plugin-danmuku.d.ts').toString().replaceAll('\r\n', '\n'))
})
test('Danmuku root and runtime consumers retain extraction, ownership and meaningful negative checks', () => {
for (const [compiler, mode] of modes) {
for (const fixture of ['danmuku-root', 'danmuku-runtime']) {
let source = read(`test/types/${fixture}.ts`)
if (fixture === 'danmuku-root' && mode === 'nodenext-esm') {
source = source.replace('import danmuku from \'artplayer-plugin-danmuku\'', 'import danmukuModule from \'artplayer-plugin-danmuku\'\nconst danmuku = danmukuModule.default')
.replace('import legacy from \'artplayer-plugin-danmuku/legacy\'', 'import legacyModule from \'artplayer-plugin-danmuku/legacy\'\nconst legacy = legacyModule.default')
}
assert.deepEqual(checkConsumer(compiler, mode, source), [], `${fixture}: ${compiler.version}/${mode}`)
const expected = []
let line = 1
for (const text of source.split('\n')) {
if (/^\s*\/\/ @ts-expect-error/u.test(text))
expected.push(line)
else line++
}
assert.equal(expected.length, fixture === 'danmuku-root' ? 4 : 14)
const invalid = checkConsumer(compiler, mode, source.replaceAll(/^[\t ]*\/\/ @ts-expect-error[^\n]*\n/gmu, ''))
assert.deepEqual(invalid.map(item => item.line).sort((a, b) => a - b), expected, 'Every invalid consumer statement must fail at its own line')
}
}
})
test('Danmuku runtime declarations are assignable from the real strict TypeScript implementation', () => {
const filename = path.resolve('packages/artplayer-plugin-danmuku/tsconfig.json')
const config = ts.readConfigFile(filename, ts.sys.readFile)
assert.equal(config.error, undefined)
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(filename))
const program = ts.createProgram([...parsed.fileNames, path.resolve('refactor/fixtures/implementation/danmuku.ts')], parsed.options)
assert.deepEqual(ts.getPreEmitDiagnostics(program).map(item => ts.flattenDiagnosticMessageText(item.messageText, '\n')), [])
})
test('Danmuku editor declarations retain the callable global and exported historical names', () => {
const generated = generatePluginEditorDeclaration(read(declaration), 'artplayerPluginDanmuku')
const core = generateCoreEditorDeclaration()
const consumer = `const option: artplayerPluginDanmuku.Option = {danmuku: [], points: [{time: 1, value: 2}]};
const art = new Artplayer({container: '#player', url: '/video.mp4'});
const result: artplayerPluginDanmuku.Result = artplayerPluginDanmuku(option)(art);
const oldReturn: artplayerPluginDanmuku.Result = result.emit({text: 'legacy editor'}); void oldReturn;`
for (const compiler of [ts, compat])
assert.deepEqual(checkPluginEditorDeclaration(generated, core, consumer, compiler), [])
})
+6 -3
View File
@@ -3362,11 +3362,14 @@
"ENG-04",
"ENG-06"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "自有模块、worker 消息、option/item/result 类型",
"acceptance": "旧参数、扩展字段和事件消费通过,vendored 边界清楚",
"evidence": []
"evidence": [
"changes/2026-09-13-PKG-DANMUKU-06-types.md",
"baselines/danmuku-types-validation.json"
]
},
{
"id": "PKG-DANMUKU-07",
@@ -3379,7 +3382,7 @@
"PKG-DANMUKU-06",
"PKG-DANMUKU-10"
],
"status": "todo",
"status": "doing",
"risk": "H",
"deliverable": "负载/渲染/资源对比及 mask 可依赖的稳定边界",
"acceptance": "在试点核心中旧插件 API 与关键帧路径通过",
+7
View File
@@ -28,3 +28,10 @@ Chromecast的PKG-CAST-04沿用此规则保留最新npm 1.1.0根声明,不引
破坏。1.0.0的export=与1.1.0的default export已有历史差异,分别保留测试证据;
准确异步注册、回调和查询类型通过新增/runtime提供。此处记录规则的应用,
不声称用户另行确认过Cast专属问题,也不扩大到未验证的运行时变化。
Danmuku的PKG-DANMUKU-06保留实际npm 5.3.0根声明的完整类型形状与字节(忽略
换行格式),包括历史同步emit结果、必填danmuku、对象形points和NodeNext ESM的
既有namespace限制。新增/runtime描述真实Promise/owner结果、可选配置字段、tuple
points、静态icons和回调receiver;根/legacy/runtime复用原分发实现,不添加不存在
的factory.default。旧入口用户不需要因本次类型迁移修改代码。切换/runtime是可选
的准确类型入口,历史声明与运行时的区别见包README;这里没有新增旧类型破坏授权。
+1 -1
View File
@@ -53,7 +53,7 @@ console.log(`✨ Built ${artplayerTSoutput}`);
const type = pluginsTS[index]
const { name, file } = parsePluginInfo(type)
const source = String(fs.readFileSync(type))
const semanticPlugin = ['artplayerPluginChromecast', 'artplayerPluginAsr', 'artplayerPluginMultipleSubtitles', 'artplayerPluginVttThumbnail', 'artplayerPluginHlsControl', 'artplayerPluginAudioTrack', 'artplayerPluginDashControl', 'artplayerPluginAds', 'artplayerPluginAmbilight', 'artplayerProxyCanvas', 'artplayerProxyMediabunny', 'artplayerPluginDocumentPip', 'ArtplayerToolIframe', 'ArtplayerToolThumbnail'].includes(name)
const semanticPlugin = ['artplayerPluginDanmuku', 'artplayerPluginChromecast', 'artplayerPluginAsr', 'artplayerPluginMultipleSubtitles', 'artplayerPluginVttThumbnail', 'artplayerPluginHlsControl', 'artplayerPluginAudioTrack', 'artplayerPluginDashControl', 'artplayerPluginAds', 'artplayerPluginAmbilight', 'artplayerProxyCanvas', 'artplayerProxyMediabunny', 'artplayerPluginDocumentPip', 'ArtplayerToolIframe', 'ArtplayerToolThumbnail'].includes(name)
const localTypes = name === 'artplayerProxyMediabunny' ? { './media': fs.readFileSync(path.join(path.dirname(type), 'media.d.ts'), 'utf8') } : {}
const code = semanticPlugin
? generatePluginEditorDeclaration(source, name, localTypes)
+12
View File
@@ -70,3 +70,15 @@ volume/rate, independent instances and cleanup. Published-only tests retain defe
See [audio validation](../refactor/audio-validation.md) for actual media tests and limitations.
Document PiP: `yarn test:dpip` runs 48 historical window/DOM/lifecycle cases. Native DOM iframe checks use `yarn test:browser test/browser/dpip.spec.js`; controlled window APIs do not establish native Document PiP support. See [validation notes](../refactor/dpip-validation.md).
Danmuku: `yarn test:danmuku` covers historical behavior and current input, scheduling,
settings, rendering and heatmap boundaries. `ARTPLAYER_DANMUKU_ARTIFACT` selects a
built candidate for integration cases; direct internal tests remain source tests.
The candidate Worker helper executes the selected artifact's Blob or data URL bytes;
only source-mode controlled imports compile `worker.ts` separately. The frozen
published helper remains unchanged. `yarn test:danmuku-types` checks the unchanged
npm root, the optional accurate `/runtime` entry, implementation assignability and
semantic editor declarations. `yarn test:danmuku-types-package` packs and installs
the packages outside the workspace, verifies exact member bytes and frozen offline
reinstallation, and checks historical and current compiler consumers. These type
checks do not replace the native browser suite or final distribution acceptance.
+11
View File
@@ -128,3 +128,14 @@ both response modes. A passing diagnostic only proves observation plus successfu
playback after release; inspect its progressed/waiting flags before claiming buffering.
These capability observation timeouts do not relax the integration assertions.
Archive the report and results (including traces) before starting another browser suite.
Danmuku input, scheduler, resources and heatmap-density specs accept
`ARTPLAYER_DANMUKU_ARTIFACT` for a built main/legacy file; unset it to compile the
current JS/TS entry. Run each format sequentially and wait for the command's final
exit before copying reports or launching the next format. A visible last test line
does not prove reporter/trace teardown is finished. These runs share the server port
and output directory, so simultaneous invocations can corrupt failure evidence.
For a timeout, inspect the retained trace before changing the test or implementation;
browser startup and pre-plugin playback delays are different from plugin execution.
The density regression retains 16000 rows, real playback and SVG/pixel geometry at
two widths. A diagnostic pass does not waive the full matrix or long-load acceptance.
+3 -1
View File
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Deterministic owned-node tests use the Node runner.
import test from 'node:test'
import { parseHTML } from 'linkedom'
import Renderer from '../packages/artplayer-plugin-danmuku/src/renderer.js'
import { loadModules } from './helpers/load.js'
const { Renderer } = await loadModules({ Renderer: 'packages/artplayer-plugin-danmuku/src/renderer' })
function fixture(t) {
const { document } = parseHTML('<html><body><div id="layer"><i id="foreign"></i></div></body></html>')
+2 -6
View File
@@ -1,9 +1,8 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Deterministic candidate scheduling uses the Node runner.
import test from 'node:test'
import vm from 'node:vm'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { danmukuCandidate, danmukuCandidateEnvironment, danmukuWorkerCode } from './helpers/danmuku-candidate.js'
import { deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
@@ -44,10 +43,7 @@ async function fixture(options = {}, { automatic = false } = {}) {
return child
}
const worker = env.workers[0]
const actualBlob = env.urls.get(worker.url)
const workerCode = actualBlob
? await actualBlob.text()
: fs.readFileSync(new URL('../packages/artplayer-plugin-danmuku/src/worker.js', import.meta.url), 'utf8')
const workerCode = await danmukuWorkerCode(implementation, env, worker)
const responses = []
const workerContext = vm.createContext({ postMessage: data => responses.push(structuredClone(data)), URL: { revokeObjectURL() {} }, location: { href: worker.url } })
workerContext.self = workerContext
+10 -6
View File
@@ -1,9 +1,8 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Controlled client faults and real worker geometry use the Node runner.
import test from 'node:test'
import vm from 'node:vm'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { danmukuCandidate, danmukuCandidateEnvironment, danmukuWorkerCode } from './helpers/danmuku-candidate.js'
import { loadModules } from './helpers/load.js'
const { WorkerClient } = await loadModules({ WorkerClient: 'packages/artplayer-plugin-danmuku/src/worker-client' })
@@ -223,13 +222,18 @@ test('danmuku worker client: independent clients never share pending ownership',
second.client.dispose()
})
// Unlike the client double above, this executes the actual unmodified worker
// script. Numeric geometry inputs are controlled; no browser layout is claimed.
const workerCode = fs.readFileSync(new URL('../packages/artplayer-plugin-danmuku/src/worker.js', import.meta.url), 'utf8')
// Unlike the client double above, this executes the actual source or artifact
// worker. Numeric geometry inputs are controlled; no browser layout is claimed.
const workerEnvironment = danmukuCandidateEnvironment(implementation)
workerEnvironment.factory({ danmuku: [] })(workerEnvironment.art)
const actualWorker = workerEnvironment.workers[0]
const workerCode = await danmukuWorkerCode(implementation, workerEnvironment, actualWorker)
workerEnvironment.destroy()
function calculateGeometry(input) {
const replies = []
const context = vm.createContext({ input, postMessage: data => replies.push(data) })
const context = vm.createContext({ input, postMessage: data => replies.push(data), URL: { revokeObjectURL() {} }, location: { href: actualWorker.url } })
context.self = context
vm.runInContext(workerCode, context, { timeout: 1000 })
vm.runInContext('onmessage({ data: input })', context, { timeout: 1000 })
assert.equal(replies.length, 1)
+24
View File
@@ -1,3 +1,4 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
@@ -6,8 +7,31 @@ import { build } from 'esbuild'
import { ensureArchive, readMember, refactorDir } from '../../refactor/scripts/releases.mjs'
import { getEntryFile } from '../../scripts/projects.js'
import { danmukuEnvironment } from './danmuku.js'
import { resolveSource } from './load.js'
const workspace = fileURLToPath(new URL('../../', import.meta.url))
let sourceWorker
export async function danmukuWorkerCode(implementation, env, worker) {
const blob = env.urls.get(worker.url)
if (blob)
return blob.text()
// Production worker-loader emits a percent-encoded data URL. Execute those
// exact shipped bytes instead of silently substituting the current source.
const dataPrefix = 'data:text/javascript;charset=utf-8,'
if (typeof worker.url === 'string' && worker.url.startsWith(dataPrefix))
return decodeURIComponent(worker.url.slice(dataPrefix.length))
assert.equal(implementation.name, 'candidate-source', 'Artifact Worker tests must execute the Worker embedded in that artifact')
sourceWorker ??= build({
entryPoints: [resolveSource('packages/artplayer-plugin-danmuku/src/worker')],
bundle: true,
write: false,
format: 'iife',
platform: 'browser',
target: 'es2020',
}).then(result => result.outputFiles[0].text)
return sourceWorker
}
export async function danmukuCandidate() {
const coreRelease = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
+22
View File
@@ -0,0 +1,22 @@
import type { Option, Result } from 'artplayer-plugin-danmuku'
import danmuku from 'artplayer-plugin-danmuku'
import legacy from 'artplayer-plugin-danmuku/legacy'
const legacyFactory: typeof danmuku = legacy
const rootFactory: typeof legacy = danmuku
const option: Option = { danmuku: [], points: [{ time: 1, value: 2 }] }
danmuku(option)
legacy(option)
declare const result: Result
const synchronous: Result = result.emit({ text: 'historical declaration' })
void [legacyFactory, rootFactory, synchronous]
// @ts-expect-error The published root requires its option argument.
danmuku()
// @ts-expect-error The published root requires danmuku in Option.
danmuku({})
// @ts-expect-error Preserve historical object points at the root.
danmuku({ danmuku: [], points: [[1, 2]] })
// @ts-expect-error Root emit keeps its published synchronous Result shape.
const asynchronous: Promise<Result> = result.emit({ text: 'old emit' })
void asynchronous
+108
View File
@@ -0,0 +1,108 @@
import type { Danmu, EventMap, Icons, Input, Item, NormalizedDanmu, NormalizedOption, Owner, Point, RuntimeFactory, RuntimeOption, RuntimeResult, Slider } from 'artplayer-plugin-danmuku/runtime'
import Artplayer from 'artplayer'
import runtime from 'artplayer-plugin-danmuku/runtime'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Assert<T extends true> = T
type FactoryArguments = Assert<Equal<Parameters<typeof runtime>, [option: RuntimeOption]>>
type FactoryReturns = Assert<Equal<ReturnType<ReturnType<typeof runtime>>, RuntimeResult>>
type EmitReturns = Assert<Equal<ReturnType<RuntimeResult['emit']>, Promise<Owner>>>
type LoadReturns = Assert<Equal<ReturnType<RuntimeResult['load']>, Promise<Owner>>>
type CommandReturns = Assert<Equal<ReturnType<RuntimeResult['config']>, Owner>>
type OwnerHasNoFacadeKeys = Assert<Equal<Extract<'name' | 'mount', keyof Owner>, never>>
type VisiblePayload = Assert<Equal<EventMap['artplayerPluginDanmuku:visible'], [item: Item]>>
type LoadedPayload = Assert<Equal<EventMap['artplayerPluginDanmuku:loaded'], [queue: Item[]]>>
function expectOption(_option: NormalizedOption) {}
const row: Danmu = { text: 'extended input', id: 'message-1', author: { name: 'test' }, style: { fontWeight: 'bold' } }
const input: Input = () => [row]
const asyncInput: Input = async () => [row]
const point: Point = [10, 5]
const margin: Slider<[number | string, number | string]> = { steps: [{ name: 'half', value: [10, '50%'], hide: true, show: false }] }
const options: RuntimeOption = {
danmuku: input,
points: [point],
MARGIN: margin,
SPEED: { steps: [{ name: 'fast', value: 2.5, hide: true }] },
filter(danmu) {
expectOption(this)
const normalized: NormalizedDanmu = danmu
const time: number = danmu.time
const extension: unknown = danmu.author
// @ts-expect-error Queue allocation has not run when filter is invoked.
const state: string = danmu.$state
void [normalized, time, extension, state]
return true
},
async beforeEmit(danmu) {
expectOption(this)
const text: string = danmu.text
void text
return true
},
async beforeVisible(item) {
expectOption(this)
const state: 'wait' | 'ready' | 'emit' | 'stop' = item.$state
const index: number = item.$index
const node: HTMLDivElement | null = item.$ref
const extra: unknown = item.author
void [state, index, node, extra]
return true
},
}
const art = new Artplayer({ container: '#player', url: '/video.mp4', plugins: [runtime(options)] })
const factory: RuntimeFactory = runtime
const result = runtime({})(art)
const registered: 'artplayerPluginDanmuku' = result.name
const icon: string = runtime.icons.$check_on
const icons: Icons = runtime.icons
runtime.icons = icons
runtime.icons.$on = '<svg></svg>'
const promise: Promise<Owner> = result.emit(row)
const loading: Promise<Owner> = result.load(asyncInput)
const replacing: Promise<Owner> = result.load()
const owner: Owner = result.config({ opacity: 0.5 })
const stopped: Owner = owner.stop().start().suspend().continue().reset()
const configured: NormalizedOption = result.option
result.option.visible = false
result.mount(document.createElement('div'))
result.mount('#danmuku-mount')
const loadedPayload: EventMap['artplayerPluginDanmuku:loaded'] = [owner.queue]
const pointsPayload: EventMap['artplayerPluginDanmuku:points'] = [[point]]
const failedPayload: EventMap['artplayerPluginDanmuku:error'] = [new Error('failed')]
async function consume() {
const emitted = await result.emit({ text: 'await owner' })
const rows: Item[] = emitted.queue
const next: Owner = emitted.hide().show()
// @ts-expect-error The resolved owner does not expose the facade name.
void emitted.name
// @ts-expect-error The resolved owner does not own setting.mount.
emitted.mount('#mount')
return [rows, next]
}
// @ts-expect-error The factory still requires its options object.
runtime()
// @ts-expect-error Undefined is not a valid options object.
runtime(undefined)
// @ts-expect-error An emitted row requires text.
result.emit({ id: 'missing-text' })
// @ts-expect-error The runtime heatmap uses mutable pairs, not historical object points.
runtime({ points: [{ time: 1, value: 2 }] })
// @ts-expect-error Margin sliders carry pairs rather than scalar numbers.
runtime({ MARGIN: { steps: [{ value: 10 }] } })
// @ts-expect-error A live mount needs an explicit destination.
result.mount()
// @ts-expect-error Facade option is a getter, although the returned options object is mutable.
result.option = configured
// @ts-expect-error Facade isHide is a getter.
result.isHide = true
// @ts-expect-error Facade isStop is a getter.
result.isStop = true
// @ts-expect-error emit returns a Promise; synchronous chaining is not available.
result.emit(row).hide()
// @ts-expect-error The factory has icons but no default self-reference.
void runtime.default
export type { CommandReturns, EmitReturns, FactoryArguments, FactoryReturns, LoadedPayload, LoadReturns, OwnerHasNoFacadeKeys, VisiblePayload }
void [factory, registered, icon, promise, loading, replacing, stopped, loadedPayload, pointsPayload, failedPayload, consume]