mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
refactor(danmuku): [PKG-DANMUKU-03] own input loading parsing and configuration
This commit is contained in:
1 parent
d3c91af92c
commit
0b2efb07f1
26 files changed
+9167
-503
No files matched your search
@@ -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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),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;
|
||||
},
|
||||
|
||||
+2
-2
@@ -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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),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
@@ -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('"', '"')
|
||||
.replaceAll(''', '\'')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -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('"', '"')
|
||||
.replaceAll(''', '\'')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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弹幕负载观察通过;所有
|
||||
|
||||
@@ -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
@@ -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
@@ -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/暂停/倍率/长时间运行无顺序和碰撞回归",
|
||||
|
||||
@@ -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 & 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) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"> "quote" 'apostrophe' <tag> & &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> & <', 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, '')
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user