mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
refactor(danmuku): [PKG-DANMUKU-04] own scheduler and worker request lifecycle
This commit is contained in:
1 parent
0b2efb07f1
commit
12332df221
24 files changed
+23441
-461
No files matched your search
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const h='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switch(t.mode){case 0:{const e=a.find(e=>e.every(e=>{if(i<e.distance)return!1;if(t.speed<e.speed)return!0;return e.right/(t.speed-e.speed)>e.time}));return e&&e[0]?e[0].top:void 0}case 1:case 2:return}}onmessage=e=>{const{data:i}=e;if(!i.id||!i.type)return;const h=(0,{getDanmuTop:t}[i.type])(i);globalThis.postMessage({result:h,id:i.id})};\n',u="undefined"!==typeof self&&self.Blob&&new Blob(["URL.revokeObjectURL(import.meta.url);",h],{type:"text/javascript;charset=utf-8"});function c(t){let e;try{if(e=u&&(self.URL||self.webkitURL).createObjectURL(u),!e)throw"";const i=new Worker(e,{type:"module",name:t?.name});return i.addEventListener("error",()=>{(self.URL||self.webkitURL).revokeObjectURL(e)}),i}catch(i){return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(h),{type:"mLine truncated
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,a=c(e);globalThis.postMessage({danmus:a,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((a,n)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const a=o;o=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?n(i):a(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const a=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(a,t.error||t)},s.onmessageerror=t=>v(a,t.error||t),s.postMessage({xml:a,id:d})}catch(n){v(a,n)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function x(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function $(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const w='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switcLine truncated
|
||||
!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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,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 = {
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@
|
||||
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
|
||||
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
"test:unit": "node --test test/danmuku-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.
|
||||
@@ -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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const h='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switch(t.mode){case 0:{const e=a.find(e=>e.every(e=>{if(i<e.distance)return!1;if(t.speed<e.speed)return!0;return e.right/(t.speed-e.speed)>e.time}));return e&&e[0]?e[0].top:void 0}case 1:case 2:return}}onmessage=e=>{const{data:i}=e;if(!i.id||!i.type)return;const h=(0,{getDanmuTop:t}[i.type])(i);globalThis.postMessage({result:h,id:i.id})};\n',u="undefined"!==typeof self&&self.Blob&&new Blob(["URL.revokeObjectURL(import.meta.url);",h],{type:"text/javascript;charset=utf-8"});function c(t){let e;try{if(e=u&&(self.URL||self.webkitURL).createObjectURL(u),!e)throw"";const i=new Worker(e,{type:"module",name:t?.name});return i.addEventListener("error",()=>{(self.URL||self.webkitURL).revokeObjectURL(e)}),i}catch(i){return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(h),{type:"mLine truncated
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,a=c(e);globalThis.postMessage({danmus:a,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((a,n)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const a=o;o=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?n(i):a(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const a=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(a,t.error||t)},s.onmessageerror=t=>v(a,t.error||t),s.postMessage({xml:a,id:d})}catch(n){v(a,n)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function x(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function $(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const w='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switcLine truncated
|
||||
!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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,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
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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、函数配置
|
||||
|
||||
@@ -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
@@ -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
@@ -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 协议保持,销毁无后台工作",
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user