mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
fix(danmuku): [PKG-DANMUKU-12] keep frame sampling during async placement
This commit is contained in:
1 parent
df6a6fe82e
commit
4f559cb31d
20 files changed
+6192
-64
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 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(""",'"').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 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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function 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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function 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;
|
||||
|
||||
@@ -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(""",'"').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 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(""",'"').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 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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function 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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function 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
@@ -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)
|
||||
@@ -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时,刚显示的弹幕下一帧就被
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user