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

+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){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
+352 -148
View File
@@ -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;
},
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:unit": "node --test test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -100,7 +100,7 @@
"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-failures.test.js refactor/scripts/danmuku-contract.test.mjs"
"test:danmuku": "node --test test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js refactor/scripts/danmuku-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -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 : [] }
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,73 @@
# PKG-DANMUKU-03 加载、解析和配置重构
弹幕生产源码已开始分层:`config.js`负责默认值、schema、变化比较与归一化;
`input.js`负责输入形式、替换/追加归属及取消;`bilibili-parser.js`保持原XML转换;
`bilibili.js`负责网络、解析Worker及资源收尾。原`danmuku.js`保留队列提交与事件出口,
`index.js`保留同步门面。包内[维护地图](../../packages/artplayer-plugin-danmuku/ARCHITECTURE.md)
和README说明这些边界。此步使用JS拆分,完整TS/Worker消息/公开声明归06,不能将本步
描述成整个弹幕包已完成TS迁移。
## 保持的契约与明确修复
- `load/emit`仍返回Promise并解析为内部Danmuku,区别于同步门面;绑定方法、getter、
属性顺序、`config/hide/show/reset`同步返回身份和`mount`的undefined结果保持。
- 空数组初始化仍在注册返回前发出show/config/reset/loaded;非空数组在首次await前
插入第一项,其后逐项await emit。过滤器接收当前option为this,加载函数无显式receiver。
- 无参数load成功获取输入后替换;显式load追加且不修改配置。并发追加彼此独立,也可
在替换后继续追加。逐项错误仍保留此前已接受项,并由load发一次原始error再拒绝。
- 修复旧替换迟到覆盖新替换;新替换只使旧替换失效。销毁取消所有加载。取消的公共
Promise成功解析为同一内部实例,不发迟到loaded/error、不写队列;外部Promise仍可
运行,但其拒绝有局部观察。可用时abort fetch,不依赖AbortController才能正确取消。
- 在reset、filter及异步返回后检查归属。只检查当前嵌套emit任务,允许旧filter里同步
发起的新替换正常写入,再阻止旧调用续写;不是把旧任务的取消状态传给整个嵌套调用栈。
- `time:0`保持0,负值钳位0,缺省为currentTime+0.5;原输入对象补mode/style/color后
过滤及浅复制行为仍保留,未借修复时间顺便更改所有对象身份。
- 单独更新函数配置现在生效,Promise按身份更新,普通结构相等项仍保持静默。先校验
临时合并值再提交,非法配置不污染当前option。初始Promise输入与既有声明对齐。
- fetch/text拒绝不再因async Promise executor悬挂;公开load保留拒绝,自动初始load
在实例内观察拒绝并console.warn。空XML正常结束,解析Worker失败沿相同parser降级。
成功/降级/协议错误/取消均清理Worker与Blob;其中一项清理抛错仍尝试另一项,原业务
错误优先。原XML实体顺序、模式、未补齐颜色与数值转换保持,不引入HTTP状态硬拒绝。
## 初始化闭环与审查结果
为了捕获初始show/config/loaded中同步销毁,提前建立destroy订阅并在入口跳过后续
Setting/heatmap创建。非法配置或初始轨道Worker构造失败回滚提前订阅并重抛原错。
新增测试实际发现旧resize解绑身份错误;本步最小修正为解绑实际this.resize,避免
初始化已销毁仍留监听。Setting既有DOM/计时器、RAF/轨道算法与heatmap没有在本步重写。
三代理分工为源码、输入测试、解析测试,父代理负责审查、真实浏览器、构建及整合。
只读复审也独立指出Worker构造失败的提前订阅泄漏,修复后通过同一原错及零新增监听
断言。没有修改冻结历史helper或把候选修复反写进旧版证据。
## 验证与失败证据
- 冻结旧契约44项及旧失败42项,共86项父代理复跑通过。
- 候选20项输入/配置/初始化和26项解析/取消/错误测试,源码、正式main、正式legacy
各46项联合通过,共138次;严格子进程确认自动加载已观察、公开load仍拒绝。
- 原生浏览器:3种实现×2核心×4场景×3引擎,共72项通过,无跳过或未处理页面错误。
核心为真实已发布5.4.0及候选5.4.1;Chromium、Firefox和Windows WebKit。
成功路径断言收到实际Worker回复,不能让fallback掩盖压缩后函数脚本失效;故障场景
单独替换原生解析Worker脚本为抛错脚本,确认局部错误处理、真实fallback与资源回收。
- 使用仓库正常build生成main/legacy/esm及docs/compiled对应产物;ESM本步为构建通过,
不把它计入三组运行用例。改动文件lint及全仓typecheck通过,后者检查366个已迁移
生产TS文件与消费者矩阵,未迁移JS不算TS覆盖。
产物单测首轮main/legacy各41通过5失败:测试在工厂前注入parser Worker故障,产物
inline Worker查全局Worker时误伤了初始轨道Worker;source夹具先捕获构造器而未显露
此问题。将parser专用故障移到正常初始化和flush之后,原清理/原错断言全部保持;
三组各26解析复验和最终各46联合复验均通过。原失败日志保留且机器报告引用哈希,
不把这五项错误测试归为新增生产缺陷。初始化泄漏的红测则确有源码修复。
完整输入指纹、构建文件、日志、浏览器版本和原始附件见
[机器证据](../baselines/danmuku-input-validation.json)。未新增依赖或改动Yarn锁文件,
根`test:unit`与`test:danmuku`加入候选测试,浏览器沿用既有CI发现规则。
## 后续范围
INPUT-01与CONFIG-01在本步关闭;ASYNC-01只完成输入/解析子项,待04/05的可见性续体、
轨道Worker及单RAF所有权。TYPE-01待06真实安装声明矩阵,CLEANUP-01只修resize部分。
此前真实轨道重叠、heatmap零采样和Setting资源问题保持开放;72输入浏览器通过不代表
它们被修复。04继续时钟/队列/轨道,05负责渲染/设置/热力图,06完整TS,07做稳定性验收。
本地XML及故障夹具不是Bilibili实网、CSP矩阵、Safari设备或长时负载验收,也不是npm
发布就绪证明。本任务独立commit,无推送、标签或发布。
+4 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
状态:todo 72 / doing 15 / blocked 0 / done 141 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 71 / doing 15 / blocked 0 / done 142 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -311,8 +311,8 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-DANMUKU-01 | artplayer-plugin-danmuku<br>核对弹幕全部公开契约 | BASE-05 | emit/load/config/hide/show/reset/mount、option/isHide/isStop/icons 与事件清单 | 源码、声明、发布包、Bilibili 输入和设置入口对照 | H | done |
| PKG-DANMUKU-02 | artplayer-plugin-danmuku<br>建立弹幕算法与浏览器基线 | PKG-DANMUKU-01, ENG-05, ENG-08 | 密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试 | 轨道选择、发射顺序、事件、内存和可视结果可重跑 | H | done |
| PKG-DANMUKU-03 | artplayer-plugin-danmuku<br>整理加载、解析与配置 | PKG-DANMUKU-02, CORE-08 | bilibili/input/parser/config 的分层与取消 | 旧格式、回调、过滤、追加/替换语义保持 | M | doing |
| PKG-DANMUKU-04 | artplayer-plugin-danmuku<br>整理时钟、队列与轨道调度 | PKG-DANMUKU-03, CORE-10 | danmuku 调度器和确定性时钟测试 | seek/暂停/倍率/长时间运行无顺序和碰撞回归 | H | todo |
| 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 | doing |
| PKG-DANMUKU-05 | artplayer-plugin-danmuku<br>整理 DOM 渲染、设置、热力图与 worker | PKG-DANMUKU-04, CORE-14, CORE-18 | renderer/setting/heatmap/worker 职责及资源归属 | mount/icons/设置和 worker 协议保持,销毁无后台工作 | H | todo |
| PKG-DANMUKU-06 | artplayer-plugin-danmuku<br>迁移 TS 与公开声明 | PKG-DANMUKU-05, ENG-04, ENG-06 | 自有模块、worker 消息、option/item/result 类型 | 旧参数、扩展字段和事件消费通过,vendored 边界清楚 | H | todo |
| PKG-DANMUKU-07 | artplayer-plugin-danmuku<br>弹幕能力稳定性验收 | PKG-DANMUKU-06 | 负载/渲染/资源对比及 mask 可依赖的稳定边界 | 在试点核心中旧插件 API 与关键帧路径通过 | H | todo |
@@ -555,6 +555,7 @@
- PKG-DPIP-05: [记录](changes/2026-09-12-PKG-DPIP-05-native-checkpoint.md) [记录](baselines/dpip-native-validation.json)
- PKG-DANMUKU-01: [记录](baselines/danmuku-release.json) [记录](baselines/danmuku-contract.md) [记录](baselines/danmuku-contract-validation.json) [记录](changes/2026-09-13-PKG-DANMUKU-01-contracts.md)
- PKG-DANMUKU-02: [记录](changes/2026-09-13-PKG-DANMUKU-02-controlled-failures.md) [记录](baselines/danmuku-failures-validation.json) [记录](changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md) [记录](baselines/danmuku-browser-validation.json)
- PKG-DANMUKU-03: [记录](changes/2026-09-13-PKG-DANMUKU-03-input.md) [记录](baselines/danmuku-input-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)
+11
View File
@@ -1,5 +1,16 @@
# 进度与证据
## PKG-DANMUKU-03 输入、解析和配置源码完成
拆分input/config/XML parser模块,修复过期替换、加载拒绝、资源清理、time0、函数配置
及初始化销毁/失败;保留同步数组前缀、内部返回身份、追加及回调约定。候选三格式
各46单测、三格式×两核心×三引擎共72真实浏览器项、旧基线86项通过;正式构建、
lint和366生产TS/消费者检查通过。首轮产物parser故障夹具误伤已校正,原日志保留。
见[变更](changes/2026-09-13-PKG-DANMUKU-03-input.md)及[机器证据](baselines/danmuku-input-validation.json)。
228项:142 done、15 doing、71 todo。04进入时钟/轨道;
本步仍是JS分层,完整TS归06,旧轨道重叠/Setting/heatmap风险继续。无推送或发布。
## PKG-DANMUKU-02 历史错误和真实浏览器基线完成
42项受控旧缺陷、48项原生Worker/视频/布局/设置及6项300弹幕负载观察通过;所有
+2 -2
View File
@@ -228,8 +228,8 @@
| 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-INPUT-01 | open / 已复现 | Danmuku replaces time zero with current time plus 0.5 and mutates supplied input objects | PKG-DANMUKU-02, PKG-DANMUKU-03 |
| DANMUKU-CONFIG-01 | open / 已复现 | Danmuku JSON configuration comparison ignores a replacement filter function | PKG-DANMUKU-02, PKG-DANMUKU-03 |
| 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 | open / 已复现 | 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-ASYNC-01 | open / 已复现 | 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 |
+33 -9
View File
@@ -5031,7 +5031,9 @@
"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-03-input.md",
"refactor/baselines/danmuku-input-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."
@@ -5040,7 +5042,7 @@
"id": "DANMUKU-INPUT-01",
"title": "Danmuku replaces time zero with current time plus 0.5 and mutates supplied input objects",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-03"
@@ -5048,16 +5050,25 @@
"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-03-input.md",
"refactor/baselines/danmuku-input-validation.json"
],
"compatibleResolution": "Separate the time-zero defect from observable input object identity/mutation before changing parsing or queue ownership.",
"closureCriteria": "Zero, absent, negative and explicit times plus object identity are covered with candidate expectations and compatibility decisions."
"closureCriteria": "Zero, absent, negative and explicit times plus object identity are covered with candidate expectations and compatibility decisions.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-03-input.md",
"refactor/baselines/danmuku-input-validation.json",
"test/danmuku-input.test.js",
"test/browser/danmuku-input.spec.js"
],
"resolutionRationale": "Candidate source/main/legacy preserve input mutation and internal returns while explicitly preserving zero and clamping negative time; native browsers confirm zero."
},
{
"id": "DANMUKU-CONFIG-01",
"title": "Danmuku JSON configuration comparison ignores a replacement filter function",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-03"
@@ -5065,10 +5076,19 @@
"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-03-input.md",
"refactor/baselines/danmuku-input-validation.json"
],
"compatibleResolution": "Compare relevant configuration values without dropping callable properties; retain callback receivers and config event ordering.",
"closureCriteria": "Function-only changes take effect, unchanged options remain stable and partial configuration/callback reentry has regression evidence."
"closureCriteria": "Function-only changes take effect, unchanged options remain stable and partial configuration/callback reentry has regression evidence.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-03-input.md",
"refactor/baselines/danmuku-input-validation.json",
"test/danmuku-input.test.js",
"test/browser/danmuku-input.spec.js"
],
"resolutionRationale": "Candidate source/main/legacy apply function-only updates, preserve equal plain values and callback receivers, reject invalid updates transactionally, and withstand synchronous reentry; native browsers confirm."
},
{
"id": "DANMUKU-CLEANUP-01",
@@ -5084,7 +5104,9 @@
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
"refactor/baselines/danmuku-browser-validation.json"
"refactor/baselines/danmuku-browser-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-03-input.md",
"refactor/baselines/danmuku-input-validation.json"
],
"compatibleResolution": "Track and release exact subscription and timer ownership across settings, renderer and instances.",
"closureCriteria": "Repeated lifecycle and pending settings actions leave no owned subscriptions, timers or late DOM writes."
@@ -5124,7 +5146,9 @@
"refactor/baselines/danmuku-failures-validation.json",
"test/danmuku-failures.test.js",
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
"refactor/baselines/danmuku-browser-validation.json"
"refactor/baselines/danmuku-browser-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-03-input.md",
"refactor/baselines/danmuku-input-validation.json"
],
"compatibleResolution": "Reproduce load ordering, Bilibili rejection and late worker/visibility paths before defining operation ownership.",
"closureCriteria": "Failures settle, obsolete results cannot alter new queues or destroyed instances, and worker resources are released with browser evidence."
+6 -3
View File
@@ -3300,11 +3300,14 @@
"PKG-DANMUKU-02",
"CORE-08"
],
"status": "doing",
"status": "done",
"risk": "M",
"deliverable": "bilibili/input/parser/config 的分层与取消",
"acceptance": "旧格式、回调、过滤、追加/替换语义保持",
"evidence": []
"evidence": [
"changes/2026-09-13-PKG-DANMUKU-03-input.md",
"baselines/danmuku-input-validation.json"
]
},
{
"id": "PKG-DANMUKU-04",
@@ -3317,7 +3320,7 @@
"PKG-DANMUKU-03",
"CORE-10"
],
"status": "todo",
"status": "doing",
"risk": "H",
"deliverable": "danmuku 调度器和确定性时钟测试",
"acceptance": "seek/暂停/倍率/长时间运行无顺序和碰撞回归",
+211
View File
@@ -0,0 +1,211 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd')
})
async function setup(page, core, testInfo) {
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
external.push(route.request().url())
return route.abort()
}
return route.continue()
})
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(() => {
const evidence = window.inputEvidence = { urls: [], revoked: [], workers: [], events: [] }
const create = URL.createObjectURL.bind(URL)
const revoke = URL.revokeObjectURL.bind(URL)
URL.createObjectURL = (blob) => {
const url = create(blob)
evidence.urls.push(url)
return url
}
URL.revokeObjectURL = (url) => {
evidence.revoked.push(url)
return revoke(url)
}
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(url, options) {
super(url, options)
this.observation = { url: String(url), terminated: 0, replies: [] }
evidence.workers.push(this.observation)
this.addEventListener('message', event => this.observation.replies.push(event.data))
}
terminate() {
this.observation.terminated++
return super.terminate()
}
}
})
await page.addScriptTag({ content: candidate })
await page.evaluate(() => {
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true })
for (const name of ['show', 'hide', 'config', 'reset', 'loaded', 'error', 'destroy']) {
window.art.on(`artplayerPluginDanmuku:${name}`, value => window.inputEvidence.events.push({ name, count: Array.isArray(value) ? value.length : undefined, error: name === 'error' ? String(value) : undefined }))
}
window.art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], heatmap: false }))
window.inputPlugin = window.art.plugins.artplayerPluginDanmuku
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await testInfo.attach('danmuku-input-build', { contentType: 'application/json', body: JSON.stringify({ core, artifact: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'in-memory source UMD', sha256: hash(candidate), worker: 'Native Blob Worker with observation only', media: '/test/pattern.mp4' }) })
return external
}
for (const core of ['published', 'candidate']) {
test(`Danmuku input native Worker startup failure falls back locally (${core})`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
await page.route('**/test/fallback.xml', route => route.fulfill({ contentType: 'text/xml', body: '<i><d p="0,5,25,255,123,0,u,1">FALLBACK</d></i>' }))
const result = await page.evaluate(async () => {
const NativeWorker = window.Worker
const createdBefore = window.inputEvidence.urls.length
const workersBefore = window.inputEvidence.workers.length
window.Worker = class extends NativeWorker {
constructor(url, options) {
const fault = URL.createObjectURL(new Blob(['throw new Error("controlled parser startup failure")'], { type: 'application/javascript' }))
super(fault, options)
URL.revokeObjectURL(fault)
}
}
const internal = await window.inputPlugin.load('/test/fallback.xml')
const result = { queue: internal.queue.map(({ text, time, mode }) => ({ text, time, mode })), workers: window.inputEvidence.workers.slice(workersBefore), urls: window.inputEvidence.urls.slice(createdBefore), revoked: window.inputEvidence.revoked, errors: window.inputEvidence.events.filter(event => event.name === 'error') }
window.art.destroy()
return result
})
expect(result.queue).toEqual([{ text: 'FALLBACK', time: 0, mode: 1 }])
expect(result.workers).toHaveLength(1)
expect(result.workers[0].terminated).toBe(1)
expect(result.urls).toHaveLength(2)
for (const url of result.urls)
expect(result.revoked.filter(item => item === url)).toHaveLength(1)
expect(result.errors).toEqual([])
expect(external).toEqual([])
await testInfo.attach('danmuku-native-worker-fault', { contentType: 'application/json', body: JSON.stringify({ ...result, fault: 'Test replaces only the newly created native parser Worker script with a thrown startup Error; fetch, fallback parser and error dispatch remain real.' }) })
})
test(`Danmuku input native parser cleanup and rejected fetch (${core})`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
await page.route('**/test/input.xml', route => route.fulfill({ contentType: 'text/xml', body: '<i><d p="0,1,25,255,123,0,user,456"> A &amp; B </d><d p="2,4,20,16777215,124,0,u,789">TOP</d></i>' }))
await page.route('**/test/empty.xml', route => route.fulfill({ contentType: 'text/xml', body: '' }))
await page.route('**/test/rejected.xml', route => route.abort('failed'))
const result = await page.evaluate(async () => {
const plugin = window.inputPlugin
const start = window.inputEvidence.workers.length
const internal = await plugin.load('/test/input.xml')
const parsed = internal.queue.map(({ text, time, mode, color, userID, rowID }) => ({ text, time, mode, color, userID, rowID }))
const emptyIdentity = await plugin.load('/test/empty.xml') === internal
let rejection
try {
await plugin.load('/test/rejected.xml')
}
catch (error) { rejection = String(error) }
const parserWorkers = window.inputEvidence.workers.slice(start)
const beforeDestroy = internal.queue.length
window.art.destroy()
return { parsed, emptyIdentity, beforeDestroy, rejection, parserWorkers, ...window.inputEvidence }
})
expect(result.parsed).toEqual([{ text: 'A & B', time: 0, mode: 0, color: '#ff', userID: 'user', rowID: 456 }, { text: 'TOP', time: 2, mode: 2, color: '#ffffff', userID: 'u', rowID: 789 }])
expect(result.emptyIdentity).toBe(true)
expect(result.beforeDestroy).toBe(2)
expect(result.rejection).toBeTruthy()
expect(result.events.filter(event => event.name === 'error')).toHaveLength(1)
expect(result.parserWorkers.length).toBeGreaterThanOrEqual(1)
for (const worker of result.parserWorkers) {
expect(worker.terminated).toBe(1)
expect(result.revoked.filter(url => url === worker.url)).toHaveLength(1)
expect(worker.replies).toHaveLength(1)
expect(Array.isArray(worker.replies[0].danmus)).toBe(true)
}
expect(external).toEqual([])
await testInfo.attach('danmuku-native-parser', { contentType: 'application/json', body: JSON.stringify(result) })
})
test(`Danmuku input replacement append and destruction (${core})`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
const result = await page.evaluate(async () => {
const plugin = window.inputPlugin
const internal = plugin.config({ danmuku: [] })
const initialEvents = window.inputEvidence.events.map(event => event.name)
let resolveOld
plugin.config({ danmuku: () => new Promise(resolve => resolveOld = resolve) })
const old = plugin.load()
plugin.config({ danmuku: [{ text: 'new', time: 0 }] })
const latestIdentity = await plugin.load() === internal
resolveOld([{ text: 'obsolete' }])
const oldIdentity = await old === internal
let resolveA
let resolveB
const a = plugin.load(() => new Promise(resolve => resolveA = resolve))
const b = plugin.load(() => new Promise(resolve => resolveB = resolve))
resolveB([{ text: 'append B' }])
await b
resolveA([{ text: 'append A' }])
await a
const queue = internal.queue.map(item => ({ text: item.text, time: item.time }))
let resolveLate
const late = plugin.load(() => new Promise(resolve => resolveLate = resolve))
const loadedBefore = window.inputEvidence.events.filter(event => event.name === 'loaded').length
window.art.destroy()
resolveLate([{ text: 'after destroy' }])
const destroyedIdentity = await late === internal
return { initialEvents, latestIdentity, oldIdentity, destroyedIdentity, queue, finalQueue: internal.queue.map(item => item.text), loadedBefore, loadedAfter: window.inputEvidence.events.filter(event => event.name === 'loaded').length, events: window.inputEvidence.events }
})
expect(result.initialEvents.slice(0, 4)).toEqual(['show', 'config', 'reset', 'loaded'])
expect(result.latestIdentity && result.oldIdentity && result.destroyedIdentity).toBe(true)
expect(result.queue.map(item => item.text)).toEqual(['new', 'append B', 'append A'])
expect(result.queue[0].time).toBe(0)
expect(result.finalQueue).toEqual(['new', 'append B', 'append A'])
expect(result.loadedAfter).toBe(result.loadedBefore)
expect(result.events.filter(event => event.name === 'error')).toEqual([])
expect(external).toEqual([])
await testInfo.attach('danmuku-native-input-ownership', { contentType: 'application/json', body: JSON.stringify(result) })
})
test(`Danmuku config callback replacement and validation (${core})`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
const result = await page.evaluate(async () => {
const plugin = window.inputPlugin
const receiver = []
const next = function (item) {
receiver.push(this === plugin.option)
return item.text !== 'excluded'
}
const internal = plugin.config({ filter: next })
const count = window.inputEvidence.events.filter(event => event.name === 'config').length
const unchangedIdentity = plugin.config({ filter: next, margin: [...plugin.option.margin] }) === internal
const unchangedCount = window.inputEvidence.events.filter(event => event.name === 'config').length
const previous = plugin.option
let error
try {
plugin.config({ speed: 'invalid' })
}
catch (failure) { error = String(failure) }
await plugin.emit({ text: 'excluded', time: 0 })
const input = { text: 'included', time: 0 }
const emittedIdentity = await plugin.emit(input) === internal
const result = { unchangedIdentity, count, unchangedCount, unchangedOption: plugin.option === previous, filterIdentity: plugin.option.filter === next, error, receiver, emittedIdentity, input, queue: internal.queue.map(item => ({ text: item.text, time: item.time })) }
window.art.destroy()
return result
})
expect(result.unchangedIdentity && result.unchangedOption && result.filterIdentity && result.emittedIdentity).toBe(true)
expect(result.unchangedCount).toBe(result.count)
expect(result.error).toBeTruthy()
expect(result.receiver).toEqual([true, true])
expect(result.queue).toEqual([{ text: 'included', time: 0 }])
expect(result.input).toMatchObject({ time: 0, mode: 0, color: '#FFFFFF', style: {} })
expect(external).toEqual([])
await testInfo.attach('danmuku-native-config', { contentType: 'application/json', body: JSON.stringify(result) })
})
}
+355
View File
@@ -0,0 +1,355 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate compatibility and failure tests use the Node runner.
import test from 'node:test'
import { setImmediate as nextTurn } from 'node:timers/promises'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
const prefix = `Danmuku ${implementation.name}`
const names = env => env.events.map(event => event.name)
const loaded = env => env.events.filter(event => event.name === 'artplayerPluginDanmuku:loaded')
const failures = env => env.events.filter(event => event.name === 'artplayerPluginDanmuku:error')
function fixture(option = { danmuku: [] }) {
const env = danmukuCandidateEnvironment(implementation)
const plugin = env.factory(option)(env.art)
return { env, plugin }
}
test(`${prefix}: synchronous empty initialization and load retain method return identity and getter surface`, async () => {
const { env, plugin } = fixture()
assert.equal(typeof plugin.then, 'undefined')
assert.deepEqual(Object.keys(plugin), ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop'])
assert.deepEqual(names(env), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config', 'artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'])
const internal = plugin.show()
assert.notEqual(internal, plugin)
assert.equal(internal.name, undefined)
const start = env.events.length
const promise = plugin.load()
assert.equal(typeof promise.then, 'function')
assert.deepEqual(names(env).slice(start), ['artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'], 'Empty arrays finish the visible work before the first await')
assert.equal(await promise, internal)
const emit = plugin.emit
assert.equal(await emit({ text: 'bound', time: 12 }), internal)
assert.equal(plugin.hide(), internal)
assert.equal(plugin.reset(), internal)
env.destroy()
})
test(`${prefix}: nonempty array load inserts the first row synchronously and resolves to internal state`, async () => {
const { env, plugin } = fixture()
const internal = plugin.show()
const count = loaded(env).length
const pending = plugin.load([{ text: 'first', time: 1 }, { text: 'second', time: 2 }])
assert.deepEqual(Array.from(internal.queue, row => row.text), ['first'])
assert.equal(loaded(env).length, count)
assert.equal(await pending, internal)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['first', 'second'])
assert.equal(loaded(env).at(-1).args[0], internal.queue)
env.destroy()
})
test(`${prefix}: explicit targets append and omitted target replaces without changing configured input`, async () => {
const { env, plugin } = fixture()
const configured = [{ text: 'configured', time: 2 }]
plugin.config({ danmuku: configured })
const internal = await plugin.load()
await plugin.load([{ text: 'append', time: 3 }])
assert.equal(plugin.option.danmuku, configured)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['configured', 'append'])
await plugin.load(undefined)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['configured'])
env.destroy()
})
test(`${prefix}: independent asynchronous appends survive reversed completion and a replacement`, async () => {
const { env, plugin } = fixture()
const first = deferred()
const second = deferred()
const a = plugin.load(() => first.promise)
const b = plugin.load(() => second.promise)
plugin.config({ danmuku: [{ text: 'replacement', time: 1 }] })
const internal = await plugin.load()
second.resolve([{ text: 'second completed first', time: 2 }])
assert.equal(await b, internal)
first.resolve([{ text: 'first completed last', time: 3 }])
assert.equal(await a, internal)
assert.deepEqual(Array.from(internal.queue, row => row.text), ['replacement', 'second completed first', 'first completed last'])
env.destroy()
})
test(`${prefix}: replacement supersedes only the older replacement and settles it before its input completes`, async () => {
const { env, plugin } = fixture()
const first = deferred()
const second = deferred()
const jobs = [first, second]
plugin.config({ danmuku: () => jobs.shift().promise })
const internal = plugin.show()
let oldSettled = false
const a = plugin.load().then((value) => {
oldSettled = true
return value
})
const b = plugin.load()
await env.flush()
assert.equal(oldSettled, true, 'Cancellation must settle the public Promise without awaiting a stale source')
assert.equal(await a, internal)
second.resolve([{ text: 'current', time: 1 }])
await b
const count = env.events.length
first.resolve([{ text: 'obsolete', time: 2 }])
await env.flush()
assert.deepEqual(Array.from(internal.queue, row => row.text), ['current'])
assert.equal(env.events.length, count)
env.destroy()
})
test(`${prefix}: destroyed pending replacement and appends settle without late state or error events`, async () => {
const { env, plugin } = fixture()
const replacement = deferred()
const append = deferred()
plugin.config({ danmuku: () => replacement.promise })
const internal = plugin.show()
const pending = [plugin.load(), plugin.load(() => append.promise)]
let settled = false
const all = Promise.all(pending).then((results) => {
settled = true
return results
})
env.destroy()
await env.flush()
assert.equal(settled, true, 'Destroy settles all owned loads without depending on their unresolved inputs')
assert.deepEqual(await all, [internal, internal])
const count = env.events.length
replacement.resolve([{ text: 'late', time: 1 }])
append.reject(new Error('late append failure'))
await nextTurn()
assert.equal(internal.queue.length, 0)
assert.equal(env.events.length, count)
assert.equal(failures(env).length, 0)
})
test(`${prefix}: reset event reentry can replace input without an older load clearing or adding rows`, async () => {
const { env, plugin } = fixture()
plugin.config({ danmuku: [{ text: 'old', time: 1 }] })
let nested
let entered = false
env.art.on('artplayerPluginDanmuku:reset', () => {
if (entered)
return
entered = true
plugin.config({ danmuku: [{ text: 'new', time: 2 }] })
nested = plugin.load()
})
const count = loaded(env).length
const internal = await plugin.load()
await nested
assert.deepEqual(Array.from(internal.queue, row => row.text), ['new'])
assert.equal(loaded(env).length, count + 1)
env.destroy()
})
for (const action of ['replace', 'destroy']) {
test(`${prefix}: filter synchronous ${action} reentry prevents the obsolete row from being pushed`, async () => {
const { env, plugin } = fixture()
let nested
plugin.config({
danmuku: [{ text: 'old1', time: 1 }, { text: 'old2', time: 2 }],
filter(row) {
if (row.text === 'old1') {
if (action === 'destroy') {
env.destroy()
}
else {
plugin.config({ danmuku: [{ text: 'new', time: 3 }] })
nested = plugin.load()
}
}
return true
},
})
const count = loaded(env).length
const internal = await plugin.load()
await nested
assert.deepEqual(Array.from(internal.queue, row => row.text), action === 'destroy' ? [] : ['new'])
assert.equal(loaded(env).length, count + (action === 'destroy' ? 0 : 1))
if (action !== 'destroy')
env.destroy()
})
}
test(`${prefix}: loader receiver, filter receiver, input mutation and extra fields retain the public behavior`, async () => {
let loaderReceiver = 'unset'
let filterReceiver
const { env, plugin } = fixture({
danmuku: [],
filter() {
filterReceiver = this
return true
},
})
const row = { text: 'zero', time: 0, id: 'custom-id', extension: { value: 1 } }
const internal = await plugin.load(function () {
loaderReceiver = this
return [row]
})
assert.equal(loaderReceiver, undefined)
assert.equal(filterReceiver, plugin.option)
assert.equal(row.time, 0, 'Accepted defect correction preserves an explicit zero timestamp')
assert.equal(row.mode, 0)
assert.equal(row.color, '#FFFFFF')
assert.deepEqual(Object.keys(row.style), [])
assert.equal(internal.queue[0].extension, row.extension)
assert.equal(internal.queue[0].id, row.id)
const missing = { text: 'default time' }
const negative = { text: 'negative', time: -2 }
await plugin.emit(missing)
await plugin.emit(negative)
assert.equal(missing.time, 10.5)
assert.equal(negative.time, 0)
env.destroy()
})
test(`${prefix}: invalid later rows preserve earlier insertions and reject with the single emitted original error`, async () => {
const { env, plugin } = fixture()
const internal = plugin.show()
let caught
const loadedCount = loaded(env).length
await assert.rejects(plugin.load([{ text: 'valid', time: 1 }, { text: 123 }]), (error) => {
caught = error
return error.message.includes('text')
})
assert.deepEqual(Array.from(internal.queue, row => row.text), ['valid'])
assert.equal(failures(env).length, 1)
assert.equal(failures(env)[0].args[0], caught)
assert.equal(loaded(env).length, loadedCount)
env.destroy()
})
test(`${prefix}: function configuration changes are applied while structurally equal plain values remain silent`, () => {
const { env, plugin } = fixture()
const original = plugin.option
const filter = () => false
const before = env.events.length
plugin.config({ filter })
assert.equal(plugin.option.filter, filter)
assert.notEqual(plugin.option, original)
assert.deepEqual(names(env).slice(before), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config'])
const updated = plugin.option
const count = env.events.length
plugin.config({ filter, margin: [10, '25%'], points: [], OPACITY: {} })
assert.equal(env.events.length, count)
assert.equal(plugin.option, updated)
env.destroy()
})
test(`${prefix}: invalid configuration cannot replace or partially mutate a valid option`, () => {
const { env, plugin } = fixture()
const original = plugin.option
const count = env.events.length
assert.throws(() => plugin.config({ opacity: 0.2, speed: 'invalid', visible: false }), /speed/iu)
assert.equal(plugin.option, original)
assert.equal(plugin.option.opacity, 1)
assert.equal(plugin.option.visible, true)
assert.equal(plugin.isHide, false)
assert.equal(env.events.length, count)
env.destroy()
})
test(`${prefix}: initial Promise source is accepted and successful completion reports loaded`, async () => {
const input = deferred()
const { env, plugin } = fixture({ danmuku: input.promise })
assert.equal(typeof plugin.then, 'undefined')
assert.equal(loaded(env).length, 0)
input.resolve([{ text: 'initial promise', time: 0 }])
await env.flush()
assert.equal(loaded(env).length, 1)
assert.equal(loaded(env)[0].args[0][0].text, 'initial promise')
assert.equal(loaded(env)[0].args[0][0].time, 0)
env.destroy()
})
test(`${prefix}: ignored constructor load rejection is locally observed while explicit load still rejects`, async () => {
const error = new Error('source failed')
const { env, plugin } = fixture({ danmuku: () => Promise.reject(error) })
await nextTurn()
assert.equal(failures(env).length, 1)
assert.equal(failures(env)[0].args[0], error)
await assert.rejects(plugin.load(), failure => failure === error)
assert.equal(failures(env).length, 2)
assert.equal(failures(env)[1].args[0], error)
env.destroy()
})
for (const event of ['show', 'config', 'loaded']) {
test(`${prefix}: synchronous initial ${event} destruction leaves a complete facade without later Worker or setting creation`, async () => {
const env = danmukuCandidateEnvironment(implementation)
const destroy = () => env.destroy()
env.art.on(`artplayerPluginDanmuku:${event}`, destroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
const plugin = env.factory({ danmuku: [], heatmap: true })(env.art)
assert.equal(env.art.isDestroy, true)
assert.equal(typeof plugin.then, 'undefined')
assert.deepEqual(Object.keys(plugin), ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop'])
assert.equal(plugin.name, 'artplayerPluginDanmuku')
for (const key of ['option', 'isHide', 'isStop']) {
const descriptor = Object.getOwnPropertyDescriptor(plugin, key)
assert.equal(typeof descriptor.get, 'function')
assert.equal(descriptor.set, undefined)
assert.equal(descriptor.enumerable, true)
assert.equal(descriptor.configurable, true)
}
assert.equal(plugin.option.heatmap, true)
assert.equal(plugin.isStop, true)
assert.equal(plugin.isHide, false)
assert.equal(plugin.mount('#external'), undefined)
assert.equal(plugin.mount(), undefined)
assert.equal(env.workers.length, event === 'loaded' ? 1 : 0)
assert(env.workers.every(worker => worker.terminated), 'A Worker created before loaded reentry must be terminated')
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before, 'Only the pre-existing user listener remains')
await env.flush()
assert.equal(env.consoleWarnings.length, 0)
})
}
test(`${prefix}: invalid initial configuration rolls back the early destroy listener before throwing`, () => {
const env = danmukuCandidateEnvironment(implementation)
const existingDestroy = () => {}
env.art.on('destroy', existingDestroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
assert.throws(() => env.factory({ danmuku: [], speed: 'invalid' })(env.art), /speed/iu)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before)
assert.deepEqual(env.listeners.get('destroy'), [existingDestroy])
assert.equal(env.workers.length, 0)
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
})
test(`${prefix}: initial scheduler Worker construction failure preserves the error and rolls back subscriptions`, () => {
const error = new Error('initial scheduler Worker failed')
const env = danmukuCandidateEnvironment(implementation, { initialWorkerError: error })
const existingDestroy = () => {}
env.art.on('destroy', existingDestroy)
const before = [...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0)
assert.throws(() => env.factory({ danmuku: [] })(env.art), failure => failure === error)
assert.equal([...env.listeners.values()].reduce((total, listeners) => total + listeners.length, 0), before)
assert.deepEqual(env.listeners.get('destroy'), [existingDestroy])
assert.equal(env.workers.length, 0)
assert.equal(env.art.template.$controlsCenter.children.length, 0)
assert.equal(env.art.template.$player.children.length, 0)
assert.equal(env.controls.length, 0)
assert.equal(env.proxies.length, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.timers.size, 0)
})
+403
View File
@@ -0,0 +1,403 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Candidate parser failures require strict Node rejection checks.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import vm from 'node:vm'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { danmukuEnvironment, danmukuHistorical, deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
const historical = await danmukuHistorical()
const prefix = 'artplayerPluginDanmuku:'
const xml = `<i>
<d p="1.25,1,25,16777215,123,0,user-a,42"> &quot;quote&quot; &apos;apostrophe&apos; &lt;tag&gt; &amp; &amp;lt; </d>
<d p="2,2,18,15,124,1,user-b,43">second\nline</d>
<d p="3,3,19,0,125,2,user-c,44">third</d>
<d p="4,4,20,255,126,3,user-d,45">bottom</d>
<d p="5,5,21,65535,127,4,user-e,46">top</d>
<d p="6,9,22,255,128,5,user-f,47">unknown mode</d>
<d p="1,2,3">short attributes</d>
</i>`
const expectedRows = [
{ text: '"quote" \'apostrophe\' <tag> & &lt;', time: 1.25, mode: 0, fontSize: 25, color: '#ffffff', timestamp: 123, pool: 0, userID: 'user-a', rowID: 42 },
{ text: 'second\nline', time: 2, mode: 0, fontSize: 18, color: '#f', timestamp: 124, pool: 1, userID: 'user-b', rowID: 43 },
{ text: 'third', time: 3, mode: 0, fontSize: 19, color: '#0', timestamp: 125, pool: 2, userID: 'user-c', rowID: 44 },
{ text: 'bottom', time: 4, mode: 2, fontSize: 20, color: '#ff', timestamp: 126, pool: 3, userID: 'user-d', rowID: 45 },
{ text: 'top', time: 5, mode: 1, fontSize: 21, color: '#ffff', timestamp: 127, pool: 4, userID: 'user-e', rowID: 46 },
{ text: 'unknown mode', time: 6, mode: 0, fontSize: 22, color: '#ff', timestamp: 128, pool: 5, userID: 'user-f', rowID: 47 },
]
function rows(owner) {
return Array.from(owner.queue, item => Object.fromEntries(Object.keys(expectedRows[0]).map(key => [key, item[key]])))
}
function events(env, name) {
return env.events.filter(event => event.name === `${prefix}${name}`)
}
async function setup(t, settings = {}, configure = () => {}) {
const env = danmukuCandidateEnvironment(implementation, settings)
const plugin = env.factory({ danmuku: [] })(env.art)
t.after(() => {
if (!env.art.isDestroy)
env.destroy()
})
await env.flush()
// Faults belong to the parser; the built inline layout Worker reads the same global.
configure(env)
return { env, plugin }
}
function parserWorker(env) {
const workers = env.workers.filter(worker => env.urls.has(worker.url))
assert.equal(workers.length, 1, 'Exactly one actual parser Blob Worker is expected')
return workers[0]
}
function released(env, worker) {
assert.equal(worker.terminated, true)
assert.deepEqual(env.revoked, [worker.url])
for (const name of ['message', 'error', 'messageerror']) {
assert.equal(worker[`on${name}`] == null, true, `${name} property detached`)
assert.equal((worker.listeners.get(name) || []).length, 0, `${name} listener detached`)
}
}
function dispatch(worker, name, event) {
worker[`on${name}`]?.(event)
for (const listener of [...worker.listeners.get(name) || []])
listener(event)
}
test('danmuku parser: real candidate and frozen release Blob scripts preserve XML fields, entities and mode mapping', { timeout: 10000 }, async (t) => {
for (const candidate of [...historical, implementation]) {
const env = candidate === implementation
? danmukuCandidateEnvironment(candidate, { fetch: async () => ({ text: async () => xml }) })
: danmukuEnvironment(candidate, { fetch: async () => ({ text: async () => xml }) })
t.after(() => env.destroy())
const plugin = env.factory({ danmuku: [] })(env.art)
await env.flush()
const pending = plugin.load('/fields.xml')
await env.flush()
const worker = parserWorker(env)
assert.equal(worker.messages.length, 1)
assert.equal(worker.messages[0].message.xml, xml)
await worker.execute(worker.messages[0].message)
const owner = await pending
assert.deepEqual(rows(owner), expectedRows, candidate.name)
if (candidate === implementation)
released(env, worker)
}
})
for (const input of ['', '<i></i>', '<i><d p="1,2,3">invalid fields</d></i>']) {
test(`danmuku parser: empty result settles and releases resources for ${JSON.stringify(input)}`, { timeout: 3000 }, async (t) => {
const { env, plugin } = await setup(t, { fetch: async () => ({ text: async () => input }) })
const loaded = events(env, 'loaded').length
const pending = plugin.load('/empty.xml')
await env.flush()
const worker = parserWorker(env)
await worker.execute(worker.messages[0].message)
const owner = await pending
assert.equal(owner.queue.length, 0)
assert.equal(events(env, 'loaded').length, loaded + 1)
assert.equal(events(env, 'error').length, 0)
released(env, worker)
})
}
for (const stage of ['fetch', 'text']) {
test(`danmuku parser: ${stage} rejection preserves public error identity and emits once`, { timeout: 3000 }, async (t) => {
const failure = new Error(`${stage}-original`)
const { env, plugin } = await setup(t, { fetch: async () => {
if (stage === 'fetch')
throw failure
return { text: async () => {
throw failure
} }
} })
const loaded = events(env, 'loaded').length
await assert.rejects(plugin.load('/failed.xml'), error => error === failure)
assert.deepEqual(events(env, 'error').map(event => event.args), [[failure]])
assert.equal(events(env, 'loaded').length, loaded)
assert.equal(env.urls.size, 0)
assert.equal(env.workers.length, 1, 'Network failure must not allocate the parser worker')
assert.deepEqual(env.revoked, [])
})
}
for (const stage of ['url', 'constructor', 'postMessage', 'error', 'messageerror']) {
test(`danmuku parser: ${stage} failure falls back to the same XML parser and releases acquired resources`, { timeout: 3000 }, async (t) => {
const failure = new Error(`${stage}-original`)
const { env, plugin } = await setup(t, { fetch: async () => ({ text: async () => xml }) }, (env) => {
const NativeWorker = env.context.Worker
if (stage === 'url') {
env.context.URL.createObjectURL = () => {
throw failure
}
}
else if (stage === 'constructor') {
env.context.Worker = class {
constructor() { throw failure }
}
}
else if (stage === 'postMessage') {
env.context.Worker = class extends NativeWorker {
postMessage() { throw failure }
}
}
})
const pending = plugin.load('/fallback.xml')
await env.flush()
if (stage === 'error' || stage === 'messageerror') {
let prevented = 0
dispatch(parserWorker(env), stage, { error: failure, message: failure.message, preventDefault() {
prevented++
} })
if (stage === 'error')
assert.equal(prevented, 1, 'Only the recovered parser Worker error is prevented locally')
}
const owner = await pending
assert.deepEqual(rows(owner), expectedRows)
assert.equal(events(env, 'error').length, 0, 'Successful fallback is not a public load error')
assert.equal(env.consoleErrors.length, 1)
assert.equal(env.consoleErrors[0][0], 'Error parsing Bilibili Danmu:')
assert.equal(env.consoleErrors[0][1], failure)
if (stage === 'url') {
assert.equal(env.urls.size, 0)
assert.deepEqual(env.revoked, [])
}
else if (stage === 'constructor') {
assert.equal(env.urls.size, 1)
assert.deepEqual(env.revoked, [...env.urls.keys()])
}
else {
released(env, parserWorker(env))
}
})
}
test('danmuku parser: ignores unrelated response IDs and disposes only after its matching result', { timeout: 3000 }, async (t) => {
const { env, plugin } = await setup(t)
let settled = false
const pending = plugin.load('/correlated.xml')
pending.then(() => settled = true, () => settled = true)
await env.flush()
const worker = parserWorker(env)
const request = worker.messages[0].message
worker.deliver({ id: `${request.id}-unrelated`, danmus: [] })
await env.flush()
assert.equal(settled, false)
assert.equal(worker.terminated, false)
assert.deepEqual(env.revoked, [])
await worker.execute(request)
const owner = await pending
assert.equal(owner.queue[0].text, 'xml')
released(env, worker)
})
test('danmuku parser: a matching malformed response rejects once and cannot later publish data', { timeout: 3000 }, async (t) => {
const { env, plugin } = await setup(t)
const pending = plugin.load('/malformed.xml')
let rejectedError
const rejected = assert.rejects(pending, (error) => {
rejectedError = error
return error.name === 'Error' && error.message === 'Invalid Bilibili Danmu worker response'
})
await env.flush()
const worker = parserWorker(env)
const { id } = worker.messages[0].message
worker.deliver({ id, danmus: { text: 'not an array' } })
await rejected
assert.deepEqual(events(env, 'error').map(event => event.args), [[rejectedError]])
released(env, worker)
const count = env.events.length
worker.deliver({ id, danmus: [{ text: 'late' }] })
await env.flush()
assert.equal(env.events.length, count)
})
for (const outcome of ['success', 'protocol-error', 'destroy']) {
test(`danmuku parser: ${outcome} attempts every resource cleanup when termination and URL revocation throw`, { timeout: 3000 }, async (t) => {
const terminateError = new Error('terminate-original')
const revokeError = new Error('revoke-second')
let terminateCalls = 0
const { env, plugin } = await setup(t, {}, (env) => {
const NativeWorker = env.context.Worker
const revoke = env.context.URL.revokeObjectURL
env.context.Worker = class extends NativeWorker {
terminate() {
terminateCalls++
super.terminate()
throw terminateError
}
}
env.context.URL.revokeObjectURL = (url) => {
revoke(url)
throw revokeError
}
})
const owner = plugin.show()
const pending = plugin.load('/cleanup.xml')
let rejectedError
const observed = outcome === 'destroy'
? pending
: assert.rejects(pending, (error) => {
rejectedError = error
return outcome === 'success'
? error === terminateError
: error.message === 'Invalid Bilibili Danmu worker response'
})
await env.flush()
const worker = parserWorker(env)
if (outcome === 'destroy')
env.destroy()
else worker.deliver({ id: worker.messages[0].message.id, danmus: outcome === 'success' ? [] : null })
const result = await observed
assert.equal(terminateCalls, 1)
released(env, worker)
if (outcome === 'destroy') {
assert.equal(result, owner)
assert.equal(events(env, 'error').length, 0)
}
else {
assert.deepEqual(events(env, 'error').map(event => event.args), [[rejectedError]])
}
})
}
test('danmuku parser: fallback parser failure rejects its original error once and releases the allocated URL', { timeout: 3000 }, async (t) => {
const workerError = new Error('worker unavailable')
const parserError = new Error('fallback parser original')
const { env, plugin } = await setup(t, { fetch: async () => ({ text: async () => xml }) }, (env) => {
env.context.Worker = class {
constructor() { throw workerError }
}
env.context.parserError = parserError
vm.runInContext('String.prototype.matchAll = function () { throw parserError }', env.context)
})
await assert.rejects(plugin.load('/fallback-failed.xml'), error => error === parserError)
assert.deepEqual(events(env, 'error').map(event => event.args), [[parserError]])
assert.equal(env.consoleErrors[0][1], workerError)
assert.equal(env.urls.size, 1)
assert.deepEqual(env.revoked, [...env.urls.keys()])
})
test('danmuku parser: successful parsing removes its abort listener before subsequent cancellation', { timeout: 3000 }, async (t) => {
const subscriptions = []
const { env, plugin } = await setup(t, {}, (env) => {
env.context.AbortController = class extends AbortController {
constructor() {
super()
const { signal } = this
const add = signal.addEventListener.bind(signal)
const remove = signal.removeEventListener.bind(signal)
signal.addEventListener = (name, callback, ...rest) => {
subscriptions.push({ name, callback, signal, removed: 0 })
add(name, callback, ...rest)
}
signal.removeEventListener = (name, callback, ...rest) => {
const entry = subscriptions.find(item => item.name === name && item.callback === callback && item.signal === signal)
assert(entry, 'Only an owned abort listener can be removed')
entry.removed++
remove(name, callback, ...rest)
}
}
}
})
const pending = plugin.load('/signal.xml')
await env.flush()
const worker = parserWorker(env)
assert.equal(subscriptions.length, 1)
assert.equal(subscriptions[0].name, 'abort')
await worker.execute(worker.messages[0].message)
await pending
assert.equal(subscriptions[0].removed, 1)
released(env, worker)
env.destroy()
assert.equal(subscriptions[0].removed, 1)
assert.deepEqual(env.revoked, [worker.url])
})
for (const stage of ['fetch', 'text', 'worker']) {
for (const abortController of [true, false]) {
test(`danmuku parser: destroy during ${stage} settles silently with AbortController ${abortController}`, { timeout: 3000 }, async (t) => {
const gate = deferred()
const { env, plugin } = await setup(t, { fetch: async () => {
if (stage === 'fetch')
return gate.promise
return { text: () => stage === 'text' ? gate.promise : Promise.resolve(xml) }
} }, (env) => {
if (!abortController) {
env.context.AbortController = undefined
env.context.window.AbortController = undefined
}
})
const owner = plugin.show()
const pending = plugin.load('/cancel.xml')
await env.flush()
const worker = stage === 'worker' ? parserWorker(env) : undefined
env.destroy()
const count = env.events.length
assert.equal(await pending, owner)
assert.equal(events(env, 'error').length, 0)
if (abortController)
assert.equal(env.fetchCalls[0].init.signal.aborted, true)
if (worker) {
released(env, worker)
worker.deliver({ id: worker.messages[0].message.id, danmus: [{ text: 'late' }] })
}
else {
assert.equal(env.urls.size, 0)
gate.resolve(stage === 'fetch' ? { text: async () => xml } : xml)
}
await env.flush()
assert.equal(env.events.length, count, 'Cancelled parser cannot emit a late loaded/error event')
assert.equal(owner.queue.length, 0)
assert.equal(env.workers.length, stage === 'worker' ? 2 : 1)
})
}
}
for (const stage of ['fetch', 'text']) {
test(`danmuku parser: strict subprocess observes automatic ${stage} failure while public load still rejects`, () => {
const program = `
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { danmukuCandidateEnvironment } from './test/helpers/danmuku-candidate.js';
const implementation = JSON.parse(fs.readFileSync(0, 'utf8'));
const failure = new Error(${JSON.stringify(`${stage}-original`)});
const env = danmukuCandidateEnvironment(implementation, { fetch: async () => {
if (${JSON.stringify(stage)} === 'fetch') throw failure;
return { text: async () => { throw failure; } };
} });
const plugin = env.factory({ danmuku: '/automatic.xml' })(env.art);
await env.flush();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(env.events.filter(event => event.name === '${prefix}error').map(event => event.args), [[failure]]);
assert.deepEqual(env.consoleWarnings, [['Failed to load initial danmuku:', failure]]);
await assert.rejects(plugin.load('/public.xml'), error => error === failure);
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(env.events.filter(event => event.name === '${prefix}error').map(event => event.args), [[failure], [failure]]);
assert.equal(env.consoleWarnings.length, 1);
assert.equal(env.workers.length, 1);
assert.equal(env.urls.size, 0);
env.destroy();
process.stdout.write('automatic-observed-public-rejected-once\\n');
`
const child = spawnSync(process.execPath, ['--unhandled-rejections=strict', '--input-type=module', '-e', program], {
cwd: fileURLToPath(new URL('../', import.meta.url)),
input: JSON.stringify(implementation),
encoding: 'utf8',
timeout: 5000,
maxBuffer: 1024 * 1024,
})
assert.equal(child.error, undefined, child.error?.message)
assert.equal(child.signal, null)
assert.equal(child.status, 0, child.stderr)
assert.equal(child.stdout, 'automatic-observed-public-rejected-once\n')
assert.equal(child.stderr, '')
})
}
+56
View File
@@ -0,0 +1,56 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'
import { ensureArchive, readMember, refactorDir } from '../../refactor/scripts/releases.mjs'
import { getEntryFile } from '../../scripts/projects.js'
import { danmukuEnvironment } from './danmuku.js'
const workspace = fileURLToPath(new URL('../../', import.meta.url))
export async function danmukuCandidate() {
const coreRelease = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
const coreCode = readMember(await ensureArchive(coreRelease), 'package/dist/artplayer.js').toString()
const artifact = process.env.ARTPLAYER_DANMUKU_ARTIFACT
if (artifact)
return { name: path.basename(artifact), code: fs.readFileSync(path.resolve(artifact), 'utf8'), coreCode }
const project = path.join(workspace, 'packages/artplayer-plugin-danmuku')
const compiled = await build({ entryPoints: [getEntryFile(project)], bundle: true, write: false, format: 'cjs', platform: 'browser', target: 'es2020', plugins: [{ name: 'danmuku-controlled-assets', setup(build) {
build.onResolve({ filter: /\?(?:worker|raw|inline)/ }, args => ({ path: path.resolve(args.resolveDir, args.path), namespace: 'controlled-asset' }))
build.onLoad({ filter: /.*/, namespace: 'controlled-asset' }, (args) => {
if (args.path.includes('?worker'))
return { contents: 'export default Worker', loader: 'js' }
return { contents: `export default ${JSON.stringify(fs.readFileSync(args.path.split('?')[0], 'utf8'))}`, loader: 'js' }
})
} }] })
return { name: 'candidate-source', code: compiled.outputFiles[0].text, coreCode }
}
export function danmukuCandidateEnvironment(implementation, settings = {}) {
// Inject before module evaluation: source worker imports capture the constructor.
const initialWorker = settings.initialWorkerError
? { ...implementation, code: `globalThis.Worker = class { constructor() { throw globalThis.__danmukuCandidateWorkerError } };\n${implementation.code}` }
: implementation
const env = danmukuEnvironment(initialWorker, settings)
if (settings.initialWorkerError)
env.context.__danmukuCandidateWorkerError = settings.initialWorkerError
const consoleWarnings = []
env.context.console.warn = (...args) => consoleWarnings.push(args)
env.context.AbortController = AbortController
env.context.AbortSignal = AbortSignal
env.context.window.AbortController = AbortController
env.context.window.AbortSignal = AbortSignal
env.context.Worker.prototype.removeEventListener = function (name, callback) {
this.listeners.set(name, (this.listeners.get(name) || []).filter(listener => listener !== callback))
}
const fetchCalls = []
env.context.fetch = async (url, init) => {
env.requests.push(url)
fetchCalls.push({ url, init })
if (settings.fetch)
return settings.fetch(url, init)
return { ok: true, status: 200, text: async () => '<i><d p="10,1,25,16777215,1,0,u,1">xml</d></i>' }
}
return { ...env, fetchCalls, consoleWarnings }
}