refactor(danmuku): [PKG-DANMUKU-04] own scheduler and worker request lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-13 19:19:25 +08:00
1 parent 0b2efb07f1
commit 12332df221
24 files changed
+23441 -461

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const h='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switch(t.mode){case 0:{const e=a.find(e=>e.every(e=>{if(i<e.distance)return!1;if(t.speed<e.speed)return!0;return e.right/(t.speed-e.speed)>e.time}));return e&&e[0]?e[0].top:void 0}case 1:case 2:return}}onmessage=e=>{const{data:i}=e;if(!i.id||!i.type)return;const h=(0,{getDanmuTop:t}[i.type])(i);globalThis.postMessage({result:h,id:i.id})};\n',u="undefined"!==typeof self&&self.Blob&&new Blob(["URL.revokeObjectURL(import.meta.url);",h],{type:"text/javascript;charset=utf-8"});function c(t){let e;try{if(e=u&&(self.URL||self.webkitURL).createObjectURL(u),!e)throw"";const i=new Worker(e,{type:"module",name:t?.name});return i.addEventListener("error",()=>{(self.URL||self.webkitURL).revokeObjectURL(e)}),i}catch(i){return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(h),{type:"mLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const a=t.states[e]||[];for(let n=0;n<a.length;n++)i(a[n]);return a}const u=Symbol("cancelled danmuku frame");class c{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}this.owner.workerClient?.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{this.owner.workerClient?.dispose(),this.owner.createWorker(),this.fault=!1}catch(t){this.report(t)}}schedule(){const t=this.owner;!this.running||this.closed||this.fault||t.art.isDestroy||t.isStop||null!==this.frame||this.operation||(this.frame=window.requestAnimationFrame(()=>{if(this.frame=null,t.timer=null,this.closed||this.fault||t.isStop||t.art.isDestroy)return;let e;const i=new Promise(t=>e=()=>t(u)),a={generation:this.generation,cancel:e,wait:t=>Promise.race([t,i]),ref:null};return this.operation=a,this.run(a).catch(t=>{this.active(a)&&this.fail(t)}).finally(()=>{this.release(a),this.operation===a&&(this.operation=null,this.schedule())})}),t.timer=this.frame)}async run(t){const e=this.owner,{setStyles:i}=e.utils;if(!e.art.playing||!this.active(t))return;e.filter("emit",t=>{const i=(Date.now()-t.$lastStartTime)/1e3;t.$restTimeLine 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){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,a=c(e);globalThis.postMessage({danmus:a,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((a,n)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const a=o;o=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?n(i):a(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const a=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(a,t.error||t)},s.onmessageerror=t=>v(a,t.error||t),s.postMessage({xml:a,id:d})}catch(n){v(a,n)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function x(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function $(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const w='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switcLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,o=Math.pow,r=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&r(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),p=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{r(i.next(t))}catch(e){a(e)}},o=t=>{try{r(i.throw(t))}catch(e){a(e)}},r=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});function h(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{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 n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(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 n=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(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
+358 -129
View File
@@ -133,7 +133,7 @@ function onmessage({ data }) {
const danmus = bilibiliDanmuParseFromXml(xml);
globalThis.postMessage({ danmus, id });
}
let nextRequest = 0;
let nextRequest$1 = 0;
function abortError(signal) {
if (signal?.reason !== void 0)
return signal.reason;
@@ -147,7 +147,7 @@ function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
let worker;
let workerUrl;
let releaseCancel;
const id = ++nextRequest;
const id = ++nextRequest$1;
const cancel = () => finish(void 0, abortError(signal));
function releaseWorker() {
let error;
@@ -264,7 +264,7 @@ function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
});
}
const inputs = /* @__PURE__ */ new WeakMap();
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku input");
const cancelled$1 = /* @__PURE__ */ Symbol("cancelled danmuku input");
function stateFor(owner) {
if (!inputs.has(owner))
inputs.set(owner, { closed: false, tasks: /* @__PURE__ */ new Set(), emitting: [] });
@@ -305,7 +305,7 @@ function beginInput(owner, replace) {
if (stopped)
return;
stopped = true;
resolveCancel(cancelled);
resolveCancel(cancelled$1);
controller?.abort();
for (const callback of [...handlers]) callback();
handlers.clear();
@@ -325,7 +325,7 @@ function beginInput(owner, replace) {
}
};
if (stopped)
resolveCancel(cancelled);
resolveCancel(cancelled$1);
else state.tasks.add(task);
return task;
}
@@ -343,6 +343,301 @@ function readInput(target, task) {
return { asynchronous: true, value: bilibiliDanmuParseFromUrl(target, task) };
return { asynchronous: false, value: Array.isArray(target) ? target : [] };
}
function filterState(owner, state, callback) {
const danmus = owner.states[state] || [];
for (let index = 0; index < danmus.length; index++) callback(danmus[index]);
return danmus;
}
function readyItems(owner) {
const { currentTime } = owner.art;
const result = [];
filterState(owner, "ready", (danmu) => result.push(danmu));
filterState(owner, "wait", (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1)
result.push(danmu);
});
return result;
}
function setItemState(owner, danmu, state) {
owner.states[danmu.$state] = owner.states[danmu.$state].filter((item) => item !== danmu);
danmu.$state = state;
if (danmu.$ref)
danmu.$ref.dataset.state = state;
owner.states[state].push(danmu);
}
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku frame");
class Scheduler {
constructor(owner) {
this.owner = owner;
this.generation = 0;
this.starts = 0;
this.frame = null;
this.operation = null;
this.closed = false;
this.running = false;
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
}
active(operation) {
const owner = this.owner;
return !this.closed && !this.fault && !owner.art.isDestroy && !owner.isStop && !owner.isHide && operation.generation === this.generation;
}
release(operation) {
const { danmu, ref } = operation;
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText;
ref.style.visibility = "hidden";
this.owner.$refs.push(ref);
danmu.$ref = null;
}
operation.ref = null;
}
invalidate() {
this.generation++;
if (this.frame !== null)
window.cancelAnimationFrame(this.frame);
this.frame = null;
this.owner.timer = null;
if (this.operation) {
const operation = this.operation;
this.operation = null;
operation.cancel();
this.release(operation);
}
this.owner.workerClient?.cancel();
this.failedItems.clear();
}
report(error) {
try {
this.owner.art.emit("artplayerPluginDanmuku:error", error);
} catch (listenerError) {
console.warn("Failed to report danmuku scheduling error:", listenerError);
}
}
fail(error) {
if (this.closed || this.fault)
return;
this.fault = true;
this.invalidate();
this.report(error);
}
recover() {
if (this.closed || this.owner.art.isDestroy)
return;
this.failedItems.clear();
if (this.fault) {
try {
this.owner.workerClient?.dispose();
this.owner.createWorker();
this.fault = false;
} catch (error) {
this.report(error);
}
}
}
schedule() {
const owner = this.owner;
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
return;
this.frame = window.requestAnimationFrame(() => {
this.frame = null;
owner.timer = null;
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return;
let cancel;
const cancellation = new Promise((resolve) => cancel = () => resolve(cancelled));
const operation = { generation: this.generation, cancel, wait: (value) => Promise.race([value, cancellation]), ref: null };
this.operation = operation;
return this.run(operation).catch((error) => {
if (this.active(operation))
this.fail(error);
}).finally(() => {
this.release(operation);
if (this.operation === operation) {
this.operation = null;
this.schedule();
}
});
});
owner.timer = this.frame;
}
async run(operation) {
const owner = this.owner;
const { setStyles } = owner.utils;
if (!owner.art.playing || !this.active(operation))
return;
owner.filter("emit", (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1e3;
danmu.$restTime -= emitTime;
danmu.$lastStartTime = Date.now();
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const readys = owner.readys;
for (const danmu of readys) {
if (!this.active(operation))
return;
if (this.failedItems.has(danmu))
continue;
let state;
try {
state = await operation.wait(owner.option.beforeVisible(danmu));
} catch (error) {
if (!this.active(operation))
return;
this.failedItems.add(danmu);
this.report(error);
continue;
}
if (!this.active(operation))
return;
if (!state)
continue;
const { clientWidth, clientHeight } = owner.$player;
const ref = owner.$ref;
operation.danmu = danmu;
operation.ref = ref;
danmu.$ref = ref;
ref.textContent = danmu.text;
owner.$danmuku.appendChild(ref);
ref.style.opacity = owner.option.opacity;
ref.style.fontSize = `${owner.fontSize}px`;
ref.style.color = danmu.color;
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null;
ref.style.backgroundColor = danmu.border ? "rgb(0 0 0 / 50%)" : null;
setStyles(ref, danmu.style);
if (!this.active(operation))
return;
danmu.$lastStartTime = Date.now();
danmu.$restTime = owner.speed;
const distance = clientWidth + ref.clientWidth;
const { result: top } = await operation.wait(owner.postMessage({
type: "getDanmuTop",
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
antiOverlap: owner.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
if (!this.active(operation) || danmu.$ref !== ref)
return;
if (top !== void 0) {
owner.setState(danmu, "emit");
ref.style.top = `${top}px`;
ref.style.visibility = "visible";
ref.dataset.mode = danmu.mode;
ref.dataset.id = danmu.id || "";
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`;
ref.style.marginLeft = "0px";
ref.style.transform = `translateX(${-distance}px)`;
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`;
break;
case 1:
case 2:
ref.style.left = "50%";
ref.style.marginLeft = `-${ref.clientWidth / 2}px`;
break;
}
operation.ref = null;
owner.art.emit("artplayerPluginDanmuku:visible", danmu);
} else {
owner.setState(danmu, "ready");
this.release(operation);
}
}
}
destroy() {
this.closed = true;
this.invalidate();
}
}
let nextRequest = 0;
class WorkerClient {
constructor(createWorker, onFailure) {
this.pending = /* @__PURE__ */ new Map();
this.closed = false;
this.failed = false;
this.onFailure = onFailure;
this.worker = createWorker();
try {
this.worker.onmessage = (event) => {
const request = this.pending.get(event.data?.id);
if (!request)
return;
this.pending.delete(event.data.id);
request.resolve(event.data);
};
this.worker.onerror = (event) => {
event.preventDefault?.();
this.fail(event.error || event);
};
this.worker.onmessageerror = (event) => this.fail(event.error || event);
} catch (error) {
try {
this.dispose();
} catch {
}
throw error;
}
}
request(message) {
message.id = ++nextRequest;
const { id } = message;
if (this.closed || this.failed)
return Promise.resolve({ id, result: void 0 });
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try {
this.worker.postMessage(message);
} catch (error) {
this.fail(error);
}
});
}
fail(error) {
if (this.closed || this.failed)
return;
this.failed = true;
for (const request of this.pending.values()) request.reject(error);
this.pending.clear();
try {
this.dispose();
} catch {
}
this.onFailure(error);
}
cancel() {
for (const [id, request] of this.pending) request.resolve({ id, result: void 0 });
this.pending.clear();
}
dispose() {
if (this.closed)
return;
this.closed = true;
this.cancel();
let failed = false;
let error;
const attempt = (callback) => {
try {
callback();
} catch (failure) {
if (!failed)
error = failure;
failed = true;
}
};
for (const name of ["onmessage", "onerror", "onmessageerror"]) {
attempt(() => this.worker[name] = null);
}
attempt(() => this.worker.terminate());
if (failed)
throw error;
}
}
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) {
@@ -383,6 +678,8 @@ class Danmuku {
this.timer = null;
this.index = 0;
this.worker = null;
this.workerClient = null;
this.scheduler = new Scheduler(this);
this.option = Danmuku.option;
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.start = this.start.bind(this);
@@ -390,16 +687,18 @@ class Danmuku {
this.reset = this.reset.bind(this);
this.resize = this.resize.bind(this);
this.destroy = this.destroy.bind(this);
this.seek = this.seek.bind(this);
inputActive(this);
art.on("destroy", this.destroy);
try {
this.config(option, true);
if (!inputActive(this))
return;
this.worker = new WorkerWrapper();
this.createWorker();
} catch (error) {
art.off("destroy", this.destroy);
cancelInputs(this);
this.scheduler.destroy();
throw error;
}
art.on("video:play", this.start);
@@ -407,6 +706,7 @@ class Danmuku {
art.on("video:pause", this.stop);
art.on("video:waiting", this.stop);
art.on("resize", this.resize);
art.on("video:seeking", this.seek);
this.load().catch((error) => console.warn("Failed to load initial danmuku:", error));
}
// 默认配置
@@ -491,15 +791,7 @@ class Danmuku {
}
// 获取准备好发送的弹幕
get readys() {
const { currentTime } = this.art;
const result = [];
this.filter("ready", (danmu) => result.push(danmu));
this.filter("wait", (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1) {
result.push(danmu);
}
});
return result;
return readyItems(this);
}
// 可见的弹幕的数据,用于计算下一个弹幕的top值
get visibles() {
@@ -595,7 +887,7 @@ class Danmuku {
});
if (!danmu.text.trim())
return this;
if (danmu.time !== void 0) {
if (danmu.time || danmu.time === 0) {
danmu.time = clamp(danmu.time, 0, Infinity);
} else {
danmu.time = this.art.currentTime + 0.5;
@@ -647,7 +939,12 @@ class Danmuku {
});
if (!inputActive(this))
return this;
const beforeVisibleChanged = this.option.beforeVisible !== next.beforeVisible;
this.option = next;
if (beforeVisibleChanged) {
this.scheduler.invalidate();
this.scheduler.schedule();
}
if (option.fontSize) {
this.reset();
if (!inputActive(this))
@@ -669,33 +966,19 @@ class Danmuku {
}
// 复杂运算交给 Web Worker 处理
postMessage(message = {}) {
return new Promise((resolve) => {
message.id = Date.now();
this.worker.postMessage(message);
this.worker.onmessage = (event) => {
const { data } = event;
if (data.id === message.id) {
resolve(data);
}
};
});
return this.workerClient.request(message);
}
createWorker() {
this.workerClient = new WorkerClient(() => new WorkerWrapper(), (error) => this.scheduler.fail(error));
this.worker = this.workerClient.worker;
}
// 根据状态获取弹幕
filter(state, callback) {
const danmus = this.states[state] || [];
for (let index = 0; index < danmus.length; index++) {
callback(danmus[index]);
}
return danmus;
return filterState(this, state, callback);
}
// 设置弹幕状态
setState(danmu, state) {
this.states[danmu.$state] = this.states[danmu.$state].filter((item) => item !== danmu);
danmu.$state = state;
if (danmu.$ref) {
danmu.$ref.dataset.state = state;
}
this.states[state].push(danmu);
setItemState(this, danmu, state);
}
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu) {
@@ -712,88 +995,8 @@ class Danmuku {
}
// 实时更新弹幕
update() {
const { setStyles } = this.utils;
this.timer = window.requestAnimationFrame(async () => {
if (this.art.playing && !this.isHide) {
this.filter("emit", (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1e3;
danmu.$restTime -= emitTime;
danmu.$lastStartTime = Date.now();
if (danmu.$restTime <= 0) {
this.makeWait(danmu);
}
});
const readys = this.readys;
for (let index = 0; index < readys.length; index++) {
const danmu = readys[index];
const state = await this.option.beforeVisible(danmu);
if (state) {
const { clientWidth, clientHeight } = this.$player;
danmu.$ref = this.$ref;
danmu.$ref.textContent = danmu.text;
this.$danmuku.appendChild(danmu.$ref);
danmu.$ref.style.opacity = this.option.opacity;
danmu.$ref.style.fontSize = `${this.fontSize}px`;
danmu.$ref.style.color = danmu.color;
danmu.$ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null;
danmu.$ref.style.backgroundColor = danmu.border ? "rgb(0 0 0 / 50%)" : null;
setStyles(danmu.$ref, danmu.style);
danmu.$lastStartTime = Date.now();
danmu.$restTime = this.speed;
const distance = clientWidth + danmu.$ref.clientWidth;
const { result: top } = await this.postMessage({
type: "getDanmuTop",
target: {
mode: danmu.mode,
height: danmu.$ref.clientHeight,
speed: distance / danmu.$restTime
},
// 当前弹幕信息
visibles: this.visibles,
// 可见的弹幕的数据
antiOverlap: this.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: this.marginBottom,
marginTop: this.marginTop
});
if (danmu.$ref) {
if (!this.isStop && top !== void 0) {
this.setState(danmu, "emit");
danmu.$ref.style.top = `${top}px`;
danmu.$ref.style.visibility = "visible";
danmu.$ref.dataset.mode = danmu.mode;
danmu.$ref.dataset.id = danmu.id || "";
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
danmu.$ref.style.left = `${clientWidth}px`;
danmu.$ref.style.marginLeft = "0px";
danmu.$ref.style.transform = `translateX(${-distance}px)`;
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`;
break;
}
case 1:
// falls through
case 2:
danmu.$ref.style.left = "50%";
danmu.$ref.style.marginLeft = `-${danmu.$ref.clientWidth / 2}px`;
break;
}
this.art.emit("artplayerPluginDanmuku:visible", danmu);
} else {
this.setState(danmu, "ready");
this.$refs.push(danmu.$ref);
danmu.$ref = null;
}
}
}
}
}
if (!this.isStop) {
this.update();
}
});
this.scheduler.running = true;
this.scheduler.schedule();
return this;
}
// 重置正在显示的弹幕: stop/emit 状态的弹幕
@@ -858,23 +1061,39 @@ class Danmuku {
}
stop() {
this.isStop = true;
this.scheduler.running = false;
this.scheduler.invalidate();
this.suspend();
window.cancelAnimationFrame(this.timer);
this.art.emit("artplayerPluginDanmuku:stop");
return this;
}
start() {
if (this.scheduler.closed || this.art.isDestroy)
return this;
const start = ++this.scheduler.starts;
const generation = this.scheduler.generation;
this.isStop = false;
this.scheduler.recover();
const obsolete = this.scheduler.closed || this.art.isDestroy || this.isStop || this.scheduler.fault || generation !== this.scheduler.generation || start !== this.scheduler.starts;
if (obsolete)
return this;
this.continue();
this.update();
this.art.emit("artplayerPluginDanmuku:start");
return this;
}
reset() {
this.scheduler.invalidate();
this.queue.forEach((danmu) => this.makeWait(danmu));
this.art.emit("artplayerPluginDanmuku:reset");
this.scheduler.recover();
this.scheduler.schedule();
return this;
}
seek() {
this.scheduler.invalidate();
this.scheduler.schedule();
}
show() {
this.isHide = false;
this.$danmuku.style.opacity = 1;
@@ -884,22 +1103,32 @@ class Danmuku {
}
hide() {
this.isHide = true;
this.scheduler.invalidate();
this.$danmuku.style.opacity = 0;
this.option.visible = false;
this.art.emit("artplayerPluginDanmuku:hide");
this.scheduler.schedule();
return this;
}
destroy() {
cancelInputs(this);
this.stop();
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.resize);
this.art.off("destroy", this.destroy);
this.art.emit("artplayerPluginDanmuku:destroy");
this.scheduler.destroy();
try {
try {
this.stop();
} finally {
this.workerClient?.dispose();
}
} finally {
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.resize);
this.art.off("video:seeking", this.seek);
this.art.off("destroy", this.destroy);
this.art.emit("artplayerPluginDanmuku:destroy");
}
}
}
const lib = {
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/danmuku-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:unit": "node --test test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js 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-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js refactor/scripts/danmuku-contract.test.mjs"
"test:danmuku": "node --test test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js refactor/scripts/danmuku-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -10,17 +10,21 @@ repository root. Do not infer runtime contracts from the old declaration file.
| 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/danmuku.js` | Internal instance, method return identity, configuration/load commits, event and lifecycle wiring |
| `src/config.js` | Fresh defaults, validation schema, configuration comparison and normalization |
| `src/input.js` | Input forms, replacement ownership, independent append operations, cancellation and synchronous callback reentry |
| `src/bilibili-parser.js` | Pure legacy-compatible XML fields, mode mapping and entity decoding |
| `src/bilibili.js` | Fetch and response text, one parser Worker per request, fallback, settlement and Blob URL cleanup |
| `src/scheduler.js` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery; DOM preparation moves to a renderer in PKG-DANMUKU-05 |
| `src/queue.js` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.js` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
| `src/worker.js` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/setting.js` | Settings 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
configuration responsibilities in JavaScript; PKG-DANMUKU-04 separates scheduling
and track requests; 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.
@@ -47,8 +51,10 @@ This intermediate structure is not the final TypeScript or release acceptance.
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.
Explicit time zero is preserved; missing time and `NaN` default to current
time + 0.5. The historical validator accepts `NaN` as a number, so preserving
zero must not accidentally remove that fallback. Positive infinity remains
positive infinity; negative values, including negative infinity, clamp 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
@@ -58,20 +64,77 @@ This intermediate structure is not the final TypeScript or release acceptance.
empty result. Fetch/text failures must reach the public load's single error
outlet and reject it; automatic initial loading observes that rejection.
## Scheduling ownership
Keep at most one pending RAF or asynchronous preparation frame. Native `play`
and `playing` both invoke the existing start path and retain its events, but
must not create competing loops. Ready items precede wait items; wait selection
retains the current-time +/- 0.1 second window and state-pool order. Keep the
existing track algorithm and geometry fields until separately reviewed.
Pause, reset, successful replacement input commit, hide, seeking, destroy and replacing the
`beforeVisible` callback invalidate unfinished preparation. Cancellation races
the user Promise so an unresolved callback cannot prevent later work. A frame
owns its allocated node; check that node identity before recycling it, and check
the generation after each await or callback. Existing emit/stop nodes survive
hide, seeking and callback changes; reset retains its explicit reset behavior.
Seek does not implicitly reset already displayed or paused comments. Date.now
still measures wall-clock lifetime, excluding paused time. With synchronous
playback enabled, new comments sample speed/playbackRate when allocated; active
comments retain their assigned remaining seconds. Do not change these timing
rules as incidental cleanup.
A rejected `beforeVisible` reports its original error once and excludes that
item from further attempts in the current run. Explicit start, reset, an
invalidation or replacement callback permits another attempt if the item is
still eligible. False results retain their ordinary next-frame evaluation.
Other items can continue after a rejection. Error-listener failures are observed
locally and logged instead of creating a detached rejected RAF Promise.
A track Worker fault rejects its pending requests with the original error,
disposes that Worker and reports one error, then halts internal scheduling.
It does not fabricate a public stop event. A later start/reset rebuilds the
Worker; repeated failure waits for another explicit recovery opportunity.
Cancelled internal requests resolve `{ id, result: undefined }` instead of
remaining pending forever. Normal responses preserve `{ id, result }`.
Recovery error listeners run synchronously and can call destroy, stop, reset,
hide or start again. After recovery, the outer start must recheck destruction,
stop and fault state, its cancellation generation and its start-attempt identity
before continuing paused rows, scheduling or emitting start. Generation checks
protect reset/hide and other invalidation; the attempt identity also protects a
nested start that succeeds without changing the generation. Do not let the outer
call revive stopped rows or add a second start event after a nested recovery.
Ordinary repeated starts still retain their historical events and internal
return identity. Recovery tests restore a working Worker factory before a
single nested action; they do not manufacture an infinite retry loop.
Install the shared handler before sending requests. IDs are unique across
requests and Worker replacement, including multiple requests in one millisecond.
Late, unknown and duplicate replies do not settle a different request.
## 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;
`yarn test:danmuku` runs frozen historical contracts/failures and candidate input,
parser, scheduler and Worker regressions. The historical helper and archives must remain frozen;
candidate tests use `test/helpers/danmuku-candidate.js` and may select built
artifacts through `ARTPLAYER_DANMUKU_ARTIFACT`.
The input tests compare `NaN` and both infinities against frozen source and real
npm main/legacy artifacts. Preserve those historical assertions alongside the
explicit-zero correction when changing numeric normalization.
`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.
`test/browser/danmuku-scheduler.spec.js` verifies native callback cancellation,
three modes, strict dense geometry, one request per prepared item, native
pause/seek/rate and visible-node reuse. Preserve both geometric and protocol
assertions; unique IDs alone do not prove non-overlapping tracks.
Settings cleanup, heatmap sampling and long-run load behavior still require
PKG-DANMUKU-05/07. Check task evidence and the risk register before changing
completion or release status; short native regressions are not package-wide
stability acceptance.
+22 -2
View File
@@ -20,11 +20,31 @@ 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.
Explicit `time: 0` is preserved. Missing time and `NaN` retain the historical
default of the current playback time plus 0.5 seconds. Positive infinity remains
unchanged; negative values, including negative infinity, are clamped to zero.
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.
## Scheduling failures and recovery
Playback uses one scheduling loop. Pausing, seeking, hiding, resetting or
destroying the player cancels unfinished visibility preparation. Updating
`beforeVisible` also releases an unfinished old callback; seeking and callback
changes preserve already displayed comments.
A rejected `beforeVisible` emits the original `artplayerPluginDanmuku:error`
once for that item in the current run, while other items continue. It is not
retried every frame. Pause/resume, reset or a replacement callback permits a
retry if the item still matches the display-time rules. A track Worker failure
reports once and halts scheduling until pause/resume or reset rebuilds it.
If an error listener synchronously stops, destroys, resets or hides the instance,
the interrupted recovery respects that action. A nested start can recover once
without the outer call resuming it again or emitting another start event.
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module ownership, compatibility
constraints, tests and the remaining staged TypeScript migration.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function 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
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const a=t.states[e]||[];for(let n=0;n<a.length;n++)i(a[n]);return a}const u=Symbol("cancelled danmuku frame");class c{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}this.owner.workerClient?.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{this.owner.workerClient?.dispose(),this.owner.createWorker(),this.fault=!1}catch(t){this.report(t)}}schedule(){const t=this.owner;!this.running||this.closed||this.fault||t.art.isDestroy||t.isStop||null!==this.frame||this.operation||(this.frame=window.requestAnimationFrame(()=>{if(this.frame=null,t.timer=null,this.closed||this.fault||t.isStop||t.art.isDestroy)return;let e;const i=new Promise(t=>e=()=>t(u)),a={generation:this.generation,cancel:e,wait:t=>Promise.race([t,i]),ref:null};return this.operation=a,this.run(a).catch(t=>{this.active(a)&&this.fail(t)}).finally(()=>{this.release(a),this.operation===a&&(this.operation=null,this.schedule())})}),t.timer=this.frame)}async run(t){const e=this.owner,{setStyles:i}=e.utils;if(!e.art.playing||!this.active(t))return;e.filter("emit",t=>{const i=(Date.now()-t.$lastStartTime)/1e3;t.$restTimeLine 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){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,a=c(e);globalThis.postMessage({danmus:a,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((a,n)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const a=o;o=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?n(i):a(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const a=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(a,t.error||t)},s.onmessageerror=t=>v(a,t.error||t),s.postMessage({xml:a,id:d})}catch(n){v(a,n)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function x(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function $(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const w='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switcLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,o=Math.pow,r=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&r(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),p=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{r(i.next(t))}catch(e){a(e)}},o=t=>{try{r(i.throw(t))}catch(e){a(e)}},r=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});function h(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{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 n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(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 n=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(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
@@ -133,7 +133,7 @@ function onmessage({ data }) {
const danmus = bilibiliDanmuParseFromXml(xml);
globalThis.postMessage({ danmus, id });
}
let nextRequest = 0;
let nextRequest$1 = 0;
function abortError(signal) {
if (signal?.reason !== void 0)
return signal.reason;
@@ -147,7 +147,7 @@ function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
let worker;
let workerUrl;
let releaseCancel;
const id = ++nextRequest;
const id = ++nextRequest$1;
const cancel = () => finish(void 0, abortError(signal));
function releaseWorker() {
let error;
@@ -264,7 +264,7 @@ function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
});
}
const inputs = /* @__PURE__ */ new WeakMap();
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku input");
const cancelled$1 = /* @__PURE__ */ Symbol("cancelled danmuku input");
function stateFor(owner) {
if (!inputs.has(owner))
inputs.set(owner, { closed: false, tasks: /* @__PURE__ */ new Set(), emitting: [] });
@@ -305,7 +305,7 @@ function beginInput(owner, replace) {
if (stopped)
return;
stopped = true;
resolveCancel(cancelled);
resolveCancel(cancelled$1);
controller?.abort();
for (const callback of [...handlers]) callback();
handlers.clear();
@@ -325,7 +325,7 @@ function beginInput(owner, replace) {
}
};
if (stopped)
resolveCancel(cancelled);
resolveCancel(cancelled$1);
else state.tasks.add(task);
return task;
}
@@ -343,6 +343,301 @@ function readInput(target, task) {
return { asynchronous: true, value: bilibiliDanmuParseFromUrl(target, task) };
return { asynchronous: false, value: Array.isArray(target) ? target : [] };
}
function filterState(owner, state, callback) {
const danmus = owner.states[state] || [];
for (let index = 0; index < danmus.length; index++) callback(danmus[index]);
return danmus;
}
function readyItems(owner) {
const { currentTime } = owner.art;
const result = [];
filterState(owner, "ready", (danmu) => result.push(danmu));
filterState(owner, "wait", (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1)
result.push(danmu);
});
return result;
}
function setItemState(owner, danmu, state) {
owner.states[danmu.$state] = owner.states[danmu.$state].filter((item) => item !== danmu);
danmu.$state = state;
if (danmu.$ref)
danmu.$ref.dataset.state = state;
owner.states[state].push(danmu);
}
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku frame");
class Scheduler {
constructor(owner) {
this.owner = owner;
this.generation = 0;
this.starts = 0;
this.frame = null;
this.operation = null;
this.closed = false;
this.running = false;
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
}
active(operation) {
const owner = this.owner;
return !this.closed && !this.fault && !owner.art.isDestroy && !owner.isStop && !owner.isHide && operation.generation === this.generation;
}
release(operation) {
const { danmu, ref } = operation;
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText;
ref.style.visibility = "hidden";
this.owner.$refs.push(ref);
danmu.$ref = null;
}
operation.ref = null;
}
invalidate() {
this.generation++;
if (this.frame !== null)
window.cancelAnimationFrame(this.frame);
this.frame = null;
this.owner.timer = null;
if (this.operation) {
const operation = this.operation;
this.operation = null;
operation.cancel();
this.release(operation);
}
this.owner.workerClient?.cancel();
this.failedItems.clear();
}
report(error) {
try {
this.owner.art.emit("artplayerPluginDanmuku:error", error);
} catch (listenerError) {
console.warn("Failed to report danmuku scheduling error:", listenerError);
}
}
fail(error) {
if (this.closed || this.fault)
return;
this.fault = true;
this.invalidate();
this.report(error);
}
recover() {
if (this.closed || this.owner.art.isDestroy)
return;
this.failedItems.clear();
if (this.fault) {
try {
this.owner.workerClient?.dispose();
this.owner.createWorker();
this.fault = false;
} catch (error) {
this.report(error);
}
}
}
schedule() {
const owner = this.owner;
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
return;
this.frame = window.requestAnimationFrame(() => {
this.frame = null;
owner.timer = null;
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return;
let cancel;
const cancellation = new Promise((resolve) => cancel = () => resolve(cancelled));
const operation = { generation: this.generation, cancel, wait: (value) => Promise.race([value, cancellation]), ref: null };
this.operation = operation;
return this.run(operation).catch((error) => {
if (this.active(operation))
this.fail(error);
}).finally(() => {
this.release(operation);
if (this.operation === operation) {
this.operation = null;
this.schedule();
}
});
});
owner.timer = this.frame;
}
async run(operation) {
const owner = this.owner;
const { setStyles } = owner.utils;
if (!owner.art.playing || !this.active(operation))
return;
owner.filter("emit", (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1e3;
danmu.$restTime -= emitTime;
danmu.$lastStartTime = Date.now();
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const readys = owner.readys;
for (const danmu of readys) {
if (!this.active(operation))
return;
if (this.failedItems.has(danmu))
continue;
let state;
try {
state = await operation.wait(owner.option.beforeVisible(danmu));
} catch (error) {
if (!this.active(operation))
return;
this.failedItems.add(danmu);
this.report(error);
continue;
}
if (!this.active(operation))
return;
if (!state)
continue;
const { clientWidth, clientHeight } = owner.$player;
const ref = owner.$ref;
operation.danmu = danmu;
operation.ref = ref;
danmu.$ref = ref;
ref.textContent = danmu.text;
owner.$danmuku.appendChild(ref);
ref.style.opacity = owner.option.opacity;
ref.style.fontSize = `${owner.fontSize}px`;
ref.style.color = danmu.color;
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null;
ref.style.backgroundColor = danmu.border ? "rgb(0 0 0 / 50%)" : null;
setStyles(ref, danmu.style);
if (!this.active(operation))
return;
danmu.$lastStartTime = Date.now();
danmu.$restTime = owner.speed;
const distance = clientWidth + ref.clientWidth;
const { result: top } = await operation.wait(owner.postMessage({
type: "getDanmuTop",
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
antiOverlap: owner.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
if (!this.active(operation) || danmu.$ref !== ref)
return;
if (top !== void 0) {
owner.setState(danmu, "emit");
ref.style.top = `${top}px`;
ref.style.visibility = "visible";
ref.dataset.mode = danmu.mode;
ref.dataset.id = danmu.id || "";
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`;
ref.style.marginLeft = "0px";
ref.style.transform = `translateX(${-distance}px)`;
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`;
break;
case 1:
case 2:
ref.style.left = "50%";
ref.style.marginLeft = `-${ref.clientWidth / 2}px`;
break;
}
operation.ref = null;
owner.art.emit("artplayerPluginDanmuku:visible", danmu);
} else {
owner.setState(danmu, "ready");
this.release(operation);
}
}
}
destroy() {
this.closed = true;
this.invalidate();
}
}
let nextRequest = 0;
class WorkerClient {
constructor(createWorker, onFailure) {
this.pending = /* @__PURE__ */ new Map();
this.closed = false;
this.failed = false;
this.onFailure = onFailure;
this.worker = createWorker();
try {
this.worker.onmessage = (event) => {
const request = this.pending.get(event.data?.id);
if (!request)
return;
this.pending.delete(event.data.id);
request.resolve(event.data);
};
this.worker.onerror = (event) => {
event.preventDefault?.();
this.fail(event.error || event);
};
this.worker.onmessageerror = (event) => this.fail(event.error || event);
} catch (error) {
try {
this.dispose();
} catch {
}
throw error;
}
}
request(message) {
message.id = ++nextRequest;
const { id } = message;
if (this.closed || this.failed)
return Promise.resolve({ id, result: void 0 });
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try {
this.worker.postMessage(message);
} catch (error) {
this.fail(error);
}
});
}
fail(error) {
if (this.closed || this.failed)
return;
this.failed = true;
for (const request of this.pending.values()) request.reject(error);
this.pending.clear();
try {
this.dispose();
} catch {
}
this.onFailure(error);
}
cancel() {
for (const [id, request] of this.pending) request.resolve({ id, result: void 0 });
this.pending.clear();
}
dispose() {
if (this.closed)
return;
this.closed = true;
this.cancel();
let failed = false;
let error;
const attempt = (callback) => {
try {
callback();
} catch (failure) {
if (!failed)
error = failure;
failed = true;
}
};
for (const name of ["onmessage", "onerror", "onmessageerror"]) {
attempt(() => this.worker[name] = null);
}
attempt(() => this.worker.terminate());
if (failed)
throw error;
}
}
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) {
@@ -383,6 +678,8 @@ class Danmuku {
this.timer = null;
this.index = 0;
this.worker = null;
this.workerClient = null;
this.scheduler = new Scheduler(this);
this.option = Danmuku.option;
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.start = this.start.bind(this);
@@ -390,16 +687,18 @@ class Danmuku {
this.reset = this.reset.bind(this);
this.resize = this.resize.bind(this);
this.destroy = this.destroy.bind(this);
this.seek = this.seek.bind(this);
inputActive(this);
art.on("destroy", this.destroy);
try {
this.config(option, true);
if (!inputActive(this))
return;
this.worker = new WorkerWrapper();
this.createWorker();
} catch (error) {
art.off("destroy", this.destroy);
cancelInputs(this);
this.scheduler.destroy();
throw error;
}
art.on("video:play", this.start);
@@ -407,6 +706,7 @@ class Danmuku {
art.on("video:pause", this.stop);
art.on("video:waiting", this.stop);
art.on("resize", this.resize);
art.on("video:seeking", this.seek);
this.load().catch((error) => console.warn("Failed to load initial danmuku:", error));
}
// 默认配置
@@ -491,15 +791,7 @@ class Danmuku {
}
// 获取准备好发送的弹幕
get readys() {
const { currentTime } = this.art;
const result = [];
this.filter("ready", (danmu) => result.push(danmu));
this.filter("wait", (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1) {
result.push(danmu);
}
});
return result;
return readyItems(this);
}
// 可见的弹幕的数据,用于计算下一个弹幕的top值
get visibles() {
@@ -595,7 +887,7 @@ class Danmuku {
});
if (!danmu.text.trim())
return this;
if (danmu.time !== void 0) {
if (danmu.time || danmu.time === 0) {
danmu.time = clamp(danmu.time, 0, Infinity);
} else {
danmu.time = this.art.currentTime + 0.5;
@@ -647,7 +939,12 @@ class Danmuku {
});
if (!inputActive(this))
return this;
const beforeVisibleChanged = this.option.beforeVisible !== next.beforeVisible;
this.option = next;
if (beforeVisibleChanged) {
this.scheduler.invalidate();
this.scheduler.schedule();
}
if (option.fontSize) {
this.reset();
if (!inputActive(this))
@@ -669,33 +966,19 @@ class Danmuku {
}
// 复杂运算交给 Web Worker 处理
postMessage(message = {}) {
return new Promise((resolve) => {
message.id = Date.now();
this.worker.postMessage(message);
this.worker.onmessage = (event) => {
const { data } = event;
if (data.id === message.id) {
resolve(data);
}
};
});
return this.workerClient.request(message);
}
createWorker() {
this.workerClient = new WorkerClient(() => new WorkerWrapper(), (error) => this.scheduler.fail(error));
this.worker = this.workerClient.worker;
}
// 根据状态获取弹幕
filter(state, callback) {
const danmus = this.states[state] || [];
for (let index = 0; index < danmus.length; index++) {
callback(danmus[index]);
}
return danmus;
return filterState(this, state, callback);
}
// 设置弹幕状态
setState(danmu, state) {
this.states[danmu.$state] = this.states[danmu.$state].filter((item) => item !== danmu);
danmu.$state = state;
if (danmu.$ref) {
danmu.$ref.dataset.state = state;
}
this.states[state].push(danmu);
setItemState(this, danmu, state);
}
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu) {
@@ -712,88 +995,8 @@ class Danmuku {
}
// 实时更新弹幕
update() {
const { setStyles } = this.utils;
this.timer = window.requestAnimationFrame(async () => {
if (this.art.playing && !this.isHide) {
this.filter("emit", (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1e3;
danmu.$restTime -= emitTime;
danmu.$lastStartTime = Date.now();
if (danmu.$restTime <= 0) {
this.makeWait(danmu);
}
});
const readys = this.readys;
for (let index = 0; index < readys.length; index++) {
const danmu = readys[index];
const state = await this.option.beforeVisible(danmu);
if (state) {
const { clientWidth, clientHeight } = this.$player;
danmu.$ref = this.$ref;
danmu.$ref.textContent = danmu.text;
this.$danmuku.appendChild(danmu.$ref);
danmu.$ref.style.opacity = this.option.opacity;
danmu.$ref.style.fontSize = `${this.fontSize}px`;
danmu.$ref.style.color = danmu.color;
danmu.$ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null;
danmu.$ref.style.backgroundColor = danmu.border ? "rgb(0 0 0 / 50%)" : null;
setStyles(danmu.$ref, danmu.style);
danmu.$lastStartTime = Date.now();
danmu.$restTime = this.speed;
const distance = clientWidth + danmu.$ref.clientWidth;
const { result: top } = await this.postMessage({
type: "getDanmuTop",
target: {
mode: danmu.mode,
height: danmu.$ref.clientHeight,
speed: distance / danmu.$restTime
},
// 当前弹幕信息
visibles: this.visibles,
// 可见的弹幕的数据
antiOverlap: this.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: this.marginBottom,
marginTop: this.marginTop
});
if (danmu.$ref) {
if (!this.isStop && top !== void 0) {
this.setState(danmu, "emit");
danmu.$ref.style.top = `${top}px`;
danmu.$ref.style.visibility = "visible";
danmu.$ref.dataset.mode = danmu.mode;
danmu.$ref.dataset.id = danmu.id || "";
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
danmu.$ref.style.left = `${clientWidth}px`;
danmu.$ref.style.marginLeft = "0px";
danmu.$ref.style.transform = `translateX(${-distance}px)`;
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`;
break;
}
case 1:
// falls through
case 2:
danmu.$ref.style.left = "50%";
danmu.$ref.style.marginLeft = `-${danmu.$ref.clientWidth / 2}px`;
break;
}
this.art.emit("artplayerPluginDanmuku:visible", danmu);
} else {
this.setState(danmu, "ready");
this.$refs.push(danmu.$ref);
danmu.$ref = null;
}
}
}
}
}
if (!this.isStop) {
this.update();
}
});
this.scheduler.running = true;
this.scheduler.schedule();
return this;
}
// 重置正在显示的弹幕: stop/emit 状态的弹幕
@@ -858,23 +1061,39 @@ class Danmuku {
}
stop() {
this.isStop = true;
this.scheduler.running = false;
this.scheduler.invalidate();
this.suspend();
window.cancelAnimationFrame(this.timer);
this.art.emit("artplayerPluginDanmuku:stop");
return this;
}
start() {
if (this.scheduler.closed || this.art.isDestroy)
return this;
const start = ++this.scheduler.starts;
const generation = this.scheduler.generation;
this.isStop = false;
this.scheduler.recover();
const obsolete = this.scheduler.closed || this.art.isDestroy || this.isStop || this.scheduler.fault || generation !== this.scheduler.generation || start !== this.scheduler.starts;
if (obsolete)
return this;
this.continue();
this.update();
this.art.emit("artplayerPluginDanmuku:start");
return this;
}
reset() {
this.scheduler.invalidate();
this.queue.forEach((danmu) => this.makeWait(danmu));
this.art.emit("artplayerPluginDanmuku:reset");
this.scheduler.recover();
this.scheduler.schedule();
return this;
}
seek() {
this.scheduler.invalidate();
this.scheduler.schedule();
}
show() {
this.isHide = false;
this.$danmuku.style.opacity = 1;
@@ -884,22 +1103,32 @@ class Danmuku {
}
hide() {
this.isHide = true;
this.scheduler.invalidate();
this.$danmuku.style.opacity = 0;
this.option.visible = false;
this.art.emit("artplayerPluginDanmuku:hide");
this.scheduler.schedule();
return this;
}
destroy() {
cancelInputs(this);
this.stop();
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.resize);
this.art.off("destroy", this.destroy);
this.art.emit("artplayerPluginDanmuku:destroy");
this.scheduler.destroy();
try {
try {
this.stop();
} finally {
this.workerClient?.dispose();
}
} finally {
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.resize);
this.art.off("video:seeking", this.seek);
this.art.off("destroy", this.destroy);
this.art.emit("artplayerPluginDanmuku:destroy");
}
}
}
const lib = {
+66 -168
View File
@@ -1,5 +1,8 @@
import { defaultOption, normalizeOption, optionChanged, optionScheme } from './config'
import { beginInput, cancelInputs, inputActive, readInput } from './input'
import { filterState, readyItems, setItemState } from './queue'
import Scheduler from './scheduler'
import WorkerClient from './worker-client'
import DanmuWorker from './worker.js?worker&inline'
export default class Danmuku {
@@ -19,6 +22,8 @@ export default class Danmuku {
this.timer = null // 定时器
this.index = 0 // 弹幕索引
this.worker = null
this.workerClient = null
this.scheduler = new Scheduler(this)
// 格式化后的配置项
this.option = Danmuku.option
@@ -32,6 +37,7 @@ export default class Danmuku {
this.reset = this.reset.bind(this)
this.resize = this.resize.bind(this)
this.destroy = this.destroy.bind(this)
this.seek = this.seek.bind(this)
// 配置事件可同步销毁播放器,先建立输入和销毁边界。
inputActive(this)
@@ -42,11 +48,12 @@ export default class Danmuku {
return
// 创建 Web Worker, 用于计算弹幕的 top 值
this.worker = new DanmuWorker()
this.createWorker()
}
catch (error) {
art.off('destroy', this.destroy)
cancelInputs(this)
this.scheduler.destroy()
throw error
}
// 监听事件
@@ -55,6 +62,7 @@ export default class Danmuku {
art.on('video:pause', this.stop)
art.on('video:waiting', this.stop)
art.on('resize', this.resize)
art.on('video:seeking', this.seek)
// 开始加载弹幕
this.load().catch(error => console.warn('Failed to load initial danmuku:', error))
@@ -160,21 +168,7 @@ export default class Danmuku {
// 获取准备好发送的弹幕
get readys() {
const { currentTime } = this.art
const result = []
// 有的是ready状态:之前因为弹幕太多而暂停发送的弹幕
this.filter('ready', danmu => result.push(danmu))
// 有的是wait状态:符合时间范围的弹幕
this.filter('wait', (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1) {
result.push(danmu)
}
})
return result
return readyItems(this)
}
// 可见的弹幕的数据,用于计算下一个弹幕的top值
@@ -289,7 +283,7 @@ export default class Danmuku {
return this
// 设置弹幕时间,如果没有则默认为当前时间加 0.5 秒
if (danmu.time !== undefined) {
if (danmu.time || danmu.time === 0) {
danmu.time = clamp(danmu.time, 0, Infinity)
}
else {
@@ -367,7 +361,12 @@ export default class Danmuku {
})
if (!inputActive(this))
return this
const beforeVisibleChanged = this.option.beforeVisible !== next.beforeVisible
this.option = next
if (beforeVisibleChanged) {
this.scheduler.invalidate()
this.scheduler.schedule()
}
// 动态配置有字体大小,需要重新渲染
if (option.fontSize) {
@@ -398,43 +397,22 @@ export default class Danmuku {
// 复杂运算交给 Web Worker 处理
postMessage(message = {}) {
return new Promise((resolve) => {
message.id = Date.now() // 生成唯一标识
this.worker.postMessage(message)
this.worker.onmessage = (event) => {
const { data } = event
// 判断是否是当前的消息
if (data.id === message.id) {
resolve(data)
}
}
})
return this.workerClient.request(message)
}
createWorker() {
this.workerClient = new WorkerClient(() => new DanmuWorker(), error => this.scheduler.fail(error))
this.worker = this.workerClient.worker
}
// 根据状态获取弹幕
filter(state, callback) {
const danmus = this.states[state] || []
for (let index = 0; index < danmus.length; index++) {
callback(danmus[index])
}
return danmus
return filterState(this, state, callback)
}
// 设置弹幕状态
setState(danmu, state) {
// 从原状态池中删除
this.states[danmu.$state] = this.states[danmu.$state].filter(item => item !== danmu)
// 设置新状态
danmu.$state = state
// 设置DOM节点状态
if (danmu.$ref) {
danmu.$ref.dataset.state = state
}
// 添加到新状态池中
this.states[state].push(danmu)
setItemState(this, danmu, state)
}
// 重置弹幕到wait状态,回收弹幕DOM节点
@@ -453,118 +431,8 @@ export default class Danmuku {
// 实时更新弹幕
update() {
const { setStyles } = this.utils
this.timer = window.requestAnimationFrame(async () => {
if (this.art.playing && !this.isHide) {
// 实时计算弹幕的剩余显示时间
this.filter('emit', (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1000
danmu.$restTime -= emitTime
danmu.$lastStartTime = Date.now()
// 超过时间即重置弹幕
if (danmu.$restTime <= 0) {
this.makeWait(danmu)
}
})
// 获取准备好发送的弹幕,可能包含ready和wait状态的弹幕
const readys = this.readys
for (let index = 0; index < readys.length; index++) {
const danmu = readys[index]
// 弹幕发送前的过滤器
const state = await this.option.beforeVisible(danmu)
if (state) {
const { clientWidth, clientHeight } = this.$player
danmu.$ref = this.$ref // 获取弹幕DOM节点
danmu.$ref.textContent = danmu.text // 设置弹幕文本
// 提前添加到弹幕层中,用于计算top值
this.$danmuku.appendChild(danmu.$ref)
// 设置初始弹幕样式
danmu.$ref.style.opacity = this.option.opacity
danmu.$ref.style.fontSize = `${this.fontSize}px`
danmu.$ref.style.color = danmu.color
danmu.$ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
danmu.$ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
// 设置单独弹幕样式
setStyles(danmu.$ref, danmu.style)
// 记录弹幕时间戳
danmu.$lastStartTime = Date.now()
// 计算弹幕剩余时间
danmu.$restTime = this.speed
// 计算弹幕滚动的距离
const distance = clientWidth + danmu.$ref.clientWidth
// 计算弹幕的top值
const { result: top } = await this.postMessage({
type: 'getDanmuTop',
target: {
mode: danmu.mode,
height: danmu.$ref.clientHeight,
speed: distance / danmu.$restTime,
}, // 当前弹幕信息
visibles: this.visibles, // 可见的弹幕的数据
antiOverlap: this.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: this.marginBottom,
marginTop: this.marginTop,
})
if (danmu.$ref) {
if (!this.isStop && top !== undefined) {
this.setState(danmu, 'emit') // 转换为emit状态
danmu.$ref.style.top = `${top}px`
danmu.$ref.style.visibility = 'visible'
danmu.$ref.dataset.mode = danmu.mode // CSS控制模式的显示和隐藏
danmu.$ref.dataset.id = danmu.id || '' // 用于悬停的唯一标识
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref.style.marginLeft = '0px'
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
case 1:
// falls through
case 2:
danmu.$ref.style.left = '50%'
danmu.$ref.style.marginLeft = `-${danmu.$ref.clientWidth / 2}px`
break
default:
break
}
this.art.emit('artplayerPluginDanmuku:visible', danmu)
}
else {
// 假如弹幕已经停止或者没有 top 值,则重置弹幕为ready状态,回收弹幕DOM节点,等待下次发送
this.setState(danmu, 'ready')
this.$refs.push(danmu.$ref)
danmu.$ref = null
}
}
}
}
}
// 递归调用
if (!this.isStop) {
this.update()
}
})
this.scheduler.running = true
this.scheduler.schedule()
return this
}
@@ -645,14 +513,24 @@ export default class Danmuku {
stop() {
this.isStop = true
this.scheduler.running = false
this.scheduler.invalidate()
this.suspend()
window.cancelAnimationFrame(this.timer)
this.art.emit('artplayerPluginDanmuku:stop')
return this
}
start() {
if (this.scheduler.closed || this.art.isDestroy)
return this
const start = ++this.scheduler.starts
const generation = this.scheduler.generation
this.isStop = false
this.scheduler.recover()
const obsolete = this.scheduler.closed || this.art.isDestroy || this.isStop || this.scheduler.fault
|| generation !== this.scheduler.generation || start !== this.scheduler.starts
if (obsolete)
return this
this.continue()
this.update()
this.art.emit('artplayerPluginDanmuku:start')
@@ -660,11 +538,19 @@ export default class Danmuku {
}
reset() {
this.scheduler.invalidate()
this.queue.forEach(danmu => this.makeWait(danmu))
this.art.emit('artplayerPluginDanmuku:reset')
this.scheduler.recover()
this.scheduler.schedule()
return this
}
seek() {
this.scheduler.invalidate()
this.scheduler.schedule()
}
show() {
this.isHide = false
this.$danmuku.style.opacity = 1
@@ -675,22 +561,34 @@ export default class Danmuku {
hide() {
this.isHide = true
this.scheduler.invalidate()
this.$danmuku.style.opacity = 0
this.option.visible = false
this.art.emit('artplayerPluginDanmuku:hide')
this.scheduler.schedule()
return this
}
destroy() {
cancelInputs(this)
this.stop()
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.resize)
this.art.off('destroy', this.destroy)
this.art.emit('artplayerPluginDanmuku:destroy')
this.scheduler.destroy()
try {
try {
this.stop()
}
finally {
this.workerClient?.dispose()
}
}
finally {
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.resize)
this.art.off('video:seeking', this.seek)
this.art.off('destroy', this.destroy)
this.art.emit('artplayerPluginDanmuku:destroy')
}
}
}
@@ -0,0 +1,24 @@
export function filterState(owner, state, callback) {
const danmus = owner.states[state] || []
for (let index = 0; index < danmus.length; index++) callback(danmus[index])
return danmus
}
export function readyItems(owner) {
const { currentTime } = owner.art
const result = []
filterState(owner, 'ready', danmu => result.push(danmu))
filterState(owner, 'wait', (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1)
result.push(danmu)
})
return result
}
export function setItemState(owner, danmu, state) {
owner.states[danmu.$state] = owner.states[danmu.$state].filter(item => item !== danmu)
danmu.$state = state
if (danmu.$ref)
danmu.$ref.dataset.state = state
owner.states[state].push(danmu)
}
@@ -0,0 +1,208 @@
const cancelled = Symbol('cancelled danmuku frame')
export default class Scheduler {
constructor(owner) {
this.owner = owner
this.generation = 0
this.starts = 0
this.frame = null
this.operation = null
this.closed = false
this.running = false
this.fault = false
this.failedItems = new Set()
}
active(operation) {
const owner = this.owner
return !this.closed && !this.fault && !owner.art.isDestroy && !owner.isStop
&& !owner.isHide && operation.generation === this.generation
}
release(operation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
}
operation.ref = null
}
invalidate() {
this.generation++
if (this.frame !== null)
window.cancelAnimationFrame(this.frame)
this.frame = null
this.owner.timer = null
if (this.operation) {
const operation = this.operation
this.operation = null
operation.cancel()
this.release(operation)
}
this.owner.workerClient?.cancel()
this.failedItems.clear()
}
report(error) {
try {
this.owner.art.emit('artplayerPluginDanmuku:error', error)
}
catch (listenerError) {
console.warn('Failed to report danmuku scheduling error:', listenerError)
}
}
fail(error) {
if (this.closed || this.fault)
return
this.fault = true
this.invalidate()
this.report(error)
}
recover() {
if (this.closed || this.owner.art.isDestroy)
return
this.failedItems.clear()
if (this.fault) {
try {
this.owner.workerClient?.dispose()
this.owner.createWorker()
this.fault = false
}
catch (error) {
this.report(error)
}
}
}
schedule() {
const owner = this.owner
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
return
this.frame = window.requestAnimationFrame(() => {
this.frame = null
owner.timer = null
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return
let cancel
const cancellation = new Promise(resolve => cancel = () => resolve(cancelled))
const operation = { generation: this.generation, cancel, wait: value => Promise.race([value, cancellation]), ref: null }
this.operation = operation
return this.run(operation).catch((error) => {
if (this.active(operation))
this.fail(error)
}).finally(() => {
this.release(operation)
if (this.operation === operation) {
this.operation = null
this.schedule()
}
})
})
owner.timer = this.frame
}
async run(operation) {
const owner = this.owner
const { setStyles } = owner.utils
if (!owner.art.playing || !this.active(operation))
return
owner.filter('emit', (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1000
danmu.$restTime -= emitTime
danmu.$lastStartTime = Date.now()
if (danmu.$restTime <= 0)
owner.makeWait(danmu)
})
const readys = owner.readys
for (const danmu of readys) {
if (!this.active(operation))
return
if (this.failedItems.has(danmu))
continue
let state
try {
state = await operation.wait(owner.option.beforeVisible(danmu))
}
catch (error) {
if (!this.active(operation))
return
this.failedItems.add(danmu)
this.report(error)
continue
}
if (!this.active(operation))
return
if (!state)
continue
const { clientWidth, clientHeight } = owner.$player
const ref = owner.$ref
operation.danmu = danmu
operation.ref = ref
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
ref.style.fontSize = `${owner.fontSize}px`
ref.style.color = danmu.color
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
setStyles(ref, danmu.style)
if (!this.active(operation))
return
danmu.$lastStartTime = Date.now()
danmu.$restTime = owner.speed
const distance = clientWidth + ref.clientWidth
const { result: top } = await operation.wait(owner.postMessage({
type: 'getDanmuTop',
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
antiOverlap: owner.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: owner.marginBottom,
marginTop: owner.marginTop,
}))
if (!this.active(operation) || danmu.$ref !== ref)
return
if (top !== undefined) {
owner.setState(danmu, 'emit')
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`
ref.style.marginLeft = '0px'
ref.style.transform = `translateX(${-distance}px)`
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
case 1:
case 2:
ref.style.left = '50%'
ref.style.marginLeft = `-${ref.clientWidth / 2}px`
break
default:
break
}
operation.ref = null
owner.art.emit('artplayerPluginDanmuku:visible', danmu)
}
else {
owner.setState(danmu, 'ready')
this.release(operation)
}
}
}
destroy() {
this.closed = true
this.invalidate()
}
}
@@ -0,0 +1,91 @@
let nextRequest = 0
export default class WorkerClient {
constructor(createWorker, onFailure) {
this.pending = new Map()
this.closed = false
this.failed = false
this.onFailure = onFailure
this.worker = createWorker()
try {
this.worker.onmessage = (event) => {
const request = this.pending.get(event.data?.id)
if (!request)
return
this.pending.delete(event.data.id)
request.resolve(event.data)
}
this.worker.onerror = (event) => {
event.preventDefault?.()
this.fail(event.error || event)
}
this.worker.onmessageerror = event => this.fail(event.error || event)
}
catch (error) {
try {
this.dispose()
}
catch {}
throw error
}
}
request(message) {
message.id = ++nextRequest
const { id } = message
if (this.closed || this.failed)
return Promise.resolve({ id, result: undefined })
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
try {
this.worker.postMessage(message)
}
catch (error) {
this.fail(error)
}
})
}
fail(error) {
if (this.closed || this.failed)
return
this.failed = true
for (const request of this.pending.values()) request.reject(error)
this.pending.clear()
try {
this.dispose()
}
catch {}
this.onFailure(error)
}
cancel() {
for (const [id, request] of this.pending) request.resolve({ id, result: undefined })
this.pending.clear()
}
dispose() {
if (this.closed)
return
this.closed = true
this.cancel()
let failed = false
let error
const attempt = (callback) => {
try {
callback()
}
catch (failure) {
if (!failed)
error = failure
failed = true
}
}
for (const name of ['onmessage', 'onerror', 'onmessageerror']) {
attempt(() => this.worker[name] = null)
}
attempt(() => this.worker.terminate())
if (failed)
throw error
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,84 @@
# PKG-DANMUKU-04 时钟、队列与轨道调度
弹幕调度现在只有一个待执行RAF或异步准备帧。新增`src/scheduler.js`负责运行归属、
取消、失败和恢复,`src/queue.js`负责状态池与候选顺序,`src/worker-client.js`负责
请求关联和Worker收尾。`danmuku.js`保留公共行为出口;原`worker.js`轨道算法逐字未改。
本步仍是JS职责拆分,完整TS和最新npm根声明/准确`/runtime`归06。
## 行为与兼容边界
- 原生play/playing可以重复进入start,仍保留旧start事件;内部只能有一条运行链。
队列先ready后wait,保留当前媒体时间前后0.1秒窗口、状态池顺序和原轨道几何。
- 暂停、reset、替换输入成功提交、hide、seeking、destroy以及更换beforeVisible
使旧准备工作失效。取消与用户Promise竞速,新工作不必等待旧回调结束。
每次await及用户回调后检查归属;回收前检查节点身份,避免旧续体回收新节点。
- hide、seeking和回调更新保留已显示/暂停节点;seek不额外发reset。Date.now寿命
排除暂停时间;同步倍率仍只在新弹幕分配时取值,不顺便改写现有弹幕剩余寿命。
- beforeVisible拒绝沿现有error出口发原错一次,本轮跳过该项并继续其他项;false
仍可下一帧重试。显式start/reset或其他失效动作允许仍符合时间条件的失败项重试。
错误监听器自身抛错有局部观察,不产生脱离调用链的未处理RAF拒绝。
- Worker共享回复监听在post前安装,ID在请求及Worker替换之间递增,避免同毫秒串线。
反序、同步、重复、未知和迟到回复不会完成其他请求。取消的内部请求解析为
`{ id, result: undefined }`;故障以原错拒绝待处理请求,独立尝试解绑及terminate。
- Worker故障停止内部调度但不伪造公共stop。后续start/reset可重建;重复失败等待
下次显式恢复。错误监听器可能同步destroy/stop/reset/hide或嵌套start,因此恢复
后再次检查生命周期、generation和启动序号,过期start不能继续或发迟到start事件。
- 门面方法、getter、同步/异步返回及内部实例身份保持。配置回调保持当前option为this。
合法的NaN时间保持旧currentTime+0.5;零保持03明确修正后的0,正无穷保持原值,
负无穷仍钳位0,不扩大原验证器对null等输入的接受范围。
## 发现的问题先修复,再保留证据
最初18项调度测试为6通过12失败;03实际main在两核心的8个Chromium场景为4通过4失败,
具体包括六条弹幕产生七个请求、同ID及空visible快照、节点复用时留下额外隐藏节点。
这个03快照本身未重叠,02另有真实重叠记录;没有把不同运行的现象拼成同一次失败。
第一轮候选48项原生矩阵通过后,只读代理发现恢复错误监听器销毁播放器仍继续start。
补齐destroy/stop/reset/hide/嵌套start五个回归,保存的修复前真实main在32项中27绿5红,
加入恢复所有权检查后全部转绿。随后复审发现03的`time !== undefined`在修正零时
误改合法NaN:历史源码/main/legacy均沿旧默认时间,候选24项23绿1红。保留零的显式
判断并恢复NaN默认路径,增加历史对照和真实浏览器可见性断言;03旧报告保持原样。
这些修复作为本步变更记录,新增已解决风险INPUT-02;没有把中间版本的通过当作最终
验收。父代理负责复审、构建、浏览器和整合,子代理分别承担调度实现、确定性测试及
独立风险复核,同一文件分工不重叠,Git由父代理统一提交。
## 最终验证
- 源码、正式main、正式legacy各104项联合通过。每组中83项随候选实现变化,18项
直接验证内部Worker客户端/原轨道源码,3项固定历史数值对照。不能把后21项重复
宣称为不同产物的额外覆盖。组成:输入24、解析26、调度33、Worker客户端21。
- 确定性时钟覆盖三小时媒体范围、180批共720条、15次120秒暂停及暂停中seek,
执行真实Worker算法,检查每条一次请求、发射顺序、唯一状态池及4节点全程复用。
这是采样生命周期的模拟时钟测试,不是实际三小时播放或帧率/内存基准。
- 三种实现各66项原生浏览器通过,共198项:实际已发布核心5.4.0及候选5.4.1,
Chromium/Firefox/Windows WebKit。包括三模式、严格轨道几何、播放/暂停/跳转/倍率、
节点复用、四种pending取消、恢复重入、NaN、实际设置/发送/mount和双实例。
- main和legacy额外各30次密集场景重复,共60次,无跳过、失败或重试后通过。
每次六条固定弹幕都断言几何不交叠、顺序、六个唯一请求、visible快照长度0至5、
回复关联及六节点;复用场景另断言同一可见节点且额外节点数组为空。
- 普通场景要求零页面错误、零Worker错误;恢复场景显式派发受控ErrorEvent并让
createWorker抛错,仅允许精确已知故障。实际视频/DOM/Blob Worker继续使用原生实现,
但不能把注入故障描述成真实CSP或平台Worker构造失败。Windows WebKit不是Safari设备。
- 仓库正常build生成三格式及docs/compiled,逐一核对字节;修改源码/测试/脚本入口lint
通过。04未改生产TS或公开声明,引用03全仓366生产TS和消费者检查,未声称04新跑TS。
构建曾在浏览器运行期间遇Windows UNKNOWN复制错误;停止并行构建后正常串行重建成功,
核对main/legacy与当时已测试文件哈希一致。NaN修复后再次完整构建并运行上述最终矩阵。
故障日志、串行重试、字节一致性证明及最终结果均保留。新增所有权逻辑使产物变大,
机器证据记录相对03的原始/gzip字节,不能声称本步缩包或性能改善。
证据及输入指纹见[机器报告](../baselines/danmuku-scheduler-validation.json)。原始截图和
附件归档独立目录,报告记录原results路径到归档路径的映射,不手改原Playwright报告。
未新增依赖或修改Yarn锁;`test:unit`和`test:danmuku`加入调度/Worker客户端测试,新增
原生spec沿既有浏览器CI发现。中间48/60项矩阵保存为过程证据,最终引用numeric-final。
## 风险关闭与下一步
ASYNC-01由03输入/解析及04可见性/Worker归属共同满足关闭标准;TRACK-01关闭范围仅为
已复现的重复运行链和请求串线,以及对应严格几何/节点重复回归。它们不代表任意负载、
任意轨道算法或包级资源稳定性已经验收。CLEANUP-01保持开放:Setting外部DOM、发送
续体和锁定计时器仍需05处理;heatmap零采样同样未修。05继续DOM渲染/设置/热力图,
06完整TS与声明,07真实长负载。完整npm安装、发布前多轮复盘仍待各自任务。
本任务完成后独立本地commit,无推送、标签或npm发布。
+4 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
状态:todo 71 / doing 15 / blocked 0 / done 142 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 70 / doing 15 / blocked 0 / done 143 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -312,8 +312,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 | 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-04 | artplayer-plugin-danmuku<br>整理时钟、队列与轨道调度 | PKG-DANMUKU-03, CORE-10 | danmuku 调度器和确定性时钟测试 | seek/暂停/倍率/长时间运行无顺序和碰撞回归 | H | done |
| PKG-DANMUKU-05 | artplayer-plugin-danmuku<br>整理 DOM 渲染、设置、热力图与 worker | PKG-DANMUKU-04, CORE-14, CORE-18 | renderer/setting/heatmap/worker 职责及资源归属 | mount/icons/设置和 worker 协议保持,销毁无后台工作 | H | doing |
| 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 |
| PKG-DANMUKU-08 | artplayer-plugin-danmuku<br>完成新旧核心与组合验收 | PKG-DANMUKU-07, CORE-22 | 最终核心/旧核心、mask/fullscreen/PiP 组合报告 | 性能无未解释退化,反复装卸无累计资源 | H | todo |
@@ -556,6 +556,7 @@
- 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-DANMUKU-04: [记录](changes/2026-09-13-PKG-DANMUKU-04-scheduler.md) [记录](baselines/danmuku-scheduler-validation.json)
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md)
+11
View File
@@ -1,5 +1,16 @@
# 进度与证据
## PKG-DANMUKU-04 时钟、队列与轨道调度完成
拆分scheduler/queue/worker-client,修复多RAF、同毫秒请求串线、取消后续写、
节点重复分配、异步拒绝及恢复重入;复审修复03引入的NaN兼容回归。
源码/main/legacy各104联合测试、三格式共198真实浏览器项及60次额外密集场景通过;
完整构建、lint通过。关闭ASYNC-01/TRACK-01,Settings/heatmap/完整TS/长负载仍待后续。
见[变更](changes/2026-09-13-PKG-DANMUKU-04-scheduler.md)及[机器证据](baselines/danmuku-scheduler-validation.json)。
228项:143 done、15 doing、70 todo;05进入实施。
没有推送或发布。
## PKG-DANMUKU-03 输入、解析和配置源码完成
拆分input/config/XML parser模块,修复过期替换、加载拒绝、资源清理、time0、函数配置
+3 -2
View File
@@ -232,7 +232,8 @@
| 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 |
| DANMUKU-ASYNC-01 | resolved / 已复现 | Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths | PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05 |
| DANMUKU-HEATMAP-01 | open / 已复现 | Danmuku heatmap sampling can become zero and points/instance ownership need browser validation | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-SOURCE-01 | open / 已复现 | Danmuku registry Git associations differ from releases and static icon provenance remains incomplete | PKG-DANMUKU-09 |
| DANMUKU-TRACK-01 | open / 已复现 | Native simultaneous fixed Danmuku comments can overlap with duplicate start loops and repeated Worker IDs | PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-07 |
| DANMUKU-TRACK-01 | resolved / 已复现 | Native simultaneous fixed Danmuku comments can overlap with duplicate start loops and repeated Worker IDs | PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-07 |
| DANMUKU-INPUT-02 | resolved / 已复现 | Intermediate explicit-zero correction regressed accepted NaN timestamp fallback | PKG-DANMUKU-04 |
+51 -7
View File
@@ -5106,7 +5106,9 @@
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
"refactor/baselines/danmuku-browser-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-03-input.md",
"refactor/baselines/danmuku-input-validation.json"
"refactor/baselines/danmuku-input-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"refactor/baselines/danmuku-scheduler-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."
@@ -5132,7 +5134,7 @@
"id": "DANMUKU-ASYNC-01",
"title": "Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-03",
@@ -5148,10 +5150,20 @@
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
"refactor/baselines/danmuku-browser-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-03-input.md",
"refactor/baselines/danmuku-input-validation.json"
"refactor/baselines/danmuku-input-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"refactor/baselines/danmuku-scheduler-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."
"closureCriteria": "Failures settle, obsolete results cannot alter new queues or destroyed instances, and worker resources are released with browser evidence.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"refactor/baselines/danmuku-scheduler-validation.json",
"test/danmuku-scheduler.test.js",
"test/danmuku-worker-client.test.js",
"test/browser/danmuku-scheduler.spec.js"
],
"resolutionRationale": "Task03 input/parser settlement and task04 worker/visibility ownership prevent stale continuation, including recovery reentry; final native matrices verify worker disposal. Settings/heatmap cleanup remains separately open."
},
{
"id": "DANMUKU-HEATMAP-01",
@@ -5194,7 +5206,7 @@
"id": "DANMUKU-TRACK-01",
"title": "Native simultaneous fixed Danmuku comments can overlap with duplicate start loops and repeated Worker IDs",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-04",
"PKG-DANMUKU-05",
@@ -5204,10 +5216,42 @@
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
"refactor/baselines/danmuku-browser-validation.json",
"test/browser/danmuku-baseline.spec.js",
"test/danmuku-failures.test.js"
"test/danmuku-failures.test.js",
"refactor/changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"refactor/baselines/danmuku-scheduler-validation.json"
],
"compatibleResolution": "Enforce one owned scheduler loop and request correlation before claiming simultaneous native anti-overlap stability; keep strict track geometry tests.",
"closureCriteria": "Deterministic duplicate-loop/worker regressions and repeated candidate native simultaneous-load geometry pass without extra nodes or stale state; a later passing old snapshot alone does not close the race."
"closureCriteria": "Deterministic duplicate-loop/worker regressions and repeated candidate native simultaneous-load geometry pass without extra nodes or stale state; a later passing old snapshot alone does not close the race.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"refactor/baselines/danmuku-scheduler-validation.json",
"test/danmuku-scheduler.test.js",
"test/danmuku-worker-client.test.js",
"test/browser/danmuku-scheduler.spec.js"
],
"resolutionRationale": "Single owned loop, correlated IDs and unchanged real geometry algorithm pass deterministic regressions, all native matrices and 60 additional repeated dense cases with strict geometry, request and node counts; broad long-load acceptance remains task07."
},
{
"id": "DANMUKU-INPUT-02",
"title": "Intermediate explicit-zero correction regressed accepted NaN timestamp fallback",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"PKG-DANMUKU-04"
],
"evidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"refactor/baselines/danmuku-scheduler-validation.json",
"test/danmuku-input.test.js"
],
"compatibleResolution": "Preserve old NaN default currentTime+0.5, corrected explicit zero and old Infinity normalization without widening validation.",
"closureCriteria": "Frozen history and current candidate numeric comparisons pass, and actual native candidate renders NaN input.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"refactor/baselines/danmuku-scheduler-validation.json",
"test/browser/danmuku-scheduler.spec.js"
],
"resolutionRationale": "Historical source/main/legacy establish old behavior; source/main/legacy final input and native matrices confirm the one-condition correction. Task03 frozen evidence is preserved."
}
]
}
+6 -3
View File
@@ -3320,11 +3320,14 @@
"PKG-DANMUKU-03",
"CORE-10"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "danmuku 调度器和确定性时钟测试",
"acceptance": "seek/暂停/倍率/长时间运行无顺序和碰撞回归",
"evidence": []
"evidence": [
"changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"baselines/danmuku-scheduler-validation.json"
]
},
{
"id": "PKG-DANMUKU-05",
@@ -3338,7 +3341,7 @@
"CORE-14",
"CORE-18"
],
"status": "todo",
"status": "doing",
"risk": "H",
"deliverable": "renderer/setting/heatmap/worker 职责及资源归属",
"acceptance": "mount/icons/设置和 worker 协议保持,销毁无后台工作",
+444
View File
@@ -0,0 +1,444 @@
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, format, scenario, testInfo) {
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
const url = new URL(route.request().url())
if (url.origin !== origin) {
external.push(url.href)
return route.abort()
}
// Same-origin blob URLs are actual inline Worker/media resources, not external requests.
return route.continue()
})
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(() => {
window.danmukuEvidence = { workers: [], events: [], gates: [], sends: [], failures: [] }
const NativeWorker = window.Worker
window.Worker = class ObservedNativeWorker extends NativeWorker {
constructor(url, options) {
super(url, options)
this.evidence = { url: String(url), requests: [], replies: [], terminated: 0, errors: [] }
window.danmukuEvidence.workers.push(this.evidence)
this.addEventListener('message', ({ data }) => this.evidence.replies.push({ id: data.id, result: data.result ?? null }))
this.addEventListener('error', event => this.evidence.errors.push(event.message))
}
terminate() {
this.evidence.terminated++
return super.terminate()
}
postMessage(message, ...args) {
this.evidence.requests.push(structuredClone(message))
return super.postMessage(message, ...args)
}
}
window.danmukuPlayers = []
window.danmukuPlugins = []
window.danmukuNodes = []
window.danmukuVisibility = new Promise((resolve) => {
window.releaseDanmukuVisibility = resolve
})
window.danmukuSending = new Promise((resolve) => {
window.releaseDanmukuSending = resolve
})
})
const implementation = { code: candidate, sha256: hash(candidate) }
await page.addScriptTag({ content: implementation.code })
await page.evaluate(({ scenario }) => {
const first = document.querySelector('.player')
first.id = 'danmuku-player-0'
first.style.cssText = 'width:640px;height:360px'
window.createDanmukuPlayer = (index) => {
const container = index === 0 ? first : document.body.appendChild(document.createElement('div'))
container.id = `danmuku-player-${index}`
container.style.cssText = 'width:640px;height:360px'
const mount = document.createElement('div')
mount.id = `danmuku-mount-${index}`
mount.style.cssText = 'width:640px;height:36px;background:#333'
container.after(mount)
const art = new window.Artplayer({ container, url: `/test/pattern.mp4?danmuku=${index}`, muted: true, mutex: false, fullscreenWeb: true })
window.danmukuPlayers[index] = art
for (const event of ['loaded', 'visible', 'start', 'stop', 'reset', 'destroy', 'config', 'error']) {
art.on(`artplayerPluginDanmuku:${event}`, (value) => {
window.danmukuEvidence.events.push({ index, event, time: art.currentTime, id: value?.id, text: value?.text, mode: value?.mode, count: Array.isArray(value) ? value.length : undefined })
if (event === 'error')
window.danmukuEvidence.failures.push(String(value))
})
}
const option = {
danmuku: [],
heatmap: false,
speed: 8,
margin: [10, 10],
fontSize: 20,
antiOverlap: true,
synchronousPlayback: true,
lockTime: 1,
...(scenario === 'settings' ? { mount } : {}),
filter: danmu => danmu.text !== 'FILTERED',
beforeVisible: async (danmu) => {
window.danmukuEvidence.gates.push({ index, id: danmu.id, text: danmu.text })
if (danmu.text === 'GATED')
return window.danmukuVisibility
return danmu.text !== 'DENIED'
},
beforeEmit: async (danmu) => {
window.danmukuEvidence.sends.push({ index, text: danmu.text, mode: danmu.mode })
return scenario === 'settings' && index === 0 ? window.danmukuSending : true
},
}
art.plugins.add(window.artplayerPluginDanmuku(option))
window.danmukuPlugins[index] = art.plugins.artplayerPluginDanmuku
return art
}
window.art = window.createDanmukuPlayer(0)
document.querySelector('#play').onclick = () => window.danmukuPlayers[0].play()
document.querySelector('#pause').onclick = () => window.danmukuPlayers[0].pause()
}, { scenario })
await expect.poll(() => page.evaluate(() => window.art.isReady && window.art.template.$video.videoWidth > 0)).toBe(true)
await testInfo.attach('danmuku-browser-inputs', { contentType: 'application/json', body: JSON.stringify({ core, plugin: 'artplayer-plugin-danmuku', format, artifact: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'in-memory source UMD', sha256: implementation.sha256, media: '/test/pattern.mp4', scenario, worker: 'Native Blob Worker, only constructor/message/termination observation; no Worker responses, media clocks, layout or RAF mocked', heatmap: false }) })
return external
}
async function geometry(page) {
return page.evaluate(() => (window.danmukuPlayers || []).map((art, index) => {
const player = art.template.$player.getBoundingClientRect()
return {
index,
playing: art.playing,
time: art.currentTime,
rate: art.playbackRate,
player: { x: player.x, y: player.y, width: player.width, height: player.height },
nodes: [...art.template.$danmuku.children].map((node) => {
const rect = node.getBoundingClientRect()
const style = getComputedStyle(node)
return { id: node.dataset.id, mode: node.dataset.mode, state: node.dataset.state, text: node.textContent, x: rect.x - player.x, y: rect.y - player.y, width: rect.width, height: rect.height, visibility: style.visibility, display: style.display, transition: node.style.transition, opacity: style.opacity }
}),
}
}))
}
const visibleIds = page => page.evaluate(() => [...new Set(window.danmukuEvidence.events.filter(item => item.event === 'visible' && item.id).map(item => item.id))])
test.afterEach(async ({ page }, testInfo) => {
await testInfo.attach('danmuku-layout', { contentType: 'application/json', body: JSON.stringify(await geometry(page)) })
await testInfo.attach('danmuku-native-page', { contentType: 'image/png', body: await page.screenshot() })
await page.evaluate(() => {
for (const art of window.danmukuPlayers || []) {
if (!art.isDestroy)
art.destroy()
}
})
const evidence = await page.evaluate(() => ({ workers: [], failures: [], ...window.danmukuEvidence, expectedFailures: window.expectedDanmukuFailures || [], version: window.Artplayer?.version, retainedExternalSettings: [...document.querySelectorAll('[id^="danmuku-mount-"]')].map(node => node.querySelectorAll('.artplayer-plugin-danmuku').length) }))
await testInfo.attach('danmuku-worker-and-lifecycle', { contentType: 'application/json', body: JSON.stringify(evidence) })
expect(evidence.failures).toEqual(evidence.expectedFailures)
for (const worker of evidence.workers) {
expect(worker.url).toMatch(/^blob:/u)
expect(worker.errors).toEqual(evidence.expectedFailures.length ? ['controlled track failure'] : [])
expect(worker.terminated).toBe(1)
}
})
for (const core of ['published', 'candidate']) {
for (const format of ['candidate']) {
const label = `${core} / Candidate Danmuku ${format}`
test(`${label}: accepted NaN timestamp keeps the historical current-time fallback`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
const numeric = await page.evaluate(async () => {
const input = { id: 'nan-time', text: 'NAN FALLBACK', mode: 1, time: Number.NaN }
const expected = window.art.currentTime + 0.5
const owner = await window.danmukuPlugins[0].emit(input)
return { expected, inputTime: input.time, queueTime: owner.queue[0].time, isNaN: Number.isNaN(input.time) }
})
expect(numeric.isNaN).toBe(false)
expect(numeric.inputTime).toBe(numeric.expected)
expect(numeric.queueTime).toBe(numeric.expected)
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('nan-time')
expect(external).toEqual([])
await testInfo.attach('danmuku-native-numeric-compatibility', { contentType: 'application/json', body: JSON.stringify(numeric) })
})
for (const action of ['destroy', 'stop']) {
test(`${label}: failed recovery respects synchronous ${action} from the error listener`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'recovery-row', text: 'RECOVERY', mode: 1, time: 0.5 }))
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('recovery-row')
const result = await page.evaluate((action) => {
const owner = window.danmukuPlugins[0].config({})
const row = owner.queue[0]
const trackError = new Error('controlled track failure')
const rebuildError = new Error('controlled track reconstruction failure')
window.expectedDanmukuFailures = ['Error: controlled track failure', 'Error: controlled track reconstruction failure']
owner.worker.dispatchEvent(new ErrorEvent('error', { error: trackError, message: trackError.message, cancelable: true }))
owner.stop()
owner.createWorker = () => {
throw rebuildError
}
window.art.on('artplayerPluginDanmuku:error', (error) => {
if (error === rebuildError) {
if (action === 'destroy')
window.art.destroy()
else owner.stop()
}
})
const before = window.danmukuEvidence.events.length
const sameOwner = owner.start() === owner
return { sameOwner, rowState: row.$state, isStop: owner.isStop, isDestroy: window.art.isDestroy, events: window.danmukuEvidence.events.slice(before), workers: window.danmukuEvidence.workers }
}, action)
expect(result.sameOwner).toBe(true)
expect(result.rowState).toBe('stop')
expect(result.isStop).toBe(true)
expect(result.isDestroy).toBe(action === 'destroy')
expect(result.events.filter(event => event.event === 'start')).toEqual([])
expect(result.workers).toHaveLength(1)
expect(result.workers[0].terminated).toBe(1)
expect(external).toEqual([])
await testInfo.attach('danmuku-native-recovery-reentry', { contentType: 'application/json', body: JSON.stringify({ action, ...result, fault: 'Actual video/DOM/track Worker; dispatch an explicit native ErrorEvent and inject a throwing createWorker recovery seam. This does not claim a real CSP or browser Worker construction failure.' }) })
})
}
for (const action of ['pause', 'reset', 'seek', 'destroy']) {
test(`${label}: native ${action} cancels pending visibility without blocking later work`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'obsolete-gate', text: 'GATED', mode: 1, time: 0.5 }))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'obsolete-gate'))).toBe(true)
if (action === 'pause') {
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
}
if (action === 'seek') {
await page.evaluate(async () => {
const sought = new Promise(resolve => window.art.once('video:seeked', resolve))
window.art.currentTime = 2
await sought
})
}
else if (action === 'destroy') {
await page.evaluate(() => window.art.destroy())
}
else {
await page.evaluate(() => window.danmukuPlugins[0].load())
}
if (action !== 'destroy') {
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'fresh-after-cancel', text: 'FRESH', mode: 1, time: window.art.currentTime + 0.5 }))
if (action === 'pause')
await page.click('#play')
// The old user Promise is still unresolved. A cancelled frame must
// release scheduler ownership so this newer item can be displayed.
await expect.poll(() => visibleIds(page)).toContain('fresh-after-cancel')
}
const before = await page.evaluate(() => ({ requests: window.danmukuEvidence.workers.map(worker => worker.requests.length), visible: window.danmukuEvidence.events.filter(event => event.event === 'visible').map(event => event.id) }))
await page.evaluate(() => window.releaseDanmukuVisibility(true))
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
expect(await visibleIds(page)).not.toContain('obsolete-gate')
if (action === 'destroy') {
expect(await page.evaluate(() => window.danmukuEvidence.workers.map(worker => worker.requests.length))).toEqual(before.requests)
expect(await page.evaluate(() => window.art.template.$danmuku.childElementCount)).toBe(0)
}
expect(external).toEqual([])
await testInfo.attach('danmuku-native-cancel', { contentType: 'application/json', body: JSON.stringify({ action, before, after: await visibleIds(page), cancelledSource: 'An unresolved user beforeVisible Promise; native RAF, Worker and media remain active.' }) })
})
}
test(`${label}: native Worker renders three modes with synchronous filter and asynchronous beforeVisible`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
await page.evaluate(async () => {
await window.danmukuPlugins[0].load([
{ id: 'scroll', text: 'SCROLL', mode: 0, time: 0.5 },
{ id: 'top', text: 'TOP', mode: 1, time: 0.5 },
{ id: 'bottom', text: 'BOTTOM', mode: 2, time: 0.5 },
{ id: 'filtered', text: 'FILTERED', mode: 1, time: 0.5 },
{ id: 'denied', text: 'DENIED', mode: 1, time: 0.5 },
{ id: 'gated', text: 'GATED', mode: 1, time: 0.5 },
])
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toEqual(expect.arrayContaining(['scroll', 'top', 'bottom']))
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'gated'))).toBe(true)
expect(await visibleIds(page)).not.toContain('gated')
expect(await visibleIds(page)).not.toContain('filtered')
expect(await visibleIds(page)).not.toContain('denied')
expect(await page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'filtered'))).toBe(false)
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
expect(await page.evaluate(() => window.danmukuEvidence.gates.filter(item => item.id === 'gated').length)).toBe(1)
await page.evaluate(() => window.releaseDanmukuVisibility(true))
await expect.poll(() => visibleIds(page)).toContain('gated')
const [layout] = await geometry(page)
for (const id of ['top', 'bottom']) {
const item = layout.nodes.find(node => node.id === id && node.visibility === 'visible')
expect(item).toBeTruthy()
expect(Math.abs(item.x + item.width / 2 - layout.player.width / 2)).toBeLessThanOrEqual(2)
expect(item.y).toBeGreaterThanOrEqual(9)
expect(item.y + item.height).toBeLessThanOrEqual(layout.player.height - 8)
}
expect(layout.nodes.find(node => node.id === 'top').y).toBeLessThan(layout.nodes.find(node => node.id === 'bottom').y)
expect(await page.evaluate(() => window.danmukuEvidence.workers[0].replies.length)).toBeGreaterThanOrEqual(4)
expect(external).toEqual([])
})
test(`${label}: six simultaneous fixed comments preserve usable track geometry and input order`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'density', testInfo)
await page.evaluate(async () => {
await window.danmukuPlugins[0].load(Array.from({ length: 6 }, (_, index) => ({ id: `dense-${index}`, text: `TRACK ${index}`, mode: 1, time: 0.5 })))
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toEqual(expect.arrayContaining(Array.from({ length: 6 }, (_, index) => `dense-${index}`)))
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
const [layout] = await geometry(page)
const rows = Array.from({ length: 6 }, (_, index) => layout.nodes.find(node => node.id === `dense-${index}` && node.visibility === 'visible'))
expect(rows.every(Boolean)).toBe(true)
for (let index = 0; index < rows.length; index++) {
expect(rows[index].y).toBeGreaterThanOrEqual(9)
expect(rows[index].y + rows[index].height).toBeLessThanOrEqual(layout.player.height - 8)
if (index)
expect(rows[index].y).toBeGreaterThanOrEqual(rows[index - 1].y + rows[index - 1].height - 1)
}
const order = await visibleIds(page)
expect(order).toEqual(Array.from({ length: 6 }, (_, index) => `dense-${index}`))
const worker = await page.evaluate(() => window.danmukuEvidence.workers[0])
expect(worker.requests).toHaveLength(6)
expect(new Set(worker.requests.map(request => request.id)).size).toBe(6)
expect(worker.requests.map(request => request.visibles.length)).toEqual([0, 1, 2, 3, 4, 5])
expect(worker.replies.map(reply => reply.id)).toEqual(worker.requests.map(request => request.id))
await testInfo.attach('danmuku-dense-tracks', { contentType: 'application/json', body: JSON.stringify(rows) })
expect(external).toEqual([])
})
test(`${label}: native pause seek rate and public reset/load retain historical pool semantics`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'playback', testInfo)
await page.evaluate(async () => {
await window.danmukuPlugins[0].emit({ id: 'moving', text: 'MOVING', mode: 0, time: 0.5 })
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('moving')
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.danmukuPlugins[0].isStop)).toBe(true)
const frozen = await page.evaluate(async () => {
const node = window.art.template.$danmuku.querySelector('[data-id="moving"]')
window.danmukuNodes = [node]
const left = node.getBoundingClientRect().left
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
return { left, next: node.getBoundingClientRect().left, state: node.dataset.state }
})
expect(frozen.state).toBe('stop')
expect(Math.abs(frozen.left - frozen.next)).toBeLessThanOrEqual(1)
await page.evaluate(async () => {
window.art.playbackRate = 2
const sought = new Promise(resolve => window.art.once('video:seeked', resolve))
window.art.currentTime = 2
await sought
})
expect(await page.evaluate(() => window.danmukuNodes[0].dataset.state)).toBe('stop')
await page.evaluate(async () => {
window.danmukuPlugins[0].reset()
await window.danmukuPlugins[0].emit({ id: 'reused', text: 'REUSED', mode: 0, time: window.art.currentTime + 0.5 })
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('reused')
const reused = await page.evaluate(() => {
const node = window.art.template.$danmuku.querySelector('[data-id="reused"]')
const original = window.danmukuNodes[0]
const nodes = [...window.art.template.$danmuku.children]
return {
poolReused: original.textContent === 'REUSED' && nodes.includes(original),
visibleSameNode: node === original,
original: { id: original.dataset.id, state: original.dataset.state, visibility: getComputedStyle(original).visibility, text: original.textContent },
extraNodes: nodes.filter(item => item !== original).map(item => ({ id: item.dataset.id, state: item.dataset.state, visibility: getComputedStyle(item).visibility, text: item.textContent })),
transition: node.style.transitionDuration,
rate: window.art.playbackRate,
}
})
// The historical competing loop reused a node and then hid it while
// allocating another. A single scheduler must reuse the visible node.
expect(reused.poolReused).toBe(true)
expect(reused.visibleSameNode).toBe(true)
expect(reused.extraNodes).toEqual([])
expect(reused.transition).toBe('4s')
expect(reused.rate).toBe(2)
await page.evaluate(async () => {
await window.danmukuPlugins[0].load([{ id: 'appended', text: 'APPENDED', mode: 1, time: window.art.currentTime + 0.5 }])
})
await expect.poll(() => visibleIds(page)).toContain('appended')
await page.click('#pause')
await page.evaluate(async () => {
await window.danmukuPlugins[0].load()
})
expect(await page.locator('#danmuku-player-0 .art-danmuku > div').count()).toBe(0)
await testInfo.attach('danmuku-pool-and-rate', { contentType: 'application/json', body: JSON.stringify({ frozen, reused, loadWithoutArgument: 'replaces from original empty option; explicit array load appends' }) })
expect(external).toEqual([])
})
test(`${label}: actual settings, asynchronous send lock, external fullscreen mount and two-player destruction`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'settings', testInfo)
const mount = page.locator('#danmuku-mount-0')
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
await mount.locator('.apd-config').hover()
await mount.locator('.apd-config-mode [data-mode="2"]').click()
expect(await page.evaluate(() => window.danmukuPlugins[0].option.modes)).toEqual([0, 1])
await mount.locator('.apd-style').hover()
await mount.locator('.apd-style-mode [data-mode="1"]').click()
expect(await page.evaluate(() => window.danmukuPlugins[0].option.mode)).toBe(1)
await mount.locator('.apd-input').fill('FIRST')
await mount.locator('.apd-send').click()
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
await mount.locator('.apd-input').press('Enter')
expect(await page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
await page.evaluate(() => window.releaseDanmukuSending(true))
await expect(mount.locator('.apd-input')).toHaveValue('')
await expect(mount.locator('.apd-send')).toHaveClass(/apd-lock/u)
await mount.locator('.apd-input').fill('LOCKED')
await mount.locator('.apd-input').press('Enter')
expect(await page.evaluate(() => window.danmukuEvidence.sends.length)).toBe(1)
await expect(mount.locator('.apd-send')).not.toHaveClass(/apd-lock/u)
await page.evaluate(() => {
window.danmukuEmitter = document.querySelector('#danmuku-mount-0 .artplayer-plugin-danmuku')
window.art.fullscreenWeb = true
})
// FULLSCREEN_WEB_IN_BODY can move the player out of its original container.
await expect.poll(() => page.evaluate(() => window.art.template.$controlsCenter.contains(window.danmukuEmitter))).toBe(true)
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(0)
await page.evaluate(() => {
window.art.fullscreenWeb = false
})
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
expect(await page.evaluate(() => document.querySelector('#danmuku-mount-0').contains(window.danmukuEmitter))).toBe(true)
await page.evaluate(() => {
const art = window.createDanmukuPlayer(1)
const button = document.createElement('button')
button.id = 'play-second-danmuku'
button.textContent = 'Play second'
button.onclick = () => art.play()
document.body.appendChild(button)
})
await expect.poll(() => page.evaluate(() => window.danmukuPlayers[1].isReady)).toBe(true)
await page.evaluate(() => window.danmukuPlayers[0].destroy())
expect(await page.evaluate(() => window.danmukuEvidence.workers.map(worker => worker.terminated))).toEqual([1, 0])
// The released plugin does not dispose external Setting DOM; retain this observation.
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
await page.locator('#danmuku-mount-1 .apd-input').fill('SECOND')
await page.locator('#danmuku-mount-1 .apd-send').click()
await page.click('#play-second-danmuku')
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.events.some(item => item.index === 1 && item.event === 'visible' && item.text === 'SECOND'))).toBe(true)
expect(await page.evaluate(() => window.danmukuPlayers[1].isDestroy)).toBe(false)
expect(external).toEqual([])
})
}
}
+18 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
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'
import { danmukuEnvironment, danmukuHistorical, deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
const prefix = `Danmuku ${implementation.name}`
@@ -11,6 +11,23 @@ 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')
for (const baseline of [...await danmukuHistorical(), implementation]) {
test(`Danmuku ${baseline.name}: NaN time retains the historical fallback while infinities retain their numeric behavior`, async () => {
const env = baseline === implementation ? danmukuCandidateEnvironment(baseline) : danmukuEnvironment(baseline)
const plugin = env.factory({ danmuku: [] })(env.art)
const internal = plugin.show()
for (const [input, expected] of [[Number.NaN, 10.5], [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], [Number.NEGATIVE_INFINITY, 0]]) {
const row = { text: `numeric time ${input}`, time: input }
assert.equal(await plugin.emit(row), internal)
assert.equal(row.time, expected)
assert.equal(internal.queue.at(-1).time, expected)
assert.equal(internal.queue.at(-1).text, row.text)
}
assert.equal(failures(env).length, 0)
env.destroy()
})
}
function fixture(option = { danmuku: [] }) {
const env = danmukuCandidateEnvironment(implementation)
const plugin = env.factory(option)(env.art)
+672
View File
@@ -0,0 +1,672 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Deterministic candidate scheduling uses the Node runner.
import test from 'node:test'
import vm from 'node:vm'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { deferred } from './helpers/danmuku.js'
const implementation = await danmukuCandidate()
const prefix = `Danmuku scheduler ${implementation.name}`
const output = (env, event) => env.events.filter(item => item.name === `artplayerPluginDanmuku:${event}`)
function measuredNode(node) {
node.clientWidth = 100
node.clientHeight = 20
Object.defineProperty(node, 'offsetTop', { get: () => Number.parseFloat(node.style.top) || 0 })
return node
}
async function fixture(options = {}, { automatic = false } = {}) {
const env = danmukuCandidateEnvironment(implementation)
const plugin = env.factory({ danmuku: [], ...options })(env.art)
const owner = plugin.show()
const createElement = env.context.document.createElement
env.context.document.createElement = tag => measuredNode(createElement(tag))
const layer = env.art.template.$danmuku
let content = layer.textContent
Object.defineProperty(layer, 'textContent', {
get: () => content,
set(value) {
content = value
for (const child of layer.children)
child.parentElement = null
layer.children.length = 0
},
})
layer.appendChild = (child) => {
const siblings = child.parentElement?.children
const index = siblings?.indexOf(child) ?? -1
if (index >= 0)
siblings.splice(index, 1)
layer.children.push(child)
child.parentElement = layer
return child
}
const worker = env.workers[0]
const actualBlob = env.urls.get(worker.url)
const workerCode = actualBlob
? await actualBlob.text()
: fs.readFileSync(new URL('../packages/artplayer-plugin-danmuku/src/worker.js', import.meta.url), 'utf8')
const responses = []
const workerContext = vm.createContext({ postMessage: data => responses.push(structuredClone(data)), URL: { revokeObjectURL() {} }, location: { href: worker.url } })
workerContext.self = workerContext
vm.runInContext(workerCode, workerContext, { timeout: 1000 })
function calculate(message) {
workerContext.input = structuredClone(message)
const count = responses.length
vm.runInContext('onmessage({ data: input })', workerContext, { timeout: 1000 })
assert.equal(responses.length, count + 1, 'The actual Worker algorithm must produce the response')
return responses.at(-1)
}
function deliver(data) {
const event = { data }
worker.onmessage?.(event)
for (const callback of [...worker.listeners.get('message') || []])
callback(event)
}
function reply(index) {
const message = worker.messages[index].message
deliver(calculate(message))
}
const post = worker.postMessage.bind(worker)
worker.postMessage = (message) => {
post(structuredClone(message))
if (automatic)
queueMicrotask(() => deliver(calculate(message)))
}
return { env, plugin, owner, worker, layer, calculate, deliver, reply }
}
function geometry(mode = 1) {
return { type: 'getDanmuTop', target: { mode, height: 20, speed: 148 }, visibles: [], antiOverlap: true, clientWidth: 640, clientHeight: 360, marginTop: 10, marginBottom: 90 }
}
test(`${prefix}: repeated play/playing and direct starts preserve events and internal return with only one queued frame`, async () => {
const { env, owner } = await fixture()
env.art.playing = true
env.art.emit('video:play')
env.art.emit('video:playing')
assert.equal(owner.start(), owner)
assert.equal(output(env, 'start').length, 3)
assert.equal(env.frames.size, 1)
assert.equal(owner.stop(), owner)
assert.equal(owner.stop(), owner)
assert.equal(output(env, 'stop').length, 2)
assert.equal(env.frames.size, 0)
env.destroy()
})
test(`${prefix}: additional starts while beforeVisible is pending do not schedule an overlapping frame`, async () => {
const gate = deferred()
const { env, plugin, owner } = await fixture({ beforeVisible: () => gate.promise })
await plugin.emit({ text: 'waiting callback', time: 10 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
owner.start()
env.art.emit('video:playing')
assert.equal(env.frames.size, 0, 'The existing asynchronous frame owns this generation')
env.destroy()
gate.resolve(true)
await env.flush()
})
for (const action of ['pause', 'destroy', 'reset', 'replace', 'seek', 'hide']) {
for (const stage of ['beforeVisible', 'worker']) {
test(`${prefix}: ${action} invalidates the pending ${stage} continuation without modifying an obsolete row`, async () => {
const gate = deferred()
const state = await fixture(stage === 'beforeVisible' ? { beforeVisible: () => gate.promise } : {})
const { env, plugin, owner, worker, layer } = state
await plugin.emit({ text: 'old', time: 10, mode: 1 })
const row = owner.queue[0]
env.art.playing = true
owner.start()
env.frame()
await env.flush()
let response
if (stage === 'worker') {
assert.equal(worker.messages.length, 1)
response = state.calculate(worker.messages[0].message)
}
if (action === 'pause') {
env.art.playing = false
owner.stop()
}
else if (action === 'destroy') {
env.destroy()
}
else if (action === 'reset') {
plugin.reset()
}
else if (action === 'replace') {
plugin.config({ danmuku: [{ text: 'new', time: 20 }] })
await plugin.load()
}
else if (action === 'hide') {
plugin.hide()
}
else {
env.art.currentTime = 20
env.art.emit('video:seeking')
}
const snapshot = { rowState: row.$state, rowRef: row.$ref, nodes: [...layer.children], events: output(env, 'visible').length, messages: worker.messages.length }
if (stage === 'worker')
state.deliver(response)
else
gate.resolve(true)
await env.flush()
assert.equal(row.$state, snapshot.rowState)
assert.equal(row.$ref, snapshot.rowRef)
assert.deepEqual(layer.children, snapshot.nodes)
assert.equal(output(env, 'visible').length, snapshot.events)
assert.equal(worker.messages.length, snapshot.messages)
if (action !== 'destroy')
env.destroy()
})
}
}
test(`${prefix}: pausing a never-settled callback does not block a fresh generation`, async () => {
const gate = deferred()
let calls = 0
const { env, plugin, owner } = await fixture({ beforeVisible: () => ++calls === 1 ? gate.promise : true }, { automatic: true })
await plugin.emit({ text: 'resumable', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
owner.stop()
owner.start()
await env.flush()
assert.equal(env.frames.size, 1)
env.frame()
await env.flush()
assert.equal(output(env, 'visible').length, 1)
assert.equal(calls, 2)
env.destroy()
gate.resolve(true)
await env.flush()
assert.equal(output(env, 'visible').length, 1)
})
test(`${prefix}: an already delivered Worker reply cannot resume its JavaScript continuation after destroy`, async () => {
const { env, plugin, owner, layer, reply } = await fixture()
await plugin.emit({ text: 'queued microtask', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
reply(0)
env.destroy()
const row = owner.queue[0]
const snapshot = { state: row.$state, ref: row.$ref, nodes: [...layer.children] }
await env.flush()
assert.equal(row.$state, snapshot.state)
assert.equal(row.$ref, snapshot.ref)
assert.deepEqual(layer.children, snapshot.nodes)
assert.equal(output(env, 'visible').length, 0)
assert.equal(env.frames.size, 0)
})
test(`${prefix}: beforeVisible rejection reports the original error once, skips that row until start, and allows other rows`, async () => {
const error = new Error('visibility hook rejected')
const calls = []
let retry = false
const { env, plugin, owner } = await fixture({
beforeVisible(row) {
calls.push(row.text)
return row.text === 'failed' && !retry ? Promise.reject(error) : true
},
}, { automatic: true })
await plugin.load([{ text: 'failed', time: 10, mode: 1 }, { text: 'healthy', time: 10, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
await env.flush()
assert.deepEqual(output(env, 'error').map(event => event.args[0]), [error])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['healthy'])
env.frame()
await env.flush()
assert.deepEqual(calls, ['failed', 'healthy'])
retry = true
assert.equal(owner.start(), owner)
assert.equal(env.frames.size, 1)
env.frame()
await env.flush()
assert.deepEqual(calls, ['failed', 'healthy', 'failed'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['healthy', 'failed'])
assert.equal(output(env, 'error').length, 1)
env.destroy()
})
test(`${prefix}: a cancelled beforeVisible rejection is observed without reporting a stale error`, async () => {
const gate = deferred()
const { env, plugin, owner } = await fixture({ beforeVisible: () => gate.promise })
await plugin.emit({ text: 'cancelled', time: 10 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.destroy()
gate.reject(new Error('rejected after destroy'))
await env.flush()
assert.equal(output(env, 'error').length, 0)
assert.equal(output(env, 'visible').length, 0)
assert.equal(env.frames.size, 0)
})
test(`${prefix}: beforeVisible false remains eligible for the next frame`, async () => {
let calls = 0
const { env, plugin, owner } = await fixture({ beforeVisible: () => ++calls > 1 }, { automatic: true })
await plugin.emit({ text: 'retry false', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.equal(output(env, 'visible').length, 0)
env.frame()
await env.flush()
assert.equal(calls, 2)
assert.equal(output(env, 'visible').length, 1)
assert.equal(output(env, 'error').length, 0)
env.destroy()
})
test(`${prefix}: hide preserves an already visible row and show permits fresh scheduling`, async () => {
const { env, plugin, owner } = await fixture({}, { automatic: true })
await plugin.emit({ text: 'visible before hide', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
const ref = row.$ref
const resets = output(env, 'reset').length
assert.equal(plugin.hide(), owner)
assert.equal(row.$state, 'emit')
assert.equal(row.$ref, ref)
assert.equal(output(env, 'reset').length, resets)
assert.equal(plugin.show(), owner)
await plugin.emit({ text: 'visible after show', time: 10, mode: 1 })
env.frame()
await env.flush()
assert.equal(output(env, 'visible').length, 2)
env.destroy()
})
test(`${prefix}: replacing beforeVisible releases an unresolved old callback without resetting displayed rows`, async () => {
const gate = deferred()
const calls = []
const { env, plugin, owner } = await fixture({
beforeVisible(row) {
calls.push(`old:${row.text}`)
return row.text === 'pending' ? gate.promise : true
},
}, { automatic: true })
await plugin.load([{ text: 'displayed', time: 10, mode: 1 }, { text: 'pending', time: 10, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
await env.flush()
const displayed = owner.queue[0]
const ref = displayed.$ref
const resets = output(env, 'reset').length
assert.deepEqual(calls, ['old:displayed', 'old:pending'])
plugin.config({
beforeVisible(row) {
calls.push(`new:${row.text}`)
return true
},
})
assert.equal(displayed.$state, 'emit')
assert.equal(displayed.$ref, ref)
assert.equal(output(env, 'reset').length, resets)
assert.equal(env.frames.size, 1)
env.frame()
await env.flush()
assert.deepEqual(calls, ['old:displayed', 'old:pending', 'new:pending'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['displayed', 'pending'])
gate.resolve(true)
await env.flush()
assert.equal(output(env, 'visible').length, 2)
env.destroy()
})
test(`${prefix}: configuring the same beforeVisible callback preserves the pending operation`, async () => {
const gate = deferred()
let calls = 0
const beforeVisible = () => {
calls++
return gate.promise
}
const { env, plugin, owner } = await fixture({ beforeVisible }, { automatic: true })
await plugin.emit({ text: 'same callback', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const configurations = output(env, 'config').length
plugin.config({ beforeVisible })
assert.equal(output(env, 'config').length, configurations)
assert.equal(env.frames.size, 0)
gate.resolve(true)
await env.flush()
assert.equal(calls, 1)
assert.equal(output(env, 'visible').length, 1)
assert.equal(env.frames.size, 1)
env.destroy()
})
for (const action of ['destroy', 'stop']) {
test(`${prefix}: ${action} inside a Worker rebuild error listener prevents the outer start from reviving stopped rows`, async () => {
const { env, plugin, owner, worker } = await fixture({}, { automatic: true })
await plugin.emit({ text: 'already displayed', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
assert.equal(row.$state, 'emit')
const workerError = new Error('initial worker fault')
worker.onerror({ error: workerError, preventDefault() {} })
owner.stop()
assert.equal(row.$state, 'stop')
const rebuildError = new Error('rebuild original error')
// This is an explicit constructor-failure seam, not a native Worker assertion.
owner.createWorker = () => {
throw rebuildError
}
env.art.on('artplayerPluginDanmuku:error', (error) => {
if (error === rebuildError) {
if (action === 'destroy')
env.destroy()
else
owner.stop()
}
})
const starts = output(env, 'start').length
assert.equal(owner.start(), owner)
await env.flush()
assert.equal(row.$state, 'stop')
assert.equal(owner.isStop, true)
assert.equal(output(env, 'start').length, starts)
assert.equal(env.frames.size, 0)
assert.deepEqual(output(env, 'error').map(event => event.args[0]), [workerError, rebuildError])
if (action !== 'destroy')
env.destroy()
})
}
for (const action of ['reset', 'hide', 'start']) {
test(`${prefix}: ${action} reentry from a rebuild error owns the recovery without a second outer start`, async () => {
const { env, plugin, owner, worker } = await fixture({}, { automatic: true })
await plugin.emit({ text: 'displayed before recovery', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
assert.equal(row.$state, 'emit')
const workerError = new Error('worker failed before reentry')
worker.onerror({ error: workerError, preventDefault() {} })
owner.stop()
const createWorker = owner.createWorker
const rebuildError = new Error('one rebuild failure')
owner.createWorker = () => {
throw rebuildError
}
let recoveries = 0
env.art.on('artplayerPluginDanmuku:error', (error) => {
if (error !== rebuildError)
return
recoveries++
assert.equal(recoveries, 1, 'The recovery listener must not retry itself indefinitely')
owner.createWorker = createWorker
owner[action]()
})
const starts = output(env, 'start').length
const resets = output(env, 'reset').length
assert.equal(owner.start(), owner)
await env.flush()
assert.equal(recoveries, 1)
assert.deepEqual(output(env, 'error').map(event => event.args[0]), [workerError, rebuildError])
assert.equal(output(env, 'start').length, starts + (action === 'start' ? 1 : 0))
assert.equal(output(env, 'reset').length, resets + (action === 'reset' ? 1 : 0))
assert.equal(row.$state, action === 'reset' ? 'wait' : action === 'start' ? 'emit' : 'stop')
assert.equal(owner.isHide, action === 'hide')
assert.equal(env.frames.size, action === 'start' ? 1 : 0)
if (action === 'reset')
assert.equal(row.$ref, null)
env.destroy()
})
}
test(`${prefix}: requests in the same millisecond have distinct ids and reverse replies settle the right promises`, async () => {
const { env, owner, worker, reply } = await fixture()
let first
let second
owner.postMessage(geometry(1)).then(value => first = value)
owner.postMessage(geometry(2)).then(value => second = value)
assert.equal(worker.messages.length, 2)
assert.notEqual(worker.messages[0].message.id, worker.messages[1].message.id)
reply(1)
await env.flush()
assert.equal(first, undefined)
assert.equal(second.result, 250)
assert.equal(second.id, worker.messages[1].message.id)
reply(0)
await env.flush()
assert.equal(first.result, 10)
assert.equal(first.id, worker.messages[0].message.id)
env.destroy()
})
test(`${prefix}: a synchronous real algorithm response cannot arrive before its resolver is installed`, async () => {
const { env, owner, worker, calculate, deliver } = await fixture()
const post = worker.postMessage.bind(worker)
worker.postMessage = (message) => {
post(message)
deliver(calculate(message))
}
let response
owner.postMessage(geometry(2)).then(value => response = value)
await env.flush()
assert.equal(response?.result, 250)
env.destroy()
})
test(`${prefix}: the actual algorithm places three same-time fixed rows on distinct tracks in one frame`, async () => {
const { env, plugin, owner, layer } = await fixture({}, { automatic: true })
await plugin.load([0, 1, 2].map(index => ({ text: `fixed-${index}`, time: 10, mode: 1 })))
env.art.playing = true
owner.start()
env.frame()
for (let step = 0; step < 3; step++)
await env.flush()
assert.deepEqual(Array.from(owner.queue, row => row.$state), ['emit', 'emit', 'emit'])
assert.deepEqual(Array.from(owner.queue, row => row.$ref.offsetTop), [10, 30, 50])
assert.equal(layer.children.length, 3)
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['fixed-0', 'fixed-1', 'fixed-2'])
assert.equal(env.frames.size, 1)
env.destroy()
})
test(`${prefix}: a sampled three-hour simulated clock preserves batch order, pooled nodes, pause and seek state`, async (t) => {
// Advance a controlled clock at lifecycle boundaries; this is not a real-time
// endurance, frame-rate, CSS animation or browser memory measurement.
const { env, plugin, owner, worker, layer } = await fixture({}, { automatic: true })
t.after(() => env.destroy())
const batchCount = 180
const batchSize = 4
const firstTime = 10
const horizon = 3 * 60 * 60
const rows = Array.from({ length: batchCount * batchSize }, (_, index) => ({
text: `long-${index}`,
time: firstTime + Math.floor(index / batchSize) * 60,
mode: 1,
}))
await plugin.load(rows)
const queue = owner.queue
const expectedOrder = Array.from(queue, row => row.text)
let advanced = 0
const tick = (milliseconds) => {
advanced += milliseconds
env.tick(milliseconds)
}
const membership = () => {
const items = Object.entries(owner.states).flatMap(([state, items]) => {
for (const item of items)
assert.equal(item.$state, state)
return Array.from(items)
})
assert.equal(items.length, queue.length)
assert.equal(new Set(items).size, queue.length, 'Every row belongs to exactly one state pool')
assert(items.every(item => queue.includes(item)))
}
let pooledNodes
let pauses = 0
env.art.playing = true
env.art.emit('video:play')
env.art.emit('video:playing')
for (let batch = 0; batch < batchCount; batch++) {
const time = firstTime + batch * 60
tick((time - env.art.currentTime) * 1000)
env.art.currentTime = time
assert.equal(env.frames.size, 1)
const requests = worker.messages.length
await env.frame()
const active = Array.from(queue.slice(batch * batchSize, (batch + 1) * batchSize))
assert.deepEqual(output(env, 'visible').slice(-batchSize).map(event => event.args[0]), active)
assert.equal(worker.messages.length, requests + batchSize, 'One position request per row in this batch')
assert.deepEqual(worker.messages.slice(requests).map(item => item.message.visibles.length), [0, 1, 2, 3])
assert.deepEqual(active.map(row => row.$state), ['emit', 'emit', 'emit', 'emit'])
assert.deepEqual(active.map(row => row.$ref.offsetTop), [10, 30, 50, 70])
if (!pooledNodes)
pooledNodes = new Set(layer.children)
assert.equal(layer.children.length, batchSize)
assert(active.every(row => pooledNodes.has(row.$ref)), 'Later batches reuse the original measured nodes')
membership()
tick(1000)
env.art.currentTime++
await env.frame()
assert(active.every(row => row.$restTime === 4))
if (batch % 12 === 11) {
pauses++
const refs = active.map(row => row.$ref)
const resetCount = output(env, 'reset').length
env.art.playing = false
env.art.emit('video:pause')
assert.equal(env.frames.size, 0)
tick(120000)
env.art.currentTime = time + 20
env.art.emit('video:seeking')
env.art.emit('video:seeked')
assert.equal(output(env, 'reset').length, resetCount)
assert(active.every(row => row.$state === 'stop' && row.$restTime === 4))
assert.deepEqual(active.map(row => row.$ref), refs)
env.art.playing = true
env.art.emit('video:play')
env.art.emit('video:playing')
assert.equal(env.frames.size, 1)
await env.frame()
assert(active.every(row => row.$state === 'emit' && row.$restTime === 4))
assert.deepEqual(active.map(row => row.$ref), refs)
assert.equal(worker.messages.length, requests + batchSize, 'Resume and seek must not allocate duplicate rows')
}
tick(5000)
env.art.currentTime += 5
await env.frame()
assert(active.every(row => row.$state === 'wait' && row.$ref === null))
assert.equal(owner.$refs.length, batchSize)
assert.equal(new Set(owner.$refs).size, batchSize, 'Each reusable node returns to the pool once')
assert.equal(layer.children.length, batchSize)
assert.equal(env.frames.size, 1)
membership()
}
tick((firstTime + horizon - env.art.currentTime) * 1000)
env.art.currentTime = firstTime + horizon
await env.frame()
assert.equal(pauses, 15)
assert(advanced > horizon * 1000, 'The simulated wall clock also includes the paused intervals')
assert.equal(owner.queue, queue)
assert(queue.every(row => row.$state === 'wait' && row.$ref === null))
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), expectedOrder)
assert.equal(worker.messages.length, rows.length)
assert.equal(new Set(worker.messages.map(item => item.message.id)).size, rows.length)
assert.equal(output(env, 'error').length, 0)
assert.equal(env.workers.length, 1)
owner.stop()
assert.equal(env.frames.size, 0)
})
test(`${prefix}: paused wall clock does not consume remaining lifetime and expiration returns a row to wait`, async () => {
const { env, plugin, owner } = await fixture({}, { automatic: true })
await plugin.emit({ text: 'clock', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
assert.equal(row.$restTime, 5)
env.tick(1000)
env.art.currentTime = 11
env.frame()
await env.flush()
assert.equal(row.$restTime, 4)
env.art.playing = false
owner.stop()
assert.equal(row.$state, 'stop')
env.tick(5000)
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.equal(row.$restTime, 4)
env.tick(500)
env.art.currentTime = 11.5
env.frame()
await env.flush()
assert.equal(row.$restTime, 3.5)
env.tick(4000)
env.art.currentTime = 15.5
env.frame()
await env.flush()
assert.equal(row.$state, 'wait')
assert.equal(row.$ref, null)
assert.equal(owner.queue.length, 1)
assert.equal(output(env, 'visible').length, 1)
env.destroy()
})
test(`${prefix}: playbackRate is sampled for new rows while active lifetime and seek reset-event semantics stay compatible`, async () => {
const { env, plugin, owner } = await fixture({ synchronousPlayback: true }, { automatic: true })
await plugin.load([{ text: 'already-visible', time: 10, mode: 1 }, { text: 'after-seek', time: 20, mode: 1 }])
env.art.playbackRate = 2
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const existing = owner.queue[0]
assert.equal(existing.$restTime, 2.5)
env.art.playbackRate = 4
const resets = output(env, 'reset').length
env.art.currentTime = 20
env.art.emit('video:seeking')
assert.equal(existing.$state, 'emit')
assert.equal(existing.$restTime, 2.5)
assert.equal(output(env, 'reset').length, resets)
assert.equal(env.frames.size, 1)
env.frame()
await env.flush()
assert.equal(owner.queue[1].$restTime, 1.25)
assert.equal(existing.$restTime, 2.5)
env.destroy()
})
+327
View File
@@ -0,0 +1,327 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Controlled client faults and real worker geometry use the Node runner.
import test from 'node:test'
import vm from 'node:vm'
import { danmukuCandidate, danmukuCandidateEnvironment } from './helpers/danmuku-candidate.js'
import { loadModules } from './helpers/load.js'
const { WorkerClient } = await loadModules({ WorkerClient: 'packages/artplayer-plugin-danmuku/src/worker-client' })
const implementation = await danmukuCandidate()
// This client double records protocol ownership; it does not simulate geometry.
function clientFixture(onFailure = () => {}) {
const messages = []
const failures = []
const worker = {
terminateCalls: 0,
postMessage(message) { messages.push({ ...message }) },
terminate() { this.terminateCalls++ },
}
const client = new WorkerClient(() => worker, (error) => {
failures.push(error)
onFailure(error)
})
return { client, worker, messages, failures }
}
function detached(worker) {
assert.equal(worker.onmessage, null)
assert.equal(worker.onerror, null)
assert.equal(worker.onmessageerror, null)
}
test('danmuku worker client: simultaneous requests have distinct IDs and reversed replies retain their owners', async () => {
const { client, worker, messages } = clientFixture()
const inputs = [{ type: 'getDanmuTop', tag: 'first' }, { type: 'getDanmuTop', tag: 'second' }]
const first = client.request(inputs[0])
const second = client.request(inputs[1])
assert.notEqual(inputs[0].id, inputs[1].id)
assert.deepEqual(messages.map(item => item.id), inputs.map(item => item.id))
assert.equal(client.pending.size, 2)
worker.onmessage({ data: { id: inputs[1].id, result: 33 } })
assert.deepEqual(await second, { id: inputs[1].id, result: 33 })
assert.equal(client.pending.size, 1)
worker.onmessage({ data: { id: inputs[0].id, result: 10 } })
assert.deepEqual(await first, { id: inputs[0].id, result: 10 })
assert.equal(client.pending.size, 0)
client.dispose()
})
test('danmuku worker client: a synchronous reply is observed before postMessage returns', async () => {
const { client, worker } = clientFixture()
const callback = worker.onmessage
worker.postMessage = message => callback({ data: { id: message.id, result: 72 } })
const message = { type: 'getDanmuTop' }
assert.deepEqual(await client.request(message), { id: message.id, result: 72 })
assert.equal(worker.onmessage, callback, 'Requests must not replace the shared dispatcher')
assert.equal(client.pending.size, 0)
client.dispose()
})
test('danmuku worker client: unrelated and repeated messages cannot settle another request', async () => {
const { client, worker } = clientFixture()
const a = { type: 'getDanmuTop' }
const b = { type: 'getDanmuTop' }
const first = client.request(a)
const second = client.request(b)
worker.onmessage({ data: { id: 'unrelated', result: 100 } })
assert.equal(client.pending.size, 2)
worker.onmessage({ data: { id: a.id, result: 10 } })
worker.onmessage({ data: { id: a.id, result: 999 } })
assert.deepEqual(await first, { id: a.id, result: 10 })
assert.equal(client.pending.size, 1)
worker.onmessage({ data: { id: b.id, result: 33 } })
assert.deepEqual(await second, { id: b.id, result: 33 })
client.dispose()
})
test('danmuku worker client: cancellation settles every owner, ignores late replies and permits new work', async () => {
const { client, worker, failures } = clientFixture()
const messages = [{ type: 'getDanmuTop' }, { type: 'getDanmuTop' }]
const pending = messages.map(message => client.request(message))
client.cancel()
assert.deepEqual(await Promise.all(pending), messages.map(message => ({ id: message.id, result: undefined })))
assert.equal(client.pending.size, 0)
assert.equal(worker.terminateCalls, 0)
const next = { type: 'getDanmuTop' }
const nextPending = client.request(next)
for (const message of messages)
worker.onmessage({ data: { id: message.id, result: 10 } })
assert.equal(client.pending.size, 1)
worker.onmessage({ data: { id: next.id, result: 56 } })
assert.deepEqual(await nextPending, { id: next.id, result: 56 })
assert.deepEqual(failures, [])
client.dispose()
})
for (const eventName of ['error', 'messageerror']) {
test(`danmuku worker client: ${eventName} rejects all pending requests with the original failure exactly once`, async () => {
const { client, worker, messages, failures } = clientFixture()
const error = new Error(`${eventName}-original`)
const a = client.request({ type: 'getDanmuTop' })
const b = client.request({ type: 'getDanmuTop' })
const rejected = Promise.all([assert.rejects(a, value => value === error), assert.rejects(b, value => value === error)])
let prevented = 0
const handler = worker[`on${eventName}`]
handler({ error, preventDefault() {
prevented++
} })
handler({ error: new Error('duplicate') })
await rejected
assert.deepEqual(failures, [error])
assert.equal(client.pending.size, 0)
assert.equal(client.failed, true)
if (eventName === 'error')
assert.equal(prevented, 1)
const ignored = { type: 'getDanmuTop' }
assert.deepEqual(await client.request(ignored), { id: ignored.id, result: undefined })
assert.equal(messages.length, 2)
client.dispose()
detached(worker)
assert.equal(worker.terminateCalls, 1)
})
}
for (const error of [new Error('post-original'), undefined, null, false]) {
test(`danmuku worker client: synchronous postMessage throw ${String(error)} retains the exact rejection value`, async () => {
const { client, worker, failures } = clientFixture()
const first = client.request({ type: 'getDanmuTop' })
const rejected = assert.rejects(first, value => value === error)
worker.postMessage = () => {
throw error
}
await assert.rejects(client.request({ type: 'getDanmuTop' }), value => value === error)
await rejected
assert.deepEqual(failures, [error])
assert.equal(client.pending.size, 0)
client.dispose()
})
}
test('danmuku worker client: dispose cancels pending work, detaches handlers and terminates exactly once', async () => {
const { client, worker, messages, failures } = clientFixture()
const message = { type: 'getDanmuTop' }
const pending = client.request(message)
const oldHandler = worker.onmessage
client.dispose()
client.dispose()
assert.deepEqual(await pending, { id: message.id, result: undefined })
detached(worker)
assert.equal(worker.terminateCalls, 1)
oldHandler({ data: { id: message.id, result: 100 } })
const after = { type: 'getDanmuTop' }
assert.deepEqual(await client.request(after), { id: after.id, result: undefined })
assert.equal(messages.length, 1)
assert.deepEqual(failures, [])
})
test('danmuku worker client: termination failure still settles pending requests and detaches owned handlers', async () => {
const { client, worker } = clientFixture()
const error = new Error('terminate-original')
worker.terminate = () => {
worker.terminateCalls++
throw error
}
const input = { type: 'getDanmuTop' }
const pending = client.request(input)
assert.throws(() => client.dispose(), value => value === error)
assert.deepEqual(await pending, { id: input.id, result: undefined })
detached(worker)
client.dispose()
assert.equal(worker.terminateCalls, 1)
})
test('danmuku worker client: fault cleanup cannot replace the original request error', async () => {
const { client, worker, failures } = clientFixture()
const original = new Error('worker-original')
const cleanup = new Error('terminate-secondary')
worker.terminate = () => {
worker.terminateCalls++
throw cleanup
}
const pending = client.request({ type: 'getDanmuTop' })
const rejected = assert.rejects(pending, error => error === original)
worker.onerror({ error: original, preventDefault() {} })
await rejected
assert.deepEqual(failures, [original])
detached(worker)
assert.equal(worker.terminateCalls, 1)
client.dispose()
assert.equal(worker.terminateCalls, 1)
})
test('danmuku worker client: initial handler setup failure terminates the worker and preserves the setup error', () => {
const original = new Error('handler-setup-original')
let terminated = 0
const worker = {
get onmessage() { return undefined },
set onmessage(_value) { throw original },
terminate() {
terminated++
throw new Error('terminate-secondary')
},
}
assert.throws(() => new WorkerClient(() => worker, () => assert.fail('No initialized client may report a runtime fault')), error => error === original)
assert.equal(terminated, 1)
})
test('danmuku worker client: independent clients never share pending ownership', async () => {
const first = clientFixture()
const second = clientFixture()
const a = { type: 'getDanmuTop' }
const b = { type: 'getDanmuTop' }
const pa = first.client.request(a)
const pb = second.client.request(b)
assert.notEqual(a.id, b.id)
first.client.dispose()
assert.deepEqual(await pa, { id: a.id, result: undefined })
assert.equal(second.worker.terminateCalls, 0)
assert.equal(second.client.pending.size, 1)
second.worker.onmessage({ data: { id: b.id, result: 70 } })
assert.deepEqual(await pb, { id: b.id, result: 70 })
second.client.dispose()
})
// Unlike the client double above, this executes the actual unmodified worker
// script. Numeric geometry inputs are controlled; no browser layout is claimed.
const workerCode = fs.readFileSync(new URL('../packages/artplayer-plugin-danmuku/src/worker.js', import.meta.url), 'utf8')
function calculateGeometry(input) {
const replies = []
const context = vm.createContext({ input, postMessage: data => replies.push(data) })
vm.runInContext(workerCode, context, { timeout: 1000 })
vm.runInContext('onmessage({ data: input })', context, { timeout: 1000 })
assert.equal(replies.length, 1)
assert.equal(replies[0].id, input.id)
return replies[0].result
}
test('danmuku actual worker: fixed tracks preserve mode isolation, margins, gap direction and full-track deferral', () => {
const base = { id: 1, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: true }
const target = mode => ({ mode, height: 20, speed: 20 })
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [] }), 10)
assert.equal(calculateGeometry({ ...base, target: target(2), visibles: [] }), 70)
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 1, top: 10, height: 20 }] }), 30)
assert.equal(calculateGeometry({ ...base, target: target(2), visibles: [{ mode: 2, top: 70, height: 20 }] }), 50)
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 0, top: 10, height: 80 }] }), 10)
assert.equal(calculateGeometry({ ...base, target: target(1), visibles: [{ mode: 1, top: 95, height: 20 }] }), 10)
for (const mode of [1, 2]) {
const visibles = Object.freeze([Object.freeze({ mode, top: 10, height: 40 }), Object.freeze({ mode, top: 50, height: 40 })])
assert.equal(calculateGeometry({ ...base, target: target(mode), visibles }), undefined)
}
})
test('danmuku actual worker: rolling collision decisions retain distance, remaining-time and relative-speed semantics', () => {
const base = { id: 2, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: true }
const visible = { mode: 0, top: 10, height: 80, left: 40, width: 40, distance: 80, right: 20, speed: 10, time: 8 }
const calculate = (speed, changed = {}) => calculateGeometry({ ...base, target: { mode: 0, height: 20, speed }, visibles: [{ ...visible, ...changed }] })
assert.equal(calculate(5), 10, 'A slower follower cannot catch the existing row')
assert.equal(calculate(10), 10, 'Equal speed with a positive gap remains safe')
assert.equal(calculate(11), 10, 'Catch-up happens after the existing remaining lifetime')
assert.equal(calculate(20), undefined, 'Catch-up before expiration defers the follower')
assert.equal(calculate(5, { distance: 110, right: -10 }), undefined, 'An existing row not fully inside blocks reuse')
})
test('danmuku actual worker: disabled antiOverlap retains the historical occupancy ranking', () => {
const base = { id: 3, type: 'getDanmuTop', clientWidth: 100, clientHeight: 100, marginTop: 10, marginBottom: 10, antiOverlap: false }
for (const mode of [0, 1, 2]) {
const visibles = [{ mode, top: 10, height: 40, width: 80, right: 10 }, { mode, top: 50, height: 40, width: 20, right: 30 }]
assert.equal(calculateGeometry({ ...base, target: { mode, height: 20, speed: 20 }, visibles }), 50)
}
})
test(`danmuku ${implementation.name}: owner requests made with the same clock tick remain distinct`, async (t) => {
const env = danmukuCandidateEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
t.after(() => env.destroy())
const owner = plugin.show()
const first = owner.postMessage({ type: 'getDanmuTop' })
const second = owner.postMessage({ type: 'getDanmuTop' })
const worker = env.workers[0]
assert.equal(worker.messages.length, 2)
const [a, b] = worker.messages.map(item => item.message.id)
assert.notEqual(a, b, 'The controlled Date.now value is unchanged between calls')
worker.deliver({ id: b, result: 33 })
assert.equal((await second).result, 33)
worker.deliver({ id: a, result: 10 })
assert.equal((await first).result, 10)
})
for (const recovery of ['start', 'reset']) {
test(`danmuku ${implementation.name}: a Worker fault stops the run once and ${recovery} rebuilds the client`, async (t) => {
const env = danmukuCandidateEnvironment(implementation)
const plugin = env.factory({ danmuku: [] })(env.art)
t.after(() => env.destroy())
const owner = plugin.show()
env.art.playing = true
env.art.emit('video:play')
assert.equal(env.frames.size, 1)
const worker = env.workers[0]
const error = new Error(`${recovery}-worker-original`)
const first = owner.postMessage({ type: 'getDanmuTop' })
const second = owner.postMessage({ type: 'getDanmuTop' })
const rejected = Promise.all([assert.rejects(first, value => value === error), assert.rejects(second, value => value === error)])
const fail = worker.onerror
fail({ error, preventDefault() {} })
fail({ error: new Error('duplicate'), preventDefault() {} })
await rejected
assert.deepEqual(env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').map(event => event.args), [[error]])
assert.equal(env.frames.size, 0)
assert.equal(worker.terminated, true)
detached(worker)
if (recovery === 'start')
env.art.emit('video:play')
else plugin.reset()
assert.equal(env.workers.length, 2)
assert.equal(env.frames.size, 1)
const fresh = env.workers[1]
assert.notEqual(fresh, worker)
assert.equal(fresh.terminated, false)
const pending = owner.postMessage({ type: 'getDanmuTop' })
const { id } = fresh.messages[0].message
fresh.deliver({ id, result: 10 })
assert.equal((await pending).result, 10)
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').length, 1)
})
}