fix(danmuku): [PKG-DANMUKU-12] keep frame sampling during async placement

This commit is contained in:
Harvey Zhao committed 2026-09-13 22:49:26 +08:00
1 parent df6a6fe82e
commit 4f559cb31d
20 files changed
+6192 -64

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,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!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)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(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.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 c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,h,p=!1;const u=++a,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!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(p)return;p=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!p){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=r?.(m),p?h?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(p)return;const r=await a.text();if(!p)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),p)return void f();if(d=new Worker(c),p)return void f();d.onmessage=t=>{if(p||t.data?.id!==u)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(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.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 c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class p{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,h={};h.top=a,h.left=s,h.height=r,h.width=o,h.right=d,h.speed=c,h.distance=l,h.time=n.$restTime,h.mode=n.mode,t.push(h)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine 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,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(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)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>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!==r){const n=r;r=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",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(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 ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),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 w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine 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,r=Math.pow,o=(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)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>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!==r){const n=r;r=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",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(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 ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),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 w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
+65 -12
View File
@@ -538,6 +538,39 @@ class Renderer {
this.nodes.clear();
}
}
class SchedulingBuffer {
constructor() {
this.pending = /* @__PURE__ */ new Set();
this.reserved = /* @__PURE__ */ new Set();
}
capture(items, failed) {
for (const item of items) {
if (!this.reserved.has(item) && !failed.has(item))
this.pending.add(item);
}
}
take() {
const ready = [];
const waiting = [];
for (const item of this.pending) {
if (item.$state === "ready")
ready.push(item);
else if (item.$state === "wait")
waiting.push(item);
}
this.pending.clear();
const batch = [...ready, ...waiting];
this.reserved = new Set(batch);
return batch;
}
complete() {
this.reserved.clear();
}
clear() {
this.pending.clear();
this.reserved.clear();
}
}
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku frame");
class Scheduler {
constructor(owner) {
@@ -550,6 +583,7 @@ class Scheduler {
this.running = false;
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
this.buffer = new SchedulingBuffer();
}
active(operation) {
const owner = this.owner;
@@ -572,6 +606,7 @@ class Scheduler {
}
this.owner.workerClient?.cancel();
this.failedItems.clear();
this.buffer.clear();
}
report(error) {
try {
@@ -603,42 +638,60 @@ class Scheduler {
}
schedule() {
const owner = this.owner;
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null)
return;
this.frame = window.requestAnimationFrame(() => {
this.frame = null;
owner.timer = null;
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return;
const generation = this.generation;
try {
if (owner.art.playing && !owner.isHide) {
owner.filter("emit", (danmu) => {
const now = Date.now();
danmu.$restTime -= (now - danmu.$lastStartTime) / 1e3;
danmu.$lastStartTime = now;
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const readys2 = owner.readys;
if (generation !== this.generation)
return;
this.buffer.capture(readys2, this.failedItems);
}
} catch (error) {
if (generation === this.generation)
this.fail(error);
return;
}
this.schedule();
if (this.operation || !owner.art.playing || owner.isHide)
return;
const readys = this.buffer.take();
if (!readys.length)
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) => {
return this.run(operation, readys).catch((error) => {
if (this.active(operation))
this.fail(error);
}).finally(() => {
this.release(operation);
if (this.operation === operation) {
this.operation = null;
this.schedule();
this.buffer.complete();
}
});
});
owner.timer = this.frame;
}
async run(operation) {
async run(operation, readys) {
const owner = this.owner;
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;
@@ -534,6 +534,39 @@ var artplayerPluginDanmuku = (function() {
this.nodes.clear();
}
}
class SchedulingBuffer {
constructor() {
this.pending = /* @__PURE__ */ new Set();
this.reserved = /* @__PURE__ */ new Set();
}
capture(items, failed) {
for (const item of items) {
if (!this.reserved.has(item) && !failed.has(item))
this.pending.add(item);
}
}
take() {
const ready = [];
const waiting = [];
for (const item of this.pending) {
if (item.$state === "ready")
ready.push(item);
else if (item.$state === "wait")
waiting.push(item);
}
this.pending.clear();
const batch = [...ready, ...waiting];
this.reserved = new Set(batch);
return batch;
}
complete() {
this.reserved.clear();
}
clear() {
this.pending.clear();
this.reserved.clear();
}
}
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku frame");
class Scheduler {
constructor(owner) {
@@ -546,6 +579,7 @@ var artplayerPluginDanmuku = (function() {
this.running = false;
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
this.buffer = new SchedulingBuffer();
}
active(operation) {
const owner = this.owner;
@@ -568,6 +602,7 @@ var artplayerPluginDanmuku = (function() {
}
this.owner.workerClient?.cancel();
this.failedItems.clear();
this.buffer.clear();
}
report(error) {
try {
@@ -599,42 +634,60 @@ var artplayerPluginDanmuku = (function() {
}
schedule() {
const owner = this.owner;
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null)
return;
this.frame = window.requestAnimationFrame(() => {
this.frame = null;
owner.timer = null;
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return;
const generation = this.generation;
try {
if (owner.art.playing && !owner.isHide) {
owner.filter("emit", (danmu) => {
const now = Date.now();
danmu.$restTime -= (now - danmu.$lastStartTime) / 1e3;
danmu.$lastStartTime = now;
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const readys2 = owner.readys;
if (generation !== this.generation)
return;
this.buffer.capture(readys2, this.failedItems);
}
} catch (error) {
if (generation === this.generation)
this.fail(error);
return;
}
this.schedule();
if (this.operation || !owner.art.playing || owner.isHide)
return;
const readys = this.buffer.take();
if (!readys.length)
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) => {
return this.run(operation, readys).catch((error) => {
if (this.active(operation))
this.fail(error);
}).finally(() => {
this.release(operation);
if (this.operation === operation) {
this.operation = null;
this.schedule();
this.buffer.complete();
}
});
});
owner.timer = this.frame;
}
async run(operation) {
async run(operation, readys) {
const owner = this.owner;
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;
@@ -15,7 +15,8 @@ historical declarations, earlier exports and failure evidence are indexed in
| `src/input.ts` | Input forms, replacement ownership, independent append operations, cancellation and synchronous callback reentry |
| `src/bilibili-parser.ts` | Pure legacy-compatible XML fields, mode mapping and entity decoding |
| `src/bilibili.ts` | Fetch and response text, one parser Worker per request, fallback, settlement and Blob URL cleanup |
| `src/scheduler.ts` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery |
| `src/scheduler.ts` | One sampling RAF and one serial asynchronous batch, cancellation generations, preparation ownership and failure recovery |
| `src/scheduling-buffer.ts` | Identity-based observations and current-batch reservations; ready-before-wait selection without public state mutation |
| `src/renderer.ts` | Owned node allocation, preparation, geometry snapshots, placement, pause/resume styling and disposal |
| `src/queue.ts` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.ts` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
@@ -135,12 +136,31 @@ legacy build targets remain es2020 and es2015; syntax lowering does not polyfill
## Scheduling ownership
Keep at most one pending RAF or asynchronous preparation frame. Native `play`
Keep at most one pending RAF and one asynchronous preparation batch. 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.
RAF sampling and visible lifetime maintenance continue while `beforeVisible` or
the Worker is pending. The scheduling buffer stores only actually observed row
references; it does not sweep elapsed time or modify public wait/ready states.
The dispatcher remains serial. Reserve every row in its batch until completion,
so a rejected placement cannot repeatedly jump ahead of later rows in that batch.
At the next RAF, capture new observations before selecting ready rows followed by
wait rows, retaining capture order within each group. Identity sets prevent the
same observed row from entering both the current and pending batch. Cancellation
clears both sets; an old operation's finally must not clear a newer batch.
This fixes missed eligibility during asynchronous preparation, not timestamps
crossed while the entire main thread is blocked. The native timing diagnostic
keeps the unchanged published miss visible beside the candidate regression and
records CPU-blocked frame gaps without calling them successful load acceptance.
Sampling still scans the existing state pools on each active frame. Buffer storage
is bounded by the distinct observed queue rows, not an arbitrary drop limit; this
does not establish a throughput or memory improvement. Measure dense queues and
long asynchronous waits in the separate sustained-load acceptance task.
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
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,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!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)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(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.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 c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,h,p=!1;const u=++a,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!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(p)return;p=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!p){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=r?.(m),p?h?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(p)return;const r=await a.text();if(!p)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),p)return void f();if(d=new Worker(c),p)return void f();d.onmessage=t=>{if(p||t.data?.id!==u)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(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.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 c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class p{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,h={};h.top=a,h.left=s,h.height=r,h.width=o,h.right=d,h.speed=c,h.distance=l,h.time=n.$restTime,h.mode=n.mode,t.push(h)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine 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,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(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)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>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!==r){const n=r;r=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",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(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 ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),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 w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine 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,r=Math.pow,o=(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)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>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!==r){const n=r;r=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",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(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 ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),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 w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
@@ -538,6 +538,39 @@ class Renderer {
this.nodes.clear();
}
}
class SchedulingBuffer {
constructor() {
this.pending = /* @__PURE__ */ new Set();
this.reserved = /* @__PURE__ */ new Set();
}
capture(items, failed) {
for (const item of items) {
if (!this.reserved.has(item) && !failed.has(item))
this.pending.add(item);
}
}
take() {
const ready = [];
const waiting = [];
for (const item of this.pending) {
if (item.$state === "ready")
ready.push(item);
else if (item.$state === "wait")
waiting.push(item);
}
this.pending.clear();
const batch = [...ready, ...waiting];
this.reserved = new Set(batch);
return batch;
}
complete() {
this.reserved.clear();
}
clear() {
this.pending.clear();
this.reserved.clear();
}
}
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku frame");
class Scheduler {
constructor(owner) {
@@ -550,6 +583,7 @@ class Scheduler {
this.running = false;
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
this.buffer = new SchedulingBuffer();
}
active(operation) {
const owner = this.owner;
@@ -572,6 +606,7 @@ class Scheduler {
}
this.owner.workerClient?.cancel();
this.failedItems.clear();
this.buffer.clear();
}
report(error) {
try {
@@ -603,42 +638,60 @@ class Scheduler {
}
schedule() {
const owner = this.owner;
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null)
return;
this.frame = window.requestAnimationFrame(() => {
this.frame = null;
owner.timer = null;
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return;
const generation = this.generation;
try {
if (owner.art.playing && !owner.isHide) {
owner.filter("emit", (danmu) => {
const now = Date.now();
danmu.$restTime -= (now - danmu.$lastStartTime) / 1e3;
danmu.$lastStartTime = now;
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const readys2 = owner.readys;
if (generation !== this.generation)
return;
this.buffer.capture(readys2, this.failedItems);
}
} catch (error) {
if (generation === this.generation)
this.fail(error);
return;
}
this.schedule();
if (this.operation || !owner.art.playing || owner.isHide)
return;
const readys = this.buffer.take();
if (!readys.length)
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) => {
return this.run(operation, readys).catch((error) => {
if (this.active(operation))
this.fail(error);
}).finally(() => {
this.release(operation);
if (this.operation === operation) {
this.operation = null;
this.schedule();
this.buffer.complete();
}
});
});
owner.timer = this.frame;
}
async run(operation) {
async run(operation, readys) {
const owner = this.owner;
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;
@@ -1,5 +1,6 @@
import type Danmuku from './danmuku'
import type { DanmuItem } from './types'
import SchedulingBuffer from './scheduling-buffer'
const cancelled = Symbol('cancelled danmuku frame')
@@ -21,6 +22,7 @@ export default class Scheduler {
declare running: boolean
declare fault: boolean
declare failedItems: Set<DanmuItem>
declare buffer: SchedulingBuffer
constructor(owner: Danmuku) {
this.owner = owner
@@ -32,6 +34,7 @@ export default class Scheduler {
this.running = false
this.fault = false
this.failedItems = new Set()
this.buffer = new SchedulingBuffer()
}
active(operation: FrameOperation) {
@@ -58,6 +61,7 @@ export default class Scheduler {
}
this.owner.workerClient?.cancel()
this.failedItems.clear()
this.buffer.clear()
}
report(error: unknown) {
@@ -95,43 +99,62 @@ export default class Scheduler {
schedule() {
const owner = this.owner
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null)
return
this.frame = window.requestAnimationFrame(() => {
this.frame = null
owner.timer = null
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return
const generation = this.generation
try {
if (owner.art.playing && !owner.isHide) {
owner.filter('emit', (danmu) => {
const now = Date.now()
danmu.$restTime -= (now - danmu.$lastStartTime) / 1000
danmu.$lastStartTime = now
if (danmu.$restTime <= 0)
owner.makeWait(danmu)
})
const readys = owner.readys
if (generation !== this.generation)
return
this.buffer.capture(readys, this.failedItems)
}
}
catch (error) {
if (generation === this.generation)
this.fail(error)
return
}
this.schedule()
if (this.operation || !owner.art.playing || owner.isHide)
return
const readys = this.buffer.take()
if (!readys.length)
return
let cancel!: () => void
const cancellation = new Promise<typeof cancelled>(resolve => cancel = () => resolve(cancelled))
const operation: FrameOperation = { generation: this.generation, cancel, wait: value => Promise.race([value, cancellation]), ref: null }
this.operation = operation
return this.run(operation).catch((error) => {
return this.run(operation, readys).catch((error) => {
if (this.active(operation))
this.fail(error)
}).finally(() => {
this.release(operation)
if (this.operation === operation) {
this.operation = null
this.schedule()
this.buffer.complete()
}
})
})
owner.timer = this.frame
}
async run(operation: FrameOperation) {
async run(operation: FrameOperation, readys: DanmuItem[]) {
const owner = this.owner
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
@@ -0,0 +1,38 @@
import type { DanmuItem } from './types'
// Sampling owns references only; the public state pools stay under Danmuku's control.
export default class SchedulingBuffer {
private pending = new Set<DanmuItem>()
private reserved = new Set<DanmuItem>()
capture(items: DanmuItem[], failed: Set<DanmuItem>) {
for (const item of items) {
if (!this.reserved.has(item) && !failed.has(item))
this.pending.add(item)
}
}
take() {
const ready: DanmuItem[] = []
const waiting: DanmuItem[] = []
for (const item of this.pending) {
if (item.$state === 'ready')
ready.push(item)
else if (item.$state === 'wait')
waiting.push(item)
}
this.pending.clear()
const batch = [...ready, ...waiting]
this.reserved = new Set(batch)
return batch
}
complete() {
this.reserved.clear()
}
clear() {
this.pending.clear()
this.reserved.clear()
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,67 @@
# PKG-DANMUKU-12 连续采样与串行放置
07原生诊断确认:beforeVisible等待600ms时,浏览器仍以约19–64ms媒体间隔出帧,
但旧调度不再读取readys达约583–647ms,中间已到时弹幕未被选中。单次ready快照
还会使已显示弹幕的到期回收等待后续异步准备。拆分本任务处理,07仍独立待验收。
## 设计与兼容边界
- 保留公开readys的当前时间±0.1秒规则,不补扫过去时间、不追赶seek跨过的区间。
每次真实RAF继续维护可见寿命并采样。已经真实采到的项由内部buffer保留引用,
不擅自把公开wait状态改为ready,不复制/替换队列对象。
- 用户beforeVisible和Worker放置仍串行,最多一个异步批次。当前批次不重排,
批内所有项保留身份占位直到整批结束,false/无轨道只能在后续RAF再捕获。
下一批次从pending按当前ready优先、各组捕获顺序构造,防止轨道满的ready行
反复抢占同批后续wait。
- pause/stop/reset/替换加载/seek/hide/destroy及失败恢复保持既有代际取消;清空
buffered引用,旧Promise迟到不能清掉新批次或显示已取消行。拒绝按本代跳过,
false仍以未来真实采样决定是否重试;没有任意条数上限或并行用户回调。
- 明确内部变化:等待异步准备时仍有一个待执行RAF,而不是把采样也暂停。公开
事件顺序、门面返回与声明不变;先前漏采样而丢失的弹幕可在其串行轮次显示。
本任务已完成实现与验证,任务和提交状态以tasks.json及Git为准。主线程完全卡住造成的未采样窗口
没有被本设计恢复,不得把这个修复称为任意负载下无漏弹幕。
## 实现与测试依据
`scheduler.ts` 拆开实际帧维护与异步批次,`scheduling-buffer.ts` 只持有观察过的
行引用及当前批次占位。没有新增依赖、公开字段、回调、声明或分发入口。
保留 API-04/05/07/08/11/12 边界;只修正异步等待期间漏采样和延期回收。
源码受控红测10项中3项失败,分别证明没有继续排队RAF、中间行丢失、可见行
未按期回收;其余7项覆盖取消和轨道占用保护。
最初原生红测有两项失败:候选确实丢失middle;已发布插件还触发其已知的
play/playing双循环,使对旧版新增的“回调串行”断言失败。该串行断言只适用
重构候选;旧版保留缺失middle的精确历史断言,不伪称旧版有串行保证。
首次接入后单测65/66通过,余下一项仍断言异步期间RAF为0;按明确的新内部
调度约束改为1,并增加执行该帧后callback仍只调用一次的保护,未放松公开事件。
包内 ARCHITECTURE.md 已同步文件职责、采样/放置关系、代际清理及CPU阻塞限制。
最终命令、构建格式、浏览器版本和结果在本任务机器证据中记录后才关闭任务。
最终验证:Node 24.21.0 / Yarn 1.22.22,完整源码264项、main/legacy调度与Worker
各68项全部通过。独立审查新增旧代finally不清新buffer、跨帧ready优先两项保护。
源码/main/legacy各102项原生浏览器测试全部通过,0重试/跳过;其中每格式66项
两核心调度、24项新旧插件真实延迟Worker寿命,以及12项候选核心时间窗口测试。
后者有6项CPU阻塞诊断,并不证明无漏弹幕;准确范围见机器证据。
分包严格TS、目标lint、三格式构建和严格工具链检查通过;没有新增依赖或修改声明。
可重跑命令:
```sh
yarn test:danmuku
yarn exec tsc -p packages/artplayer-plugin-danmuku/tsconfig.json --noEmit
yarn build artplayer-plugin-danmuku
yarn test:browser test/browser/danmuku-timing-diagnostic.spec.js test/browser/danmuku-scheduler.spec.js test/browser/danmuku-lifetime.spec.js --workers=2
```
产物复跑分别设置 `ARTPLAYER_DANMUKU_ARTIFACT` 为dist下的 `.js` / `.legacy.js`,
每次等待进程终态并归档报告再启动下一格式。受控产物测试运行
`node --test test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js`。
证据:[danmuku-frame-sampling-validation.json](../baselines/danmuku-frame-sampling-validation.json)。
## 发布与回退
沿用每包下一major及原接口兼容政策,本次没有发布授权。回退本任务的scheduler、
buffer和生成产物即可恢复11的串行采样实现;保留测试用于展示回退会重现的漏采样。
不要同时回退11已修正的Worker可见寿命起点。07仍需真实持续负载、资源及Mask验收。
+6 -4
View File
@@ -2,9 +2,9 @@
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 230 项,范围 22 个包及工作区/示例。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 231 项,范围 22 个包及工作区/示例。
状态:todo 68 / doing 15 / blocked 0 / done 147 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 68 / doing 15 / blocked 0 / done 148 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -22,7 +22,7 @@
| artplayer-plugin-auto-thumbnail | 1.1.0 | PKG-AUTO-THUMB-01, PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-04, PKG-AUTO-THUMB-05, PKG-AUTO-THUMB-06 |
| artplayer-plugin-chapter | 1.1.0 | PILOT-01, PKG-CHAPTER-01, PKG-CHAPTER-02, PKG-CHAPTER-03, PKG-CHAPTER-04, PKG-CHAPTER-05, PKG-CHAPTER-06 |
| artplayer-plugin-chromecast | 1.1.0 | PKG-CAST-01, PKG-CAST-02, PKG-CAST-03, PKG-CAST-04, PKG-CAST-05, PKG-CAST-06 |
| artplayer-plugin-danmuku | 5.3.0 | PKG-DANMUKU-01, PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-06, PKG-DANMUKU-07, PKG-DANMUKU-08, PKG-DANMUKU-09, PKG-DANMUKU-10, PKG-DANMUKU-11 |
| artplayer-plugin-danmuku | 5.3.0 | PKG-DANMUKU-01, PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-06, PKG-DANMUKU-07, PKG-DANMUKU-08, PKG-DANMUKU-09, PKG-DANMUKU-10, PKG-DANMUKU-11, PKG-DANMUKU-12 |
| artplayer-plugin-danmuku-mask | 1.1.0 | PKG-MASK-01, PKG-MASK-02, PKG-MASK-03, PKG-MASK-04, PKG-MASK-05, PKG-MASK-06 |
| artplayer-plugin-dash-control | 1.1.0 | PKG-DASH-01, PKG-DASH-02, PKG-DASH-03, PKG-DASH-04, PKG-DASH-05, PKG-DASH-06 |
| artplayer-plugin-document-pip | 1.1.0 | PKG-DPIP-01, PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-04, PKG-DPIP-05, PKG-DPIP-06 |
@@ -315,11 +315,12 @@
| 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 | done |
| PKG-DANMUKU-06 | artplayer-plugin-danmuku<br>迁移 TS 与公开声明 | PKG-DANMUKU-05, ENG-04, ENG-06 | 自有模块、worker 消息、option/item/result 类型 | 旧参数、扩展字段和事件消费通过,vendored 边界清楚 | H | done |
| PKG-DANMUKU-07 | artplayer-plugin-danmuku<br>弹幕能力稳定性验收 | PKG-DANMUKU-06, PKG-DANMUKU-10, PKG-DANMUKU-11 | 负载/渲染/资源对比及 mask 可依赖的稳定边界 | 在试点核心中旧插件 API 与关键帧路径通过 | H | doing |
| PKG-DANMUKU-07 | artplayer-plugin-danmuku<br>弹幕能力稳定性验收 | PKG-DANMUKU-06, PKG-DANMUKU-10, PKG-DANMUKU-11, PKG-DANMUKU-12 | 负载/渲染/资源对比及 mask 可依赖的稳定边界 | 在试点核心中旧插件 API 与关键帧路径通过 | H | doing |
| PKG-DANMUKU-08 | artplayer-plugin-danmuku<br>完成新旧核心与组合验收 | PKG-DANMUKU-07, CORE-22 | 最终核心/旧核心、mask/fullscreen/PiP 组合报告 | 性能无未解释退化,反复装卸无累计资源 | H | todo |
| PKG-DANMUKU-09 | artplayer-plugin-danmuku<br>完成分发、示例与文档 | PKG-DANMUKU-08, ENG-07 | danmuku.js、README、声明、worker 与产物 | tarball 和静态 icons 等旧调用通过,有独立回退版本 | H | todo |
| PKG-DANMUKU-10 | artplayer-plugin-danmuku<br>修复 issue958 密集热力图过高与曲线裁平 | PKG-DANMUKU-05 | 自动密度缩放与曲线边界、历史自定义坐标兼容及真实浏览器红绿证据 | 16000条三种分布在两核心三引擎和source/main/legacy不遮挡;显式轴和自定义points保持旧解释,有单独commit | M | done |
| PKG-DANMUKU-11 | artplayer-plugin-danmuku<br>修复 Worker 隐藏等待吞掉可见寿命 | PKG-DANMUKU-06 | 成功放置时的可见计时与延迟回复回归 | 真实 Worker 延迟下完整显示寿命;保留暂停/回收/取消和公开契约 | M | done |
| PKG-DANMUKU-12 | artplayer-plugin-danmuku<br>保持异步放置期间的原生帧采样与寿命维护 | PKG-DANMUKU-11 | 每帧采样与串行dispatcher、代际buffer及回归 | 异步等待不丢已到时采样、不延迟既有寿命维护;保留顺序、取消与公开readys窗口 | H | done |
## 5 包迁移:artplayer-proxy-canvas
@@ -591,3 +592,4 @@
- PKG-ASR-09: [记录](changes/2026-09-13-PKG-ASR-09-explicit-capture.md) [记录](baselines/asr-explicit-capture-validation.json)
- PKG-DANMUKU-10: [记录](changes/2026-09-13-PKG-DANMUKU-10-heatmap-density.md) [记录](baselines/danmuku-heatmap-density-validation.json)
- PKG-DANMUKU-11: [记录](changes/2026-09-13-PKG-DANMUKU-11-visible-lifetime.md) [记录](baselines/danmuku-lifetime-validation.json)
- PKG-DANMUKU-12: [记录](changes/2026-09-13-PKG-DANMUKU-12-frame-sampling.md) [记录](baselines/danmuku-frame-sampling-validation.json)
+14
View File
@@ -1,5 +1,19 @@
# 进度与证据
## PKG-DANMUKU-12 持续采样与串行放置完成
异步beforeVisible/Worker等待期间继续原生RAF采样和可见寿命维护,以内部身份
buffer保留真实采到的行;公开readys窗口和状态不改,callback/放置仍串行。
取消清空待处理引用,旧finally不清新代buffer;同批不重排,后续批保持ready优先。
完整源码264项、main/legacy各68项单测,以及三格式各102共306项浏览器通过,
无重试/跳过;后者含CPU阻塞诊断,不能计作任意负载无漏弹幕证明。
严格分包TS、lint、构建和工具链检查通过。红测、历史双循环差异、内部RAF断言
更新及准确验证范围见[变更](changes/2026-09-13-PKG-DANMUKU-12-frame-sampling.md)
和[证据](baselines/danmuku-frame-sampling-validation.json)。
231项:148 done、15 doing、68 todo。关闭DANMUKU-SAMPLING-01;07恢复doing,
DANMUKU-LOAD-01保留,继续真实持续负载、资源和Mask边界,不跳过后续插件迁移。
本任务独立本地commit;未推送、未发布。
## PKG-DANMUKU-11 Worker 等待寿命修复
07负载审查独立发现并复现:隐藏Worker等待超过speed时,刚显示的弹幕下一帧就被
+1
View File
@@ -240,3 +240,4 @@
| DANMUKU-LOAD-01 | open / 待取证 | Native lifecycle fixtures sometimes miss timestamp eligibility during concurrent workload; real frame/window behavior needs load evidence | PKG-DANMUKU-07 |
| DANMUKU-HEATMAP-02 | resolved / 已复现 | Issue958 dense automatic heatmaps exceed the fixed Y domain and flatten across video | PKG-DANMUKU-10 |
| DANMUKU-TIMING-01 | resolved / 已复现 | Hidden Worker wait is deducted from visible lifetime and can recycle a comment immediately after visible | PKG-DANMUKU-11 |
| DANMUKU-SAMPLING-01 | resolved / 已复现 | Serial asynchronous placement stops native frame eligibility sampling and delays existing visible expiry | PKG-DANMUKU-12 |
+21
View File
@@ -5346,6 +5346,27 @@
"refactor/changes/2026-09-13-PKG-DANMUKU-11-visible-lifetime.md"
],
"resolutionRationale": "Three exact controlled modes and real delayed Worker/native video regression establish full visible lifetime, with the unchanged published defect retained and existing scheduling/pause/cancellation checks green."
},
{
"id": "DANMUKU-SAMPLING-01",
"title": "Serial asynchronous placement stops native frame eligibility sampling and delays existing visible expiry",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"PKG-DANMUKU-12"
],
"evidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-12-frame-sampling.md",
"test/browser/danmuku-timing-diagnostic.spec.js",
"refactor/baselines/danmuku-frame-sampling-validation.json"
],
"compatibleResolution": "Keep original per-frame readys semantics and serial callbacks, buffer actually sampled rows by identity, expire visible rows independently, and cancel all buffered work on lifecycle invalidation.",
"closureCriteria": "Controlled ordering/expiry/cancellation tests and native media/RAF evidence show sampled rows survive asynchronous waits without parallel callbacks or stale lifecycle emissions.",
"resolutionEvidence": [
"refactor/baselines/danmuku-frame-sampling-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-12-frame-sampling.md"
],
"resolutionRationale": "Controlled red/green tests prove continued observation and expiry with serial callback/Worker ownership, lifecycle clearing and ready priority. Three-format native media/RAF and existing two-core scheduling/lifetime tests pass. CPU-blocked unobserved timestamps and sustained load remain separately tracked."
}
]
}
+21 -1
View File
@@ -3381,7 +3381,8 @@
"dependsOn": [
"PKG-DANMUKU-06",
"PKG-DANMUKU-10",
"PKG-DANMUKU-11"
"PKG-DANMUKU-11",
"PKG-DANMUKU-12"
],
"status": "doing",
"risk": "H",
@@ -4701,6 +4702,25 @@
"changes/2026-09-13-PKG-DANMUKU-11-visible-lifetime.md",
"baselines/danmuku-lifetime-validation.json"
]
},
{
"id": "PKG-DANMUKU-12",
"phase": "5 包迁移:artplayer-plugin-danmuku",
"title": "保持异步放置期间的原生帧采样与寿命维护",
"scope": [
"artplayer-plugin-danmuku"
],
"dependsOn": [
"PKG-DANMUKU-11"
],
"status": "done",
"risk": "H",
"deliverable": "每帧采样与串行dispatcher、代际buffer及回归",
"acceptance": "异步等待不丢已到时采样、不延迟既有寿命维护;保留顺序、取消与公开readys窗口",
"evidence": [
"changes/2026-09-13-PKG-DANMUKU-12-frame-sampling.md",
"baselines/danmuku-frame-sampling-validation.json"
]
}
]
}
+8
View File
@@ -139,3 +139,11 @@ For a timeout, inspect the retained trace before changing the test or implementa
browser startup and pre-plugin playback delays are different from plugin execution.
The density regression retains 16000 rows, real playback and SVG/pixel geometry at
two widths. A diagnostic pass does not waive the full matrix or long-load acceptance.
`danmuku-timing-diagnostic.spec.js` records independent native RAF/media progress
and the original eligibility getter under an 800ms CPU block or 600ms asynchronous
visibility callback. Its async case asserts that the candidate retains all three
rows and serial callbacks; the fixed published baseline retains its missing middle
row. CPU-block observations remain diagnostic, not no-loss acceptance. Both it and
`danmuku-lifetime.spec.js` accept the same artifact override. The latter verifies
the full visible lifetime after a controlled delay to an actual Worker request.
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
const release = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/danmuku-release.json', import.meta.url), 'utf8')).release
const implementations = new Map()
test.beforeAll(async () => {
const member = `package/${release.manifest.main.replace(/^\.\//u, '')}`
const published = readMember(await ensureArchive(release), member)
assert.equal(hash(published), release.files[member])
implementations.set('published', published.toString())
implementations.set('candidate', process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd'))
})
for (const implementation of ['published', 'candidate']) {
for (const scenario of ['main-thread-gap', 'async-visibility-gap']) {
test(`${implementation}: native ${scenario} records actual frame and eligibility gaps`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate')
const code = implementations.get(implementation)
await page.addScriptTag({ content: code })
await page.evaluate(() => window.createPlayer('/assets/sample/video.mp4'))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.click('#pause')
await page.evaluate(async (scenario) => {
const art = window.art
const evidence = window.timingEvidence = { frames: [], eligibility: [], visible: [], callbacks: [], block: null }
const base = art.currentTime
window.timingEnd = base + 2.5
const rows = [
{ id: 'first', text: 'first', time: base + 0.45, mode: 1 },
{ id: 'middle', text: 'middle', time: base + 0.75, mode: 1 },
{ id: 'sentinel', text: 'after gap', time: base + 1.7, mode: 2 },
]
art.on('artplayerPluginDanmuku:visible', row => evidence.visible.push({ id: row.id, time: art.currentTime, wall: performance.now() }))
art.plugins.add(window.artplayerPluginDanmuku({
danmuku: [],
heatmap: false,
emitter: false,
antiOverlap: false,
beforeVisible(row) {
const entry = { id: row.id, start: performance.now(), media: art.currentTime }
evidence.callbacks.push(entry)
if (scenario === 'async-visibility-gap' && row.id === 'first') {
return new Promise(resolve => setTimeout(() => {
entry.end = performance.now()
resolve(true)
}, 600))
}
entry.end = performance.now()
return true
},
}))
const plugin = art.plugins.artplayerPluginDanmuku
const owner = window.timingOwner = plugin.config({})
const getter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys').get
// Record the real getter invocation without changing rows, state or returned order.
Object.defineProperty(owner, 'readys', { configurable: true, get() {
const ready = getter.call(this)
evidence.eligibility.push({ wall: performance.now(), time: art.currentTime, ids: ready.map(row => row.id) })
return ready
} })
await plugin.load(rows)
let blocked = false
function observe(wall) {
if (art.playing) {
evidence.frames.push({ wall, time: art.currentTime })
if (scenario === 'main-thread-gap' && !blocked && art.currentTime >= base + 0.2) {
blocked = true
const start = performance.now()
const before = art.currentTime
// Intentional bounded main-thread work. Media and native RAF are never faked.
while (performance.now() - start < 800) { /* Controlled CPU load. */ }
evidence.block = { start, end: performance.now(), before, after: art.currentTime }
}
}
window.timingFrame = requestAnimationFrame(observe)
}
window.timingFrame = requestAnimationFrame(observe)
}, scenario)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime >= window.timingEnd)).toBe(true)
await page.click('#pause')
const observation = await page.evaluate(() => {
cancelAnimationFrame(window.timingFrame)
const evidence = window.timingEvidence
const rows = window.timingOwner.queue.map(row => ({ id: row.id, time: row.time, state: row.$state }))
const visible = new Set(evidence.visible.map(row => row.id))
return { ...evidence, rows, missing: rows.filter(row => !visible.has(row.id)).map(row => row.id), media: { time: window.art.currentTime, width: window.art.video.videoWidth } }
})
await testInfo.attach('danmuku-timing-observation', { contentType: 'application/json', body: JSON.stringify({ implementation, scenario, sha256: hash(code), observation, scope: 'Diagnostic only: native timing with controlled load, original readys getter and unmodified queue states. Missing rows are reported, not waived as stability acceptance.' }) })
expect(observation.media.width).toBeGreaterThan(0)
expect(observation.rows).toHaveLength(3)
expect(observation.frames.length).toBeGreaterThan(2)
expect(observation.eligibility.length).toBeGreaterThan(2)
expect(observation.visible.some(row => row.id === 'sentinel')).toBe(true)
if (scenario === 'main-thread-gap')
expect(observation.block.end - observation.block.start).toBeGreaterThanOrEqual(800)
else
expect(observation.callbacks.find(row => row.id === 'first').end - observation.callbacks.find(row => row.id === 'first').start).toBeGreaterThanOrEqual(550)
if (scenario === 'async-visibility-gap') {
expect(observation.missing).toEqual(implementation === 'candidate' ? [] : ['middle'])
expect(observation.visible.map(row => row.id)).toEqual(implementation === 'candidate' ? ['first', 'middle', 'sentinel'] : ['first', 'sentinel'])
if (implementation === 'candidate') {
for (let index = 1; index < observation.callbacks.length; index++)
expect(observation.callbacks[index].start).toBeGreaterThanOrEqual(observation.callbacks[index - 1].end)
}
}
await page.evaluate(() => window.art.destroy())
})
}
}
+284 -5
View File
@@ -93,9 +93,17 @@ test(`${prefix}: repeated play/playing and direct starts preserve events and int
env.destroy()
})
test(`${prefix}: additional starts while beforeVisible is pending do not schedule an overlapping frame`, async () => {
test(`${prefix}: additional starts keep one sampling frame without overlapping beforeVisible or Worker dispatch`, async (t) => {
const gate = deferred()
const { env, plugin, owner } = await fixture({ beforeVisible: () => gate.promise })
let calls = 0
const { env, plugin, owner, worker } = await fixture({ beforeVisible: () => {
calls++
return gate.promise
} })
t.after(() => {
env.destroy()
gate.resolve(true)
})
await plugin.emit({ text: 'waiting callback', time: 10 })
env.art.playing = true
owner.start()
@@ -103,10 +111,279 @@ test(`${prefix}: additional starts while beforeVisible is pending do not schedul
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()
assert.equal(env.frames.size, 1, 'One sampler continues while the single dispatcher waits')
assert.equal(calls, 1)
assert.equal(worker.messages.length, 0)
env.frame()
await env.flush()
assert.equal(env.frames.size, 1)
assert.equal(calls, 1, 'Sampling and additional starts cannot duplicate the pending callback')
assert.equal(worker.messages.length, 0, 'No placement starts before the pending callback allows it')
})
test(`${prefix}: sampling dispatcher retains a middle timestamp while the first callback waits`, async (t) => {
const gate = deferred()
const calls = []
const { env, plugin, owner, worker } = await fixture({ beforeVisible: (row) => {
calls.push(row.text)
return row.text === 'first' ? gate.promise : true
} }, { automatic: true })
t.after(() => {
env.destroy()
gate.resolve(true)
})
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'middle', time: 10.5, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.tick(500)
env.art.currentTime = 10.5
// Drive only callbacks the implementation actually scheduled; the old single
// asynchronous frame has none here and consequently loses this timestamp.
if (env.frames.size)
env.frame()
await env.flush()
if (env.frames.size)
env.frame()
await env.flush()
assert.deepEqual(calls, ['first'], 'Sampling must not run another user callback concurrently')
assert.equal(worker.messages.length, 0)
assert.equal(owner.queue[1].$state, 'wait', 'Sampling must not rewrite the public state pool')
env.tick(500)
env.art.currentTime = 11
if (env.frames.size)
env.frame()
await env.flush()
assert.equal(owner.readys.includes(owner.queue[1]), false, 'The public getter retains its current-time window')
gate.resolve(true)
await env.flush()
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'middle'], 'The observed middle timestamp must be dispatched exactly once after the first batch')
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'middle'])
assert.equal(worker.messages.length, 2)
assert.equal(new Set(worker.messages.map(item => item.message.id)).size, 2)
})
test(`${prefix}: sampling dispatcher expires a visible row while another callback remains pending`, async (t) => {
const gate = deferred()
const { env, plugin, owner, worker } = await fixture({ speed: 1, beforeVisible: row => row.text === 'blocked' ? gate.promise : true }, { automatic: true })
t.after(() => {
env.destroy()
gate.resolve(false)
})
await plugin.emit({ text: 'visible', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
const row = owner.queue[0]
const ref = row.$ref
assert.equal(row.$state, 'emit')
await plugin.emit({ text: 'blocked', time: 10.05, mode: 1 })
env.tick(50)
env.art.currentTime = 10.05
env.frame()
await env.flush()
assert.equal(worker.messages.length, 1)
env.tick(1000)
env.art.currentTime = 11.05
if (env.frames.size)
env.frame()
await env.flush()
assert.equal(row.$state, 'wait', 'A different pending hook must not extend an already visible row lifetime')
assert.equal(row.$ref, null)
assert.equal(ref.style.visibility, 'hidden')
assert.deepEqual(Array.from(owner.$refs), [ref])
assert.equal(worker.messages.length, 1, 'The unresolved callback still owns the only dispatcher')
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['visible'])
})
test(`${prefix}: sampling dispatcher discards buffered observations on lifecycle invalidation`, async (t) => {
for (const action of ['seek', 'hide', 'stop', 'reset', 'destroy']) {
await t.test(action, async (t) => {
const gate = deferred()
const calls = []
const { env, plugin, owner, worker } = await fixture({ beforeVisible: (row) => {
calls.push(row.text)
return row.text === 'first' ? gate.promise : true
} }, { automatic: true })
t.after(() => {
if (!env.art.isDestroy)
env.destroy()
gate.resolve(true)
})
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'buffered', time: 10.5, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
env.tick(500)
env.art.currentTime = 10.5
if (env.frames.size)
env.frame()
await env.flush()
env.tick(500)
env.art.currentTime = 11
if (action === 'destroy')
env.destroy()
else owner[action]()
gate.resolve(true)
await env.flush()
if (env.frames.size)
env.frame()
await env.flush()
assert.deepEqual(calls, ['first'])
assert.equal(worker.messages.length, 0, 'Invalidated observations must not allocate or place a late row')
assert.equal(output(env, 'visible').length, 0)
if (action !== 'destroy') {
if (action === 'hide')
owner.show()
owner.start()
await plugin.emit({ text: 'fresh', time: 11, mode: 1 })
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'fresh'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['fresh'])
assert.equal(worker.messages.length, 1)
}
})
}
})
test(`${prefix}: sampling dispatcher does not let a full ready track starve a later wait row in its batch`, async (t) => {
const calls = []
const { env, plugin, owner, worker } = await fixture({ margin: [10, 10], beforeVisible: (row) => {
calls.push(row.text)
return true
} }, { automatic: true })
t.after(() => env.destroy())
owner.$player.clientHeight = 40
await plugin.emit({ text: 'occupant', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
await plugin.emit({ text: 'full-ready', time: 10, mode: 1 })
env.frame()
await env.flush()
assert.equal(owner.queue[1].$state, 'ready', 'The real positioning algorithm must defer the occupied track')
await plugin.emit({ text: 'later-wait', time: 10, mode: 2 })
const previousCalls = calls.length
const requests = worker.messages.length
env.frame()
await env.flush()
await env.flush()
assert.deepEqual(calls.slice(previousCalls), ['full-ready', 'later-wait'])
assert.equal(worker.messages.length, requests + 2)
assert.equal(owner.queue[1].$state, 'ready')
assert.equal(owner.queue[2].$state, 'emit')
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['occupant', 'later-wait'])
})
test(`${prefix}: an old finalizer preserves the new generation dispatcher and its sampled pending row`, async (t) => {
const oldGate = deferred()
const newGate = deferred()
const calls = []
let firstCalls = 0
const { env, plugin, owner, worker } = await fixture({ beforeVisible: (row) => {
calls.push(row.text)
if (row.text === 'first')
return ++firstCalls === 1 ? oldGate.promise : newGate.promise
return true
} }, { automatic: true })
t.after(() => {
env.destroy()
oldGate.resolve(true)
newGate.resolve(true)
})
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'new-middle', time: 10.5, mode: 1 }])
env.art.playing = true
owner.start()
env.frame()
await env.flush()
assert.deepEqual(calls, ['first'])
owner.seek()
// These are actually queued RAF callbacks. Run the new generation and sample
// its next row before yielding to the cancelled old dispatcher's microtasks.
env.frame()
env.tick(500)
env.art.currentTime = 10.5
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'first'])
assert.equal(worker.messages.length, 0)
env.tick(500)
env.art.currentTime = 11
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'first'], 'The old finalizer must not release the new pending callback ownership')
assert.equal(worker.messages.length, 0)
newGate.resolve(true)
await env.flush()
env.frame()
await env.flush()
assert.deepEqual(calls, ['first', 'first', 'new-middle'])
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'new-middle'])
assert.equal(worker.messages.length, 2)
oldGate.resolve(true)
await env.flush()
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'new-middle'])
assert.equal(worker.messages.length, 2)
})
test(`${prefix}: sampling dispatcher prioritizes ready over a wait row captured in an earlier frame`, async (t) => {
const gate = deferred()
const calls = []
const { env, plugin, owner, worker } = await fixture({ margin: [10, 10], beforeVisible: (row) => {
calls.push(row.text)
return row.text === 'blocking' ? gate.promise : true
} }, { automatic: true })
t.after(() => {
env.destroy()
gate.resolve(false)
})
owner.$player.clientHeight = 40
await plugin.emit({ text: 'occupant', time: 10, mode: 1 })
env.art.playing = true
owner.start()
env.frame()
await env.flush()
await plugin.emit({ text: 'full-ready', time: 10, mode: 1 })
env.frame()
await env.flush()
assert.equal(owner.queue[1].$state, 'ready')
await plugin.emit({ text: 'blocking', time: 10.3, mode: 2 })
env.tick(300)
env.art.currentTime = 10.3
env.frame()
await env.flush()
await env.flush()
assert.equal(calls.at(-1), 'blocking')
await plugin.emit({ text: 'earlier-captured-wait', time: 10.8, mode: 2 })
env.tick(500)
env.art.currentTime = 10.8
env.frame()
await env.flush()
assert.equal(calls.at(-1), 'blocking', 'Sampling must preserve the active batch until its callback settles')
assert.equal(owner.queue[3].$state, 'wait')
gate.resolve(false)
await env.flush()
const previousCalls = calls.length
const previousRequests = worker.messages.length
env.frame()
await env.flush()
await env.flush()
assert.deepEqual(calls.slice(previousCalls), ['full-ready', 'earlier-captured-wait'], 'The next batch retains ready priority even when wait was captured first')
assert.equal(worker.messages.length, previousRequests + 2)
assert.equal(owner.queue[1].$state, 'ready')
assert.equal(owner.queue[3].$state, 'emit')
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['occupant', 'earlier-captured-wait'])
})
for (const action of ['pause', 'destroy', 'reset', 'replace', 'seek', 'hide']) {
@@ -348,7 +625,9 @@ test(`${prefix}: configuring the same beforeVisible callback preserves the pendi
const configurations = output(env, 'config').length
plugin.config({ beforeVisible })
assert.equal(output(env, 'config').length, configurations)
assert.equal(env.frames.size, 0)
assert.equal(env.frames.size, 1)
await env.frame()
assert.equal(calls, 1, 'Sampling cannot start a second callback while the first is pending')
gate.resolve(true)
await env.flush()
assert.equal(calls, 1)