mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-15 09:06:16 -08:00
refactor(danmuku): [PKG-DANMUKU-05] own renderer settings and heatmap resources
This commit is contained in:
1 parent
12332df221
commit
053d211fb0
38 files changed
+65630
-2420
No files matched your search
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
|
||||
@@ -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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+3
-2
@@ -51,7 +51,7 @@
|
||||
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
|
||||
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
"test:unit": "node --test test/danmuku-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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const a=t.states[e]||[];for(let n=0;n<a.length;n++)i(a[n]);return a}const u=Symbol("cancelled danmuku frame");class c{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}this.owner.workerClient?.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{this.owner.workerClient?.dispose(),this.owner.createWorker(),this.fault=!1}catch(t){this.report(t)}}schedule(){const t=this.owner;!this.running||this.closed||this.fault||t.art.isDestroy||t.isStop||null!==this.frame||this.operation||(this.frame=window.requestAnimationFrame(()=>{if(this.frame=null,t.timer=null,this.closed||this.fault||t.isStop||t.art.isDestroy)return;let e;const i=new Promise(t=>e=()=>t(u)),a={generation:this.generation,cancel:e,wait:t=>Promise.race([t,i]),ref:null};return this.operation=a,this.run(a).catch(t=>{this.active(a)&&this.fail(t)}).finally(()=>{this.release(a),this.operation===a&&(this.operation=null,this.schedule())})}),t.timer=this.frame)}async run(t){const e=this.owner,{setStyles:i}=e.utils;if(!e.art.playing||!this.active(t))return;e.filter("emit",t=>{const i=(Date.now()-t.$lastStartTime)/1e3;t.$restTimeLine truncated
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
|
||||
@@ -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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
|
||||
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
|
||||
+898
-507
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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、同毫秒请求串线、取消后续写、
|
||||
|
||||
@@ -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
@@ -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
@@ -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 边界清楚",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user