refactor(danmuku): [PKG-DANMUKU-03] own input loading parsing and configuration

This commit is contained in:
Harvey Zhao committed 2026-09-13 18:35:28 +08:00
1 parent d3c91af92c
commit 0b2efb07f1
26 files changed
+9167 -503

No files matched your search

@@ -0,0 +1,77 @@
# Danmuku maintenance map
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.
## Module responsibilities
| Module | Responsibility |
| --- | --- |
| `src/index.js` | Plugin factory, facade getters, bound methods, setting and heatmap composition |
| `src/danmuku.js` | Internal instance, public method return identity, queue commits and events; scheduling is still here pending PKG-DANMUKU-04 |
| `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/worker.js` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/setting.js` | Settings and emitter UI; lifecycle restructuring follows in PKG-DANMUKU-05 |
| `src/heatmap.js` | Heatmap rendering; restructuring follows in PKG-DANMUKU-05 |
The package is undergoing staged migration. PKG-DANMUKU-03 separates input and
configuration responsibilities in JavaScript; 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.
## Contracts to retain
- The registrar returns its facade synchronously. `emit()` and `load()` return
Promises resolving to the internal Danmuku instance, which differs from that
facade. `config/hide/show/reset` return that same internal instance
synchronously; `mount()` returns undefined. Keep the three live getters.
- `load()` and `load(undefined)` replace only after input succeeds.
`load(target)` appends without updating `option.danmuku`. Independent appends
may complete in either order and can append after a replacement.
- Arrays enter synchronously. Empty initial input emits
`show -> config -> reset -> loaded` before registration returns. Nonempty input
inserts the first item before the first await, then awaits each `emit()` in
order. A later invalid item leaves earlier accepted items present.
- A new replacement supersedes an older replacement. Destroy cancels all input
operations. Cancelled public loads resolve to the same internal instance;
they do not emit late `loaded/error` events or write stale rows. The input
Promise itself may be uncancellable; observe its eventual rejection locally.
- Invoke a loader function without an explicit receiver. Invoke the filter
through the current option so its receiver remains that option. Check load
ownership again after callbacks and reset events, including synchronous
callbacks that start another replacement.
- Keep input normalization observable: accepted objects receive missing mode,
style and color before filtering, and the queue receives a shallow copy.
Explicit time zero is preserved; missing time defaults to current time + 0.5,
and negative time is clamped to zero.
- Function and Promise configuration changes use identity. Structurally equal
ordinary values retain the historical JSON comparison behavior. Validate and
normalize a prospective option before replacing the current option; invalid
updates cannot corrupt it. Configuration does not implicitly reload input.
- Keep XML entity order, unpadded hexadecimal colors, numeric conversions,
extension fields and existing mode mapping. An empty XML document is a valid
empty result. Fetch/text failures must reach the public load's single error
outlet and reject it; automatic initial loading observes that rejection.
## Verification and remaining work
`yarn test:danmuku` runs frozen historical contracts/failures and candidate input
and parser regressions. The historical helper and archives must remain frozen;
candidate tests use `test/helpers/danmuku-candidate.js` and may select built
artifacts through `ARTPLAYER_DANMUKU_ARTIFACT`.
`test/browser/danmuku-input.spec.js` uses native video and Blob Workers against
published and candidate cores. Run its source, main and legacy variants after
the normal package build. Browser evidence distinguishes local XML/fault
fixtures from the real Bilibili service, and Windows WebKit from Safari devices.
The older native track-overlap observation remains open until scheduler and
rendering work is verified. A passing input test is not evidence that duplicate
RAF loops, visibility continuations, settings cleanup, heatmap sampling or
long-run load behavior have been fixed. See PKG-DANMUKU-04/05/07 and the risk
register before changing completion or release status.
@@ -6,6 +6,28 @@ Danmuku plugin for ArtPlayer
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-danmuku/index.js&example=danmuku)
## Loading and configuration
`plugin.load()` reloads the configured input and replaces the queue after input
succeeds. `plugin.load(input)` appends; concurrent append operations remain
independent. A newer replacement supersedes an unfinished older replacement.
Destroy cancels pending loads and prevents late queue changes. Cancelled loads
resolve to the same internal Danmuku instance as successful loads, without a
late `loaded` or `error` event.
Fetch or response-text failures emit `artplayerPluginDanmuku:error` once and
reject the corresponding public `load()` Promise. Initial automatic loading
observes its rejection and logs a warning. XML Worker startup/runtime failures
use the existing parser locally and release Worker/Blob resources.
Explicit `time: 0` is preserved. A replacement callback passed to `config()`
takes effect, and invalid configuration leaves the current option intact.
Existing method return identities, getters, input normalization and XML fields
remain compatible. Configuration changes do not automatically reload data.
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module ownership, compatibility
constraints, tests and the remaining staged TypeScript migration.
## License
MIT © Harvey Zhao
@@ -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){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function e(e){if("string"!==typeof e)return[];const i=e.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(e=>{const i=e.groups.p.split(",");if(i.length>=8){return{text:e.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:t(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 i({data:t}){const{xml:i,id:a}=t;if(!a||!i)return;const n=e(i);globalThis.postMessage({danmus:n,id:a})}function a(){const a=`\n ${t.toString()}\n ${e.toString()}\n onmessage = ${i.toString()}\n `,n=new Blob([a],{type:"application/javascript"});return new Worker(URL.createObjectURL(n))}const n='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switch(t.mode){case 0:{const e=a.find(e=>e.every(e=>{if(i<e.distance)return!1;if(t.speed<e.speed)return!0;return e.right/(t.speed-e.speed)>e.time}));return e&&e[0]?e[0].top:void 0}case 1:case 2:return}}onmessage=e=>{const{data:i}=e;if(!i.id||!i.type)return;const h=(0,{getDanmuTop:t}[i.type])(i);globalThis.postMessage({result:h,id:i.id})};\n',s="undefined"!==typeof self&&self.Blob&&new Blob(["URL.revokeObjectURL(import.meta.url);",n],{type:"text/javascript;charset=utf-8"});function o(t){let e;try{if(e=s&&(self.URL||self.webkitURL).createObjectURL(s),!e)throw"";const i=new Worker(e,{type:"module",name:t?.name});return i.addEventListener("error",()=>{(self.URL||self.webkitURL).revokeObjectURL(e)}),i}catch(i){return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(n),{type:"module",name:t?.name})}}class r{constructor(t,e){const{constructor:i,template:a}=t;this.utils=i.utils,this.validator=i.validator,this.$danmuku=a.$danmuku,this.$player=a.$player,this.art=t,this.queue=[],this.$refs=[],this.isStop=!1,this.isHide=!1,this.timer=null,this.index=0,this.option=r.option,this.states={wait:[],ready:[],emit:[],stop:[]},this.config(e,!0),this.worker=new o,this.start=this.start.bind(this),this.stop=this.stop.bind(this),this.reset=this.reset.bind(this),this.resize=this.resize.bind(this),this.destroy=this.destroy.bind(this),t.on("video:play",this.start),t.on("video:playing",this.start),t.on("video:pause",this.stop),t.on("video:waiting",this.stop),t.on("destroy",this.destroy),t.on("resize",this.resize),this.load()}static get option(){return{danmuku:[],speed:5,margin:[10,"25%"],opacity:1,color:"#FFFFFF",mode:0,modes:[0,1,2],fontSize:25,antiOverlap:!0,synchronousPlayback:!1,mount:void 0,heatmap:!1,width:512,points:[],filter:()=>!0,beforeEmit:()=>!0,beforeVisible:()=>!0,visible:!0,emitter:!0,maxLength:200,lockTime:5,theme:"dark",OPACITY:{},FONT_SIZE:{},MARGIN:{},SPEED:{},COLOR:[]}}static get scheme(){return{danmuku:"array|function|string",speed:"number",margin:"array",opacity:"number",color:"string",mode:"number",modes:"array",fontSize:"number|string",antiOverlap:"boolean",synchronousPlayback:"boolean",mount:"?htmldivelement|string",heatmap:"object|boolean",width:"number",points:"array",filter:"function",beforeEmit:"function",beforeVisible:"function",visible:"boolean",emitter:"boolean",maxLength:"number",lockTime:"number",theme:"string",OPACITY:"object",FONT_SIZE:"object",MARGIN:"object",SPEED:"object",COLOR:"array"}}static get cssLine 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 a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,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!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)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(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.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 p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const h='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switch(t.mode){case 0:{const e=a.find(e=>e.every(e=>{if(i<e.distance)return!1;if(t.speed<e.speed)return!0;return e.right/(t.speed-e.speed)>e.time}));return e&&e[0]?e[0].top:void 0}case 1:case 2:return}}onmessage=e=>{const{data:i}=e;if(!i.id||!i.type)return;const h=(0,{getDanmuTop:t}[i.type])(i);globalThis.postMessage({result:h,id:i.id})};\n',u="undefined"!==typeof self&&self.Blob&&new Blob(["URL.revokeObjectURL(import.meta.url);",h],{type:"text/javascript;charset=utf-8"});function c(t){let e;try{if(e=u&&(self.URL||self.webkitURL).createObjectURL(u),!e)throw"";const i=new Worker(e,{type:"module",name:t?.name});return i.addEventListener("error",()=>{(self.URL||self.webkitURL).revokeObjectURL(e)}),i}catch(i){return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(h),{type:"mLine 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,a=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,o=Math.pow,r=(e,i,a)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:a}):e[i]=a,l=(t,e)=>{for(var i in e||(e={}))n.call(e,i)&&r(t,i,e[i]);if(a)for(var i of a(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,a)=>e(t,i(a)),p=(t,e,i)=>new Promise((a,n)=>{var s=t=>{try{r(i.next(t))}catch(e){n(e)}},o=t=>{try{r(i.throw(t))}catch(e){n(e)}},r=t=>t.done?a(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});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 c({data:t}){const{xml:e,id:i}=t;if(!i||!e)return;const a=u(e);globalThis.postMessage({danmus:a,id:i})}function m(){const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${c.toString()}\n `,e=new Blob([t],{type:"application/javascript"});return new Worker(URL.createObjectURL(e))}const f='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switch(t.mode){case 0:{const e=a.find(e=>e.every(e=>{if(i<e.distance)return!1;if(t.speed<e.speed)return!0;return e.right/(t.speed-e.speed)>e.time}));return e&&e[0]?e[0].top:void 0}case 1:case 2:return}}onmessage=e=>{const{data:i}=e;if(!i.id||!i.type)return;const h=(0,{getDanmuTop:t}[i.type])(i);globalThis.postMessage({result:h,id:i.id})};\n',g="undefined"!==typeof self&&self.Blob&&new Blob(["URL.revokeObjectURL(import.meta.url);",f],{type:"text/javascript;charset=utf-8"});function y(t){let e;try{if(e=g&&(self.URL||self.webkitURL).createObjectURL(g),!e)throw"";const i=new Worker(e,{type:"module",name:null==t?void 0:t.name});return i.addEventListener("error",()=>{(self.URL||self.webkitURL).revokeObjectURL(e)}),i}catch(i){return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(f),{type:"module",name:null==t?void 0:t.name})}}class v{constructor(t,e){const{constructor:i,template:a}=t;this.utils=i.utils,this.validator=i.validator,this.$danmuku=a.$danmuku,this.$player=a.$player,this.art=t,this.queue=[],this.$refs=[],this.isStop=!1,this.isHide=!1,this.timer=null,this.index=0,this.option=v.option,this.states={wait:[],ready:[],emit:[],stop:[]},this.config(e,!0),this.worker=new y,this.start=this.start.bind(this),this.stop=this.stop.bind(this),this.reset=this.reset.bind(this),this.resize=this.resize.bind(this),this.destroy=this.destroy.bind(this),t.on("video:play",this.start),t.on("video:playing",this.start),t.on("video:pause",this.stop),t.on("video:waiting",this.stop),t.on("destroy",this.destroy),t.on("resize",this.resize),this.load()}static get option(){return{danmuku:[],speed:5,margin:[10,"25%"],opacity:1,color:"#FFFFFF",mode:0,modes:[0,1,2],fontSize:25,antiOverlap:!0,synchronousPlayback:!1,mount:void 0,heatmap:!1,width:512,points:[],filter:()=>!0,beforeEmit:()=>!0,beforeVisible:()=>!0,vLine 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,a=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,o=Math.pow,r=(e,i,a)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:a}):e[i]=a,l=(t,e)=>{for(var i in e||(e={}))n.call(e,i)&&r(t,i,e[i]);if(a)for(var i of a(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,a)=>e(t,i(a)),p=(t,e,i)=>new Promise((a,n)=>{var s=t=>{try{r(i.next(t))}catch(e){n(e)}},o=t=>{try{r(i.throw(t))}catch(e){n(e)}},r=t=>t.done?a(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});function h(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(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:u(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,a=c(e);globalThis.postMessage({danmus:a,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((a,n)=>{let s,o,r,l=!1;const d=++f,h=()=>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!==o){const a=o;o=void 0;try{URL.revokeObjectURL(a)}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",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?n(i):a(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const a=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),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(a,t.error||t)},s.onmessageerror=t=>v(a,t.error||t),s.postMessage({xml:a,id:d})}catch(n){v(a,n)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function x(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function $(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const w='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switcLine truncated
@@ -4,6 +4,91 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function defaultOption() {
return {
danmuku: [],
speed: 5,
margin: [10, "25%"],
opacity: 1,
color: "#FFFFFF",
mode: 0,
modes: [0, 1, 2],
fontSize: 25,
antiOverlap: true,
synchronousPlayback: false,
mount: void 0,
heatmap: false,
width: 512,
points: [],
filter: () => true,
beforeEmit: () => true,
beforeVisible: () => true,
visible: true,
emitter: true,
maxLength: 200,
lockTime: 5,
theme: "dark",
OPACITY: {},
FONT_SIZE: {},
MARGIN: {},
SPEED: {},
COLOR: []
};
}
function optionScheme() {
return {
danmuku: "array|function|string|promise",
speed: "number",
margin: "array",
opacity: "number",
color: "string",
mode: "number",
modes: "array",
fontSize: "number|string",
antiOverlap: "boolean",
synchronousPlayback: "boolean",
mount: "?htmldivelement|string",
heatmap: "object|boolean",
width: "number",
points: "array",
filter: "function",
beforeEmit: "function",
beforeVisible: "function",
visible: "boolean",
emitter: "boolean",
maxLength: "number",
lockTime: "number",
theme: "string",
OPACITY: "object",
FONT_SIZE: "object",
MARGIN: "object",
SPEED: "object",
COLOR: "array"
};
}
function isPromiseInput(value) {
return value instanceof Promise || Object.prototype.toString.call(value) === "[object Promise]";
}
function optionChanged(previous, update) {
return Object.keys(update).some((key) => {
const before = previous[key];
const after = update[key];
if (typeof before === "function" || typeof after === "function" || isPromiseInput(before) || isPromiseInput(after))
return before !== after;
return JSON.stringify(before) !== JSON.stringify(after);
});
}
function normalizeOption(previous, update, { defaults, validate, clamp, mount }) {
const next = Object.assign({}, defaults, previous, update);
validate(next);
next.mode = clamp(next.mode, 0, 2);
next.speed = clamp(next.speed, 1, 10);
next.opacity = clamp(next.opacity, 0, 1);
next.lockTime = clamp(next.lockTime, 1, 60);
next.maxLength = clamp(next.maxLength, 1, 1e3);
next.mount = next.mount || mount;
return next;
}
function getMode(key) {
switch (key) {
case 1:
@@ -45,41 +130,219 @@ function bilibiliDanmuParseFromXml(xmlString) {
}
function onmessage({ data }) {
const { xml, id } = data;
if (!id || !xml)
return;
const danmus = bilibiliDanmuParseFromXml(xml);
globalThis.postMessage({ danmus, id });
}
function createWorker() {
const workerText = `
${getMode.toString()}
${bilibiliDanmuParseFromXml.toString()}
onmessage = ${onmessage.toString()}
`;
const blob2 = new Blob([workerText], { type: "application/javascript" });
return new Worker(URL.createObjectURL(blob2));
let nextRequest = 0;
function abortError(signal) {
if (signal?.reason !== void 0)
return signal.reason;
const error = new Error("Bilibili Danmu loading cancelled");
error.name = "AbortError";
return error;
}
function bilibiliDanmuParseFromUrl(url) {
return new Promise(async (resolve) => {
const res = await fetch(url);
const xml = await res.text();
try {
const worker = createWorker();
worker.onmessage = (event) => {
const { danmus, id } = event.data;
if (!id || !danmus)
return;
resolve(danmus);
worker.terminate();
};
worker.postMessage({ xml, id: Date.now() });
} catch (error) {
console.error("Error parsing Bilibili Danmu:", error);
const danmus = bilibiliDanmuParseFromXml(xml);
resolve(danmus);
function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
return new Promise((resolve, reject) => {
let settled = false;
let worker;
let workerUrl;
let releaseCancel;
const id = ++nextRequest;
const cancel = () => finish(void 0, abortError(signal));
function releaseWorker() {
let error;
let failed = false;
try {
if (worker) {
const current = worker;
worker = void 0;
current.onmessage = null;
current.onerror = null;
current.onmessageerror = null;
current.terminate();
}
} catch (failure) {
error = failure;
failed = true;
}
if (workerUrl !== void 0) {
const current = workerUrl;
workerUrl = void 0;
try {
URL.revokeObjectURL(current);
} catch (failure) {
if (!failed)
error = failure;
failed = true;
}
}
if (failed)
throw error;
}
function finish(value, error) {
if (settled)
return;
settled = true;
let failed = arguments.length > 1;
signal?.removeEventListener("abort", cancel);
releaseCancel?.();
try {
releaseWorker();
} catch (cleanupError) {
if (!failed)
error = cleanupError;
failed = true;
}
if (failed)
reject(error);
else resolve(value);
}
function fallback(xml, error) {
if (settled)
return;
console.error("Error parsing Bilibili Danmu:", error);
try {
finish(bilibiliDanmuParseFromXml(xml));
} catch (failure) {
finish(void 0, failure);
}
}
async function read() {
const response = signal ? await fetch(url, { signal }) : await fetch(url);
if (settled)
return;
const xml = await response.text();
if (settled)
return;
try {
const workerText = `
${getMode.toString()}
${bilibiliDanmuParseFromXml.toString()}
onmessage = ${onmessage.toString()}
`;
workerUrl = URL.createObjectURL(new Blob([workerText], { type: "application/javascript" }));
if (settled) {
releaseWorker();
return;
}
worker = new Worker(workerUrl);
if (settled) {
releaseWorker();
return;
}
worker.onmessage = (event) => {
if (settled || event.data?.id !== id)
return;
const { danmus } = event.data;
if (!Array.isArray(danmus)) {
finish(void 0, new Error("Invalid Bilibili Danmu worker response"));
return;
}
finish(danmus);
};
worker.onerror = (event) => {
event.preventDefault?.();
fallback(xml, event.error || event);
};
worker.onmessageerror = (event) => fallback(xml, event.error || event);
worker.postMessage({ xml, id });
} catch (error) {
fallback(xml, error);
}
}
if (signal?.aborted) {
cancel();
return;
}
signal?.addEventListener("abort", cancel);
releaseCancel = onCancel?.(cancel);
if (settled) {
releaseCancel?.();
return;
}
read().catch((error) => finish(void 0, error));
});
}
const inputs = /* @__PURE__ */ new WeakMap();
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku input");
function stateFor(owner) {
if (!inputs.has(owner))
inputs.set(owner, { closed: false, tasks: /* @__PURE__ */ new Set(), emitting: [] });
return inputs.get(owner);
}
function inputActive(owner) {
const state = stateFor(owner);
const task = state.emitting[state.emitting.length - 1];
return !state.closed && !owner.art.isDestroy && (!task || task.active());
}
function beginInput(owner, replace) {
const state = stateFor(owner);
if (replace) {
for (const task2 of [...state.tasks]) {
if (task2.replace)
task2.cancel();
}
}
let stopped = state.closed || owner.art.isDestroy;
let resolveCancel;
const cancellation = new Promise((resolve) => resolveCancel = resolve);
const handlers = /* @__PURE__ */ new Set();
const controller = typeof AbortController === "function" ? new AbortController() : void 0;
const task = {
replace,
signal: controller?.signal,
active: () => !stopped && !state.closed && !owner.art.isDestroy,
onCancel(callback) {
if (!task.active()) {
callback();
return () => {
};
}
handlers.add(callback);
return () => handlers.delete(callback);
},
cancel() {
if (stopped)
return;
stopped = true;
resolveCancel(cancelled);
controller?.abort();
for (const callback of [...handlers]) callback();
handlers.clear();
},
wait: (value) => Promise.race([value, cancellation]),
emit(danmu) {
state.emitting.push(task);
try {
return owner.emit(danmu);
} finally {
state.emitting.pop();
}
},
finish() {
state.tasks.delete(task);
handlers.clear();
}
};
if (stopped)
resolveCancel(cancelled);
else state.tasks.add(task);
return task;
}
function cancelInputs(owner) {
const state = stateFor(owner);
state.closed = true;
for (const task of [...state.tasks]) task.cancel();
}
function readInput(target, task) {
if (typeof target === "function")
return { asynchronous: true, value: target() };
if (isPromiseInput(target))
return { asynchronous: true, value: target };
if (typeof target === "string")
return { asynchronous: true, value: bilibiliDanmuParseFromUrl(target, task) };
return { asynchronous: false, value: Array.isArray(target) ? target : [] };
}
const jsContent = '/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }) {\n const maxTop = clientHeight - marginBottom;\n const danmus = visibles.filter((item) => item.mode === target.mode && item.top <= maxTop).sort((prev, next) => prev.top - next.top);\n if (danmus.length === 0) {\n if (target.mode === 2) {\n return maxTop - target.height;\n } else {\n return marginTop;\n }\n }\n danmus.unshift({\n type: "top",\n top: 0,\n left: 0,\n right: 0,\n height: marginTop,\n width: clientWidth,\n speed: 0,\n distance: clientWidth\n });\n danmus.push({\n type: "bottom",\n top: maxTop,\n left: 0,\n right: 0,\n height: marginBottom,\n width: clientWidth,\n speed: 0,\n distance: clientWidth\n });\n if (target.mode === 2) {\n for (let index = danmus.length - 2; index >= 0; index -= 1) {\n const item = danmus[index];\n const prev = danmus[index + 1];\n const itemBottom = item.top + item.height;\n const diff = prev.top - itemBottom;\n if (diff >= target.height) {\n return prev.top - target.height;\n }\n }\n } else {\n for (let index = 1; index < danmus.length; index += 1) {\n const item = danmus[index];\n const prev = danmus[index - 1];\n const prevBottom = prev.top + prev.height;\n const diff = item.top - prevBottom;\n if (diff >= target.height) {\n return prevBottom;\n }\n }\n }\n const topMap = [];\n for (let index = 1; index < danmus.length - 1; index += 1) {\n const item = danmus[index];\n if (topMap.length) {\n const last = topMap[topMap.length - 1];\n if (last[0].top === item.top) {\n last.push(item);\n } else {\n topMap.push([item]);\n }\n } else {\n topMap.push([item]);\n }\n }\n if (antiOverlap) {\n switch (target.mode) {\n case 0: {\n const result = topMap.find((list) => {\n return list.every((danmu) => {\n if (clientWidth < danmu.distance)\n return false;\n if (target.speed < danmu.speed)\n return true;\n const overlapTime = danmu.right / (target.speed - danmu.speed);\n if (overlapTime > danmu.time)\n return true;\n return false;\n });\n });\n return result && result[0] ? result[0].top : void 0;\n }\n // 静止弹幕没有重叠问题\n case 1:\n case 2:\n return void 0;\n }\n } else {\n switch (target.mode) {\n case 0:\n topMap.sort((prev, next) => {\n const nextMinRight = Math.min(...next.map((item) => item.right));\n const prevMinRight = Math.min(...prev.map((item) => item.right));\n return nextMinRight * next.length - prevMinRight * prev.length;\n });\n break;\n case 1:\n case 2:\n topMap.sort((prev, next) => {\n const nextMaxWidth = Math.max(...next.map((item) => item.width));\n const prevMaxWidth = Math.max(...prev.map((item) => item.width));\n return prevMaxWidth * prev.length - nextMaxWidth * next.length;\n });\n break;\n }\n return topMap[0][0].top;\n }\n}\nonmessage = (event) => {\n const { data } = event;\n if (!data.id || !data.type)\n return;\n const fns = { getDanmuTop };\n const fn = fns[data.type];\n const result = fn(data);\n globalThis.postMessage({\n result,\n id: data.id\n });\n};\n';
const blob = typeof self !== "undefined" && self.Blob && new Blob(["URL.revokeObjectURL(import.meta.url);", jsContent], { type: "text/javascript;charset=utf-8" });
function WorkerWrapper(options) {
@@ -119,113 +382,40 @@ class Danmuku {
this.isHide = false;
this.timer = null;
this.index = 0;
this.worker = null;
this.option = Danmuku.option;
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.config(option, true);
this.worker = new WorkerWrapper();
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this.reset = this.reset.bind(this);
this.resize = this.resize.bind(this);
this.destroy = this.destroy.bind(this);
inputActive(this);
art.on("destroy", this.destroy);
try {
this.config(option, true);
if (!inputActive(this))
return;
this.worker = new WorkerWrapper();
} catch (error) {
art.off("destroy", this.destroy);
cancelInputs(this);
throw error;
}
art.on("video:play", this.start);
art.on("video:playing", this.start);
art.on("video:pause", this.stop);
art.on("video:waiting", this.stop);
art.on("destroy", this.destroy);
art.on("resize", this.resize);
this.load();
this.load().catch((error) => console.warn("Failed to load initial danmuku:", error));
}
// 默认配置
static get option() {
return {
danmuku: [],
// 弹幕数据
speed: 5,
// 弹幕持续时间,范围在[1 ~ 10]
margin: [10, "25%"],
// 弹幕上下边距,支持像素数字和百分比
opacity: 1,
// 弹幕透明度,范围在[0 ~ 1]
color: "#FFFFFF",
// 默认弹幕颜色,可以被单独弹幕项覆盖
mode: 0,
// 默认弹幕模式: 0: 滚动,1: 顶部,2: 底部
modes: [0, 1, 2],
// 弹幕可见的模式
fontSize: 25,
// 弹幕字体大小,支持像素数字和百分比
antiOverlap: true,
// 弹幕是否防重叠
synchronousPlayback: false,
// 是否同步播放速度
mount: void 0,
// 弹幕发射器挂载点, 默认为播放器控制栏中部
heatmap: false,
// 是否开启热力图
width: 512,
// 当播放器宽度小于此值时,弹幕发射器置于播放器底部
points: [],
// 热力图数据
filter: () => true,
// 弹幕载入前的过滤器,只支持返回布尔值
beforeEmit: () => true,
// 弹幕发送前的过滤器,支持返回 Promise
beforeVisible: () => true,
// 弹幕显示前的过滤器,支持返回 Promise
visible: true,
// 弹幕层是否可见
emitter: true,
// 是否开启弹幕发射器
maxLength: 200,
// 弹幕输入框最大长度, 范围在[1 ~ 1000]
lockTime: 5,
// 输入框锁定时间,范围在[1 ~ 60]
theme: "dark",
// 弹幕主题,支持 dark 和 light,只在自定义挂载时生效
OPACITY: {},
// 不透明度配置项
FONT_SIZE: {},
// 弹幕字号配置项
MARGIN: {},
// 显示区域配置项
SPEED: {},
// 弹幕速度配置项
COLOR: []
// 颜色列表配置项
};
return defaultOption();
}
// 配置校验
static get scheme() {
return {
danmuku: "array|function|string",
speed: "number",
margin: "array",
opacity: "number",
color: "string",
mode: "number",
modes: "array",
fontSize: "number|string",
antiOverlap: "boolean",
synchronousPlayback: "boolean",
mount: "?htmldivelement|string",
heatmap: "object|boolean",
width: "number",
points: "array",
filter: "function",
beforeEmit: "function",
beforeVisible: "function",
visible: "boolean",
emitter: "boolean",
maxLength: "number",
lockTime: "number",
theme: "string",
OPACITY: "object",
FONT_SIZE: "object",
MARGIN: "object",
SPEED: "object",
COLOR: "array"
};
return optionScheme();
}
// 初始弹幕样式
static get cssText() {
@@ -345,40 +535,48 @@ class Danmuku {
// 加载弹幕
async load(danmuku) {
const { errorHandle } = this.utils;
let danmus = [];
const target = danmuku || this.option.danmuku;
const task = beginInput(this, danmuku === void 0);
try {
if (typeof target === "function") {
danmus = await target();
} else if (target instanceof Promise) {
danmus = await target;
} else if (typeof target === "string") {
danmus = await bilibiliDanmuParseFromUrl(target);
} else if (Array.isArray(target)) {
danmus = target;
}
if (!task.active())
return this;
const target = danmuku || this.option.danmuku;
const input = readInput(target, task);
const danmus = input.asynchronous ? await task.wait(input.value) : input.value;
if (!task.active())
return this;
errorHandle(Array.isArray(danmus), "Danmuku need return an array as result");
if (danmuku === void 0) {
this.reset();
if (!task.active())
return this;
this.queue = [];
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.$refs = [];
this.$danmuku.textContent = "";
}
for (let index = 0; index < danmus.length; index++) {
if (!task.active())
return this;
const danmu = danmus[index];
await this.emit(danmu);
await task.emit(danmu);
}
this.art.emit("artplayerPluginDanmuku:loaded", this.queue);
if (task.active())
this.art.emit("artplayerPluginDanmuku:loaded", this.queue);
} catch (error) {
if (!task.active())
return this;
this.art.emit("artplayerPluginDanmuku:error", error);
throw error;
} finally {
task.finish();
}
return this;
}
// 把原始弹幕转换到弹幕队列
async emit(danmu) {
const { clamp } = this.utils;
if (!inputActive(this))
return this;
this.validator(danmu, {
id: "?string",
// 弹幕唯一标识
@@ -397,7 +595,7 @@ class Danmuku {
});
if (!danmu.text.trim())
return this;
if (danmu.time) {
if (danmu.time !== void 0) {
danmu.time = clamp(danmu.time, 0, Infinity);
} else {
danmu.time = this.art.currentTime + 0.5;
@@ -415,6 +613,8 @@ class Danmuku {
return this;
if (!this.option.filter(danmu))
return this;
if (!inputActive(this))
return this;
const item = {
...danmu,
$state: "wait",
@@ -436,28 +636,30 @@ class Danmuku {
config(option, isInit = false) {
const { clamp } = this.utils;
const { $controlsCenter } = this.art.template;
const changed = Object.keys(option).some(
(key) => JSON.stringify(this.option[key]) !== JSON.stringify(option[key])
);
const changed = optionChanged(this.option, option);
if (!changed && !isInit)
return this;
this.option = Object.assign({}, Danmuku.option, this.option, option);
this.validator(this.option, Danmuku.scheme);
this.option.mode = clamp(this.option.mode, 0, 2);
this.option.speed = clamp(this.option.speed, 1, 10);
this.option.opacity = clamp(this.option.opacity, 0, 1);
this.option.lockTime = clamp(this.option.lockTime, 1, 60);
this.option.maxLength = clamp(this.option.maxLength, 1, 1e3);
this.option.mount = this.option.mount || $controlsCenter;
const next = normalizeOption(this.option, option, {
defaults: Danmuku.option,
validate: (value) => this.validator(value, Danmuku.scheme),
clamp,
mount: $controlsCenter
});
if (!inputActive(this))
return this;
this.option = next;
if (option.fontSize) {
this.reset();
if (!inputActive(this))
return this;
}
if (this.option.visible) {
this.show();
} else {
this.hide();
}
this.art.emit("artplayerPluginDanmuku:config", this.option);
if (inputActive(this))
this.art.emit("artplayerPluginDanmuku:config", this.option);
return this;
}
// 计算DOM的left值,受到旋屏影响
@@ -688,13 +890,14 @@ class Danmuku {
return this;
}
destroy() {
cancelInputs(this);
this.stop();
this.worker.terminate();
this.worker?.terminate();
this.art.off("video:play", this.start);
this.art.off("video:playing", this.start);
this.art.off("video:pause", this.stop);
this.art.off("video:waiting", this.stop);
this.art.off("resize", this.reset);
this.art.off("resize", this.resize);
this.art.off("destroy", this.destroy);
this.art.emit("artplayerPluginDanmuku:destroy");
}
@@ -1536,8 +1739,8 @@ if (typeof document !== "undefined") {
function artplayerPluginDanmuku(option) {
return (art) => {
const danmuku = new Danmuku(art, option);
const setting = new Setting(art, danmuku);
if (danmuku.option.heatmap) {
const setting = art.isDestroy ? void 0 : new Setting(art, danmuku);
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap);
}
return {
@@ -1548,7 +1751,8 @@ function artplayerPluginDanmuku(option) {
hide: danmuku.hide.bind(danmuku),
show: danmuku.show.bind(danmuku),
reset: danmuku.reset.bind(danmuku),
mount: setting.mount.bind(setting),
mount: setting ? setting.mount.bind(setting) : () => {
},
get option() {
return danmuku.option;
},
@@ -0,0 +1,56 @@
export function getMode(key) {
switch (key) {
case 1:
case 2:
case 3:
return 0
case 4:
return 2
case 5:
return 1
default:
return 0
}
}
export function bilibiliDanmuParseFromXml(xmlString) {
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(',')
if (attr.length >= 8) {
const text = match.groups.text
.trim()
.replaceAll('&quot;', '"')
.replaceAll('&apos;', '\'')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
return {
text,
time: Number(attr[0]),
mode: getMode(Number(attr[1])),
fontSize: Number(attr[2]),
color: `#${Number(attr[3]).toString(16)}`,
timestamp: Number(attr[4]),
pool: Number(attr[5]),
userID: attr[6],
rowID: Number(attr[7]),
}
}
else {
return null
}
})
.filter(Boolean)
}
export function onmessage({ data }) {
const { xml, id } = data
const danmus = bilibiliDanmuParseFromXml(xml)
globalThis.postMessage({ danmus, id })
}
+132 -81
View File
@@ -1,93 +1,144 @@
function getMode(key) {
switch (key) {
case 1:
case 2:
case 3:
return 0
case 4:
return 2
case 5:
return 1
default:
return 0
}
import { bilibiliDanmuParseFromXml, getMode, onmessage } from './bilibili-parser'
let nextRequest = 0
function abortError(signal) {
if (signal?.reason !== undefined)
return signal.reason
const error = new Error('Bilibili Danmu loading cancelled')
error.name = 'AbortError'
return error
}
function bilibiliDanmuParseFromXml(xmlString) {
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(',')
if (attr.length >= 8) {
const text = match.groups.text
.trim()
.replaceAll('&quot;', '"')
.replaceAll('&apos;', '\'')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
return new Promise((resolve, reject) => {
let settled = false
let worker
let workerUrl
let releaseCancel
const id = ++nextRequest
const cancel = () => finish(undefined, abortError(signal))
return {
text,
time: Number(attr[0]),
mode: getMode(Number(attr[1])),
fontSize: Number(attr[2]),
color: `#${Number(attr[3]).toString(16)}`,
timestamp: Number(attr[4]),
pool: Number(attr[5]),
userID: attr[6],
rowID: Number(attr[7]),
function releaseWorker() {
let error
let failed = false
try {
if (worker) {
const current = worker
worker = undefined
current.onmessage = null
current.onerror = null
current.onmessageerror = null
current.terminate()
}
}
else {
return null
catch (failure) {
error = failure
failed = true
}
})
.filter(Boolean)
}
function onmessage({ data }) {
const { xml, id } = data
if (!id || !xml)
return
const danmus = bilibiliDanmuParseFromXml(xml)
globalThis.postMessage({ danmus, id })
}
function createWorker() {
const workerText = `
${getMode.toString()}
${bilibiliDanmuParseFromXml.toString()}
onmessage = ${onmessage.toString()}
`
const blob = new Blob([workerText], { type: 'application/javascript' })
return new Worker(URL.createObjectURL(blob))
}
export function bilibiliDanmuParseFromUrl(url) {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
const res = await fetch(url)
const xml = await res.text()
try {
const worker = createWorker()
worker.onmessage = (event) => {
const { danmus, id } = event.data
if (!id || !danmus)
return
resolve(danmus)
worker.terminate()
if (workerUrl !== undefined) {
const current = workerUrl
workerUrl = undefined
try {
URL.revokeObjectURL(current)
}
catch (failure) {
if (!failed)
error = failure
failed = true
}
}
worker.postMessage({ xml, id: Date.now() })
if (failed)
throw error
}
catch (error) {
function finish(value, error) {
if (settled)
return
settled = true
let failed = arguments.length > 1
signal?.removeEventListener('abort', cancel)
releaseCancel?.()
try {
releaseWorker()
}
catch (cleanupError) {
if (!failed)
error = cleanupError
failed = true
}
if (failed)
reject(error)
else resolve(value)
}
function fallback(xml, error) {
if (settled)
return
console.error('Error parsing Bilibili Danmu:', error)
const danmus = bilibiliDanmuParseFromXml(xml)
resolve(danmus)
try {
finish(bilibiliDanmuParseFromXml(xml))
}
catch (failure) {
finish(undefined, failure)
}
}
async function read() {
const response = signal ? await fetch(url, { signal }) : await fetch(url)
if (settled)
return
const xml = await response.text()
if (settled)
return
try {
const workerText = `
${getMode.toString()}
${bilibiliDanmuParseFromXml.toString()}
onmessage = ${onmessage.toString()}
`
workerUrl = URL.createObjectURL(new Blob([workerText], { type: 'application/javascript' }))
if (settled) {
releaseWorker()
return
}
worker = new Worker(workerUrl)
if (settled) {
releaseWorker()
return
}
worker.onmessage = (event) => {
if (settled || event.data?.id !== id)
return
const { danmus } = event.data
if (!Array.isArray(danmus)) {
finish(undefined, new Error('Invalid Bilibili Danmu worker response'))
return
}
finish(danmus)
}
worker.onerror = (event) => {
event.preventDefault?.()
fallback(xml, event.error || event)
}
worker.onmessageerror = event => fallback(xml, event.error || event)
worker.postMessage({ xml, id })
}
catch (error) {
fallback(xml, error)
}
}
if (signal?.aborted) {
cancel()
return
}
signal?.addEventListener('abort', cancel)
releaseCancel = onCancel?.(cancel)
if (settled) {
releaseCancel?.()
return
}
read().catch(error => finish(undefined, error))
})
}
@@ -0,0 +1,89 @@
export function defaultOption() {
return {
danmuku: [],
speed: 5,
margin: [10, '25%'],
opacity: 1,
color: '#FFFFFF',
mode: 0,
modes: [0, 1, 2],
fontSize: 25,
antiOverlap: true,
synchronousPlayback: false,
mount: undefined,
heatmap: false,
width: 512,
points: [],
filter: () => true,
beforeEmit: () => true,
beforeVisible: () => true,
visible: true,
emitter: true,
maxLength: 200,
lockTime: 5,
theme: 'dark',
OPACITY: {},
FONT_SIZE: {},
MARGIN: {},
SPEED: {},
COLOR: [],
}
}
export function optionScheme() {
return {
danmuku: 'array|function|string|promise',
speed: 'number',
margin: 'array',
opacity: 'number',
color: 'string',
mode: 'number',
modes: 'array',
fontSize: 'number|string',
antiOverlap: 'boolean',
synchronousPlayback: 'boolean',
mount: '?htmldivelement|string',
heatmap: 'object|boolean',
width: 'number',
points: 'array',
filter: 'function',
beforeEmit: 'function',
beforeVisible: 'function',
visible: 'boolean',
emitter: 'boolean',
maxLength: 'number',
lockTime: 'number',
theme: 'string',
OPACITY: 'object',
FONT_SIZE: 'object',
MARGIN: 'object',
SPEED: 'object',
COLOR: 'array',
}
}
export function isPromiseInput(value) {
return value instanceof Promise || Object.prototype.toString.call(value) === '[object Promise]'
}
export function optionChanged(previous, update) {
return Object.keys(update).some((key) => {
const before = previous[key]
const after = update[key]
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 }) {
const next = Object.assign({}, defaults, previous, update)
validate(next)
next.mode = clamp(next.mode, 0, 2)
next.speed = clamp(next.speed, 1, 10)
next.opacity = clamp(next.opacity, 0, 1)
next.lockTime = clamp(next.lockTime, 1, 60)
next.maxLength = clamp(next.maxLength, 1, 1000)
next.mount = next.mount || mount
return next
}
+65 -100
View File
@@ -1,4 +1,5 @@
import { bilibiliDanmuParseFromUrl } from './bilibili'
import { defaultOption, normalizeOption, optionChanged, optionScheme } from './config'
import { beginInput, cancelInputs, inputActive, readInput } from './input'
import DanmuWorker from './worker.js?worker&inline'
export default class Danmuku {
@@ -17,6 +18,7 @@ export default class Danmuku {
this.isHide = false // 是否隐藏
this.timer = null // 定时器
this.index = 0 // 弹幕索引
this.worker = null
// 格式化后的配置项
this.option = Danmuku.option
@@ -24,12 +26,6 @@ export default class Danmuku {
// 弹幕状态池
this.states = { wait: [], ready: [], emit: [], stop: [] }
// 初始化配置
this.config(option, true)
// 创建 Web Worker, 用于计算弹幕的 top 值
this.worker = new DanmuWorker()
// 绑定公用事件
this.start = this.start.bind(this)
this.stop = this.stop.bind(this)
@@ -37,82 +33,41 @@ export default class Danmuku {
this.resize = this.resize.bind(this)
this.destroy = this.destroy.bind(this)
// 配置事件可同步销毁播放器,先建立输入和销毁边界。
inputActive(this)
art.on('destroy', this.destroy)
try {
this.config(option, true)
if (!inputActive(this))
return
// 创建 Web Worker, 用于计算弹幕的 top 值
this.worker = new DanmuWorker()
}
catch (error) {
art.off('destroy', this.destroy)
cancelInputs(this)
throw error
}
// 监听事件
art.on('video:play', this.start)
art.on('video:playing', this.start)
art.on('video:pause', this.stop)
art.on('video:waiting', this.stop)
art.on('destroy', this.destroy)
art.on('resize', this.resize)
// 开始加载弹幕
this.load()
this.load().catch(error => console.warn('Failed to load initial danmuku:', error))
}
// 默认配置
static get option() {
return {
danmuku: [], // 弹幕数据
speed: 5, // 弹幕持续时间,范围在[1 ~ 10]
margin: [10, '25%'], // 弹幕上下边距,支持像素数字和百分比
opacity: 1, // 弹幕透明度,范围在[0 ~ 1]
color: '#FFFFFF', // 默认弹幕颜色,可以被单独弹幕项覆盖
mode: 0, // 默认弹幕模式: 0: 滚动,1: 顶部,2: 底部
modes: [0, 1, 2], // 弹幕可见的模式
fontSize: 25, // 弹幕字体大小,支持像素数字和百分比
antiOverlap: true, // 弹幕是否防重叠
synchronousPlayback: false, // 是否同步播放速度
mount: undefined, // 弹幕发射器挂载点, 默认为播放器控制栏中部
heatmap: false, // 是否开启热力图
width: 512, // 当播放器宽度小于此值时,弹幕发射器置于播放器底部
points: [], // 热力图数据
filter: () => true, // 弹幕载入前的过滤器,只支持返回布尔值
beforeEmit: () => true, // 弹幕发送前的过滤器,支持返回 Promise
beforeVisible: () => true, // 弹幕显示前的过滤器,支持返回 Promise
visible: true, // 弹幕层是否可见
emitter: true, // 是否开启弹幕发射器
maxLength: 200, // 弹幕输入框最大长度, 范围在[1 ~ 1000]
lockTime: 5, // 输入框锁定时间,范围在[1 ~ 60]
theme: 'dark', // 弹幕主题,支持 dark 和 light,只在自定义挂载时生效
OPACITY: {}, // 不透明度配置项
FONT_SIZE: {}, // 弹幕字号配置项
MARGIN: {}, // 显示区域配置项
SPEED: {}, // 弹幕速度配置项
COLOR: [], // 颜色列表配置项
}
return defaultOption()
}
// 配置校验
static get scheme() {
return {
danmuku: 'array|function|string',
speed: 'number',
margin: 'array',
opacity: 'number',
color: 'string',
mode: 'number',
modes: 'array',
fontSize: 'number|string',
antiOverlap: 'boolean',
synchronousPlayback: 'boolean',
mount: '?htmldivelement|string',
heatmap: 'object|boolean',
width: 'number',
points: 'array',
filter: 'function',
beforeEmit: 'function',
beforeVisible: 'function',
visible: 'boolean',
emitter: 'boolean',
maxLength: 'number',
lockTime: 'number',
theme: 'string',
OPACITY: 'object',
FONT_SIZE: 'object',
MARGIN: 'object',
SPEED: 'object',
COLOR: 'array',
}
return optionScheme()
}
// 初始弹幕样式
@@ -264,29 +219,25 @@ export default class Danmuku {
// 加载弹幕
async load(danmuku) {
const { errorHandle } = this.utils
let danmus = []
const target = danmuku || this.option.danmuku
const task = beginInput(this, danmuku === undefined)
try {
if (typeof target === 'function') {
danmus = await target() // 异步函数获取
}
else if (target instanceof Promise) {
danmus = await target // 从 Promise 对象获取
}
else if (typeof target === 'string') {
danmus = await bilibiliDanmuParseFromUrl(target) // 从B站xml链接解析
}
else if (Array.isArray(target)) {
danmus = target // 直接传入数组
}
if (!task.active())
return this
const target = danmuku || this.option.danmuku
const input = readInput(target, task)
// 数组在第一次 await 前提交,空数组的 loaded 保持同步。
const danmus = input.asynchronous ? await task.wait(input.value) : input.value
if (!task.active())
return this
errorHandle(Array.isArray(danmus), 'Danmuku need return an array as result')
// 假如没有传入弹幕参数,则清空弹幕,否则追加弹幕
if (danmuku === undefined) {
this.reset() // 重置弹幕
if (!task.active())
return this
this.queue = [] // 清空弹幕队列
this.states = { wait: [], ready: [], emit: [], stop: [] } // 清空弹幕状态池
this.$refs = [] // 清空弹幕DOM节点池
@@ -295,16 +246,24 @@ export default class Danmuku {
// 逐个验证原始弹幕并转换到弹幕队列
for (let index = 0; index < danmus.length; index++) {
if (!task.active())
return this
const danmu = danmus[index]
await this.emit(danmu)
await task.emit(danmu)
}
this.art.emit('artplayerPluginDanmuku:loaded', this.queue)
if (task.active())
this.art.emit('artplayerPluginDanmuku:loaded', this.queue)
}
catch (error) {
if (!task.active())
return this
this.art.emit('artplayerPluginDanmuku:error', error)
throw error
}
finally {
task.finish()
}
return this
}
@@ -312,6 +271,8 @@ export default class Danmuku {
// 把原始弹幕转换到弹幕队列
async emit(danmu) {
const { clamp } = this.utils
if (!inputActive(this))
return this
this.validator(danmu, {
id: '?string', // 弹幕唯一标识
@@ -328,7 +289,7 @@ export default class Danmuku {
return this
// 设置弹幕时间,如果没有则默认为当前时间加 0.5 秒
if (danmu.time) {
if (danmu.time !== undefined) {
danmu.time = clamp(danmu.time, 0, Infinity)
}
else {
@@ -357,6 +318,8 @@ export default class Danmuku {
// 自定义弹幕过滤函数
if (!this.option.filter(danmu))
return this
if (!inputActive(this))
return this
// 添加自定义属性
const item = {
@@ -389,28 +352,28 @@ export default class Danmuku {
const { $controlsCenter } = this.art.template
// 判断配置项是否有变化
const changed = Object.keys(option).some(
key => JSON.stringify(this.option[key]) !== JSON.stringify(option[key]),
)
const changed = optionChanged(this.option, option)
// 没有变化则直接返回
if (!changed && !isInit)
return this
// 更新配置项
this.option = Object.assign({}, Danmuku.option, this.option, option)
this.validator(this.option, Danmuku.scheme)
this.option.mode = clamp(this.option.mode, 0, 2)
this.option.speed = clamp(this.option.speed, 1, 10)
this.option.opacity = clamp(this.option.opacity, 0, 1)
this.option.lockTime = clamp(this.option.lockTime, 1, 60)
this.option.maxLength = clamp(this.option.maxLength, 1, 1000)
this.option.mount = this.option.mount || $controlsCenter
const next = normalizeOption(this.option, option, {
defaults: Danmuku.option,
validate: value => this.validator(value, Danmuku.scheme),
clamp,
mount: $controlsCenter,
})
if (!inputActive(this))
return this
this.option = next
// 动态配置有字体大小,需要重新渲染
if (option.fontSize) {
this.reset()
if (!inputActive(this))
return this
}
// 通过配置项控制弹幕的显示和隐藏
@@ -421,7 +384,8 @@ export default class Danmuku {
this.hide()
}
this.art.emit('artplayerPluginDanmuku:config', this.option)
if (inputActive(this))
this.art.emit('artplayerPluginDanmuku:config', this.option)
return this
}
@@ -718,13 +682,14 @@ export default class Danmuku {
}
destroy() {
cancelInputs(this)
this.stop()
this.worker.terminate()
this.worker?.terminate()
this.art.off('video:play', this.start)
this.art.off('video:playing', this.start)
this.art.off('video:pause', this.stop)
this.art.off('video:waiting', this.stop)
this.art.off('resize', this.reset)
this.art.off('resize', this.resize)
this.art.off('destroy', this.destroy)
this.art.emit('artplayerPluginDanmuku:destroy')
}
@@ -5,9 +5,9 @@ import Setting from './setting'
export default function artplayerPluginDanmuku(option) {
return (art) => {
const danmuku = new Danmuku(art, option)
const setting = new Setting(art, danmuku)
const setting = art.isDestroy ? undefined : new Setting(art, danmuku)
if (danmuku.option.heatmap) {
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
}
@@ -19,7 +19,7 @@ export default function artplayerPluginDanmuku(option) {
hide: danmuku.hide.bind(danmuku),
show: danmuku.show.bind(danmuku),
reset: danmuku.reset.bind(danmuku),
mount: setting.mount.bind(setting),
mount: setting ? setting.mount.bind(setting) : () => {},
get option() {
return danmuku.option
},
@@ -0,0 +1,88 @@
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)
}
export function inputActive(owner) {
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) {
const state = stateFor(owner)
if (replace) {
for (const task of [...state.tasks]) {
if (task.replace)
task.cancel()
}
}
let stopped = state.closed || owner.art.isDestroy
let resolveCancel
const cancellation = new Promise(resolve => resolveCancel = resolve)
const handlers = new Set()
const controller = typeof AbortController === 'function' ? new AbortController() : undefined
const task = {
replace,
signal: controller?.signal,
active: () => !stopped && !state.closed && !owner.art.isDestroy,
onCancel(callback) {
if (!task.active()) {
callback()
return () => {}
}
handlers.add(callback)
return () => handlers.delete(callback)
},
cancel() {
if (stopped)
return
stopped = true
resolveCancel(cancelled)
controller?.abort()
for (const callback of [...handlers]) callback()
handlers.clear()
},
wait: value => Promise.race([value, cancellation]),
emit(danmu) {
state.emitting.push(task)
try {
return owner.emit(danmu)
}
finally {
state.emitting.pop()
}
},
finish() {
state.tasks.delete(task)
handlers.clear()
},
}
if (stopped)
resolveCancel(cancelled)
else state.tasks.add(task)
return task
}
export function cancelInputs(owner) {
const state = stateFor(owner)
state.closed = true
for (const task of [...state.tasks]) task.cancel()
}
export function readInput(target, task) {
if (typeof target === 'function')
return { asynchronous: true, value: target() }
if (isPromiseInput(target))
return { asynchronous: true, value: target }
if (typeof target === 'string')
return { asynchronous: true, value: bilibiliDanmuParseFromUrl(target, task) }
return { asynchronous: false, value: Array.isArray(target) ? target : [] }
}