refactor(danmuku): [PKG-DANMUKU-05] own renderer settings and heatmap resources

This commit is contained in:
Harvey Zhao committed 2026-09-13 20:45:16 +08:00
1 parent 12332df221
commit 053d211fb0
38 files changed
+65630 -2420

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const a=t.states[e]||[];for(let n=0;n<a.length;n++)i(a[n]);return a}const u=Symbol("cancelled danmuku frame");class c{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}this.owner.workerClient?.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{this.owner.workerClient?.dispose(),this.owner.createWorker(),this.fault=!1}catch(t){this.report(t)}}schedule(){const t=this.owner;!this.running||this.closed||this.fault||t.art.isDestroy||t.isStop||null!==this.frame||this.operation||(this.frame=window.requestAnimationFrame(()=>{if(this.frame=null,t.timer=null,this.closed||this.fault||t.isStop||t.art.isDestroy)return;let e;const i=new Promise(t=>e=()=>t(u)),a={generation:this.generation,cancel:e,wait:t=>Promise.race([t,i]),ref:null};return this.operation=a,this.run(a).catch(t=>{this.active(a)&&this.fail(t)}).finally(()=>{this.release(a),this.operation===a&&(this.operation=null,this.schedule())})}),t.timer=this.frame)}async run(t){const e=this.owner,{setStyles:i}=e.utils;if(!e.art.playing||!this.active(t))return;e.filter("emit",t=>{const i=(Date.now()-t.$lastStartTime)/1e3;t.$restTimeLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
@@ -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,o=Math.pow,r=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&r(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),p=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{r(i.next(t))}catch(e){a(e)}},o=t=>{try{r(i.throw(t))}catch(e){a(e)}},r=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});function h(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -100,7 +100,7 @@
"test:chromecast": "node --test test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:chromecast-types-package": "node refactor/scripts/chromecast-package-types.mjs",
"test:danmuku-mask": "node --test test/danmuku-mask-failures.test.js refactor/scripts/danmuku-mask-contract.test.mjs",
"test:danmuku": "node --test test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js refactor/scripts/danmuku-contract.test.mjs"
"test:danmuku": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js refactor/scripts/danmuku-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -120,6 +120,7 @@
"glob": "13.0.6",
"lerna": "8.2.4",
"less": "4.5.1",
"linkedom": "0.18.13",
"prompts": "2.4.2",
"semver": "7.7.4",
"servor": "4.0.2",
@@ -15,16 +15,25 @@ repository root. Do not infer runtime contracts from the old declaration file.
| `src/input.js` | Input forms, replacement ownership, independent append operations, cancellation and synchronous callback reentry |
| `src/bilibili-parser.js` | Pure legacy-compatible XML fields, mode mapping and entity decoding |
| `src/bilibili.js` | Fetch and response text, one parser Worker per request, fallback, settlement and Blob URL cleanup |
| `src/scheduler.js` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery; DOM preparation moves to a renderer in PKG-DANMUKU-05 |
| `src/scheduler.js` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery |
| `src/renderer.js` | Owned node allocation, preparation, geometry snapshots, placement, pause/resume styling and disposal |
| `src/queue.js` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.js` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
| `src/worker.js` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/setting.js` | Settings and emitter UI; lifecycle restructuring follows in PKG-DANMUKU-05 |
| `src/heatmap.js` | Heatmap rendering; restructuring follows in PKG-DANMUKU-05 |
| `src/setting.js` | Settings coordinator, configuration controls, mount and fullscreen layout |
| `src/setting-template.js` | Existing template HTML and static icon values |
| `src/setting-slider.js` | Slider index, pointer and rotation behavior |
| `src/setting-send.js` | Pending send, original error outlet and lock countdown |
| `src/setting-lifecycle.js` | Exact subscription/proxy disposal, cancellation and conditional host-property restoration |
| `src/setting-style.js` | Shared document style and one pending DOMContentLoaded installation |
| `src/heatmap.js` | Owned control, progress stops, subscriptions and per-instance gradient |
| `src/heatmap-sampling.js` | Sorted-time bin counts with historical numeric boundaries |
| `src/heatmap-geometry.js` | Legacy point normalization, caller-visible point writes and SVG curve calculation |
The package is undergoing staged migration. PKG-DANMUKU-03 separates input and
configuration responsibilities in JavaScript; PKG-DANMUKU-04 separates scheduling
and track requests; PKG-DANMUKU-06 converts all owned
and track requests; PKG-DANMUKU-05 separates rendering and UI resources;
PKG-DANMUKU-06 converts all owned
modules and Worker messages to TypeScript and validates installed consumers.
This intermediate structure is not the final TypeScript or release acceptance.
@@ -134,7 +143,61 @@ three modes, strict dense geometry, one request per prepared item, native
pause/seek/rate and visible-node reuse. Preserve both geometric and protocol
assertions; unique IDs alone do not prove non-overlapping tracks.
Settings cleanup, heatmap sampling and long-run load behavior still require
PKG-DANMUKU-05/07. Check task evidence and the risk register before changing
completion or release status; short native regressions are not package-wide
stability acceptance.
Long-run native load behavior still requires PKG-DANMUKU-07. Check task evidence
and the risk register before changing completion or release status; short native
regressions are not package-wide stability acceptance.
## Rendering and UI resources
The scheduler owns whether work may proceed; the renderer owns the actual nodes.
Destroy removes those nodes even when the core retains its HTML. It does not
remove unrelated children. Replacement loading retains the historical whole-layer
clear and starts a fresh node pool. Keep cancellation identity checks in the
scheduler/renderer boundary when moving preparation logic.
Replacement also removes owned nodes moved outside the layer before releasing
their ownership records. Unrelated nodes outside the layer are left in place.
Setting registers its destruction boundary before creating UI. Initialization
failure releases its own resources; the factory also rolls back the previously
created Danmuku and Worker, preserving the original error. Store exact art
subscriptions and proxy disposers. On old cores, use `events.remove` when the
disposer remains registered; calling only the returned function can leave a
registry entry. A disposal failure must not prevent other owned cleanup.
Pending `beforeEmit` is raced against destruction. Preserve its option receiver
and strict `true` acceptance. Ordinary send inserts synchronously, clears input
and starts the existing countdown; observing the emitted Promise must not delay
that UI behavior. Retain `console.error('Error emitting danmuku:', error)` as the
existing send-error outlet. Destruction prevents late input writes and timers.
Mount and fullscreen move the same Setting node. Destroy removes only that node.
Dataset and display writes restore the preceding owner/original value only while
the current value is still owned. Shared mounts must retain the surviving
instance, and subsequent user writes must survive. The injected stylesheet is
document-wide, stays available to other instances and retains import-time
installation. Pending DOMContentLoaded installation is shared and released once
it runs; it is not an instance leak.
Heatmap owns its control and listeners. Removal/replacement detaches them and
must not remove a newer same-name control. The gradient ID is instance-specific;
the first available `heatmap-solids` and local `heatmap-start`/`heatmap-stop` hooks
remain, including repeated bundle evaluation. Zero or invalid sampling
uses a positive default, and invalid dimensions/duration render no SVG. Preserve
fractional step progression and strict-left/inclusive-right time bins. The
original points event clones only the outer array and mutates inner y values;
preserve that observable behavior, including repeated updates.
`test/danmuku-setting.test.js` uses Linkedom and real published core utilities
for template parsing, selectors, events and lifecycle tests. Linkedom has no
layout; its measured rectangles are controlled inputs, not browser evidence.
`test/browser/danmuku-resources.spec.js` covers native external/shared mounts,
pending sends, timers, retained-core HTML, initialization rollback and independent
heatmaps. Run it with the scheduler/input specs for source and built artifacts.
Dedicated recovery/new-continuation cases establish a ready item as a test
precondition; obsolete gates and NaN use the actual media clock at 0.25 rate.
These lifecycle preconditions do not establish stress-load timestamp delivery.
The renderer resource cases also begin with an explicitly eligible row, then
require native Worker completion and visible DOM before testing replacement or
destruction. The separate pause/seek/rate case observes the original ready getter
without changing its result and records bounded native media-time/queue samples
to distinguish eligibility misses from placement failures.
@@ -45,6 +45,19 @@ If an error listener synchronously stops, destroys, resets or hides the instance
the interrupted recovery respects that action. A nested start can recover once
without the outer call resuming it again or emitting another start event.
## Settings and heatmap cleanup
Destroy removes the plugin's external settings panel, cancels its send countdown
and prevents an unfinished `beforeEmit` from clearing input or starting another
send. Other instances and later user changes to their shared mount remain intact.
The shared stylesheet remains installed for other players. Setting initialization
failure also releases the already-created plugin Worker.
Heatmap supports narrow containers with a positive sampling step and uses an
independent gradient for each instance. Its existing points event still changes
the inner point arrays as before; copy those arrays first if the original values
must be retained. Removing or replacing its control releases its listeners.
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module ownership, compatibility
constraints, tests and the remaining staged TypeScript migration.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const a=t.states[e]||[];for(let n=0;n<a.length;n++)i(a[n]);return a}const u=Symbol("cancelled danmuku frame");class c{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}this.owner.workerClient?.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{this.owner.workerClient?.dispose(),this.owner.createWorker(),this.fault=!1}catch(t){this.report(t)}}schedule(){const t=this.owner;!this.running||this.closed||this.fault||t.art.isDestroy||t.isStop||null!==this.frame||this.operation||(this.frame=window.requestAnimationFrame(()=>{if(this.frame=null,t.timer=null,this.closed||this.fault||t.isStop||t.art.isDestroy)return;let e;const i=new Promise(t=>e=()=>t(u)),a={generation:this.generation,cancel:e,wait:t=>Promise.race([t,i]),ref:null};return this.operation=a,this.run(a).catch(t=>{this.active(a)&&this.fail(t)}).finally(()=>{this.release(a),this.operation===a&&(this.operation=null,this.schedule())})}),t.timer=this.frame)}async run(t){const e=this.owner,{setStyles:i}=e.utils;if(!e.art.playing||!this.active(t))return;e.filter("emit",t=>{const i=(Date.now()-t.$lastStartTime)/1e3;t.$restTimeLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
@@ -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,o=Math.pow,r=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&r(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),p=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{r(i.next(t))}catch(e){a(e)}},o=t=>{try{r(i.throw(t))}catch(e){a(e)}},r=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});function h(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
File diff suppressed because it is too large. Load diff
+11 -109
View File
@@ -1,6 +1,7 @@
import { defaultOption, normalizeOption, optionChanged, optionScheme } from './config'
import { beginInput, cancelInputs, inputActive, readInput } from './input'
import { filterState, readyItems, setItemState } from './queue'
import Renderer from './renderer'
import Scheduler from './scheduler'
import WorkerClient from './worker-client'
import DanmuWorker from './worker.js?worker&inline'
@@ -23,6 +24,7 @@ export default class Danmuku {
this.index = 0 // 弹幕索引
this.worker = null
this.workerClient = null
this.renderer = new Renderer(this)
this.scheduler = new Scheduler(this)
// 格式化后的配置项
@@ -158,12 +160,7 @@ export default class Danmuku {
// 获取弹幕DOM节点
get $ref() {
const $ref = this.$refs.pop() || document.createElement('div')
$ref.style.cssText = Danmuku.cssText
$ref.dataset.mode = ''
$ref.dataset.id = ''
$ref.className = ''
return $ref
return this.renderer.acquire()
}
// 获取准备好发送的弹幕
@@ -173,34 +170,7 @@ export default class Danmuku {
// 可见的弹幕的数据,用于计算下一个弹幕的top值
get visibles() {
const result = []
const { clientWidth } = this.$player
const clientLeft = this.getLeft(this.$player)
this.filter('emit', (danmu) => {
const top = danmu.$ref.offsetTop
const left = this.getLeft(danmu.$ref) - clientLeft
const height = danmu.$ref.clientHeight
const width = danmu.$ref.clientWidth
const distance = left + width
const right = clientWidth - distance
const speed = distance / danmu.$restTime
const emit = {}
emit.top = top
emit.left = left
emit.height = height
emit.width = width
emit.right = right
emit.speed = speed
emit.distance = distance
emit.time = danmu.$restTime
emit.mode = danmu.mode
result.push(emit)
})
return result
return this.renderer.visibles
}
// 计算弹幕速度
@@ -234,8 +204,7 @@ export default class Danmuku {
return this
this.queue = [] // 清空弹幕队列
this.states = { wait: [], ready: [], emit: [], stop: [] } // 清空弹幕状态池
this.$refs = [] // 清空弹幕DOM节点池
this.$danmuku.textContent = '' // 清空弹幕层
this.renderer.clear()
}
// 逐个验证原始弹幕并转换到弹幕队列
@@ -391,8 +360,7 @@ export default class Danmuku {
// 计算DOM的left值,受到旋屏影响
getLeft($ref) {
const rect = $ref.getBoundingClientRect()
return this.isRotate ? rect.top : rect.left
return this.renderer.left($ref)
}
// 复杂运算交给 Web Worker 处理
@@ -417,16 +385,7 @@ export default class Danmuku {
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu) {
this.setState(danmu, 'wait')
if (danmu.$ref) {
danmu.$ref.style.cssText = Danmuku.cssText
danmu.$ref.style.visibility = 'hidden'
danmu.$ref.style.marginLeft = '0px'
danmu.$ref.style.transform = 'translateX(0px)'
danmu.$ref.style.transition = 'transform 0s linear 0s'
this.$refs.push(danmu.$ref)
danmu.$ref = null
}
this.renderer.makeWait(danmu)
}
// 实时更新弹幕
@@ -438,76 +397,18 @@ export default class Danmuku {
// 重置正在显示的弹幕: stop/emit 状态的弹幕
resize() {
const { clientWidth } = this.$player
this.filter('stop', (danmu) => {
switch (danmu.mode) {
// 滚动的弹幕
case 0:
danmu.$ref.style.left = `${clientWidth}px`
break
default:
break
}
})
this.filter('emit', (danmu) => {
danmu.$lastStartTime = Date.now()
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
break
}
})
this.renderer.resize()
}
// 继续弹幕
continue() {
const { clientWidth } = this.$player
this.filter('stop', (danmu) => {
this.setState(danmu, 'emit') // 转换为emit状态
danmu.$lastStartTime = Date.now()
switch (danmu.mode) {
// 继续滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
break
}
})
this.renderer.continue()
return this
}
// 暂停弹幕
suspend() {
const { clientWidth } = this.$player
this.filter('emit', (danmu) => {
this.setState(danmu, 'stop') // 转换为stop状态
switch (danmu.mode) {
// 停止滚动的弹幕
case 0: {
const translateX = clientWidth - (this.getLeft(danmu.$ref) - this.getLeft(this.$player))
danmu.$ref.style.transform = `translateX(${-translateX}px)`
danmu.$ref.style.transition = 'transform 0s linear 0s'
break
}
default:
break
}
})
this.renderer.suspend()
return this
}
@@ -581,6 +482,7 @@ export default class Danmuku {
}
}
finally {
this.renderer.destroy()
this.art.off('video:play', this.start)
this.art.off('video:playing', this.start)
this.art.off('video:pause', this.stop)
@@ -0,0 +1,78 @@
import { MAX_HEATMAP_POINTS, sampleHeatmap } from './heatmap-sampling'
function map(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
}
function line(pointA, pointB) {
const lengthX = pointB[0] - pointA[0]
const lengthY = pointB[1] - pointA[1]
return { length: Math.sqrt(lengthX ** 2 + lengthY ** 2), angle: Math.atan2(lengthY, lengthX) }
}
export function heatmapGeometry({ width, height, duration, queue, option, points: input = [] }) {
if (![width, height, duration].every(value => Number.isFinite(value) && value > 0))
return null
const defaults = {
xMin: 0,
xMax: width,
yMin: 0,
yMax: 128,
scale: 0.25,
opacity: 0.2,
minHeight: Math.floor(height * 0.05),
sampling: Math.max(1, Math.floor(width / 100)),
smoothing: 0.2,
flattening: 0.2,
}
const options = Object.assign({}, defaults, typeof option === 'object' ? option : {})
for (const key of Object.keys(defaults)) {
if (!Number.isFinite(options[key]))
options[key] = defaults[key]
}
if (options.sampling <= 0)
options.sampling = defaults.sampling
if (options.xMin === options.xMax || options.yMin === options.yMax)
return null
const points = Array.isArray(input) && input.length ? [...input] : sampleHeatmap(queue, width, duration, options.sampling)
if (!points.length
|| !points.every(point => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]))) { return null }
const lastPoint = points[points.length - 1]
if (lastPoint[0] !== width) {
if (points.length === MAX_HEATMAP_POINTS)
return null
points.push([width, lastPoint[1]])
}
let yMin = Infinity
let yMax = -Infinity
for (const point of points) {
yMin = Math.min(yMin, point[1])
yMax = Math.max(yMax, point[1])
}
const yMid = (yMin + yMax) / 2
// Historical points events copy only the outer array: callers observe these y writes.
for (const point of points) {
const y = point[1]
point[1] = y * (y > yMid ? 1 + options.scale : 1 - options.scale) + options.minHeight
}
const controlPoint = (current, previous, next, reverse) => {
const geometry = line(previous || current, next || current)
const flat = map(Math.cos(geometry.angle) * options.flattening, 0, 1, 1, 0)
const angle = geometry.angle * flat + (reverse ? Math.PI : 0)
const length = geometry.length * options.smoothing
return [current[0] + Math.cos(angle) * length, current[1] + Math.sin(angle) * length]
}
const positions = points.map(point => [map(point[0], options.xMin, options.xMax, 0, width), map(point[1], options.yMin, options.yMax, height, 0)])
if (!positions.every(point => point.every(Number.isFinite)))
return null
const path = positions.map((point, index, all) => {
if (index === 0)
return `M ${all[all.length - 1][0]},${height} L ${point[0]},${height} L ${point[0]},${point[1]}`
const start = controlPoint(all[index - 1], all[index - 2], point)
const end = controlPoint(point, all[index - 1], all[index + 1], true)
return `C ${start[0]},${start[1]} ${end[0]},${end[1]} ${point[0]},${point[1]}${index === all.length - 1 ? ' z' : ''}`
}).join(' ')
if (/NaN|Infinity/u.test(path))
return null
return { path, opacity: options.opacity, width, height }
}
@@ -0,0 +1,32 @@
export const MAX_HEATMAP_POINTS = 0xFFFFFFFF
function upperBound(values, value) {
let start = 0
let end = values.length
while (start < end) {
const middle = Math.floor((start + end) / 2)
if (values[middle] <= value)
start = middle + 1
else end = middle
}
return start
}
export function sampleHeatmap(queue, width, duration, sampling) {
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(duration) || duration <= 0
|| !Number.isFinite(sampling) || sampling <= 0 || Math.floor(width / sampling) + 1 > MAX_HEATMAP_POINTS) { return [] }
const times = queue.map(item => item.time).filter(time => typeof time === 'number' && !Number.isNaN(time)).sort((a, b) => a - b)
const points = []
const gap = duration / width
if (!Number.isFinite(gap))
return []
// Keep the old floating-point x progression and strict-left/inclusive-right bins.
for (let x = 0; x <= width; x += sampling) {
if (points.length >= MAX_HEATMAP_POINTS || x + sampling === x)
return []
const left = x * gap
const right = (x + sampling) * gap
points.push([x, upperBound(times, right) - upperBound(times, left)])
}
return points
}
+137 -177
View File
@@ -1,186 +1,146 @@
const lib = {
map(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
},
range(start, end, tick) {
const s = Math.round(start / tick) * tick
return Array.from(
{
length: Math.floor((end - start) / tick),
},
(v, k) => {
return k * tick + s
},
)
},
}
import { heatmapGeometry } from './heatmap-geometry'
function line(pointA, pointB) {
const lengthX = pointB[0] - pointA[0]
const lengthY = pointB[1] - pointA[1]
return {
length: Math.sqrt(lengthX ** 2 + lengthY ** 2),
angle: Math.atan2(lengthY, lengthX),
}
}
let nextGradient = 0
export default function heatmap(art, danmuku, option) {
if (art.isDestroy)
return
const { query } = art.constructor.utils
let gradient
const subscriptions = []
let element
let start
let stop
let closed = false
art.controls.add({
name: 'heatmap',
position: 'top',
html: '',
style: {
position: 'absolute',
top: '-100px',
left: '0px',
right: '0px',
height: '100px',
width: '100%',
pointerEvents: 'none',
},
mounted($heatmap) {
let $start = null
let $stop = null
function update(arg = []) {
$start = null
$stop = null
$heatmap.innerHTML = ''
if (!art.duration || art.option.isLive)
return
const svg = {
w: $heatmap.offsetWidth,
h: $heatmap.offsetHeight,
}
const options = {
xMin: 0,
xMax: svg.w,
yMin: 0,
yMax: 128,
scale: 0.25,
opacity: 0.2,
minHeight: Math.floor(svg.h * 0.05),
sampling: Math.floor(svg.w / 100),
smoothing: 0.2,
flattening: 0.2,
}
if (typeof option === 'object') {
Object.assign(options, option)
}
let points = []
if (Array.isArray(arg) && arg.length) {
points = [...arg]
}
else {
const gap = art.duration / svg.w
for (let x = 0; x <= svg.w; x += options.sampling) {
const y = danmuku.queue.filter(
({ time }) => time > x * gap && time <= (x + options.sampling) * gap,
).length
points.push([x, y])
}
}
if (points.length === 0)
return
const lastPoint = points[points.length - 1]
const lastX = lastPoint[0]
const lastY = lastPoint[1]
if (lastX !== svg.w) {
points.push([svg.w, lastY])
}
const yPoints = points.map(point => point[1])
const yMin = Math.min(...yPoints)
const yMax = Math.max(...yPoints)
const yMid = (yMin + yMax) / 2
for (let i = 0; i < points.length; i++) {
const point = points[i]
const y = point[1]
point[1] = y * (y > yMid ? 1 + options.scale : 1 - options.scale) + options.minHeight
}
const controlPoint = (current, previous, next, reverse) => {
const p = previous || current
const n = next || current
const o = line(p, n)
const flat = lib.map(Math.cos(o.angle) * options.flattening, 0, 1, 1, 0)
const angle = o.angle * flat + (reverse ? Math.PI : 0)
const length = o.length * options.smoothing
const x = current[0] + Math.cos(angle) * length
const y = current[1] + Math.sin(angle) * length
return [x, y]
}
const bezierCommand = (point, i, a) => {
const cps = controlPoint(a[i - 1], a[i - 2], point)
const cpe = controlPoint(point, a[i - 1], a[i + 1], true)
const close = i === a.length - 1 ? ' z' : ''
return `C ${cps[0]},${cps[1]} ${cpe[0]},${cpe[1]} ${point[0]},${point[1]}${close}`
}
const pointsPositions = points.map((e) => {
const x = lib.map(e[0], options.xMin, options.xMax, 0, svg.w)
const y = lib.map(e[1], options.yMin, options.yMax, svg.h, 0)
return [x, y]
})
const pathD = pointsPositions.reduce(
(acc, e, i, a) =>
i === 0
? `M ${a[a.length - 1][0]},${svg.h} L ${e[0]},${svg.h} L ${e[0]},${e[1]}`
: `${acc} ${bezierCommand(e, i, a)}`,
'',
)
$heatmap.innerHTML = `
<svg viewBox="0 0 ${svg.w} ${svg.h}">
<defs>
<linearGradient id="heatmap-solids" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${options.opacity}" />
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${options.opacity}" id="heatmap-start" />
<stop offset="0%" style="stop-color:var(--art-progress-color);stop-opacity:1" id="heatmap-stop" />
<stop offset="100%" style="stop-color:var(--art-progress-color);stop-opacity:1" />
</linearGradient>
</defs>
<path fill="url(#heatmap-solids)" d="${pathD}"></path>
</svg>
`
$start = query('#heatmap-start', $heatmap)
$stop = query('#heatmap-stop', $heatmap)
$start.setAttribute('offset', `${art.played * 100}%`)
$stop.setAttribute('offset', `${art.played * 100}%`)
const active = () => !closed && !art.isDestroy
function listen(name, callback) {
subscriptions.push([name, callback])
art.on(name, callback)
}
function dispose(removeControl) {
if (closed)
return
closed = true
start = null
stop = null
let failure
let failed = false
const attempt = (callback) => {
try {
callback()
}
art.on('video:timeupdate', () => {
if ($start && $stop) {
$start.setAttribute('offset', `${art.played * 100}%`)
$stop.setAttribute('offset', `${art.played * 100}%`)
catch (error) {
if (!failed)
failure = error
failed = true
}
}
for (const [name, callback] of subscriptions.splice(0))
attempt(() => art.off(name, callback))
if (removeControl && element && art.controls.heatmap === element)
attempt(() => art.controls.remove('heatmap'))
element = undefined
if (failed)
throw failure
}
function progress(value) {
if (active() && start && stop) {
start.setAttribute('offset', `${value * 100}%`)
stop.setAttribute('offset', `${value * 100}%`)
}
}
function draw(points = []) {
if (!active())
return
start = null
stop = null
element.innerHTML = ''
if (art.option.isLive)
return
const shape = heatmapGeometry({ width: element.offsetWidth, height: element.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points })
if (!shape || !active())
return
const document = element.ownerDocument
if (!gradient)
gradient = document ? 'heatmap-solids' : `heatmap-solids-${++nextGradient}`
while (document?.getElementById(gradient))
gradient = `heatmap-solids-${++nextGradient}`
element.innerHTML = `
<svg viewBox="0 0 ${shape.width} ${shape.height}">
<defs>
<linearGradient id="${gradient}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${shape.opacity}" />
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${shape.opacity}" id="heatmap-start" />
<stop offset="0%" style="stop-color:var(--art-progress-color);stop-opacity:1" id="heatmap-stop" />
<stop offset="100%" style="stop-color:var(--art-progress-color);stop-opacity:1" />
</linearGradient>
</defs>
<path fill="url(#${gradient})" d="${shape.path}"></path>
</svg>
`
start = query('#heatmap-start', element)
stop = query('#heatmap-stop', element)
progress(art.played)
}
function update(points) {
try {
draw(points)
}
catch (error) {
try {
dispose(true)
}
catch {}
throw error
}
}
listen('destroy', () => dispose(true))
try {
art.controls.add({
name: 'heatmap',
position: 'top',
html: '',
style: {
position: 'absolute',
top: '-100px',
left: '0px',
right: '0px',
height: '100px',
width: '100%',
pointerEvents: 'none',
},
beforeUnmount() { dispose(false) },
mounted(value) {
element = value
if (closed) {
if (art.controls.heatmap === value)
art.controls.remove('heatmap')
element = undefined
return
}
})
art.on('setBar', (type, percentage) => {
if ($start && $stop && type === 'played') {
$start.setAttribute('offset', `${percentage * 100}%`)
$stop.setAttribute('offset', `${percentage * 100}%`)
if (!active()) {
dispose(true)
return
}
})
art.on('ready', () => update())
art.on('resize', () => update())
art.on('artplayerPluginDanmuku:loaded', () => update())
art.on('artplayerPluginDanmuku:points', points => update(points))
},
})
listen('video:timeupdate', () => progress(art.played))
listen('setBar', (type, percentage) => {
if (type === 'played')
progress(percentage)
})
listen('ready', () => update())
listen('resize', () => update())
listen('artplayerPluginDanmuku:loaded', () => update())
listen('artplayerPluginDanmuku:points', points => update(points))
},
})
}
catch (error) {
try {
dispose(true)
}
catch {}
throw error
}
return () => dispose(true)
}
+17 -4
View File
@@ -5,10 +5,23 @@ import Setting from './setting'
export default function artplayerPluginDanmuku(option) {
return (art) => {
const danmuku = new Danmuku(art, option)
const setting = art.isDestroy ? undefined : new Setting(art, danmuku)
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
let setting
try {
setting = art.isDestroy ? undefined : new Setting(art, danmuku)
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
}
}
catch (error) {
try {
setting?.destroy()
}
catch {}
try {
danmuku.destroy()
}
catch {}
throw error
}
return {
@@ -0,0 +1,203 @@
export default class Renderer {
constructor(owner) {
this.owner = owner
this.nodes = new Set()
}
acquire() {
const $ref = this.owner.$refs.pop() || document.createElement('div')
$ref.style.cssText = this.owner.constructor.cssText
$ref.dataset.mode = ''
$ref.dataset.id = ''
$ref.className = ''
this.nodes.add($ref)
return $ref
}
get visibles() {
const result = []
const { clientWidth } = this.owner.$player
const clientLeft = this.owner.getLeft(this.owner.$player)
this.owner.filter('emit', (danmu) => {
const top = danmu.$ref.offsetTop
const left = this.owner.getLeft(danmu.$ref) - clientLeft
const height = danmu.$ref.clientHeight
const width = danmu.$ref.clientWidth
const distance = left + width
const right = clientWidth - distance
const speed = distance / danmu.$restTime
const emit = {}
emit.top = top
emit.left = left
emit.height = height
emit.width = width
emit.right = right
emit.speed = speed
emit.distance = distance
emit.time = danmu.$restTime
emit.mode = danmu.mode
result.push(emit)
})
return result
}
left($ref) {
const rect = $ref.getBoundingClientRect()
return this.owner.isRotate ? rect.top : rect.left
}
makeWait(danmu) {
this.owner.setState(danmu, 'wait')
if (danmu.$ref) {
danmu.$ref.style.cssText = this.owner.constructor.cssText
danmu.$ref.style.visibility = 'hidden'
danmu.$ref.style.marginLeft = '0px'
danmu.$ref.style.transform = 'translateX(0px)'
danmu.$ref.style.transition = 'transform 0s linear 0s'
this.owner.$refs.push(danmu.$ref)
danmu.$ref = null
}
}
resize() {
const { clientWidth } = this.owner.$player
this.owner.filter('stop', (danmu) => {
switch (danmu.mode) {
// 滚动的弹幕
case 0:
danmu.$ref.style.left = `${clientWidth}px`
break
default:
break
}
})
this.owner.filter('emit', (danmu) => {
danmu.$lastStartTime = Date.now()
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
break
}
})
}
continue() {
const { clientWidth } = this.owner.$player
this.owner.filter('stop', (danmu) => {
this.owner.setState(danmu, 'emit') // 转换为emit状态
danmu.$lastStartTime = Date.now()
switch (danmu.mode) {
// 继续滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
break
}
})
return this
}
suspend() {
const { clientWidth } = this.owner.$player
this.owner.filter('emit', (danmu) => {
this.owner.setState(danmu, 'stop') // 转换为stop状态
switch (danmu.mode) {
// 停止滚动的弹幕
case 0: {
const translateX = clientWidth - (this.owner.getLeft(danmu.$ref) - this.owner.getLeft(this.owner.$player))
danmu.$ref.style.transform = `translateX(${-translateX}px)`
danmu.$ref.style.transition = 'transform 0s linear 0s'
break
}
default:
break
}
})
return this
}
prepare(danmu, operation) {
const owner = this.owner
const ref = owner.$ref
operation.danmu = danmu
operation.ref = ref
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
ref.style.fontSize = `${owner.fontSize}px`
ref.style.color = danmu.color
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
owner.utils.setStyles(ref, danmu.style)
return ref
}
place(danmu, ref, top, distance, clientWidth) {
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`
ref.style.marginLeft = '0px'
ref.style.transform = `translateX(${-distance}px)`
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
case 1:
case 2:
ref.style.left = '50%'
ref.style.marginLeft = `-${ref.clientWidth / 2}px`
break
default:
break
}
}
release(operation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
}
operation.ref = null
}
clear() {
this.destroy()
this.owner.$refs = []
this.owner.$danmuku.textContent = ''
}
destroy() {
for (const node of this.nodes) {
if (node.parentElement)
node.parentElement.removeChild(node)
}
for (const danmu of this.owner.queue) danmu.$ref = null
this.owner.$refs.length = 0
this.nodes.clear()
}
}
@@ -20,14 +20,7 @@ export default class Scheduler {
}
release(operation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
}
operation.ref = null
this.owner.renderer.release(operation)
}
invalidate() {
@@ -108,7 +101,6 @@ export default class Scheduler {
async run(operation) {
const owner = this.owner
const { setStyles } = owner.utils
if (!owner.art.playing || !this.active(operation))
return
owner.filter('emit', (danmu) => {
@@ -141,18 +133,7 @@ export default class Scheduler {
continue
const { clientWidth, clientHeight } = owner.$player
const ref = owner.$ref
operation.danmu = danmu
operation.ref = ref
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
ref.style.fontSize = `${owner.fontSize}px`
ref.style.color = danmu.color
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
setStyles(ref, danmu.style)
const ref = owner.renderer.prepare(danmu, operation)
if (!this.active(operation))
return
danmu.$lastStartTime = Date.now()
@@ -172,25 +153,7 @@ export default class Scheduler {
return
if (top !== undefined) {
owner.setState(danmu, 'emit')
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`
ref.style.marginLeft = '0px'
ref.style.transform = `translateX(${-distance}px)`
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
case 1:
case 2:
ref.style.left = '50%'
ref.style.marginLeft = `-${ref.clientWidth / 2}px`
break
default:
break
}
owner.renderer.place(danmu, ref, top, distance, clientWidth)
operation.ref = null
owner.art.emit('artplayerPluginDanmuku:visible', danmu)
}
@@ -0,0 +1,154 @@
const properties = new WeakMap()
export default class SettingLifecycle {
constructor(art) {
this.art = art
this.closed = false
this.disposers = new Set()
this.cancelled = new Promise(resolve => this.cancel = resolve)
}
get active() {
return !this.closed && !this.art.isDestroy
}
own(dispose) {
if (this.closed)
dispose()
else this.disposers.add(dispose)
}
write(target, key, value, apply = value => target[key] = value) {
if (!this.active)
return
const priority = () => typeof target.getPropertyPriority === 'function' ? target.getPropertyPriority(key) : undefined
let keys = properties.get(target)
if (!keys) {
keys = new Map()
properties.set(target, keys)
}
let entry = keys.get(key)
if (!entry) {
entry = {
original: target[key],
present: key in target || target[key] !== undefined,
priority: priority(),
owners: new Map(),
apply,
}
keys.set(key, entry)
}
if (!entry.owners.has(this)) {
this.own(() => {
const owners = [...entry.owners.keys()]
const current = owners[owners.length - 1] === this
entry.owners.delete(this)
// A later user write belongs to the host, not this settings instance.
if (current && target[key] === entry.last && priority() === entry.lastPriority) {
const values = [...entry.owners.values()]
if (values.length) {
entry.apply(values[values.length - 1])
entry.last = target[key]
entry.lastPriority = priority()
}
else if (entry.priority !== undefined) {
target.setProperty(key, entry.original, entry.priority)
}
else if (entry.present) {
entry.apply(entry.original)
}
else {
delete target[key]
}
}
if (!entry.owners.size) {
keys.delete(key)
if (!keys.size)
properties.delete(target)
}
})
}
entry.owners.delete(this)
entry.owners.set(this, value)
entry.apply(value)
entry.last = target[key]
entry.lastPriority = priority()
}
on(name, callback) {
if (!this.active)
return
const lifecycle = this
function listener(...args) {
if (lifecycle.active)
return callback.apply(this, args)
}
const dispose = () => this.art.off(name, listener)
this.own(dispose)
try {
this.art.on(name, listener)
if (!this.active)
dispose()
}
catch (error) {
this.release(dispose)
throw error
}
}
proxy(target, name, callback) {
if (!this.active)
return
const lifecycle = this
function listener(...args) {
if (lifecycle.active)
return callback.apply(this, args)
}
let remove
const dispose = () => {
if (typeof remove === 'function') {
const registry = this.art.events
if (typeof registry?.remove === 'function' && registry.destroyEvents?.has(remove))
registry.remove(remove)
else remove()
}
else {
target.removeEventListener?.(name, listener)
}
}
this.own(dispose)
try {
remove = this.art.proxy(target, name, listener)
if (!this.active)
dispose()
}
catch (error) {
this.release(dispose)
throw error
}
}
wait(value) {
return Promise.race([value, this.cancelled])
}
release(dispose) {
try {
dispose()
}
catch (error) {
console.warn('Failed to dispose danmuku setting resource:', error)
}
}
close() {
if (this.closed)
return
this.closed = true
this.cancel()
for (const dispose of this.disposers) {
this.release(dispose)
}
this.disposers.clear()
}
}
@@ -0,0 +1,79 @@
export async function emitSetting(setting) {
const { lifecycle, template: { $input } } = setting
if (!lifecycle.active)
return
const text = $input.value.trim()
if (!text.length || setting.isLock || setting.emitting)
return
const danmu = {
text,
mode: setting.option.mode,
color: setting.option.color,
time: setting.art.currentTime,
}
const report = (error) => {
if (lifecycle.active)
console.error('Error emitting danmuku:', error)
}
try {
setting.emitting = true
const state = await lifecycle.wait(setting.option.beforeEmit(danmu))
if (!lifecycle.active)
return
setting.emitting = false
if (state !== true)
return
danmu.border = true
delete danmu.time
// emit inserts synchronously; observe its Promise without delaying the UI.
Promise.resolve(setting.danmuku.emit(danmu)).catch(report)
if (!lifecycle.active)
return
$input.value = ''
setting.lock()
}
catch (error) {
report(error)
if (lifecycle.active)
setting.emitting = false
}
}
export function lockSetting(setting) {
if (!setting.lifecycle.active)
return
const { addClass } = setting.utils
const { $send } = setting.template
clearTimeout(setting.timer)
setting.isLock = true
let time = setting.option.lockTime
$send.textContent = time
addClass($send, 'apd-lock')
const loop = () => {
setting.timer = setTimeout(() => {
if (!setting.lifecycle.active)
return
if (time === 0) {
setting.unlock()
}
else {
time -= 1
$send.textContent = time
loop()
}
}, 1000)
}
loop()
}
export function unlockSetting(setting) {
clearTimeout(setting.timer)
setting.timer = null
setting.isLock = false
if (!setting.lifecycle.active)
return
const { removeClass } = setting.utils
const { $send } = setting.template
$send.textContent = '发送'
removeClass($send, 'apd-lock')
}
@@ -0,0 +1,80 @@
export default function createSlider({ min, max, container, findIndex, onChange, steps = [] }) {
const { lifecycle } = this
if (!lifecycle.active)
return { reset() {} }
const { query, clamp, setStyle } = this.utils
setStyle(container, 'touch-action', 'none')
container.innerHTML = `
<div class="apd-slider-line">
<div class="apd-slider-points">
${steps.map(() => `<div class="apd-slider-point"></div>`).join('')}
</div>
<div class="apd-slider-progress"></div>
</div>
<div class="apd-slider-dot"></div>
<div class="apd-slider-steps">
${steps.map(step => (step.hide ? '' : `<div class="apd-slider-step">${step.name}</div>`)).join('')}
</div>
`
const $dot = query('.apd-slider-dot', container)
const $progress = query('.apd-slider-progress', container)
let isDroging = false
lifecycle.own(() => isDroging = false)
function reset(index) {
if (!lifecycle.active)
return
if (index === undefined)
index = findIndex()
if (index < min || index > max)
return
const percentage = (index - min) / (max - min)
$dot.style.left = `${percentage * 100}%`
if (steps.length === 0) {
$progress.style.width = $dot.style.left
}
onChange(index)
}
function updateLeft(event) {
const { top, height, left, width } = container.getBoundingClientRect()
if (this.art.isRotate) {
const value = clamp(event.clientY - top, 0, height)
const index = Math.round((value / height) * (max - min) + min)
reset(index)
}
else {
const value = clamp(event.clientX - left, 0, width)
const index = Math.round((value / width) * (max - min) + min)
reset(index)
}
}
lifecycle.proxy(container, 'click', (event) => {
updateLeft.call(this, event)
})
lifecycle.proxy(container, 'pointerdown', (event) => {
isDroging = event.button === 0
})
lifecycle.on('document:pointermove', (event) => {
if (isDroging) {
updateLeft.call(this, event)
}
})
lifecycle.on('document:pointerup', (event) => {
if (isDroging) {
isDroging = false
updateLeft.call(this, event)
}
})
return { reset }
}
@@ -0,0 +1,29 @@
import style from './style.less?inline'
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-danmuku'
const pending = Symbol.for('artplayer-plugin-danmuku.pending-style')
let $style = document.getElementById(id) || document[pending]
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document[pending] = $style
const ready = () => {
document.removeEventListener('DOMContentLoaded', ready)
if (document[pending] === $style)
delete document[pending]
const existing = document.getElementById(id)
if (existing)
existing.textContent = $style.textContent
else
(document.head || document.documentElement).appendChild($style)
}
document.addEventListener('DOMContentLoaded', ready, { once: true })
}
else {
(document.head || document.documentElement).appendChild($style)
}
}
$style.textContent = style
}
@@ -0,0 +1,126 @@
import $check_off from './img/check_off.svg?raw'
import $check_on from './img/check_on.svg?raw'
import $config from './img/config.svg?raw'
import $mode_0_off from './img/mode_0_off.svg?raw'
import $mode_0_on from './img/mode_0_on.svg?raw'
import $mode_1_off from './img/mode_1_off.svg?raw'
import $mode_1_on from './img/mode_1_on.svg?raw'
import $mode_2_off from './img/mode_2_off.svg?raw'
import $mode_2_on from './img/mode_2_on.svg?raw'
import $off from './img/off.svg?raw'
import $on from './img/on.svg?raw'
import $style from './img/style.svg?raw'
export function settingIcons() {
return {
$on,
$off,
$config,
$style,
$mode_0_off,
$mode_0_on,
$mode_1_off,
$mode_1_on,
$mode_2_off,
$mode_2_on,
$check_on,
$check_off,
}
}
export function settingTemplate() {
const { option } = this
return `
<div class="apd-toggle">
${$on}${$off}
</div>
<div class="apd-config">
${$config}
<div class="apd-config-panel">
<div class="apd-config-panel-inner">
<div class="apd-config-mode">
按类型屏蔽
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_off}${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_off}${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_off}${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-config-other">
<div class="apd-other apd-anti-overlap">
${$check_on}${$check_off}
防止弹幕重叠
</div>
<div class="apd-other apd-sync-video">
${$check_on}${$check_off}
同步视频速度
</div>
</div>
<div class="apd-config-slider apd-config-opacity">
不透明度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-margin">
显示区域
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-fontSize">
弹幕字号
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-speed">
弹幕速度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
</div>
</div>
</div>
<div class="apd-emitter">
<div class="apd-style">
${$style}
<div class="apd-style-panel">
<div class="apd-style-panel-inner">
<div class="apd-style-mode">
模式
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-style-color">
颜色
<div class="apd-colors">
${this.COLOR.map(color => `<div data-color="${color}" class="apd-color" style="background-color: ${color}"></div>`).join('')}
</div>
</div>
</div>
</div>
</div>
<input class="apd-input" placeholder="发个友善的弹幕见证当下" autocomplete="off" maxLength="${option.maxLength}" />
<div class="apd-send">发送</div>
</div>
`
}
+99 -307
View File
@@ -1,16 +1,8 @@
import $check_off from './img/check_off.svg?raw'
import $check_on from './img/check_on.svg?raw'
import $config from './img/config.svg?raw'
import $mode_0_off from './img/mode_0_off.svg?raw'
import $mode_0_on from './img/mode_0_on.svg?raw'
import $mode_1_off from './img/mode_1_off.svg?raw'
import $mode_1_on from './img/mode_1_on.svg?raw'
import $mode_2_off from './img/mode_2_off.svg?raw'
import $mode_2_on from './img/mode_2_on.svg?raw'
import $off from './img/off.svg?raw'
import $on from './img/on.svg?raw'
import $style from './img/style.svg?raw'
import style from './style.less?inline'
import SettingLifecycle from './setting-lifecycle'
import { emitSetting, lockSetting, unlockSetting } from './setting-send'
import createSlider from './setting-slider'
import { settingIcons, settingTemplate } from './setting-template'
import './setting-style'
export default class Setting {
constructor(art, danmuku) {
@@ -20,7 +12,6 @@ export default class Setting {
const { setStyle } = this.utils
const { $controlsCenter } = art.template
setStyle($controlsCenter, 'display', 'flex')
this.template = {
$controlsCenter,
@@ -57,46 +48,59 @@ export default class Setting {
this.isLock = false
this.timer = null
this.createTemplate()
this.createSliders()
this.createEvents()
this.lifecycle = new SettingLifecycle(art)
this.destroy = this.destroy.bind(this)
this.lifecycle.own(() => art.off('destroy', this.destroy))
this.mount(this.option.mount)
try {
art.on('destroy', this.destroy)
if (!this.lifecycle.active)
return
this.lifecycle.write($controlsCenter.style, 'display', 'flex', value => setStyle($controlsCenter, 'display', value))
art.on('resize', () => this.resize())
art.on('fullscreen', state => this.onFullscreen(state))
art.on('fullscreenWeb', state => this.onFullscreen(state))
this.createTemplate()
if (!this.lifecycle.active)
return
this.createSliders()
if (!this.lifecycle.active)
return
this.createEvents()
art.proxy(this.template.$config, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$config,
$panel: this.template.$configPanel,
this.mount(this.option.mount)
if (!this.lifecycle.active)
return
this.lifecycle.on('resize', () => this.resize())
this.lifecycle.on('fullscreen', state => this.onFullscreen(state))
this.lifecycle.on('fullscreenWeb', state => this.onFullscreen(state))
this.lifecycle.proxy(this.template.$config, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$config,
$panel: this.template.$configPanel,
})
})
})
art.proxy(this.template.$style, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$style,
$panel: this.template.$stylePanel,
this.lifecycle.proxy(this.template.$style, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$style,
$panel: this.template.$stylePanel,
})
})
})
}
catch (error) {
try {
this.destroy()
}
catch (cleanupError) {
console.warn('Failed to roll back danmuku setting:', cleanupError)
}
throw error
}
}
static get icons() {
return {
$on,
$off,
$config,
$style,
$mode_0_off,
$mode_0_on,
$mode_1_off,
$mode_1_on,
$mode_2_off,
$mode_2_on,
$check_on,
$check_off,
}
return settingIcons()
}
get option() {
@@ -108,100 +112,7 @@ export default class Setting {
}
get TEMPLATE() {
const { option } = this
return `
<div class="apd-toggle">
${$on}${$off}
</div>
<div class="apd-config">
${$config}
<div class="apd-config-panel">
<div class="apd-config-panel-inner">
<div class="apd-config-mode">
按类型屏蔽
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_off}${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_off}${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_off}${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-config-other">
<div class="apd-other apd-anti-overlap">
${$check_on}${$check_off}
防止弹幕重叠
</div>
<div class="apd-other apd-sync-video">
${$check_on}${$check_off}
同步视频速度
</div>
</div>
<div class="apd-config-slider apd-config-opacity">
不透明度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-margin">
显示区域
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-fontSize">
弹幕字号
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-speed">
弹幕速度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
</div>
</div>
</div>
<div class="apd-emitter">
<div class="apd-style">
${$style}
<div class="apd-style-panel">
<div class="apd-style-panel-inner">
<div class="apd-style-mode">
模式
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-style-color">
颜色
<div class="apd-colors">
${this.COLOR.map(color => `<div data-color="${color}" class="apd-color" style="background-color: ${color}"></div>`).join('')}
</div>
</div>
</div>
</div>
</div>
<input class="apd-input" placeholder="发个友善的弹幕见证当下" autocomplete="off" maxLength="${option.maxLength}" />
<div class="apd-send">发送</div>
</div>
`
return settingTemplate.call(this)
}
get OPACITY() {
@@ -308,6 +219,8 @@ export default class Setting {
}
append(el, target) {
if (!this.lifecycle.active)
return
const { append } = this.utils
const children = [...el.children]
if (children.includes(target))
@@ -316,11 +229,13 @@ export default class Setting {
}
setData(key, value) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
const { $mount } = this.template
$player.dataset[key] = value
this.lifecycle.write($player.dataset, key, value)
if (this.outside) {
$mount.dataset[key] = value
this.lifecycle.write($mount.dataset, key, value)
}
}
@@ -328,10 +243,10 @@ export default class Setting {
const { createElement, tooltip } = this.utils
const $danmuku = createElement('div')
this.template.$danmuku = $danmuku
$danmuku.className = 'artplayer-plugin-danmuku'
$danmuku.innerHTML = this.TEMPLATE
this.template.$danmuku = $danmuku
this.template.$toggle = this.query('.apd-toggle')
this.template.$config = this.query('.apd-config')
this.template.$configPanel = this.query('.apd-config-panel')
@@ -355,11 +270,11 @@ export default class Setting {
const { $toggle } = this.template
this.art.on('artplayerPluginDanmuku:show', () => {
this.lifecycle.on('artplayerPluginDanmuku:show', () => {
tooltip($toggle, '关闭弹幕')
})
this.art.on('artplayerPluginDanmuku:hide', () => {
this.lifecycle.on('artplayerPluginDanmuku:hide', () => {
tooltip($toggle, '打开弹幕')
})
}
@@ -367,14 +282,14 @@ export default class Setting {
createEvents() {
const { $toggle, $configModes, $styleModes, $colors, $antiOverlap, $syncVideo, $send, $input } = this.template
this.art.proxy($toggle, 'click', () => {
this.lifecycle.proxy($toggle, 'click', () => {
this.danmuku.config({
visible: !this.option.visible,
})
this.reset()
})
this.art.proxy($configModes, 'click', (event) => {
this.lifecycle.proxy($configModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
if (!$mode)
return
@@ -392,21 +307,21 @@ export default class Setting {
this.reset()
})
this.art.proxy($antiOverlap, 'click', () => {
this.lifecycle.proxy($antiOverlap, 'click', () => {
this.danmuku.config({
antiOverlap: !this.option.antiOverlap,
})
this.reset()
})
this.art.proxy($syncVideo, 'click', () => {
this.lifecycle.proxy($syncVideo, 'click', () => {
this.danmuku.config({
synchronousPlayback: !this.option.synchronousPlayback,
})
this.reset()
})
this.art.proxy($styleModes, 'click', (event) => {
this.lifecycle.proxy($styleModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
if (!$mode)
return
@@ -417,7 +332,7 @@ export default class Setting {
this.reset()
})
this.art.proxy($colors, 'click', (event) => {
this.lifecycle.proxy($colors, 'click', (event) => {
const $color = event.target.closest('.apd-color')
if (!$color)
return
@@ -427,9 +342,9 @@ export default class Setting {
this.reset()
})
this.art.proxy($send, 'click', () => this.emit())
this.lifecycle.proxy($send, 'click', () => this.emit())
this.art.proxy($input, 'keypress', (event) => {
this.lifecycle.proxy($input, 'keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
this.emit()
@@ -509,79 +424,13 @@ export default class Setting {
})
}
createSlider({ min, max, container, findIndex, onChange, steps = [] }) {
const { query, clamp, setStyle } = this.utils
setStyle(container, 'touch-action', 'none')
container.innerHTML = `
<div class="apd-slider-line">
<div class="apd-slider-points">
${steps.map(() => `<div class="apd-slider-point"></div>`).join('')}
</div>
<div class="apd-slider-progress"></div>
</div>
<div class="apd-slider-dot"></div>
<div class="apd-slider-steps">
${steps.map(step => (step.hide ? '' : `<div class="apd-slider-step">${step.name}</div>`)).join('')}
</div>
`
const $dot = query('.apd-slider-dot', container)
const $progress = query('.apd-slider-progress', container)
let isDroging = false
function reset(index = findIndex()) {
if (index < min || index > max)
return
const percentage = (index - min) / (max - min)
$dot.style.left = `${percentage * 100}%`
if (steps.length === 0) {
$progress.style.width = $dot.style.left
}
onChange(index)
}
function updateLeft(event) {
const { top, height, left, width } = container.getBoundingClientRect()
if (this.art.isRotate) {
const value = clamp(event.clientY - top, 0, height)
const index = Math.round((value / height) * (max - min) + min)
reset(index)
}
else {
const value = clamp(event.clientX - left, 0, width)
const index = Math.round((value / width) * (max - min) + min)
reset(index)
}
}
this.art.proxy(container, 'click', (event) => {
updateLeft.call(this, event)
})
this.art.proxy(container, 'pointerdown', (event) => {
isDroging = event.button === 0
})
this.art.on('document:pointermove', (event) => {
if (isDroging) {
updateLeft.call(this, event)
}
})
this.art.on('document:pointerup', (event) => {
if (isDroging) {
isDroging = false
updateLeft.call(this, event)
}
})
return { reset }
createSlider(option) {
return createSlider.call(this, option)
}
onFullscreen(state) {
if (!this.lifecycle.active)
return
const { $danmuku, $controlsCenter, $mount } = this.template
if (this.outside) {
if (state) {
@@ -597,6 +446,8 @@ export default class Setting {
}
onMouseEnter({ $control, $panel }) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
const controlRect = $control.getBoundingClientRect()
const panelRect = $panel.getBoundingClientRect()
@@ -617,80 +468,21 @@ export default class Setting {
}
}
async emit() {
const { $input } = this.template
const text = $input.value.trim()
if (!text.length)
return
if (this.isLock)
return
if (this.emitting)
return
const danmu = {
text,
mode: this.option.mode,
color: this.option.color,
time: this.art.currentTime,
}
try {
this.emitting = true
const state = await this.option.beforeEmit(danmu)
this.emitting = false
if (state !== true)
return
danmu.border = true
delete danmu.time
this.danmuku.emit(danmu)
$input.value = ''
this.lock()
}
catch (error) {
console.error('Error emitting danmuku:', error)
this.emitting = false
}
emit() {
return emitSetting(this)
}
lock() {
const { addClass } = this.utils
const { $send } = this.template
this.isLock = true
let time = this.option.lockTime
$send.textContent = time
addClass($send, 'apd-lock')
const loop = () => {
this.timer = setTimeout(() => {
if (time === 0) {
this.unlock()
}
else {
time -= 1
$send.textContent = time
loop()
}
}, 1000)
}
loop()
lockSetting(this)
}
unlock() {
const { removeClass } = this.utils
const { $send } = this.template
clearTimeout(this.timer)
this.isLock = false
$send.textContent = '发送'
removeClass($send, 'apd-lock')
unlockSetting(this)
}
resize() {
if (!this.lifecycle.active)
return
if (this.outside)
return
if (this.art.fullscreen)
@@ -708,13 +500,16 @@ export default class Setting {
}
reset() {
if (!this.lifecycle.active)
return
const { inverseClass, tooltip } = this.utils
const { $toggle, $colors } = this.template
this.slider.opacity.reset()
this.slider.margin.reset()
this.slider.fontSize.reset()
this.slider.speed.reset()
for (const slider of Object.values(this.slider)) {
slider.reset()
if (!this.lifecycle.active)
return
}
this.setData('danmukuVisible', this.option.visible)
this.setData('danmukuMode', this.option.mode)
@@ -737,6 +532,8 @@ export default class Setting {
}
mount(target) {
if (!this.lifecycle.active)
return
const { errorHandle } = this.utils
const $el = typeof target === 'string' ? document.querySelector(target) : target
errorHandle($el, `Can not find the mount point: ${target}`)
@@ -744,22 +541,17 @@ export default class Setting {
this.template.$mount = $el
this.reset()
}
}
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-danmuku'
let $style = document.getElementById(id)
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild($style)
})
}
else {
(document.head || document.documentElement).appendChild($style)
}
destroy() {
if (this.lifecycle.closed)
return
this.lifecycle.close()
clearTimeout(this.timer)
this.timer = null
this.emitting = false
this.isLock = false
const $danmuku = this.template.$danmuku
if ($danmuku?.parentElement)
$danmuku.parentElement.removeChild($danmuku)
}
$style.textContent = style
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,118 @@
# PKG-DANMUKU-05 渲染、设置和热力图资源归属
本步完成JS职责整理,完整自有TS、Worker消息和已安装声明消费者归06。渲染模块负责
节点和几何,设置模块拆为协调器、模板/icons、Slider、发送、生命周期及全局样式;
热力图拆为采样、曲线计算和control生命周期。实际维护地图见包内
[ARCHITECTURE.md](../../packages/artplayer-plugin-danmuku/ARCHITECTURE.md)。
## 实现与兼容
- renderer接管原节点分配、样式、位置快照及暂停/恢复几何,scheduler继续决定操作
是否有效;两者之间保留generation与节点身份检查。destroy(false)仍移除自己的
弹幕节点、清除引用与空闲池,保留其他节点。替换加载仍执行旧整层清空,并在
释放归属记录前移除已被移动到层外的自有节点,保留层外无关内容。
- Setting在创建资源前登记销毁;自身初始化失败清理资源,入口再回滚已创建的
Danmuku/Worker并保留原错。精确解绑art监听、proxy和registry条目;某项解绑
抛错仍清理其他项。旧核心5.4.0的proxy返回函数需配合events.remove删除登记。
- pending beforeEmit与销毁竞速,销毁后不清input、不加锁、不迟到发送。保留
option receiver及严格true;普通发送仍同步插入、清input和启动原倒计时。
新增对emit返回Promise的局部观察,继续使用原console.error出口,不改为插件error。
- mount/fullscreen移动同一节点;销毁只移除自有设置DOM。宿主dataset/display按
最后写入值及实例归属恢复;共享mount保留存活实例,用户后续写入不被覆盖。
原始display的!important通过真实浏览器验证。全局样式仍在import时安装且不随
单实例销毁,DOMContentLoaded等待在重复bundle间去重,触发后解除监听。
- 热力图窄宽度和无效sampling使用正步长,非法尺寸/duration不生成NaN SVG。采样
由逐bin扫描整队列改为排序及二分计数,保留原浮点步进、严格左开右闭和数值边界。
曲线与旧源码、实际5.3.0 main/legacy逐字对照;points事件仍只复制外层,内层y
原地变化及重复调用累积保持,未静默改成immutable。
- 不设置任意100000数量上限,合法100001+采样/自定义点继续工作。仅拒绝不可表示
的数组长度、非有限参数或无法推进的数值步长;不声称任意巨大输入CPU成本有界。
- 无冲突的首个gradient保留旧heatmap-solids,其他实例使用独立ID;同文档重复
bundle也检查实际ID占用。heatmap-start/stop局部钩子、control名、样式与进度保持。
control卸载解除订阅,销毁不误删后来替换的同名control。
- 工厂门面、返回身份、getter、静态icons、合法输入和原轨道Worker算法保持。
不修改公共声明、分发入口或消费者运行时依赖。版本递增仍由统一版本任务实施。
## 验证及证据边界
源码、正式main、正式legacy各159项联合测试通过:原输入/解析/调度/Worker104项,
Setting36项、heatmap14项、renderer5项。每组101项随候选实现变化,另58项固定为
原Worker模块18、历史数值3、历史Setting18、直接heatmap14、直接renderer5;不能
把这些共同测试重复描述成不同产物覆盖。完整yarn test:danmuku为245项通过。
最终源码、main、legacy各138项原生浏览器通过:输入24、调度66、资源48,共414项。
每种实现使用2并发,最终报告unexpected/skipped/flaky均为0,retries为0。此前的
手工诊断和失败分别留档,不把最终通过描述成此前从未失败或负载问题已关闭。
实际已发布5.4.0和候选5.4.1核心,Chromium、Firefox、Windows WebKit。
覆盖真实视频/Worker、旧调度轨道、pending发送和计时器、外部及共享mount、初始化
回滚、原生!important、destroy(false)和重复bundle热力图。WebKit不是Safari真机。
完整构建生成main/legacy/esm并与docs/compiled逐一核对字节;ESM只计构建与复制,
不列入额外运行矩阵。源码/测试lint、Yarn冻结安装、严格工具链、全仓366生产TS与
消费者检查通过;未迁移JS不计入TS覆盖。早前被中断的typecheck日志不作为成功证据。
内置浏览器Chrome/152.0.0.0打开现有8082 docs编辑器及danmuku示例,使用正常yarn dev
构建和本地video.mp4/danmuku.xml:播放时间推进至46.89784、实际解码宽640,暂停后
保持46.911033;真实弹幕可见,示例异步beforeEmit发送完成后input清空,按钮恢复。
这是开发示例smoke,不是实际发布包或设备验收。读媒体时两次调试调用失败已记录,
不混同为页面错误,也不从一次截图推断无后台工作。
移动节点修正后,重新正常构建dev示例并在单元测试结束后重载验证:发送
Final resource verification,beforeEmit记录媒体时间0.267138,真实播放UI推进到
00:47,随后暂停在53.404841,后次原生读取仍为同值,解码宽640,input为空且发送
按钮恢复。页面warning/error列表为空。一次旧AX索引因开发重载失效,一次只读
video locator读取超时;使用当前UI和支持的CDP只读接口完成最终取证,均单独记录。
本步新增根devDependency linkedom@0.18.13并维护唯一yarn.lock,用于真实模板解析、
selector和事件测试,不随播放器发布。其[官方说明](https://github.com/WebReflection/linkedom)
明确不是完整浏览器;测试受控几何/计时器与真实浏览器证据分别记录。根test:unit和
test:danmuku加入三组新测试,浏览器spec沿既有CI发现。远端CI未在本步执行。
## 失败如何处置
- 旧04正式main的两核心Chrome六项资源回归均失败:外部DOM残留、pending完成后
清input并加锁、锁计时器保留及renderer节点引用残留。修复后由最终矩阵验证。
- renderer首轮受控检查因冻结假Element没有removeChild报错,仅给候选helper补
删除语义;冻结历史helper不改。随后原109项联合检查通过,另用Linkedom和原生
DOM断言真实节点删除,避免假DOM掩盖清理。
- 最后复审复现另一条资源遗漏:节点移到body后,替换加载清空归属集合但未移除
层外节点,后续destroy也无法清理。增加红例后,clear先释放自有节点再执行原
整层清空;受控回归验证层外无关节点保留,并新增两核心三引擎真实DOM用例。
- 中间热力图100000硬上限在复审中被否决。大输入两个红例中,一个巨大差异格式化
导致断言报告内存分配失败,另一个实际返回null。移除上限,并在比较大数组前先
断言长度,最终完整采样、曲线及内层mutation通过,不保留不兼容限制。
- 三引擎首轮128/132通过。两个WebKit例把打字/点击期间所有一秒定时器都当作
发送锁,捕获了额外计时器;改为从beforeEmit的同步发送续体观察初始锁ID,仍
要求该真实ID在销毁时清除。另两个取消/恢复例在预期时间未进入beforeVisible,
Worker请求为0。其目的在验证进入准备后的生命周期,改为明确建立ready前置
状态;原真实时间、轨道及数值例保持。不能据此宣称所有负载下的时间窗口无遗漏,
此项由LOAD-01留给07采集真实帧/媒体时间和压力证据。第一次ready夹具修改又引入
六个seek失败:旧项变为永久ready,seek后重新进入未结束的gate;恢复它按媒体时间
触发,仅恢复/新续行项设ready。NaN与旧gate用真实0.25倍率扩大对应墙钟窗口,
不代表任意调度延迟下均可送达。该轮还有一项NaN错过窗口,125/132通过,完整归档。最终受影响42项
三引擎定向复验通过后重跑全矩阵。未扩大超时、改变生产逻辑或删除最终断言。
- 后续源码132项通过,主构建三并发131/132通过:WebKit旧核心的reused弹幕未进入
beforeVisible,只有初始moving的Worker请求。queue与04及冻结源码的±0.1秒规则
相同;2倍速时窗口约100毫秒,但没有当时逐帧媒体读数,不能断言帧间隔是根因。
- 同一主构建、同一132项改为单并发后128通过、4失败,原两核心倍速复用例均通过。
新失败为两个初始媒体ready超时、一个context关闭超时、一个page创建超时及其
null page后续错误。完整报告保留;这些不是移动节点修复的红例,也不能证明
主机或WebKit自身是唯一原因。此后修复清理遗漏,新产物使用与CI一致的2并发
重新运行完整矩阵,retries仍为0,前述失败并未被删除或算作已修复。
- 移动节点修复后的源码137/138通过,唯一失败在新增清理例的MOVED出现之前。
清理例不负责固定时间窗口,把两个renderer资源例的起点明确设为ready,再要求
原生视频、真实Worker和可见DOM后执行清理;原生时间/倍速/轨道用例继续保留。
倍速用例额外代理原readys getter一次且原样返回,记录最多2000份真实媒体时间、
队列与ready样本,方便后续区分资格遗漏与渲染失败,不修改生产时间或轨道判断。
受影响18项定向验证通过后,重新运行三种产物完整414项,均通过。
机器记录、文件指纹、失败及最终报告见
[danmuku-resources-validation.json](../baselines/danmuku-resources-validation.json)。
首次三引擎失败完整归档,最终档案的截图附件保留原报告路径到archive目录的映射。
## 交接和回退
CLEANUP-01/HEATMAP-01按本步具体标准关闭。LOAD-01、完整TS/声明、真实长负载、
设备及发布前复盘仍归后续任务,不由本步通过替代。下一项06在提交核验后启动。
本步对应独立本地commit;若回退,整体revert该提交并同步任务/风险状态,不单独
恢复dist或只退资源模块。没有推送、标签或npm发布。
+4 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
状态:todo 70 / doing 15 / blocked 0 / done 143 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 69 / doing 15 / blocked 0 / done 144 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -313,8 +313,8 @@
| PKG-DANMUKU-02 | artplayer-plugin-danmuku<br>建立弹幕算法与浏览器基线 | PKG-DANMUKU-01, ENG-05, ENG-08 | 密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试 | 轨道选择、发射顺序、事件、内存和可视结果可重跑 | H | done |
| PKG-DANMUKU-03 | artplayer-plugin-danmuku<br>整理加载、解析与配置 | PKG-DANMUKU-02, CORE-08 | bilibili/input/parser/config 的分层与取消 | 旧格式、回调、过滤、追加/替换语义保持 | M | done |
| PKG-DANMUKU-04 | artplayer-plugin-danmuku<br>整理时钟、队列与轨道调度 | PKG-DANMUKU-03, CORE-10 | danmuku 调度器和确定性时钟测试 | seek/暂停/倍率/长时间运行无顺序和碰撞回归 | H | done |
| PKG-DANMUKU-05 | artplayer-plugin-danmuku<br>整理 DOM 渲染、设置、热力图与 worker | PKG-DANMUKU-04, CORE-14, CORE-18 | renderer/setting/heatmap/worker 职责及资源归属 | mount/icons/设置和 worker 协议保持,销毁无后台工作 | H | doing |
| PKG-DANMUKU-06 | artplayer-plugin-danmuku<br>迁移 TS 与公开声明 | PKG-DANMUKU-05, ENG-04, ENG-06 | 自有模块、worker 消息、option/item/result 类型 | 旧参数、扩展字段和事件消费通过,vendored 边界清楚 | H | todo |
| PKG-DANMUKU-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 | doing |
| PKG-DANMUKU-07 | artplayer-plugin-danmuku<br>弹幕能力稳定性验收 | PKG-DANMUKU-06 | 负载/渲染/资源对比及 mask 可依赖的稳定边界 | 在试点核心中旧插件 API 与关键帧路径通过 | H | todo |
| PKG-DANMUKU-08 | artplayer-plugin-danmuku<br>完成新旧核心与组合验收 | PKG-DANMUKU-07, CORE-22 | 最终核心/旧核心、mask/fullscreen/PiP 组合报告 | 性能无未解释退化,反复装卸无累计资源 | H | todo |
| PKG-DANMUKU-09 | artplayer-plugin-danmuku<br>完成分发、示例与文档 | PKG-DANMUKU-08, ENG-07 | danmuku.js、README、声明、worker 与产物 | tarball 和静态 icons 等旧调用通过,有独立回退版本 | H | todo |
@@ -557,6 +557,7 @@
- PKG-DANMUKU-02: [记录](changes/2026-09-13-PKG-DANMUKU-02-controlled-failures.md) [记录](baselines/danmuku-failures-validation.json) [记录](changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md) [记录](baselines/danmuku-browser-validation.json)
- PKG-DANMUKU-03: [记录](changes/2026-09-13-PKG-DANMUKU-03-input.md) [记录](baselines/danmuku-input-validation.json)
- PKG-DANMUKU-04: [记录](changes/2026-09-13-PKG-DANMUKU-04-scheduler.md) [记录](baselines/danmuku-scheduler-validation.json)
- PKG-DANMUKU-05: [记录](changes/2026-09-13-PKG-DANMUKU-05-resources.md) [记录](baselines/danmuku-resources-validation.json)
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md)
+12
View File
@@ -1,5 +1,17 @@
# 进度与证据
## PKG-DANMUKU-05 渲染、设置与热力图完成
拆renderer、Setting模板/滑块/发送/资源/全局样式和heatmap采样/几何/生命周期。
修复外部DOM、锁计时器、订阅与节点清理、初始化回滚及窄热力图卡死,保留共享宿主
用户写入、原points mutation、首实例SVG钩子与合法大数据。源码/main/legacy各159项
联合测试,完整test:danmuku245项、三格式×138共414原生浏览器项通过;冻结安装、
严格工具链、完整类型检查和正常构建通过。内置浏览器完成现有docs示例播放/暂停/发送。
关闭CLEANUP-01/HEATMAP-01,见[变更](changes/2026-09-13-PKG-DANMUKU-05-resources.md)及[机器证据](baselines/danmuku-resources-validation.json)。
228项:144 done、15 doing、69 todo;06进入完整TS。
真实长负载、发布前复盘仍待对应任务;无推送或发布。
## PKG-DANMUKU-04 时钟、队列与轨道调度完成
拆分scheduler/queue/worker-client,修复多RAF、同毫秒请求串线、取消后续写、
+3 -2
View File
@@ -230,10 +230,11 @@
| DANMUKU-TYPE-01 | open / 已复现 | Danmuku historical declared results, optional mount and initial Promise input disagree with runtime | PKG-DANMUKU-03, PKG-DANMUKU-06 |
| DANMUKU-INPUT-01 | resolved / 已复现 | Danmuku replaces time zero with current time plus 0.5 and mutates supplied input objects | PKG-DANMUKU-02, PKG-DANMUKU-03 |
| DANMUKU-CONFIG-01 | resolved / 已复现 | Danmuku JSON configuration comparison ignores a replacement filter function | PKG-DANMUKU-02, PKG-DANMUKU-03 |
| DANMUKU-CLEANUP-01 | open / 已复现 | Danmuku removes a different resize callback and retains the settings lock timer on destroy | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-CLEANUP-01 | resolved / 已复现 | Danmuku removes a different resize callback and retains the settings lock timer on destroy | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-TYPE-02 | open / 源码/产物事实 | Danmuku id, icons, Slider steps, heatmap points and partial configuration drift from declarations | PKG-DANMUKU-05, PKG-DANMUKU-06 |
| DANMUKU-ASYNC-01 | resolved / 已复现 | Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths | PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05 |
| DANMUKU-HEATMAP-01 | open / 已复现 | Danmuku heatmap sampling can become zero and points/instance ownership need browser validation | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-HEATMAP-01 | resolved / 已复现 | Danmuku heatmap sampling can become zero and points/instance ownership need browser validation | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-SOURCE-01 | open / 已复现 | Danmuku registry Git associations differ from releases and static icon provenance remains incomplete | PKG-DANMUKU-09 |
| DANMUKU-TRACK-01 | resolved / 已复现 | Native simultaneous fixed Danmuku comments can overlap with duplicate start loops and repeated Worker IDs | PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-07 |
| DANMUKU-INPUT-02 | resolved / 已复现 | Intermediate explicit-zero correction regressed accepted NaN timestamp fallback | PKG-DANMUKU-04 |
| DANMUKU-LOAD-01 | open / 待取证 | Native lifecycle fixtures sometimes miss timestamp eligibility during concurrent workload; real frame/window behavior needs load evidence | PKG-DANMUKU-07 |
+42 -6
View File
@@ -5094,7 +5094,7 @@
"id": "DANMUKU-CLEANUP-01",
"title": "Danmuku removes a different resize callback and retains the settings lock timer on destroy",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-05"
@@ -5108,10 +5108,20 @@
"refactor/changes/2026-09-13-PKG-DANMUKU-03-input.md",
"refactor/baselines/danmuku-input-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-04-scheduler.md",
"refactor/baselines/danmuku-scheduler-validation.json"
"refactor/baselines/danmuku-scheduler-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-05-resources.md",
"refactor/baselines/danmuku-resources-validation.json"
],
"compatibleResolution": "Track and release exact subscription and timer ownership across settings, renderer and instances.",
"closureCriteria": "Repeated lifecycle and pending settings actions leave no owned subscriptions, timers or late DOM writes."
"closureCriteria": "Repeated lifecycle and pending settings actions leave no owned subscriptions, timers or late DOM writes.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-05-resources.md",
"refactor/baselines/danmuku-resources-validation.json",
"test/danmuku-setting.test.js",
"test/danmuku-heatmap.test.js",
"test/browser/danmuku-resources.spec.js"
],
"resolutionRationale": "Exact subscriptions/proxy registry entries, send countdowns, external settings and owned renderer nodes are released; stale sends are cancelled and shared host values restored conditionally. Native two-core source/main/legacy matrices and real docs smoke passed."
},
{
"id": "DANMUKU-TYPE-02",
@@ -5169,7 +5179,7 @@
"id": "DANMUKU-HEATMAP-01",
"title": "Danmuku heatmap sampling can become zero and points/instance ownership need browser validation",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-05"
@@ -5181,10 +5191,20 @@
"refactor/baselines/danmuku-failures-validation.json",
"test/danmuku-failures.test.js",
"refactor/changes/2026-09-13-PKG-DANMUKU-02-browser-baseline.md",
"refactor/baselines/danmuku-browser-validation.json"
"refactor/baselines/danmuku-browser-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-05-resources.md",
"refactor/baselines/danmuku-resources-validation.json"
],
"compatibleResolution": "Use isolated timeout-protected reproduction for narrow dimensions, then define finite sampling and per-instance rendering.",
"closureCriteria": "Narrow widths terminate, points/config behavior is explicit and multiple SVG/control instances remain independent."
"closureCriteria": "Narrow widths terminate, points/config behavior is explicit and multiple SVG/control instances remain independent.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-05-resources.md",
"refactor/baselines/danmuku-resources-validation.json",
"test/danmuku-setting.test.js",
"test/danmuku-heatmap.test.js",
"test/browser/danmuku-resources.spec.js"
],
"resolutionRationale": "Narrow/invalid sampling terminates; exact historical curves and nested point mutation retained; valid large inputs are not capped arbitrarily. Native multi-instance and repeated-bundle gradient isolation preserves the first legacy ID and independent progress/control cleanup."
},
{
"id": "DANMUKU-SOURCE-01",
@@ -5252,6 +5272,22 @@
"test/browser/danmuku-scheduler.spec.js"
],
"resolutionRationale": "Historical source/main/legacy establish old behavior; source/main/legacy final input and native matrices confirm the one-condition correction. Task03 frozen evidence is preserved."
},
{
"id": "DANMUKU-LOAD-01",
"title": "Native lifecycle fixtures sometimes miss timestamp eligibility during concurrent workload; real frame/window behavior needs load evidence",
"confirmation": "unverified",
"status": "open",
"owners": [
"PKG-DANMUKU-07"
],
"evidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-05-resources.md",
"refactor/baselines/danmuku-resources-validation.json",
"test/browser/danmuku-scheduler.spec.js"
],
"compatibleResolution": "Capture native RAF/media-time gaps and scheduled/eligible/visible item counts under realistic load before deciding whether the old plus/minus 0.1 second eligibility rule needs a compatible correction.",
"closureCriteria": "Real load runs explain missing scheduled items with frame and media evidence, and any confirmed implementation defect has a regression and compatible fix; seeding ready in lifecycle fixtures is not closure evidence."
}
]
}
+6 -3
View File
@@ -3341,11 +3341,14 @@
"CORE-14",
"CORE-18"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "renderer/setting/heatmap/worker 职责及资源归属",
"acceptance": "mount/icons/设置和 worker 协议保持,销毁无后台工作",
"evidence": []
"evidence": [
"changes/2026-09-13-PKG-DANMUKU-05-resources.md",
"baselines/danmuku-resources-validation.json"
]
},
{
"id": "PKG-DANMUKU-06",
@@ -3359,7 +3362,7 @@
"ENG-04",
"ENG-06"
],
"status": "todo",
"status": "doing",
"risk": "H",
"deliverable": "自有模块、worker 消息、option/item/result 类型",
"acceptance": "旧参数、扩展字段和事件消费通过,vendored 边界清楚",
+291
View File
@@ -0,0 +1,291 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd')
})
async function setup(page, core, scenario, testInfo) {
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
return new URL(route.request().url()).origin === origin ? route.continue() : route.abort()
})
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: candidate })
await page.evaluate(({ scenario }) => {
window.resourcePlayers = []
window.resourcePlugins = []
window.resourceOwners = []
window.resourceMessages = []
window.resourceSends = []
window.resourceErrors = []
window.resourceWorkers = []
window.resourceTimers = new Set()
const nativeSetTimeout = window.setTimeout.bind(window)
const nativeClearTimeout = window.clearTimeout.bind(window)
window.setTimeout = (callback, delay, ...args) => {
const id = nativeSetTimeout(() => {
window.resourceTimers.delete(id)
callback(...args)
}, delay)
if (window.captureNextResourceTimer && delay === 1000) {
window.resourceTimers.add(id)
window.captureNextResourceTimer = false
}
return id
}
window.clearTimeout = (id) => {
window.resourceTimers.delete(id)
nativeClearTimeout(id)
}
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.record = { terminated: 0 }
window.resourceWorkers.push(this.record)
}
terminate() {
this.record.terminated++
super.terminate()
}
}
const originalError = console.error
console.error = (...args) => {
window.resourceErrors.push(args.map(arg => arg?.message || String(arg)))
originalError(...args)
}
window.sendGate = new Promise((resolve, reject) => {
window.resolveSend = resolve
window.rejectSend = reject
})
window.createResourcePlayer = (index, extra = {}) => {
const container = index === 0 ? document.querySelector('.player') : document.body.appendChild(document.createElement('div'))
container.style.cssText = 'width:640px;height:360px'
const mount = document.body.appendChild(document.createElement('div'))
mount.id = `resource-mount-${index}`
const art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, fullscreenWeb: true, setting: true })
window.resourcePlayers[index] = art
art.on('artplayerPluginDanmuku:visible', item => window.resourceMessages.push(item.text))
const option = {
danmuku: [],
mount,
heatmap: scenario === 'heatmap' ? { sampling: 0 } : false,
lockTime: 10,
margin: [10, 10],
fontSize: 20,
speed: 2,
beforeEmit(danmu) {
window.resourceSends.push({ index, text: danmu.text, receiver: this === window.resourcePlugins[index].option })
// Capture the lock scheduled in this send's await continuation, not
// unrelated browser/core one-second timers while typing or clicking.
if (window.observeResourceTimers)
window.captureNextResourceTimer = true
return scenario === 'pending' || scenario === 'reject' ? window.sendGate : true
},
...extra,
}
art.plugins.add(window.artplayerPluginDanmuku(option))
const plugin = art.plugins.artplayerPluginDanmuku
window.resourcePlugins[index] = plugin
window.resourceOwners[index] = plugin.show()
return art
}
window.art = window.createResourcePlayer(0)
document.querySelector('#play').onclick = () => window.art.play()
}, { scenario })
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await testInfo.attach('danmuku-resources-inputs', { contentType: 'application/json', body: JSON.stringify({ core, scenario, artifact: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', sha256: hash(candidate), native: 'video, RAF, DOM, Worker; observe the first one-second timer after beforeEmit in the synchronous send continuation, no fake timer clock' }) })
}
test.afterEach(async ({ page }, testInfo) => {
await testInfo.attach('danmuku-resources-page', { contentType: 'image/png', body: await page.screenshot() })
const evidence = await page.evaluate(() => {
for (const art of window.resourcePlayers || []) {
if (!art.isDestroy)
art.destroy(false)
}
return { workers: window.resourceWorkers, sends: window.resourceSends, messages: window.resourceMessages, errors: window.resourceErrors, retainedSettings: document.querySelectorAll('[id^="resource-mount-"] .artplayer-plugin-danmuku').length, timers: window.resourceTimers?.size }
})
await testInfo.attach('danmuku-resources-evidence', { contentType: 'application/json', body: JSON.stringify(evidence) })
expect(evidence.retainedSettings).toBe(0)
for (const worker of evidence.workers || []) expect(worker.terminated).toBe(1)
})
async function renderResourceRow(page, text) {
await page.evaluate(async (text) => {
const owner = await window.resourcePlugins[0].emit({ text, time: 0.2, mode: 1 })
// Resource tests start with an eligible row, then require a real Worker and
// rendered DOM. Native timestamp delivery is covered by the scheduler spec.
owner.setState(owner.queue[owner.queue.length - 1], 'ready')
}, text)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.resourceMessages)).toContain(text)
}
for (const core of ['published', 'candidate']) {
test(`${core}: shared setting mount preserves the surviving instance and later user changes`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
const result = await page.evaluate(() => {
const mount = document.querySelector('#resource-mount-0')
window.createResourcePlayer(1, { mount, theme: 'light' })
window.art.destroy(false)
const survivor = { panels: mount.querySelectorAll('.artplayer-plugin-danmuku').length, theme: mount.dataset.danmukuTheme }
mount.dataset.danmukuTheme = 'user-value'
window.resourcePlayers[1].destroy(false)
return { survivor, theme: mount.dataset.danmukuTheme, ownedKeys: Object.keys(mount.dataset).filter(key => key.startsWith('danmuku') && key !== 'danmukuTheme'), panels: mount.querySelectorAll('.artplayer-plugin-danmuku').length }
})
expect(result).toEqual({ survivor: { panels: 1, theme: 'light' }, theme: 'user-value', ownedKeys: [], panels: 0 })
})
test(`${core}: invalid setting mount rolls back plugin resources while preserving the original error`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
const result = await page.evaluate(() => {
const container = document.body.appendChild(document.createElement('div'))
container.style.cssText = 'width:640px;height:360px'
const art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
window.resourcePlayers.push(art)
art.template.$controlsCenter.style.setProperty('display', 'grid', 'important')
const display = art.template.$controlsCenter.style.display
let error
try {
art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], mount: '#missing-setting-mount' }))
}
catch (failure) {
error = failure.message
}
return { error, panels: container.querySelectorAll('.artplayer-plugin-danmuku').length, worker: window.resourceWorkers.at(-1), display: art.template.$controlsCenter.style.display, priority: art.template.$controlsCenter.style.getPropertyPriority('display'), previousDisplay: display }
})
expect(result.error).toContain('Can not find the mount point: #missing-setting-mount')
expect(result.panels).toBe(0)
expect(result.worker.terminated).toBe(1)
expect(result.display).toBe(result.previousDisplay)
expect(result.priority).toBe('important')
})
test(`${core}: destroy cancels pending setting send without clearing retained input or starting a lock`, async ({ page }, testInfo) => {
await setup(page, core, 'pending', testInfo)
const mount = page.locator('#resource-mount-0')
await mount.locator('.apd-input').fill('PENDING')
await mount.locator('.apd-send').click()
expect(await page.evaluate(() => window.resourceSends)).toEqual([{ index: 0, text: 'PENDING', receiver: true }])
const result = await page.evaluate(async () => {
const input = document.querySelector('#resource-mount-0 .apd-input')
const send = document.querySelector('#resource-mount-0 .apd-send')
window.art.destroy(false)
window.resolveSend(true)
await new Promise(resolve => setTimeout(resolve, 30))
return { value: input.value, locked: send.classList.contains('apd-lock'), queue: window.resourceOwners[0].queue.map(item => item.text), connected: input.isConnected }
})
expect(result).toEqual({ value: 'PENDING', locked: false, queue: [], connected: false })
expect(await page.evaluate(() => window.resourceErrors)).toEqual([])
})
test(`${core}: destroy clears active send timer and leaves another mounted instance usable`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
await page.evaluate(() => window.observeResourceTimers = true)
await page.locator('#resource-mount-0 .apd-input').fill('LOCK')
await page.locator('#resource-mount-0 .apd-send').click()
await expect(page.locator('#resource-mount-0 .apd-send')).toHaveClass(/apd-lock/u)
expect(await page.evaluate(() => window.resourceTimers.size)).toBe(1)
await page.evaluate(() => {
window.observeResourceTimers = false
window.createResourcePlayer(1)
window.art.destroy(false)
})
expect(await page.evaluate(() => window.resourceTimers.size)).toBe(0)
await expect(page.locator('#resource-mount-0 .artplayer-plugin-danmuku')).toHaveCount(0)
await page.locator('#resource-mount-1 .apd-input').fill('SECOND')
await page.locator('#resource-mount-1 .apd-send').click()
await expect.poll(() => page.evaluate(() => window.resourceOwners[1].queue.map(row => row.text))).toEqual(['SECOND'])
expect(await page.locator('style#artplayer-plugin-danmuku').count()).toBe(1)
})
test(`${core}: rejected setting callback retains input and original console error without an unhandled rejection`, async ({ page }, testInfo) => {
await setup(page, core, 'reject', testInfo)
await page.locator('#resource-mount-0 .apd-input').fill('REJECT')
await page.locator('#resource-mount-0 .apd-send').click()
await page.evaluate(() => window.rejectSend(new Error('controlled setting rejection')))
await expect.poll(() => page.evaluate(() => window.resourceErrors)).toEqual([['Error emitting danmuku:', 'controlled setting rejection']])
await expect(page.locator('#resource-mount-0 .apd-input')).toHaveValue('REJECT')
await expect(page.locator('#resource-mount-0 .apd-send')).not.toHaveClass(/apd-lock/u)
expect(await page.evaluate(() => window.resourceOwners[0].queue.length)).toBe(0)
})
test(`${core}: destroy false releases actual renderer nodes while retaining the player shell`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
await renderResourceRow(page, 'VISIBLE')
const result = await page.evaluate(() => {
const layer = window.art.template.$danmuku
const node = [...layer.children].find(item => item.textContent === 'VISIBLE')
const owner = window.resourceOwners[0]
window.art.destroy(false)
return { connected: node.isConnected, nodes: layer.children.length, refs: owner.$refs.length, dangling: owner.queue.filter(item => item.$ref).length, shell: !!window.art.template.$player }
})
expect(result).toEqual({ connected: false, nodes: 0, refs: 0, dangling: 0, shell: true })
})
test(`${core}: replacement load removes a moved renderer node and preserves unrelated body content`, async ({ page }, testInfo) => {
await setup(page, core, 'normal', testInfo)
await renderResourceRow(page, 'MOVED')
const result = await page.evaluate(async () => {
window.art.pause()
const layer = window.art.template.$danmuku
const node = [...layer.children].find(item => item.textContent === 'MOVED')
const foreign = document.createElement('aside')
document.body.append(node, foreign)
await window.resourcePlugins[0].load()
const replacement = { connected: node.isConnected, nodes: layer.childElementCount, foreign: foreign.isConnected, refs: window.resourceOwners[0].$refs.length }
window.art.destroy(false)
return { replacement, foreignAfterDestroy: foreign.isConnected }
})
expect(result).toEqual({ replacement: { connected: false, nodes: 0, foreign: true, refs: 0 }, foreignAfterDestroy: true })
})
test(`${core}: narrow heatmap invalid sampling stays responsive and two instances have independent gradients`, async ({ page }, testInfo) => {
await setup(page, core, 'heatmap', testInfo)
// A second evaluation has fresh module counters but shares this document.
await page.addScriptTag({ content: candidate })
await page.evaluate(() => {
window.createResourcePlayer(1)
const first = window.art.controls.heatmap
first.style.width = '99px'
window.art.emit('resize')
})
await expect.poll(() => page.evaluate(() => window.resourcePlayers[1].isReady)).toBe(true)
await expect.poll(() => page.locator('.art-control-heatmap svg').count()).toBe(2)
const result = await page.evaluate(() => {
const first = window.resourcePlayers[0].controls.heatmap
const second = window.resourcePlayers[1].controls.heatmap
const gradient = node => node.querySelector('linearGradient').id
window.resourcePlayers[0].emit('artplayerPluginDanmuku:points', [[0, 0], [49, 40], [99, 10]])
window.resourcePlayers[1].emit('artplayerPluginDanmuku:points', [[0, 20], [300, 0], [640, 35]])
window.resourcePlayers[0].emit('setBar', 'played', 0.25)
window.resourcePlayers[1].emit('setBar', 'played', 0.75)
return {
ids: [gradient(first), gradient(second)],
fills: [first.querySelector('path').getAttribute('fill'), second.querySelector('path').getAttribute('fill')],
offsets: [first.querySelector('#heatmap-start').getAttribute('offset'), second.querySelector('#heatmap-start').getAttribute('offset')],
validPaths: [first, second].every(node => !/NaN|Infinity/.test(node.querySelector('path').getAttribute('d'))),
}
})
expect(new Set(result.ids).size).toBe(2)
expect(result.ids[0]).toBe('heatmap-solids')
expect(result.fills).toEqual(result.ids.map(id => `url(#${id})`))
expect(result.offsets).toEqual(['25%', '75%'])
expect(result.validPaths).toBe(true)
await testInfo.attach('danmuku-resources-heatmap', { contentType: 'application/json', body: JSON.stringify(result) })
await testInfo.attach('danmuku-heatmap-live-page', { contentType: 'image/png', body: await page.screenshot({ fullPage: true }) })
await page.evaluate(() => window.art.destroy(false))
await expect(page.locator('.art-control-heatmap')).toHaveCount(1)
await page.evaluate(() => window.resourcePlayers[1].emit('resize'))
await expect(page.locator('.art-control-heatmap svg')).toHaveCount(1)
})
}
+33 -6
View File
@@ -133,6 +133,15 @@ async function geometry(page) {
const visibleIds = page => page.evaluate(() => [...new Set(window.danmukuEvidence.events.filter(item => item.event === 'visible' && item.id).map(item => item.id))])
async function queueReady(page, item) {
await page.evaluate(async (item) => {
const owner = await window.danmukuPlugins[0].emit(item)
// These cases verify cancellation/recovery after eligibility. Seed that
// precondition; the separate timing/geometry cases still use the media clock.
owner.setState(owner.queue[owner.queue.length - 1], 'ready')
}, item)
}
test.afterEach(async ({ page }, testInfo) => {
await testInfo.attach('danmuku-layout', { contentType: 'application/json', body: JSON.stringify(await geometry(page)) })
await testInfo.attach('danmuku-native-page', { contentType: 'image/png', body: await page.screenshot() })
@@ -158,6 +167,7 @@ for (const core of ['published', 'candidate']) {
test(`${label}: accepted NaN timestamp keeps the historical current-time fallback`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
await page.evaluate(() => window.art.playbackRate = 0.25)
const numeric = await page.evaluate(async () => {
const input = { id: 'nan-time', text: 'NAN FALLBACK', mode: 1, time: Number.NaN }
const expected = window.art.currentTime + 0.5
@@ -176,7 +186,7 @@ for (const core of ['published', 'candidate']) {
for (const action of ['destroy', 'stop']) {
test(`${label}: failed recovery respects synchronous ${action} from the error listener`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'recovery-row', text: 'RECOVERY', mode: 1, time: 0.5 }))
await queueReady(page, { id: 'recovery-row', text: 'RECOVERY', mode: 1, time: 0.5 })
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('recovery-row')
const result = await page.evaluate((action) => {
@@ -216,7 +226,10 @@ for (const core of ['published', 'candidate']) {
for (const action of ['pause', 'reset', 'seek', 'destroy']) {
test(`${label}: native ${action} cancels pending visibility without blocking later work`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'modes', testInfo)
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'obsolete-gate', text: 'GATED', mode: 1, time: 0.5 }))
await page.evaluate(async () => {
window.art.playbackRate = 0.25
await window.danmukuPlugins[0].emit({ id: 'obsolete-gate', text: 'GATED', mode: 1, time: 0.5 })
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.danmukuEvidence.gates.some(item => item.id === 'obsolete-gate'))).toBe(true)
if (action === 'pause') {
@@ -237,7 +250,7 @@ for (const core of ['published', 'candidate']) {
await page.evaluate(() => window.danmukuPlugins[0].load())
}
if (action !== 'destroy') {
await page.evaluate(() => window.danmukuPlugins[0].emit({ id: 'fresh-after-cancel', text: 'FRESH', mode: 1, time: window.art.currentTime + 0.5 }))
await queueReady(page, { id: 'fresh-after-cancel', text: 'FRESH', mode: 1 })
if (action === 'pause')
await page.click('#play')
// The old user Promise is still unresolved. A cancelled frame must
@@ -325,7 +338,21 @@ for (const core of ['published', 'candidate']) {
test(`${label}: native pause seek rate and public reset/load retain historical pool semantics`, async ({ page }, testInfo) => {
const external = await setup(page, core, format, 'playback', testInfo)
await page.evaluate(async () => {
await window.danmukuPlugins[0].emit({ id: 'moving', text: 'MOVING', mode: 0, time: 0.5 })
const owner = await window.danmukuPlugins[0].emit({ id: 'moving', text: 'MOVING', mode: 0, time: 0.5 })
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys')
window.danmukuEvidence.eligibility = []
Object.defineProperty(owner, 'readys', {
configurable: true,
get() {
const before = owner.art.currentTime
const rows = descriptor.get.call(owner)
const samples = window.danmukuEvidence.eligibility
if (samples.length < 2000) {
samples.push({ wall: performance.now(), before, after: owner.art.currentTime, playing: owner.art.playing, paused: owner.art.template.$video.paused, seeking: owner.art.template.$video.seeking, rate: owner.art.playbackRate, stop: owner.isStop, generation: owner.scheduler.generation, fault: owner.scheduler.fault, ready: rows.map(row => row.id), queue: owner.queue.map(row => ({ id: row.id, time: row.time, state: row.$state })) })
}
return rows
},
})
})
await page.click('#play')
await expect.poll(() => visibleIds(page)).toContain('moving')
@@ -431,8 +458,8 @@ for (const core of ['published', 'candidate']) {
await expect.poll(() => page.evaluate(() => window.danmukuPlayers[1].isReady)).toBe(true)
await page.evaluate(() => window.danmukuPlayers[0].destroy())
expect(await page.evaluate(() => window.danmukuEvidence.workers.map(worker => worker.terminated))).toEqual([1, 0])
// The released plugin does not dispose external Setting DOM; retain this observation.
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
// The candidate owns external Setting DOM and removes it on destruction.
await expect(mount.locator('.artplayer-plugin-danmuku')).toHaveCount(0)
await page.locator('#danmuku-mount-1 .apd-input').fill('SECOND')
await page.locator('#danmuku-mount-1 .apd-send').click()
await page.click('#play-second-danmuku')
+352
View File
@@ -0,0 +1,352 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Heatmap compatibility and isolated historical hangs use Node.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { danmukuEnvironment, danmukuHistorical } from './helpers/danmuku.js'
import { loadModules } from './helpers/load.js'
const { heatmap, heatmapGeometry, sampleHeatmap } = await loadModules({
heatmap: 'packages/artplayer-plugin-danmuku/src/heatmap',
heatmapGeometry: { file: 'packages/artplayer-plugin-danmuku/src/heatmap-geometry', name: 'heatmapGeometry' },
sampleHeatmap: { file: 'packages/artplayer-plugin-danmuku/src/heatmap-sampling', name: 'sampleHeatmap' },
})
const historical = await danmukuHistorical()
function fixture({ width = 400, height = 100, duration = 60, queue = [] } = {}) {
const listeners = new Map()
const removed = []
const element = { offsetWidth: width, offsetHeight: height, writes: 0, nodes: new Map(), removed: 0 }
let html = ''
Object.defineProperty(element, 'innerHTML', {
get: () => html,
set(value) {
html = value
element.writes++
element.nodes = new Map(['#heatmap-start', '#heatmap-stop'].map(key => [key, {
attributes: {},
writes: 0,
setAttribute(name, value) {
this.attributes[name] = value
this.writes++
},
}]))
},
})
const art = {
duration,
played: 0.25,
option: { isLive: false },
constructor: { utils: { query: (selector, root) => root.nodes.get(selector) } },
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(item => item !== callback)) },
emit(name, ...args) {
for (const callback of [...listeners.get(name) || []])
callback(...args)
},
controls: {
add(option) {
this.option = option
this.heatmap = element
option.mounted(element)
return undefined
},
remove(name) {
assert.equal(name, 'heatmap')
this.option.beforeUnmount?.(this.heatmap)
this.heatmap.removed++
delete this.heatmap
removed.push(name)
},
},
}
const danmuku = { queue }
return {
art,
danmuku,
element,
listeners,
removed,
install: option => heatmap(art, danmuku, option),
destroy() {
art.isDestroy = true
art.emit('destroy')
},
}
}
function pathOf(element) {
return element.innerHTML.match(/<path[^>]+d="([^"]*)"/u)?.[1]
}
function listenerCount(env) {
return [...env.listeners.values()].reduce((sum, list) => sum + list.length, 0)
}
test('heatmap: sampled SVG path matches frozen source and actual 5.3.0 main/legacy for legal options', async () => {
const queue = [0, 0.6, 1, 2, 2, 2.1, 8, 15, 28, 59.9, 60, Number.NaN, Infinity, -Infinity].map(time => ({ time }))
for (const option of [true, { sampling: 2.5, smoothing: 0.3, flattening: 0.1, scale: 0.4, opacity: 0.7, minHeight: 2, xMin: -10, xMax: 450, yMin: -1, yMax: 40 }]) {
const candidate = fixture({ queue })
candidate.install(option)
candidate.art.emit('ready')
assert(pathOf(candidate.element))
for (const implementation of historical) {
const env = danmukuEnvironment(implementation, { heatmapWidth: 400 })
const plugin = env.factory({ danmuku: [], heatmap: option })(env.art)
await env.flush()
plugin.show().queue = queue
env.art.emit('ready')
assert.equal(pathOf(candidate.element), pathOf(env.controls[0].element), `${implementation.name} / ${JSON.stringify(option)}`)
env.destroy()
}
candidate.destroy()
}
})
test('heatmap: custom points retain nested mutation, repeated transformation and exact historical SVG curves', async () => {
const option = { sampling: 4, scale: 0.25, minHeight: 5, smoothing: 0.2, flattening: 0.2 }
const candidate = fixture()
candidate.install(option)
const points = [[0, 10], [200, 20], [300, 0]]
const references = [...points]
const snapshots = []
for (let index = 0; index < 2; index++) {
candidate.art.emit('artplayerPluginDanmuku:points', points)
snapshots.push({ points: structuredClone(points), path: pathOf(candidate.element) })
}
assert.deepEqual(snapshots[0].points, [[0, 12.5], [200, 30], [300, 5]])
assert.equal(points.length, 3, 'The closing point is appended only to the copied outer array')
assert.equal(points[1], references[1])
for (const implementation of historical) {
const env = danmukuEnvironment(implementation, { heatmapWidth: 400 })
env.factory({ danmuku: [], heatmap: option, points: [[0, 999], [400, 999]] })(env.art)
await env.flush()
const oldPoints = [[0, 10], [200, 20], [300, 0]]
for (const expected of snapshots) {
env.art.emit('artplayerPluginDanmuku:points', oldPoints)
assert.deepEqual(oldPoints, expected.points, implementation.name)
assert.equal(pathOf(env.controls[0].element), expected.path, implementation.name)
}
env.destroy()
}
candidate.art.emit('resize')
assert.notEqual(pathOf(candidate.element), snapshots[1].path, 'A later automatic update resamples the queue, not the previous custom points')
candidate.destroy()
})
test('heatmap: historical zero/default-narrow/negative sampling hangs are bounded in isolated processes', () => {
for (const implementation of historical) {
for (const scenario of [{ width: 99, option: true }, { width: 400, option: { sampling: 0 } }, { width: 400, option: { sampling: -1 } }]) {
const program = `
import fs from 'node:fs';
import vm from 'node:vm';
import { danmukuEnvironment } from './test/helpers/danmuku.js';
const { implementation, scenario } = JSON.parse(fs.readFileSync(0, 'utf8'));
const env = danmukuEnvironment(implementation, { heatmapWidth: scenario.width });
env.factory({ danmuku: [], heatmap: scenario.option })(env.art);
const sandbox = vm.createContext({ update: () => env.art.emit('ready') });
try { vm.runInContext('update()', sandbox, { timeout: 100 }); process.stdout.write('unexpected-completion'); }
catch (error) { if (error.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw error; process.stdout.write(error.code); }
`
const child = spawnSync(process.execPath, ['--input-type=module', '-e', program], {
cwd: fileURLToPath(new URL('../', import.meta.url)),
input: JSON.stringify({ implementation, scenario }),
encoding: 'utf8',
timeout: 5000,
maxBuffer: 1024 * 1024,
})
assert.equal(child.error, undefined)
assert.equal(child.status, 0, child.stderr)
assert.equal(child.stdout, 'ERR_SCRIPT_EXECUTION_TIMEOUT')
}
}
})
test('heatmap: narrow widths and invalid sampling produce finite bounded curves without a hanging loop', () => {
for (const width of [1, 50, 99, 100, 640]) {
for (const sampling of [undefined, 0, -1, Number.NaN, Infinity, -Infinity]) {
const result = heatmapGeometry({ width, height: 100, duration: 60, queue: [{ time: 1 }], option: { sampling } })
assert(result, `${width}/${sampling}`)
assert.doesNotMatch(result.path, /NaN|Infinity/u)
assert(result.path.length < 100000)
}
}
})
test('heatmap: invalid dimensions, duration and coordinate ranges leave an empty chart', () => {
const base = { width: 400, height: 100, duration: 60, queue: [] }
for (const key of ['width', 'height', 'duration']) {
for (const value of [0, -1, Number.NaN, Infinity, -Infinity])
assert.equal(heatmapGeometry({ ...base, [key]: value }), null, `${key}/${value}`)
}
for (const option of [{ xMin: 1, xMax: 1 }, { yMin: 1, yMax: 1 }])
assert.equal(heatmapGeometry({ ...base, option }), null)
const env = fixture()
env.install(true)
env.art.emit('ready')
assert(pathOf(env.element))
env.art.duration = Infinity
env.art.emit('resize')
assert.equal(env.element.innerHTML, '')
env.art.duration = 60
env.art.option.isLive = true
env.art.emit('ready')
assert.equal(env.element.innerHTML, '')
env.destroy()
})
test('heatmap sampling: strict-left inclusive-right bins preserve fractional step boundaries and numeric edge values', () => {
const queue = [0, -1, 0.1, 0.2, 0.3, 0.6, 1, 60, Number.NaN, Infinity, -Infinity].map(time => ({ time }))
for (const [width, duration, sampling] of [[10, 1, 0.3], [400, 60, 2.5], [100, 60, 1], [1, 1e308, 1e308]]) {
const expected = []
const gap = duration / width
for (let x = 0; x <= width; x += sampling)
expected.push([x, queue.filter(({ time }) => time > x * gap && time <= (x + sampling) * gap).length])
assert.deepEqual(sampleHeatmap(queue, width, duration, sampling), expected)
}
})
test('heatmap sampling: only unrepresentable array lengths are rejected before scanning the queue', () => {
const queue = {
map() { assert.fail('Oversized sampling must stop before reading the queue') },
}
assert.deepEqual(sampleHeatmap(queue, 400, 60, Number.MIN_VALUE), [])
assert.deepEqual(sampleHeatmap(queue, 0xFFFFFFFF, 60, 1), [])
assert.equal(heatmapGeometry({ width: 400, height: 100, duration: 60, queue, option: { sampling: Number.MIN_VALUE } }), null)
})
test('heatmap sampling: legal small steps retain more than 100000 exact historical bins', () => {
const queue = [0, 0.001, 1, 50, 100, 100.001, 101].map(time => ({ time }))
const expected = []
for (let x = 0; x <= 101; x += 0.001)
expected.push([x, queue.filter(({ time }) => time > x && time <= x + 0.001).length])
assert(expected.length > 100000)
const actual = sampleHeatmap(queue, 101, 101, 0.001)
assert.equal(actual.length, expected.length)
assert.deepEqual(actual, expected)
assert.equal(sampleHeatmap([], 100001, 60, 1).length, 100002)
})
test('heatmap geometry: more than 100000 custom points retain all segments and nested y writes', () => {
const points = Array.from({ length: 100001 }, (_, index) => [index, index % 2 ? 20 : 10])
const first = points[0]
const result = heatmapGeometry({ width: 100001, height: 100, duration: 60, queue: [], points })
assert(result)
assert.equal(points.length, 100001)
assert.equal(points[0], first)
assert.deepEqual(points[0], [0, 12.5])
assert.deepEqual(points[1], [1, 30])
assert.equal(result.path.match(/C /gu).length, 100001, 'Every caller segment and the closing width point remain')
assert.doesNotMatch(result.path, /NaN|Infinity/u)
assert(result.path.endsWith(' z'))
})
test('heatmap: malformed points do not generate NaN SVG or change caller data', () => {
for (const points of [[undefined], [[1]], [[0, Number.NaN]], [[Infinity, 2]], [{ time: 1, value: 2 }]]) {
const original = structuredClone(points)
assert.equal(heatmapGeometry({ width: 400, height: 100, duration: 60, queue: [], points }), null)
assert.deepEqual(points, original)
}
})
test('heatmap: instances own different gradients while preserving local stop hooks and progress updates', () => {
const a = fixture()
const b = fixture()
a.install(true)
b.install({ opacity: 0.7 })
a.art.emit('ready')
b.art.emit('ready')
const gradient = env => env.element.innerHTML.match(/linearGradient id="([^"]+)"/u)[1]
assert.notEqual(gradient(a), gradient(b))
for (const env of [a, b]) {
assert(env.element.innerHTML.includes(`fill="url(#${gradient(env)})"`))
assert.equal(env.element.nodes.get('#heatmap-start').attributes.offset, '25%')
assert.equal(env.art.controls.option.name, 'heatmap')
assert.equal(env.art.controls.option.position, 'top')
}
a.art.played = 0.5
a.art.emit('video:timeupdate')
assert.equal(a.element.nodes.get('#heatmap-start').attributes.offset, '50%')
assert.equal(b.element.nodes.get('#heatmap-start').attributes.offset, '25%')
a.art.emit('setBar', 'loaded', 0.9)
assert.equal(a.element.nodes.get('#heatmap-stop').attributes.offset, '50%')
a.art.emit('setBar', 'played', 0.9)
assert.equal(a.element.nodes.get('#heatmap-stop').attributes.offset, '90%')
a.destroy()
assert.equal(listenerCount(a), 0)
assert.deepEqual(a.removed, ['heatmap'])
b.art.emit('resize')
assert(pathOf(b.element))
assert.equal(b.removed.length, 0)
b.destroy()
})
test('heatmap: control unmount detaches all subscriptions and stale callbacks cannot write', () => {
const env = fixture()
const dispose = env.install(true)
env.art.emit('ready')
const oldCallbacks = [...env.listeners].map(([name, callbacks]) => [name, [...callbacks]])
const oldStops = [...env.element.nodes.values()]
env.art.controls.remove('heatmap')
assert.equal(listenerCount(env), 0)
const writes = env.element.writes
const stopWrites = oldStops.map(node => node.writes)
for (const [name, callbacks] of oldCallbacks) {
for (const callback of callbacks) {
if (name === 'artplayerPluginDanmuku:points')
callback([[0, 10], [400, 20]])
else if (name === 'setBar')
callback('played', 0.9)
else callback()
}
}
dispose()
assert.equal(env.element.writes, writes)
assert.deepEqual(oldStops.map(node => node.writes), stopWrites)
assert.deepEqual(env.removed, ['heatmap'])
})
test('heatmap: destroy preserves a later same-name control and cleanup attempts every owned removal', () => {
const env = fixture()
const dispose = env.install(true)
const replacement = { removed: 0 }
env.art.controls.heatmap = replacement
const originalOff = env.art.off.bind(env.art)
const failure = new Error('off-original')
let attempts = 0
env.art.off = (...args) => {
originalOff(...args)
attempts++
if (attempts === 1)
throw failure
}
assert.throws(dispose, error => error === failure)
assert.equal(attempts, 7)
assert.equal(listenerCount(env), 0)
assert.equal(env.art.controls.heatmap, replacement)
assert.equal(replacement.removed, 0)
dispose()
})
test('heatmap: setup and update failures clean subscriptions/control and preserve their original errors', () => {
const first = fixture()
const setupError = new Error('add-original')
first.art.controls.add = () => {
throw setupError
}
assert.throws(() => first.install(true), error => error === setupError)
assert.equal(listenerCount(first), 0)
const second = fixture()
second.install(true)
const geometryError = new Error('points-original')
const point = [0, 1]
Object.defineProperty(point, '1', {
get: () => 1,
set() { throw geometryError },
})
assert.throws(() => second.art.emit('artplayerPluginDanmuku:points', [point, [400, 2]]), error => error === geometryError)
assert.equal(listenerCount(second), 0)
assert.deepEqual(second.removed, ['heatmap'])
assert.equal(second.art.controls.heatmap, undefined)
})
+115
View File
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Deterministic owned-node tests use the Node runner.
import test from 'node:test'
import { parseHTML } from 'linkedom'
import Renderer from '../packages/artplayer-plugin-danmuku/src/renderer.js'
function fixture(t) {
const { document } = parseHTML('<html><body><div id="layer"><i id="foreign"></i></div></body></html>')
const previous = globalThis.document
globalThis.document = document
t.after(() => {
if (previous === undefined)
delete globalThis.document
else
globalThis.document = previous
})
const owner = {
constructor: { cssText: 'visibility:hidden;position:absolute' },
$danmuku: document.querySelector('#layer'),
$refs: [],
queue: [],
option: { opacity: 0.7 },
fontSize: 20,
utils: { setStyles: (node, styles) => Object.assign(node.style, styles) },
}
const renderer = new Renderer(owner)
Object.defineProperty(owner, '$ref', { get: () => renderer.acquire() })
return { owner, renderer, document }
}
test('Danmuku renderer destroys only owned nodes, including moved and idle nodes, and clears retained references', (t) => {
const { owner, renderer, document } = fixture(t)
const active = renderer.acquire()
const idle = renderer.acquire()
owner.$danmuku.append(active, idle)
owner.queue.push({ $ref: active })
owner.$refs.push(idle)
document.body.append(active)
renderer.destroy()
assert.equal(active.parentElement, null)
assert.equal(idle.parentElement, null)
assert.equal(owner.$danmuku.firstElementChild.id, 'foreign')
assert.equal(owner.queue[0].$ref, null)
assert.equal(owner.$refs.length, 0)
assert.equal(renderer.nodes.size, 0)
renderer.destroy()
assert.equal(owner.$danmuku.childElementCount, 1)
})
test('Danmuku renderer cancellation cannot recycle a different operation node', (t) => {
const { owner, renderer } = fixture(t)
const old = renderer.acquire()
const current = renderer.acquire()
const row = { $ref: current }
const operation = { danmu: row, ref: old }
renderer.release(operation)
assert.equal(row.$ref, current)
assert.equal(operation.ref, null)
assert.deepEqual(owner.$refs, [])
const actual = { danmu: row, ref: current }
renderer.release(actual)
renderer.release(actual)
assert.deepEqual(owner.$refs, [current])
assert.equal(row.$ref, null)
assert.equal(current.style.visibility, 'hidden')
})
test('Danmuku renderer reuse clears custom style and identity before preparing the next row', (t) => {
const { owner, renderer } = fixture(t)
const first = { text: 'first', color: '#fff', border: true, style: { letterSpacing: '3px' } }
const operation = {}
const ref = renderer.prepare(first, operation)
ref.dataset.id = 'old'
ref.className = 'old-class'
assert.equal(ref.style.letterSpacing, '3px')
renderer.release(operation)
const second = { text: 'second', color: '#000', style: {} }
const next = renderer.prepare(second, {})
assert.equal(next, ref)
assert.equal(next.textContent, 'second')
assert.equal(next.dataset.id, '')
assert.equal(next.className, '')
assert.equal(next.style.letterSpacing || '', '')
assert.equal(next.style.visibility, 'hidden')
assert.equal(owner.$danmuku.querySelectorAll('div').length, 1)
})
test('Danmuku renderer replacement clears the historical whole layer and moved owned nodes before starting a new pool', (t) => {
const { owner, renderer, document } = fixture(t)
const old = renderer.acquire()
const moved = renderer.acquire()
const foreign = document.createElement('aside')
owner.$danmuku.append(old)
document.body.append(moved, foreign)
owner.$refs.push(old)
renderer.clear()
assert.equal(owner.$danmuku.childElementCount, 0)
assert.equal(old.parentElement, null)
assert.equal(moved.isConnected, false)
assert.equal(foreign.isConnected, true)
assert.equal(renderer.nodes.size, 0)
assert.equal(owner.$refs.length, 0)
assert.notEqual(renderer.acquire(), old)
renderer.destroy()
assert.equal(foreign.isConnected, true)
})
test('Danmuku renderer left uses the existing auto-orientation axis', (t) => {
const { owner, renderer } = fixture(t)
const node = renderer.acquire()
node.getBoundingClientRect = () => ({ top: 31, left: 72 })
assert.equal(renderer.left(node), 72)
owner.isRotate = true
assert.equal(renderer.left(node), 31)
})
+411
View File
@@ -0,0 +1,411 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Real-template controlled DOM regressions use the Node runner.
import test from 'node:test'
import { danmukuCandidate } from './helpers/danmuku-candidate.js'
import { danmukuSettingEnvironment } from './helpers/danmuku-setting.js'
import { danmukuHistorical, deferred } from './helpers/danmuku.js'
const candidate = await danmukuCandidate()
const historical = await danmukuHistorical()
const emitted = env => env.events.filter(event => event.name === 'artplayerPluginDanmuku:emit')
function fixture(implementation = candidate, option = {}, environment = {}) {
const env = danmukuSettingEnvironment(implementation, environment)
const plugin = env.factory({ danmuku: [], mount: '#external', ...option })(env.art)
return { env, plugin, owner: plugin.show(), ui: env.query('.artplayer-plugin-danmuku') }
}
for (const implementation of [...historical, candidate]) {
test(`Danmuku Setting ${implementation.name}: static icons and the actual template retain published structure`, () => {
const { env, plugin, ui } = fixture(implementation)
const reference = danmukuSettingEnvironment(historical[0]).factory.icons
assert.deepEqual(Object.keys(env.factory.icons), Object.keys(reference))
for (const key of Object.keys(reference))
assert.equal(env.factory.icons[key], reference[key])
assert.equal(Object.getOwnPropertyDescriptor(env.factory, 'icons').writable, true)
assert.equal(ui.parentElement.id, 'external')
assert.equal(ui.querySelectorAll('.apd-config-mode .apd-mode').length, 3)
assert.equal(ui.querySelectorAll('.apd-style-mode .apd-mode').length, 3)
assert.equal(ui.querySelectorAll('.apd-color').length, 14)
assert.equal(ui.querySelectorAll('.apd-slider').length, 4)
assert.equal(ui.querySelectorAll('.apd-config-speed .apd-slider-step').length, 3)
assert.equal(ui.querySelector('.apd-input').getAttribute('maxLength'), String(plugin.option.maxLength))
assert.equal(env.query('#unrelated').textContent, 'Keep me')
env.destroy()
})
test(`Danmuku Setting ${implementation.name}: mount/fullscreen and responsive moves preserve one actual UI subtree`, () => {
const { env, plugin, ui } = fixture(implementation)
assert.equal(plugin.mount('#second'), undefined)
assert.equal(ui.parentElement.id, 'second')
assert.equal(env.query('#external').children.length, 1)
for (const type of ['fullscreen', 'fullscreenWeb']) {
env.art.emit(type, true)
assert.equal(ui.parentElement, env.art.template.$controlsCenter)
env.art.emit(type, false)
assert.equal(ui.parentElement.id, 'second')
}
assert.equal(plugin.mount(env.art.template.$controlsCenter), undefined)
env.art.width = 400
env.art.emit('resize')
assert.equal(ui.parentElement, env.art.template.$player)
env.art.width = 640
env.art.emit('resize')
assert.equal(ui.parentElement, env.art.template.$controlsCenter)
assert.equal(env.document.querySelectorAll('.artplayer-plugin-danmuku').length, 1)
env.destroy()
})
test(`Danmuku Setting ${implementation.name}: Slider clicks, pointer dragging and rotation update actual controls`, () => {
const { env, plugin, ui } = fixture(implementation)
const opacity = ui.querySelector('.apd-config-opacity .apd-slider')
opacity.testRect = { left: 20, top: 30, width: 100, height: 200 }
env.dispatch(opacity, 'click', { clientX: 45 })
assert.equal(plugin.option.opacity, 0.25)
assert.equal(ui.querySelector('.apd-config-opacity .apd-value').textContent, '25%')
assert.equal(opacity.querySelector('.apd-slider-dot').style.left, '25%')
env.dispatch(opacity, 'pointerdown', { button: 0 })
env.art.emit('document:pointermove', { clientX: 90 })
assert.equal(plugin.option.opacity, 0.7)
env.art.emit('document:pointerup', { clientX: 120 })
assert.equal(plugin.option.opacity, 1)
env.art.emit('document:pointermove', { clientX: 20 })
assert.equal(plugin.option.opacity, 1)
env.art.isRotate = true
env.dispatch(opacity, 'click', { clientY: 130 })
assert.equal(plugin.option.opacity, 0.5)
const speed = ui.querySelector('.apd-config-speed .apd-slider')
speed.testRect = { width: 100, height: 100 }
env.dispatch(speed, 'click', { clientY: 25 })
assert.equal(plugin.option.speed, 7.5, 'Hidden step labels do not remove numeric slider indices')
assert.equal(ui.querySelector('.apd-config-speed .apd-value').textContent, '\u8F83\u6162')
env.destroy()
})
test(`Danmuku Setting ${implementation.name}: beforeEmit receiver, strict true acceptance and local original rejection stay compatible`, async () => {
const gate = deferred()
let receiver
let inputDanmu
const { env, plugin, owner, ui } = fixture(implementation, {
beforeEmit(danmu) {
receiver = this
inputDanmu = danmu
return gate.promise
},
})
const input = ui.querySelector('.apd-input')
input.value = ' accepted text '
env.dispatch(ui.querySelector('.apd-send'), 'click')
assert.equal(receiver, plugin.option)
assert.equal(inputDanmu.text, 'accepted text')
assert.equal(inputDanmu.time, 10)
gate.resolve(true)
await env.flush()
assert.equal(owner.queue.length, 1)
assert.equal(owner.queue[0].border, true)
assert.equal(owner.queue[0].time, 10.5)
assert.equal(input.value, '')
assert.equal(env.timers.size, 1)
assert.equal(ui.querySelector('.apd-send').classList.contains('apd-lock'), true)
env.destroy()
const error = new Error('beforeEmit original')
const denied = fixture(implementation, { beforeEmit: () => Promise.reject(error) })
denied.ui.querySelector('.apd-input').value = 'retained'
denied.env.dispatch(denied.ui.querySelector('.apd-send'), 'click')
await denied.env.flush()
assert.equal(denied.owner.queue.length, 0)
assert.equal(denied.ui.querySelector('.apd-input').value, 'retained')
assert.equal(denied.env.timers.size, 0)
assert.deepEqual(denied.env.consoleErrors, [['Error emitting danmuku:', error]])
assert.equal(denied.env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').length, 0)
denied.plugin.config({ beforeEmit: () => 1 })
denied.env.dispatch(denied.ui.querySelector('.apd-send'), 'click')
await denied.env.flush()
assert.equal(denied.owner.queue.length, 0)
assert.equal(denied.env.timers.size, 0)
denied.env.destroy()
})
}
test(`Danmuku Setting ${candidate.name}: destroy removes external UI, subscriptions, proxy handlers and pending lock work`, async () => {
const { env, ui } = fixture()
const input = ui.querySelector('.apd-input')
input.value = 'send once'
env.dispatch(ui.querySelector('.apd-send'), 'click')
await env.flush()
assert.equal(env.timers.size, 1)
assert.ok(env.listenerCount() > 0)
assert.ok(env.proxyCount() > 0)
env.destroy()
assert.equal(ui.parentElement, null)
assert.equal(env.query('#unrelated').textContent, 'Keep me')
assert.equal(env.timers.size, 0)
assert.equal(env.listenerCount(), 0)
assert.equal(env.proxyCount(), 0)
const events = env.events.length
env.dispatch(ui.querySelector('.apd-toggle'), 'click')
env.dispatch(ui.querySelector('.apd-send'), 'click')
await env.flush()
assert.equal(env.events.length, events)
assert.ok(env.workers.every(worker => worker.terminated))
})
test(`Danmuku Setting ${candidate.name}: destroy cancels pending beforeEmit without sending, clearing input or locking`, async () => {
const gate = deferred()
const { env, owner, ui } = fixture(candidate, { beforeEmit: () => gate.promise })
const input = ui.querySelector('.apd-input')
input.value = 'pending user text'
env.dispatch(ui.querySelector('.apd-send'), 'click')
env.destroy()
const count = emitted(env).length
gate.resolve(true)
await env.flush()
assert.equal(owner.queue.length, 0)
assert.equal(emitted(env).length, count)
assert.equal(input.value, 'pending user text')
assert.equal(ui.querySelector('.apd-send').classList.contains('apd-lock'), false)
assert.equal(env.timers.size, 0)
})
test(`Danmuku Setting ${candidate.name}: invalid initial mount rolls back template, Worker, subscriptions and proxies`, () => {
const env = danmukuSettingEnvironment(candidate)
assert.throws(() => env.factory({ danmuku: [], mount: '#missing' })(env.art), /Can not find the mount point: #missing/u)
assert.equal(env.listenerCount(), 0)
assert.equal(env.proxyCount(), 0)
assert.equal(env.timers.size, 0)
assert.equal(env.document.querySelectorAll('.artplayer-plugin-danmuku').length, 0)
assert.ok(env.workers.every(worker => worker.terminated))
assert.equal(env.query('#unrelated').textContent, 'Keep me')
})
test(`Danmuku Setting ${candidate.name}: late DOMContentLoaded installs only the shared style and releases its listener after instance destruction`, () => {
const { env, ui } = fixture(candidate, {}, { readyState: 'loading' })
assert.equal(env.document.getElementById('artplayer-plugin-danmuku'), null)
env.destroy()
env.dispatch(env.document, 'DOMContentLoaded')
const style = env.document.getElementById('artplayer-plugin-danmuku')
assert.ok(style)
assert.ok(style.textContent.includes('.artplayer-plugin-danmuku'))
assert.equal(ui.parentElement, null)
assert.equal(env.proxyCount(), 0)
assert.equal(env.documentListeners.filter(record => record.type === 'DOMContentLoaded' && record.active).length, 0)
env.dispatch(env.document, 'DOMContentLoaded')
assert.equal(env.document.querySelectorAll('#artplayer-plugin-danmuku').length, 1)
})
test(`Danmuku Setting ${candidate.name}: repeated bundle evaluation shares pending style installation`, () => {
const env = danmukuSettingEnvironment(candidate, { readyState: 'loading' })
env.reloadPlugin()
env.reloadPlugin()
assert.equal(env.documentListeners.filter(record => record.type === 'DOMContentLoaded' && record.active).length, 1)
env.dispatch(env.document, 'DOMContentLoaded')
assert.equal(env.document.querySelectorAll('#artplayer-plugin-danmuku').length, 1)
assert.equal(env.documentListeners.filter(record => record.type === 'DOMContentLoaded' && record.active).length, 0)
env.reloadPlugin()
assert.equal(env.document.querySelectorAll('#artplayer-plugin-danmuku').length, 1)
assert.equal(env.documentListeners.filter(record => record.type === 'DOMContentLoaded' && record.active).length, 0)
})
test(`Danmuku Setting ${candidate.name}: Enter starts one pending send and preserves a synchronous destroy decision`, async () => {
let calls = 0
const env = danmukuSettingEnvironment(candidate)
const plugin = env.factory({
danmuku: [],
beforeEmit() {
calls++
env.destroy()
return true
},
})(env.art)
const input = env.query('.apd-input')
const send = env.query('.apd-send')
input.value = 'retain on synchronous destroy'
const keypress = env.event('keypress', { key: 'Enter' })
input.dispatchEvent(keypress)
await env.flush()
assert.equal(keypress.defaultPrevented, true)
assert.equal(calls, 1)
assert.equal(plugin.show().queue.length, 0)
assert.equal(input.value, 'retain on synchronous destroy')
assert.equal(send.classList.contains('apd-lock'), false)
assert.equal(env.timers.size, 0)
assert.equal(env.proxyCount(), 0)
})
test(`Danmuku Setting ${candidate.name}: pending sends are deduplicated and a rejection after destroy is observed without late UI work`, async () => {
const gate = deferred()
let calls = 0
const { env, ui } = fixture(candidate, {
beforeEmit() {
calls++
return gate.promise
},
})
const input = ui.querySelector('.apd-input')
input.value = 'pending rejection'
env.dispatch(ui.querySelector('.apd-send'), 'click')
env.dispatch(input, 'keypress', { key: 'Enter' })
assert.equal(calls, 1)
env.destroy()
gate.reject(new Error('cancelled beforeEmit rejection'))
await env.flush()
assert.equal(input.value, 'pending rejection')
assert.equal(env.timers.size, 0)
assert.equal(emitted(env).length, 0)
assert.equal(env.consoleErrors.length, 0)
})
test(`Danmuku Setting ${candidate.name}: rejection from the invoked emit Promise is observed with the original error`, async () => {
const error = new Error('filter failed during accepted send')
const { env, ui } = fixture(candidate, {
filter() {
throw error
},
})
const input = ui.querySelector('.apd-input')
input.value = 'accepted but rejected by filter'
env.dispatch(ui.querySelector('.apd-send'), 'click')
await env.flush()
assert.deepEqual(env.consoleErrors, [['Error emitting danmuku:', error]])
assert.equal(env.events.filter(event => event.name === 'artplayerPluginDanmuku:error').length, 0)
assert.equal(input.value, '', 'Sending still clears and locks immediately after invoking emit')
assert.equal(env.timers.size, 1)
env.destroy()
})
test(`Danmuku Setting ${candidate.name}: a complete lock countdown retains its existing delay and restores the actual send control`, async () => {
const { env, ui } = fixture(candidate, { lockTime: 1 })
ui.querySelector('.apd-input').value = 'countdown'
const send = ui.querySelector('.apd-send')
env.dispatch(send, 'click')
await env.flush()
assert.equal(send.textContent, '1')
assert.equal([...env.timers.values()][0].delay, 1000)
env.timeout()
assert.equal(send.textContent, '0')
assert.equal(send.classList.contains('apd-lock'), true)
env.timeout()
assert.equal(send.textContent, '\u53D1\u9001')
assert.equal(send.classList.contains('apd-lock'), false)
assert.equal(env.timers.size, 0)
env.destroy()
})
for (const first of ['older', 'newer']) {
test(`Danmuku Setting ${candidate.name}: shared mount restores remaining owner and original host data when ${first} owner is destroyed first`, () => {
const env = danmukuSettingEnvironment(candidate)
const external = env.query('#external')
external.dataset.danmukuMode = 'host mode'
env.art.template.$controlsCenter.style.display = 'grid'
const peer = env.createPeer()
peer.art.template.$controlsCenter.style.display = 'block'
env.factory({ danmuku: [], mount: '#external', mode: 0 })(env.art)
env.factory({ danmuku: [], mount: '#external', mode: 2 })(peer.art)
assert.equal(external.dataset.danmukuMode, '2')
assert.equal(external.querySelectorAll('.artplayer-plugin-danmuku').length, 2)
if (first === 'older') {
env.destroy()
assert.equal(external.dataset.danmukuMode, '2')
peer.destroy()
}
else {
peer.destroy()
assert.equal(external.dataset.danmukuMode, '0')
env.destroy()
}
assert.equal(external.dataset.danmukuMode, 'host mode')
assert.equal(external.hasAttribute('data-danmuku-visible'), false)
assert.equal(external.querySelectorAll('.artplayer-plugin-danmuku').length, 0)
assert.equal(env.art.template.$controlsCenter.style.display, 'grid')
assert.equal(peer.art.template.$controlsCenter.style.display, 'block')
assert.equal(env.query('#unrelated').textContent, 'Keep me')
})
}
test(`Danmuku Setting ${candidate.name}: destroy preserves host dataset and display values changed after initialization`, () => {
const env = danmukuSettingEnvironment(candidate)
const external = env.query('#external')
external.dataset.danmukuMode = 'original host value'
external.dataset.getPropertyPriority = 'a host data attribute, not a method'
env.factory({ danmuku: [], mount: '#external' })(env.art)
external.dataset.danmukuMode = 'new host value'
external.dataset.danmukuVisible = 'host visibility'
env.art.template.$controlsCenter.style.display = 'inline-grid'
env.destroy()
assert.equal(external.dataset.danmukuMode, 'new host value')
assert.equal(external.dataset.danmukuVisible, 'host visibility')
assert.equal(env.art.template.$controlsCenter.style.display, 'inline-grid')
assert.equal(external.hasAttribute('data-danmuku-color'), false)
assert.equal(external.dataset.getPropertyPriority, 'a host data attribute, not a method')
})
test(`Danmuku Setting ${candidate.name}: a disposer failure preserves the original initialization error and cleans other resources`, () => {
const env = danmukuSettingEnvironment(candidate)
const proxy = env.art.proxy
const cleanupError = new Error('one proxy disposer failed')
let shouldThrow = true
env.art.proxy = (...args) => {
const dispose = proxy(...args)
return () => {
dispose()
if (shouldThrow) {
shouldThrow = false
throw cleanupError
}
}
}
assert.throws(() => env.factory({ danmuku: [], mount: '#missing' })(env.art), /Can not find the mount point: #missing/u)
assert.equal(env.proxyCount(), 0)
assert.equal(env.listenerCount(), 0)
assert.ok(env.workers.every(worker => worker.terminated))
assert.ok(env.consoleWarnings.some(args => args.includes(cleanupError)))
})
test(`Danmuku Setting ${candidate.name}: synchronous destroy during proxy registration leaves an inert mount facade`, () => {
const env = danmukuSettingEnvironment(candidate)
const proxy = env.art.proxy
let first = true
env.art.proxy = (...args) => {
const dispose = proxy(...args)
if (first) {
first = false
env.destroy()
}
return dispose
}
const plugin = env.factory({ danmuku: [], mount: '#external' })(env.art)
assert.equal(plugin.mount('#second'), undefined)
assert.equal(env.document.querySelectorAll('.artplayer-plugin-danmuku').length, 0)
assert.equal(env.proxyCount(), 0)
assert.equal(env.listenerCount(), 0)
assert.equal(env.timers.size, 0)
assert.ok(env.workers.every(worker => worker.terminated))
})
for (const implementation of historical) {
test(`Danmuku Setting historical defect ${implementation.name}: destroy retains external UI, subscriptions, proxies and lock timer`, async () => {
const { env, ui } = fixture(implementation)
ui.querySelector('.apd-input').value = 'historical lock'
env.dispatch(ui.querySelector('.apd-send'), 'click')
await env.flush()
env.destroy()
assert.equal(ui.parentElement.id, 'external')
assert.ok(env.listenerCount() > 0)
assert.ok(env.proxyCount() > 0)
assert.equal(env.timers.size, 1)
})
test(`Danmuku Setting historical defect ${implementation.name}: late beforeEmit still clears input and locks after destroy`, async () => {
const gate = deferred()
const { env, ui } = fixture(implementation, { beforeEmit: () => gate.promise })
ui.querySelector('.apd-input').value = 'historical pending'
env.dispatch(ui.querySelector('.apd-send'), 'click')
env.destroy()
gate.resolve(true)
await env.flush()
assert.equal(ui.querySelector('.apd-input').value, '')
assert.equal(env.timers.size, 1)
assert.equal(ui.querySelector('.apd-send').classList.contains('apd-lock'), true)
})
}
+18
View File
@@ -33,6 +33,24 @@ export function danmukuCandidateEnvironment(implementation, settings = {}) {
? { ...implementation, code: `globalThis.Worker = class { constructor() { throw globalThis.__danmukuCandidateWorkerError } };\n${implementation.code}` }
: implementation
const env = danmukuEnvironment(initialWorker, settings)
const createElement = env.context.document.createElement.bind(env.context.document)
const removeChild = function (child) {
const index = this.children.indexOf(child)
if (index < 0)
throw new Error('Cannot remove a node from a different parent')
this.children.splice(index, 1)
child.parentElement = null
return child
}
env.context.document.createElement = (...args) => {
const element = createElement(...args)
element.removeChild = removeChild
return element
}
for (const element of Object.values(env.art.template)) {
if (element?.children)
element.removeChild = removeChild
}
if (settings.initialWorkerError)
env.context.__danmukuCandidateWorkerError = settings.initialWorkerError
const consoleWarnings = []
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict'
import vm from 'node:vm'
import { parseHTML } from 'linkedom'
import { danmukuCandidateEnvironment } from './danmuku-candidate.js'
export function danmukuSettingEnvironment(implementation, { readyState = 'complete' } = {}) {
const env = danmukuCandidateEnvironment(implementation)
// Load actual core utilities without initializing the core's browser banner/style.
delete env.context.document
const core = evaluate(implementation.coreCode)
const { document, window } = parseHTML('<!doctype html><html><head></head><body><div id="external"><span id="unrelated">Keep me</span></div><div id="second"></div><div id="player"><div id="controls"></div><div id="danmuku"></div></div></body></html>')
Object.defineProperty(document, 'readyState', { value: readyState, configurable: true })
// Linkedom parses the real templates and dispatches events, but has no layout.
Object.defineProperties(window.HTMLElement.prototype, {
clientWidth: { configurable: true, get() { return this.testRect?.width ?? 640 } },
clientHeight: { configurable: true, get() { return this.testRect?.height ?? 360 } },
offsetWidth: { configurable: true, get() { return this.clientWidth } },
offsetHeight: { configurable: true, get() { return this.clientHeight } },
})
window.HTMLElement.prototype.getBoundingClientRect = function () {
const { left = 0, top = 0, width = 640, height = 360 } = this.testRect || {}
return { x: left, y: top, left, top, width, height, right: left + width, bottom: top + height }
}
env.context.document = document
env.context.Element = window.Element
env.context.HTMLElement = window.HTMLElement
env.context.window.document = document
env.context.window.getComputedStyle = element => element.style
env.context.getComputedStyle = element => element.style
const documentListeners = []
const add = document.addEventListener.bind(document)
const remove = document.removeEventListener.bind(document)
document.addEventListener = (type, callback, options) => {
const record = { type, callback, active: true, options }
documentListeners.push(record)
record.wrapped = (...args) => {
if (options?.once)
record.active = false
return callback(...args)
}
add(type, record.wrapped, options)
}
document.removeEventListener = (type, callback, options) => {
for (const record of documentListeners) {
if (record.type === type && record.callback === callback && record.active) {
record.active = false
remove(type, record.wrapped, options)
}
}
}
function evaluate(code) {
env.context.__settingModule = { exports: {} }
vm.runInContext(`(function(module, exports) {\n${code}\n})(__settingModule, __settingModule.exports)`, env.context, { timeout: 1000 })
return env.context.__settingModule.exports
}
env.art.constructor.utils = core.utils
env.art.template = {
$player: document.querySelector('#player'),
$controlsCenter: document.querySelector('#controls'),
$danmuku: document.querySelector('#danmuku'),
}
const exported = evaluate(implementation.code)
env.factory = typeof exported === 'function' ? exported : exported.default
env.proxies.length = 0
env.art.proxy = (target, type, callback, options) => {
assert.ok(target?.addEventListener, `A real template target is required for ${type}`)
const record = { target, type, callback, options, active: true }
env.proxies.push(record)
target.addEventListener(type, callback, options)
record.dispose = () => {
record.active = false
target.removeEventListener(type, callback, options)
}
return record.dispose
}
env.art.events = { remove: dispose => dispose() }
const event = (type, values = {}) => Object.assign(new window.Event(type, { bubbles: true, cancelable: true }), values)
return {
...env,
document,
domWindow: window,
documentListeners,
event,
query: selector => document.querySelector(selector),
reloadPlugin() { return evaluate(implementation.code) },
createPeer() {
const peerListeners = new Map()
const player = document.createElement('div')
const controls = document.createElement('div')
const danmuku = document.createElement('div')
player.append(controls, danmuku)
document.body.appendChild(player)
const art = {
...env.art,
isDestroy: false,
template: { $player: player, $controlsCenter: controls, $danmuku: danmuku },
on(name, callback) { peerListeners.set(name, [...peerListeners.get(name) || [], callback]) },
off(name, callback) { peerListeners.set(name, (peerListeners.get(name) || []).filter(item => item !== callback)) },
emit(name, ...args) {
for (const callback of [...peerListeners.get(name) || []])
callback(...args)
},
}
return {
art,
destroy() {
art.isDestroy = true
art.emit('destroy')
},
}
},
listenerCount: () => [...env.listeners.values()].reduce((count, callbacks) => count + callbacks.length, 0),
proxyCount: () => env.proxies.filter(record => record.active).length,
dispatch(target, type, values) { return target.dispatchEvent(event(type, values)) },
timeout() {
const [id, timer] = env.timers.entries().next().value || []
assert.ok(timer, 'Expected a pending controlled timer')
env.timers.delete(id)
timer.callback()
},
}
}
+99
View File
@@ -2431,6 +2431,11 @@ boolbase@^1.0.0:
resolved "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
boolbase@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-2.0.0.tgz#832968d211d5c008d833b86e049bb3943036e5b1"
integrity sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==
brace-expansion@^1.1.7:
version "1.1.12"
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz"
@@ -2969,6 +2974,17 @@ css-select@^6.0.0:
domutils "^3.2.2"
nth-check "^2.1.1"
css-select@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-7.0.0.tgz#7532bab5977b1f0e83bc16036661dbe0968b4dec"
integrity sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==
dependencies:
boolbase "^2.0.0"
css-what "^8.0.0"
domhandler "^6.0.1"
domutils "^4.0.2"
nth-check "^3.0.1"
css-tree@^3.0.1:
version "3.1.0"
resolved "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz"
@@ -2990,6 +3006,11 @@ css-what@^7.0.0:
resolved "https://registry.yarnpkg.com/css-what/-/css-what-7.0.0.tgz#5796fbebd43571d73c60ba0dd7a6e75dd0d22fe4"
integrity sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==
css-what@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-8.0.0.tgz#237889d60c4afef4736892f39e133ab4c8c21f0c"
integrity sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz"
@@ -3002,6 +3023,11 @@ csso@^5.0.5:
dependencies:
css-tree "~2.2.0"
cssom@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36"
integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==
csstype@^3.1.3, csstype@^3.2.3:
version "3.2.3"
resolved "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz"
@@ -3112,11 +3138,25 @@ dom-serializer@^2.0.0:
domhandler "^5.0.2"
entities "^4.2.0"
dom-serializer@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-3.1.1.tgz#54be70ee4fcc2da010f488165e62294798dc57d3"
integrity sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==
dependencies:
domelementtype "^3.0.0"
domhandler "^6.0.0"
entities "^8.0.0"
domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domelementtype@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-3.0.0.tgz#e6c0a24bd39ca5eb7ea67a98d2f699497d7bcc55"
integrity sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz"
@@ -3124,6 +3164,13 @@ domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
domhandler@^6.0.0, domhandler@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-6.0.1.tgz#75f351a03a6e10c35e08418f9cf0d287e00797cf"
integrity sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==
dependencies:
domelementtype "^3.0.0"
domutils@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
@@ -3133,6 +3180,15 @@ domutils@^3.2.2:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-4.0.2.tgz#0c1ac1fdbe8f554a60a6f5eb143ee85630327c94"
integrity sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==
dependencies:
dom-serializer "^3.0.0"
domelementtype "^3.0.0"
domhandler "^6.0.0"
dot-prop@^5.1.0:
version "5.3.0"
resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz"
@@ -3260,6 +3316,11 @@ entities@^7.0.1:
resolved "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz"
integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
entities@^8.0.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-8.1.0.tgz#9632d69619ebdbe0cf0ef22f53088fe5a2163d86"
integrity sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==
entities@~3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz"
@@ -4342,11 +4403,26 @@ html-escaper@^2.0.0:
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
html-escaper@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-3.0.3.tgz#4d336674652beb1dcbc29ef6b6ba7f6be6fdfed6"
integrity sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==
html-void-elements@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz"
integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==
htmlparser2@^10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4"
integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.2.2"
entities "^7.0.1"
http-cache-semantics@^4.1.1:
version "4.2.0"
resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz"
@@ -5013,6 +5089,17 @@ lines-and-columns@^1.1.6:
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
linkedom@0.18.13:
version "0.18.13"
resolved "https://registry.yarnpkg.com/linkedom/-/linkedom-0.18.13.tgz#db4582d42d5606961dd8e596fc273bd8c06f50df"
integrity sha512-ES/o9qotMpzpN2MHs+Iq/JcVoOj8Fa5wiQYrTdFpvAnwXL0g66XHHUc9WUMk6nAlBtGsFQ24ne+SYnvnaQ2FSw==
dependencies:
css-select "^7.0.0"
cssom "^0.5.0"
html-escaper "^3.0.3"
htmlparser2 "^10.1.0"
uhyphen "^0.2.0"
linkify-it@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz"
@@ -6120,6 +6207,13 @@ nth-check@^2.1.1:
dependencies:
boolbase "^1.0.0"
nth-check@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-3.0.1.tgz#a5ede96060f7f0b74d7d3d8425f2a8f0610c5776"
integrity sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==
dependencies:
boolbase "^2.0.0"
"nx@>=17.1.2 < 21":
version "20.8.4"
resolved "https://registry.npmjs.org/nx/-/nx-20.8.4.tgz"
@@ -7740,6 +7834,11 @@ uglify-js@^3.1.4:
resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz"
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
uhyphen@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/uhyphen/-/uhyphen-0.2.0.tgz#8fdf0623314486e020a3c00ee5cc7a12fe722b81"
integrity sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==
unicorn-magic@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.4.0.tgz#78c6a090fd6d07abd2468b83b385603e00dfdb24"