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

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

No files matched your search

@@ -15,16 +15,25 @@ repository root. Do not infer runtime contracts from the old declaration file.
| `src/input.js` | Input forms, replacement ownership, independent append operations, cancellation and synchronous callback reentry |
| `src/bilibili-parser.js` | Pure legacy-compatible XML fields, mode mapping and entity decoding |
| `src/bilibili.js` | Fetch and response text, one parser Worker per request, fallback, settlement and Blob URL cleanup |
| `src/scheduler.js` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery; DOM preparation moves to a renderer in PKG-DANMUKU-05 |
| `src/scheduler.js` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery |
| `src/renderer.js` | Owned node allocation, preparation, geometry snapshots, placement, pause/resume styling and disposal |
| `src/queue.js` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.js` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
| `src/worker.js` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/setting.js` | Settings and emitter UI; lifecycle restructuring follows in PKG-DANMUKU-05 |
| `src/heatmap.js` | Heatmap rendering; restructuring follows in PKG-DANMUKU-05 |
| `src/setting.js` | Settings coordinator, configuration controls, mount and fullscreen layout |
| `src/setting-template.js` | Existing template HTML and static icon values |
| `src/setting-slider.js` | Slider index, pointer and rotation behavior |
| `src/setting-send.js` | Pending send, original error outlet and lock countdown |
| `src/setting-lifecycle.js` | Exact subscription/proxy disposal, cancellation and conditional host-property restoration |
| `src/setting-style.js` | Shared document style and one pending DOMContentLoaded installation |
| `src/heatmap.js` | Owned control, progress stops, subscriptions and per-instance gradient |
| `src/heatmap-sampling.js` | Sorted-time bin counts with historical numeric boundaries |
| `src/heatmap-geometry.js` | Legacy point normalization, caller-visible point writes and SVG curve calculation |
The package is undergoing staged migration. PKG-DANMUKU-03 separates input and
configuration responsibilities in JavaScript; PKG-DANMUKU-04 separates scheduling
and track requests; PKG-DANMUKU-06 converts all owned
and track requests; PKG-DANMUKU-05 separates rendering and UI resources;
PKG-DANMUKU-06 converts all owned
modules and Worker messages to TypeScript and validates installed consumers.
This intermediate structure is not the final TypeScript or release acceptance.
@@ -134,7 +143,61 @@ three modes, strict dense geometry, one request per prepared item, native
pause/seek/rate and visible-node reuse. Preserve both geometric and protocol
assertions; unique IDs alone do not prove non-overlapping tracks.
Settings cleanup, heatmap sampling and long-run load behavior still require
PKG-DANMUKU-05/07. Check task evidence and the risk register before changing
completion or release status; short native regressions are not package-wide
stability acceptance.
Long-run native load behavior still requires PKG-DANMUKU-07. Check task evidence
and the risk register before changing completion or release status; short native
regressions are not package-wide stability acceptance.
## Rendering and UI resources
The scheduler owns whether work may proceed; the renderer owns the actual nodes.
Destroy removes those nodes even when the core retains its HTML. It does not
remove unrelated children. Replacement loading retains the historical whole-layer
clear and starts a fresh node pool. Keep cancellation identity checks in the
scheduler/renderer boundary when moving preparation logic.
Replacement also removes owned nodes moved outside the layer before releasing
their ownership records. Unrelated nodes outside the layer are left in place.
Setting registers its destruction boundary before creating UI. Initialization
failure releases its own resources; the factory also rolls back the previously
created Danmuku and Worker, preserving the original error. Store exact art
subscriptions and proxy disposers. On old cores, use `events.remove` when the
disposer remains registered; calling only the returned function can leave a
registry entry. A disposal failure must not prevent other owned cleanup.
Pending `beforeEmit` is raced against destruction. Preserve its option receiver
and strict `true` acceptance. Ordinary send inserts synchronously, clears input
and starts the existing countdown; observing the emitted Promise must not delay
that UI behavior. Retain `console.error('Error emitting danmuku:', error)` as the
existing send-error outlet. Destruction prevents late input writes and timers.
Mount and fullscreen move the same Setting node. Destroy removes only that node.
Dataset and display writes restore the preceding owner/original value only while
the current value is still owned. Shared mounts must retain the surviving
instance, and subsequent user writes must survive. The injected stylesheet is
document-wide, stays available to other instances and retains import-time
installation. Pending DOMContentLoaded installation is shared and released once
it runs; it is not an instance leak.
Heatmap owns its control and listeners. Removal/replacement detaches them and
must not remove a newer same-name control. The gradient ID is instance-specific;
the first available `heatmap-solids` and local `heatmap-start`/`heatmap-stop` hooks
remain, including repeated bundle evaluation. Zero or invalid sampling
uses a positive default, and invalid dimensions/duration render no SVG. Preserve
fractional step progression and strict-left/inclusive-right time bins. The
original points event clones only the outer array and mutates inner y values;
preserve that observable behavior, including repeated updates.
`test/danmuku-setting.test.js` uses Linkedom and real published core utilities
for template parsing, selectors, events and lifecycle tests. Linkedom has no
layout; its measured rectangles are controlled inputs, not browser evidence.
`test/browser/danmuku-resources.spec.js` covers native external/shared mounts,
pending sends, timers, retained-core HTML, initialization rollback and independent
heatmaps. Run it with the scheduler/input specs for source and built artifacts.
Dedicated recovery/new-continuation cases establish a ready item as a test
precondition; obsolete gates and NaN use the actual media clock at 0.25 rate.
These lifecycle preconditions do not establish stress-load timestamp delivery.
The renderer resource cases also begin with an explicitly eligible row, then
require native Worker completion and visible DOM before testing replacement or
destruction. The separate pause/seek/rate case observes the original ready getter
without changing its result and records bounded native media-time/queue samples
to distinguish eligibility misses from placement failures.
@@ -45,6 +45,19 @@ If an error listener synchronously stops, destroys, resets or hides the instance
the interrupted recovery respects that action. A nested start can recover once
without the outer call resuming it again or emitting another start event.
## Settings and heatmap cleanup
Destroy removes the plugin's external settings panel, cancels its send countdown
and prevents an unfinished `beforeEmit` from clearing input or starting another
send. Other instances and later user changes to their shared mount remain intact.
The shared stylesheet remains installed for other players. Setting initialization
failure also releases the already-created plugin Worker.
Heatmap supports narrow containers with a positive sampling step and uses an
independent gradient for each instance. Its existing points event still changes
the inner point arrays as before; copy those arrays first if the original values
must be retained. Removing or replacing its control releases its listeners.
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module ownership, compatibility
constraints, tests and the remaining staged TypeScript migration.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const a=t.states[e]||[];for(let n=0;n<a.length;n++)i(a[n]);return a}const u=Symbol("cancelled danmuku frame");class c{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}this.owner.workerClient?.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{this.owner.workerClient?.dispose(),this.owner.createWorker(),this.fault=!1}catch(t){this.report(t)}}schedule(){const t=this.owner;!this.running||this.closed||this.fault||t.art.isDestroy||t.isStop||null!==this.frame||this.operation||(this.frame=window.requestAnimationFrame(()=>{if(this.frame=null,t.timer=null,this.closed||this.fault||t.isStop||t.art.isDestroy)return;let e;const i=new Promise(t=>e=()=>t(u)),a={generation:this.generation,cancel:e,wait:t=>Promise.race([t,i]),ref:null};return this.operation=a,this.run(a).catch(t=>{this.active(a)&&this.fail(t)}).finally(()=>{this.release(a),this.operation===a&&(this.operation=null,this.schedule())})}),t.timer=this.frame)}async run(t){const e=this.owner,{setStyles:i}=e.utils;if(!e.art.playing||!this.active(t))return;e.filter("emit",t=>{const i=(Date.now()-t.$lastStartTime)/1e3;t.$restTimeLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,o=Math.pow,r=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&r(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),p=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{r(i.next(t))}catch(e){a(e)}},o=t=>{try{r(i.throw(t))}catch(e){a(e)}},r=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});function h(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.defineProperties,i=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,r=Math.pow,o=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&o(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&o(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),c=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{o(i.next(t))}catch(e){a(e)}},r=t=>{try{o(i.throw(t))}catch(e){a(e)}},o=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,r);o((i=i.apply(t,e)).next())});function p(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function h(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function u(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:h(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=u(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,r,o,l=!1;const d=++f,p=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==r){const n=r;r=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",p),null==o||o();try{g()}catch(r){s||(i=r),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(u(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?p():(null==e||e.addEventListener("abort",p),o=null==i?void 0:i(p),l?null==o||o():function(){return c(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${h.toString()}\n ${u.toString()}\n onmessage = ${m.toString()}\n `;if(r=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(r),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function w(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function k(t){const e=w(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=w(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function b(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class ${constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emitLine truncated
File diff suppressed because it is too large. Load diff
+11 -109
View File
@@ -1,6 +1,7 @@
import { defaultOption, normalizeOption, optionChanged, optionScheme } from './config'
import { beginInput, cancelInputs, inputActive, readInput } from './input'
import { filterState, readyItems, setItemState } from './queue'
import Renderer from './renderer'
import Scheduler from './scheduler'
import WorkerClient from './worker-client'
import DanmuWorker from './worker.js?worker&inline'
@@ -23,6 +24,7 @@ export default class Danmuku {
this.index = 0 // 弹幕索引
this.worker = null
this.workerClient = null
this.renderer = new Renderer(this)
this.scheduler = new Scheduler(this)
// 格式化后的配置项
@@ -158,12 +160,7 @@ export default class Danmuku {
// 获取弹幕DOM节点
get $ref() {
const $ref = this.$refs.pop() || document.createElement('div')
$ref.style.cssText = Danmuku.cssText
$ref.dataset.mode = ''
$ref.dataset.id = ''
$ref.className = ''
return $ref
return this.renderer.acquire()
}
// 获取准备好发送的弹幕
@@ -173,34 +170,7 @@ export default class Danmuku {
// 可见的弹幕的数据,用于计算下一个弹幕的top值
get visibles() {
const result = []
const { clientWidth } = this.$player
const clientLeft = this.getLeft(this.$player)
this.filter('emit', (danmu) => {
const top = danmu.$ref.offsetTop
const left = this.getLeft(danmu.$ref) - clientLeft
const height = danmu.$ref.clientHeight
const width = danmu.$ref.clientWidth
const distance = left + width
const right = clientWidth - distance
const speed = distance / danmu.$restTime
const emit = {}
emit.top = top
emit.left = left
emit.height = height
emit.width = width
emit.right = right
emit.speed = speed
emit.distance = distance
emit.time = danmu.$restTime
emit.mode = danmu.mode
result.push(emit)
})
return result
return this.renderer.visibles
}
// 计算弹幕速度
@@ -234,8 +204,7 @@ export default class Danmuku {
return this
this.queue = [] // 清空弹幕队列
this.states = { wait: [], ready: [], emit: [], stop: [] } // 清空弹幕状态池
this.$refs = [] // 清空弹幕DOM节点池
this.$danmuku.textContent = '' // 清空弹幕层
this.renderer.clear()
}
// 逐个验证原始弹幕并转换到弹幕队列
@@ -391,8 +360,7 @@ export default class Danmuku {
// 计算DOM的left值,受到旋屏影响
getLeft($ref) {
const rect = $ref.getBoundingClientRect()
return this.isRotate ? rect.top : rect.left
return this.renderer.left($ref)
}
// 复杂运算交给 Web Worker 处理
@@ -417,16 +385,7 @@ export default class Danmuku {
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu) {
this.setState(danmu, 'wait')
if (danmu.$ref) {
danmu.$ref.style.cssText = Danmuku.cssText
danmu.$ref.style.visibility = 'hidden'
danmu.$ref.style.marginLeft = '0px'
danmu.$ref.style.transform = 'translateX(0px)'
danmu.$ref.style.transition = 'transform 0s linear 0s'
this.$refs.push(danmu.$ref)
danmu.$ref = null
}
this.renderer.makeWait(danmu)
}
// 实时更新弹幕
@@ -438,76 +397,18 @@ export default class Danmuku {
// 重置正在显示的弹幕: stop/emit 状态的弹幕
resize() {
const { clientWidth } = this.$player
this.filter('stop', (danmu) => {
switch (danmu.mode) {
// 滚动的弹幕
case 0:
danmu.$ref.style.left = `${clientWidth}px`
break
default:
break
}
})
this.filter('emit', (danmu) => {
danmu.$lastStartTime = Date.now()
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
break
}
})
this.renderer.resize()
}
// 继续弹幕
continue() {
const { clientWidth } = this.$player
this.filter('stop', (danmu) => {
this.setState(danmu, 'emit') // 转换为emit状态
danmu.$lastStartTime = Date.now()
switch (danmu.mode) {
// 继续滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
break
}
})
this.renderer.continue()
return this
}
// 暂停弹幕
suspend() {
const { clientWidth } = this.$player
this.filter('emit', (danmu) => {
this.setState(danmu, 'stop') // 转换为stop状态
switch (danmu.mode) {
// 停止滚动的弹幕
case 0: {
const translateX = clientWidth - (this.getLeft(danmu.$ref) - this.getLeft(this.$player))
danmu.$ref.style.transform = `translateX(${-translateX}px)`
danmu.$ref.style.transition = 'transform 0s linear 0s'
break
}
default:
break
}
})
this.renderer.suspend()
return this
}
@@ -581,6 +482,7 @@ export default class Danmuku {
}
}
finally {
this.renderer.destroy()
this.art.off('video:play', this.start)
this.art.off('video:playing', this.start)
this.art.off('video:pause', this.stop)
@@ -0,0 +1,78 @@
import { MAX_HEATMAP_POINTS, sampleHeatmap } from './heatmap-sampling'
function map(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
}
function line(pointA, pointB) {
const lengthX = pointB[0] - pointA[0]
const lengthY = pointB[1] - pointA[1]
return { length: Math.sqrt(lengthX ** 2 + lengthY ** 2), angle: Math.atan2(lengthY, lengthX) }
}
export function heatmapGeometry({ width, height, duration, queue, option, points: input = [] }) {
if (![width, height, duration].every(value => Number.isFinite(value) && value > 0))
return null
const defaults = {
xMin: 0,
xMax: width,
yMin: 0,
yMax: 128,
scale: 0.25,
opacity: 0.2,
minHeight: Math.floor(height * 0.05),
sampling: Math.max(1, Math.floor(width / 100)),
smoothing: 0.2,
flattening: 0.2,
}
const options = Object.assign({}, defaults, typeof option === 'object' ? option : {})
for (const key of Object.keys(defaults)) {
if (!Number.isFinite(options[key]))
options[key] = defaults[key]
}
if (options.sampling <= 0)
options.sampling = defaults.sampling
if (options.xMin === options.xMax || options.yMin === options.yMax)
return null
const points = Array.isArray(input) && input.length ? [...input] : sampleHeatmap(queue, width, duration, options.sampling)
if (!points.length
|| !points.every(point => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]))) { return null }
const lastPoint = points[points.length - 1]
if (lastPoint[0] !== width) {
if (points.length === MAX_HEATMAP_POINTS)
return null
points.push([width, lastPoint[1]])
}
let yMin = Infinity
let yMax = -Infinity
for (const point of points) {
yMin = Math.min(yMin, point[1])
yMax = Math.max(yMax, point[1])
}
const yMid = (yMin + yMax) / 2
// Historical points events copy only the outer array: callers observe these y writes.
for (const point of points) {
const y = point[1]
point[1] = y * (y > yMid ? 1 + options.scale : 1 - options.scale) + options.minHeight
}
const controlPoint = (current, previous, next, reverse) => {
const geometry = line(previous || current, next || current)
const flat = map(Math.cos(geometry.angle) * options.flattening, 0, 1, 1, 0)
const angle = geometry.angle * flat + (reverse ? Math.PI : 0)
const length = geometry.length * options.smoothing
return [current[0] + Math.cos(angle) * length, current[1] + Math.sin(angle) * length]
}
const positions = points.map(point => [map(point[0], options.xMin, options.xMax, 0, width), map(point[1], options.yMin, options.yMax, height, 0)])
if (!positions.every(point => point.every(Number.isFinite)))
return null
const path = positions.map((point, index, all) => {
if (index === 0)
return `M ${all[all.length - 1][0]},${height} L ${point[0]},${height} L ${point[0]},${point[1]}`
const start = controlPoint(all[index - 1], all[index - 2], point)
const end = controlPoint(point, all[index - 1], all[index + 1], true)
return `C ${start[0]},${start[1]} ${end[0]},${end[1]} ${point[0]},${point[1]}${index === all.length - 1 ? ' z' : ''}`
}).join(' ')
if (/NaN|Infinity/u.test(path))
return null
return { path, opacity: options.opacity, width, height }
}
@@ -0,0 +1,32 @@
export const MAX_HEATMAP_POINTS = 0xFFFFFFFF
function upperBound(values, value) {
let start = 0
let end = values.length
while (start < end) {
const middle = Math.floor((start + end) / 2)
if (values[middle] <= value)
start = middle + 1
else end = middle
}
return start
}
export function sampleHeatmap(queue, width, duration, sampling) {
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(duration) || duration <= 0
|| !Number.isFinite(sampling) || sampling <= 0 || Math.floor(width / sampling) + 1 > MAX_HEATMAP_POINTS) { return [] }
const times = queue.map(item => item.time).filter(time => typeof time === 'number' && !Number.isNaN(time)).sort((a, b) => a - b)
const points = []
const gap = duration / width
if (!Number.isFinite(gap))
return []
// Keep the old floating-point x progression and strict-left/inclusive-right bins.
for (let x = 0; x <= width; x += sampling) {
if (points.length >= MAX_HEATMAP_POINTS || x + sampling === x)
return []
const left = x * gap
const right = (x + sampling) * gap
points.push([x, upperBound(times, right) - upperBound(times, left)])
}
return points
}
+137 -177
View File
@@ -1,186 +1,146 @@
const lib = {
map(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
},
range(start, end, tick) {
const s = Math.round(start / tick) * tick
return Array.from(
{
length: Math.floor((end - start) / tick),
},
(v, k) => {
return k * tick + s
},
)
},
}
import { heatmapGeometry } from './heatmap-geometry'
function line(pointA, pointB) {
const lengthX = pointB[0] - pointA[0]
const lengthY = pointB[1] - pointA[1]
return {
length: Math.sqrt(lengthX ** 2 + lengthY ** 2),
angle: Math.atan2(lengthY, lengthX),
}
}
let nextGradient = 0
export default function heatmap(art, danmuku, option) {
if (art.isDestroy)
return
const { query } = art.constructor.utils
let gradient
const subscriptions = []
let element
let start
let stop
let closed = false
art.controls.add({
name: 'heatmap',
position: 'top',
html: '',
style: {
position: 'absolute',
top: '-100px',
left: '0px',
right: '0px',
height: '100px',
width: '100%',
pointerEvents: 'none',
},
mounted($heatmap) {
let $start = null
let $stop = null
function update(arg = []) {
$start = null
$stop = null
$heatmap.innerHTML = ''
if (!art.duration || art.option.isLive)
return
const svg = {
w: $heatmap.offsetWidth,
h: $heatmap.offsetHeight,
}
const options = {
xMin: 0,
xMax: svg.w,
yMin: 0,
yMax: 128,
scale: 0.25,
opacity: 0.2,
minHeight: Math.floor(svg.h * 0.05),
sampling: Math.floor(svg.w / 100),
smoothing: 0.2,
flattening: 0.2,
}
if (typeof option === 'object') {
Object.assign(options, option)
}
let points = []
if (Array.isArray(arg) && arg.length) {
points = [...arg]
}
else {
const gap = art.duration / svg.w
for (let x = 0; x <= svg.w; x += options.sampling) {
const y = danmuku.queue.filter(
({ time }) => time > x * gap && time <= (x + options.sampling) * gap,
).length
points.push([x, y])
}
}
if (points.length === 0)
return
const lastPoint = points[points.length - 1]
const lastX = lastPoint[0]
const lastY = lastPoint[1]
if (lastX !== svg.w) {
points.push([svg.w, lastY])
}
const yPoints = points.map(point => point[1])
const yMin = Math.min(...yPoints)
const yMax = Math.max(...yPoints)
const yMid = (yMin + yMax) / 2
for (let i = 0; i < points.length; i++) {
const point = points[i]
const y = point[1]
point[1] = y * (y > yMid ? 1 + options.scale : 1 - options.scale) + options.minHeight
}
const controlPoint = (current, previous, next, reverse) => {
const p = previous || current
const n = next || current
const o = line(p, n)
const flat = lib.map(Math.cos(o.angle) * options.flattening, 0, 1, 1, 0)
const angle = o.angle * flat + (reverse ? Math.PI : 0)
const length = o.length * options.smoothing
const x = current[0] + Math.cos(angle) * length
const y = current[1] + Math.sin(angle) * length
return [x, y]
}
const bezierCommand = (point, i, a) => {
const cps = controlPoint(a[i - 1], a[i - 2], point)
const cpe = controlPoint(point, a[i - 1], a[i + 1], true)
const close = i === a.length - 1 ? ' z' : ''
return `C ${cps[0]},${cps[1]} ${cpe[0]},${cpe[1]} ${point[0]},${point[1]}${close}`
}
const pointsPositions = points.map((e) => {
const x = lib.map(e[0], options.xMin, options.xMax, 0, svg.w)
const y = lib.map(e[1], options.yMin, options.yMax, svg.h, 0)
return [x, y]
})
const pathD = pointsPositions.reduce(
(acc, e, i, a) =>
i === 0
? `M ${a[a.length - 1][0]},${svg.h} L ${e[0]},${svg.h} L ${e[0]},${e[1]}`
: `${acc} ${bezierCommand(e, i, a)}`,
'',
)
$heatmap.innerHTML = `
<svg viewBox="0 0 ${svg.w} ${svg.h}">
<defs>
<linearGradient id="heatmap-solids" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${options.opacity}" />
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${options.opacity}" id="heatmap-start" />
<stop offset="0%" style="stop-color:var(--art-progress-color);stop-opacity:1" id="heatmap-stop" />
<stop offset="100%" style="stop-color:var(--art-progress-color);stop-opacity:1" />
</linearGradient>
</defs>
<path fill="url(#heatmap-solids)" d="${pathD}"></path>
</svg>
`
$start = query('#heatmap-start', $heatmap)
$stop = query('#heatmap-stop', $heatmap)
$start.setAttribute('offset', `${art.played * 100}%`)
$stop.setAttribute('offset', `${art.played * 100}%`)
const active = () => !closed && !art.isDestroy
function listen(name, callback) {
subscriptions.push([name, callback])
art.on(name, callback)
}
function dispose(removeControl) {
if (closed)
return
closed = true
start = null
stop = null
let failure
let failed = false
const attempt = (callback) => {
try {
callback()
}
art.on('video:timeupdate', () => {
if ($start && $stop) {
$start.setAttribute('offset', `${art.played * 100}%`)
$stop.setAttribute('offset', `${art.played * 100}%`)
catch (error) {
if (!failed)
failure = error
failed = true
}
}
for (const [name, callback] of subscriptions.splice(0))
attempt(() => art.off(name, callback))
if (removeControl && element && art.controls.heatmap === element)
attempt(() => art.controls.remove('heatmap'))
element = undefined
if (failed)
throw failure
}
function progress(value) {
if (active() && start && stop) {
start.setAttribute('offset', `${value * 100}%`)
stop.setAttribute('offset', `${value * 100}%`)
}
}
function draw(points = []) {
if (!active())
return
start = null
stop = null
element.innerHTML = ''
if (art.option.isLive)
return
const shape = heatmapGeometry({ width: element.offsetWidth, height: element.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points })
if (!shape || !active())
return
const document = element.ownerDocument
if (!gradient)
gradient = document ? 'heatmap-solids' : `heatmap-solids-${++nextGradient}`
while (document?.getElementById(gradient))
gradient = `heatmap-solids-${++nextGradient}`
element.innerHTML = `
<svg viewBox="0 0 ${shape.width} ${shape.height}">
<defs>
<linearGradient id="${gradient}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${shape.opacity}" />
<stop offset="0%" style="stop-color:var(--art-theme);stop-opacity:${shape.opacity}" id="heatmap-start" />
<stop offset="0%" style="stop-color:var(--art-progress-color);stop-opacity:1" id="heatmap-stop" />
<stop offset="100%" style="stop-color:var(--art-progress-color);stop-opacity:1" />
</linearGradient>
</defs>
<path fill="url(#${gradient})" d="${shape.path}"></path>
</svg>
`
start = query('#heatmap-start', element)
stop = query('#heatmap-stop', element)
progress(art.played)
}
function update(points) {
try {
draw(points)
}
catch (error) {
try {
dispose(true)
}
catch {}
throw error
}
}
listen('destroy', () => dispose(true))
try {
art.controls.add({
name: 'heatmap',
position: 'top',
html: '',
style: {
position: 'absolute',
top: '-100px',
left: '0px',
right: '0px',
height: '100px',
width: '100%',
pointerEvents: 'none',
},
beforeUnmount() { dispose(false) },
mounted(value) {
element = value
if (closed) {
if (art.controls.heatmap === value)
art.controls.remove('heatmap')
element = undefined
return
}
})
art.on('setBar', (type, percentage) => {
if ($start && $stop && type === 'played') {
$start.setAttribute('offset', `${percentage * 100}%`)
$stop.setAttribute('offset', `${percentage * 100}%`)
if (!active()) {
dispose(true)
return
}
})
art.on('ready', () => update())
art.on('resize', () => update())
art.on('artplayerPluginDanmuku:loaded', () => update())
art.on('artplayerPluginDanmuku:points', points => update(points))
},
})
listen('video:timeupdate', () => progress(art.played))
listen('setBar', (type, percentage) => {
if (type === 'played')
progress(percentage)
})
listen('ready', () => update())
listen('resize', () => update())
listen('artplayerPluginDanmuku:loaded', () => update())
listen('artplayerPluginDanmuku:points', points => update(points))
},
})
}
catch (error) {
try {
dispose(true)
}
catch {}
throw error
}
return () => dispose(true)
}
+17 -4
View File
@@ -5,10 +5,23 @@ import Setting from './setting'
export default function artplayerPluginDanmuku(option) {
return (art) => {
const danmuku = new Danmuku(art, option)
const setting = art.isDestroy ? undefined : new Setting(art, danmuku)
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
let setting
try {
setting = art.isDestroy ? undefined : new Setting(art, danmuku)
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
}
}
catch (error) {
try {
setting?.destroy()
}
catch {}
try {
danmuku.destroy()
}
catch {}
throw error
}
return {
@@ -0,0 +1,203 @@
export default class Renderer {
constructor(owner) {
this.owner = owner
this.nodes = new Set()
}
acquire() {
const $ref = this.owner.$refs.pop() || document.createElement('div')
$ref.style.cssText = this.owner.constructor.cssText
$ref.dataset.mode = ''
$ref.dataset.id = ''
$ref.className = ''
this.nodes.add($ref)
return $ref
}
get visibles() {
const result = []
const { clientWidth } = this.owner.$player
const clientLeft = this.owner.getLeft(this.owner.$player)
this.owner.filter('emit', (danmu) => {
const top = danmu.$ref.offsetTop
const left = this.owner.getLeft(danmu.$ref) - clientLeft
const height = danmu.$ref.clientHeight
const width = danmu.$ref.clientWidth
const distance = left + width
const right = clientWidth - distance
const speed = distance / danmu.$restTime
const emit = {}
emit.top = top
emit.left = left
emit.height = height
emit.width = width
emit.right = right
emit.speed = speed
emit.distance = distance
emit.time = danmu.$restTime
emit.mode = danmu.mode
result.push(emit)
})
return result
}
left($ref) {
const rect = $ref.getBoundingClientRect()
return this.owner.isRotate ? rect.top : rect.left
}
makeWait(danmu) {
this.owner.setState(danmu, 'wait')
if (danmu.$ref) {
danmu.$ref.style.cssText = this.owner.constructor.cssText
danmu.$ref.style.visibility = 'hidden'
danmu.$ref.style.marginLeft = '0px'
danmu.$ref.style.transform = 'translateX(0px)'
danmu.$ref.style.transition = 'transform 0s linear 0s'
this.owner.$refs.push(danmu.$ref)
danmu.$ref = null
}
}
resize() {
const { clientWidth } = this.owner.$player
this.owner.filter('stop', (danmu) => {
switch (danmu.mode) {
// 滚动的弹幕
case 0:
danmu.$ref.style.left = `${clientWidth}px`
break
default:
break
}
})
this.owner.filter('emit', (danmu) => {
danmu.$lastStartTime = Date.now()
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
break
}
})
}
continue() {
const { clientWidth } = this.owner.$player
this.owner.filter('stop', (danmu) => {
this.owner.setState(danmu, 'emit') // 转换为emit状态
danmu.$lastStartTime = Date.now()
switch (danmu.mode) {
// 继续滚动的弹幕
case 0: {
const distance = clientWidth + danmu.$ref.clientWidth
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
default:
break
}
})
return this
}
suspend() {
const { clientWidth } = this.owner.$player
this.owner.filter('emit', (danmu) => {
this.owner.setState(danmu, 'stop') // 转换为stop状态
switch (danmu.mode) {
// 停止滚动的弹幕
case 0: {
const translateX = clientWidth - (this.owner.getLeft(danmu.$ref) - this.owner.getLeft(this.owner.$player))
danmu.$ref.style.transform = `translateX(${-translateX}px)`
danmu.$ref.style.transition = 'transform 0s linear 0s'
break
}
default:
break
}
})
return this
}
prepare(danmu, operation) {
const owner = this.owner
const ref = owner.$ref
operation.danmu = danmu
operation.ref = ref
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
ref.style.fontSize = `${owner.fontSize}px`
ref.style.color = danmu.color
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
owner.utils.setStyles(ref, danmu.style)
return ref
}
place(danmu, ref, top, distance, clientWidth) {
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`
ref.style.marginLeft = '0px'
ref.style.transform = `translateX(${-distance}px)`
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
case 1:
case 2:
ref.style.left = '50%'
ref.style.marginLeft = `-${ref.clientWidth / 2}px`
break
default:
break
}
}
release(operation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
}
operation.ref = null
}
clear() {
this.destroy()
this.owner.$refs = []
this.owner.$danmuku.textContent = ''
}
destroy() {
for (const node of this.nodes) {
if (node.parentElement)
node.parentElement.removeChild(node)
}
for (const danmu of this.owner.queue) danmu.$ref = null
this.owner.$refs.length = 0
this.nodes.clear()
}
}
@@ -20,14 +20,7 @@ export default class Scheduler {
}
release(operation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
}
operation.ref = null
this.owner.renderer.release(operation)
}
invalidate() {
@@ -108,7 +101,6 @@ export default class Scheduler {
async run(operation) {
const owner = this.owner
const { setStyles } = owner.utils
if (!owner.art.playing || !this.active(operation))
return
owner.filter('emit', (danmu) => {
@@ -141,18 +133,7 @@ export default class Scheduler {
continue
const { clientWidth, clientHeight } = owner.$player
const ref = owner.$ref
operation.danmu = danmu
operation.ref = ref
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
ref.style.fontSize = `${owner.fontSize}px`
ref.style.color = danmu.color
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
setStyles(ref, danmu.style)
const ref = owner.renderer.prepare(danmu, operation)
if (!this.active(operation))
return
danmu.$lastStartTime = Date.now()
@@ -172,25 +153,7 @@ export default class Scheduler {
return
if (top !== undefined) {
owner.setState(danmu, 'emit')
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`
ref.style.marginLeft = '0px'
ref.style.transform = `translateX(${-distance}px)`
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
case 1:
case 2:
ref.style.left = '50%'
ref.style.marginLeft = `-${ref.clientWidth / 2}px`
break
default:
break
}
owner.renderer.place(danmu, ref, top, distance, clientWidth)
operation.ref = null
owner.art.emit('artplayerPluginDanmuku:visible', danmu)
}
@@ -0,0 +1,154 @@
const properties = new WeakMap()
export default class SettingLifecycle {
constructor(art) {
this.art = art
this.closed = false
this.disposers = new Set()
this.cancelled = new Promise(resolve => this.cancel = resolve)
}
get active() {
return !this.closed && !this.art.isDestroy
}
own(dispose) {
if (this.closed)
dispose()
else this.disposers.add(dispose)
}
write(target, key, value, apply = value => target[key] = value) {
if (!this.active)
return
const priority = () => typeof target.getPropertyPriority === 'function' ? target.getPropertyPriority(key) : undefined
let keys = properties.get(target)
if (!keys) {
keys = new Map()
properties.set(target, keys)
}
let entry = keys.get(key)
if (!entry) {
entry = {
original: target[key],
present: key in target || target[key] !== undefined,
priority: priority(),
owners: new Map(),
apply,
}
keys.set(key, entry)
}
if (!entry.owners.has(this)) {
this.own(() => {
const owners = [...entry.owners.keys()]
const current = owners[owners.length - 1] === this
entry.owners.delete(this)
// A later user write belongs to the host, not this settings instance.
if (current && target[key] === entry.last && priority() === entry.lastPriority) {
const values = [...entry.owners.values()]
if (values.length) {
entry.apply(values[values.length - 1])
entry.last = target[key]
entry.lastPriority = priority()
}
else if (entry.priority !== undefined) {
target.setProperty(key, entry.original, entry.priority)
}
else if (entry.present) {
entry.apply(entry.original)
}
else {
delete target[key]
}
}
if (!entry.owners.size) {
keys.delete(key)
if (!keys.size)
properties.delete(target)
}
})
}
entry.owners.delete(this)
entry.owners.set(this, value)
entry.apply(value)
entry.last = target[key]
entry.lastPriority = priority()
}
on(name, callback) {
if (!this.active)
return
const lifecycle = this
function listener(...args) {
if (lifecycle.active)
return callback.apply(this, args)
}
const dispose = () => this.art.off(name, listener)
this.own(dispose)
try {
this.art.on(name, listener)
if (!this.active)
dispose()
}
catch (error) {
this.release(dispose)
throw error
}
}
proxy(target, name, callback) {
if (!this.active)
return
const lifecycle = this
function listener(...args) {
if (lifecycle.active)
return callback.apply(this, args)
}
let remove
const dispose = () => {
if (typeof remove === 'function') {
const registry = this.art.events
if (typeof registry?.remove === 'function' && registry.destroyEvents?.has(remove))
registry.remove(remove)
else remove()
}
else {
target.removeEventListener?.(name, listener)
}
}
this.own(dispose)
try {
remove = this.art.proxy(target, name, listener)
if (!this.active)
dispose()
}
catch (error) {
this.release(dispose)
throw error
}
}
wait(value) {
return Promise.race([value, this.cancelled])
}
release(dispose) {
try {
dispose()
}
catch (error) {
console.warn('Failed to dispose danmuku setting resource:', error)
}
}
close() {
if (this.closed)
return
this.closed = true
this.cancel()
for (const dispose of this.disposers) {
this.release(dispose)
}
this.disposers.clear()
}
}
@@ -0,0 +1,79 @@
export async function emitSetting(setting) {
const { lifecycle, template: { $input } } = setting
if (!lifecycle.active)
return
const text = $input.value.trim()
if (!text.length || setting.isLock || setting.emitting)
return
const danmu = {
text,
mode: setting.option.mode,
color: setting.option.color,
time: setting.art.currentTime,
}
const report = (error) => {
if (lifecycle.active)
console.error('Error emitting danmuku:', error)
}
try {
setting.emitting = true
const state = await lifecycle.wait(setting.option.beforeEmit(danmu))
if (!lifecycle.active)
return
setting.emitting = false
if (state !== true)
return
danmu.border = true
delete danmu.time
// emit inserts synchronously; observe its Promise without delaying the UI.
Promise.resolve(setting.danmuku.emit(danmu)).catch(report)
if (!lifecycle.active)
return
$input.value = ''
setting.lock()
}
catch (error) {
report(error)
if (lifecycle.active)
setting.emitting = false
}
}
export function lockSetting(setting) {
if (!setting.lifecycle.active)
return
const { addClass } = setting.utils
const { $send } = setting.template
clearTimeout(setting.timer)
setting.isLock = true
let time = setting.option.lockTime
$send.textContent = time
addClass($send, 'apd-lock')
const loop = () => {
setting.timer = setTimeout(() => {
if (!setting.lifecycle.active)
return
if (time === 0) {
setting.unlock()
}
else {
time -= 1
$send.textContent = time
loop()
}
}, 1000)
}
loop()
}
export function unlockSetting(setting) {
clearTimeout(setting.timer)
setting.timer = null
setting.isLock = false
if (!setting.lifecycle.active)
return
const { removeClass } = setting.utils
const { $send } = setting.template
$send.textContent = '发送'
removeClass($send, 'apd-lock')
}
@@ -0,0 +1,80 @@
export default function createSlider({ min, max, container, findIndex, onChange, steps = [] }) {
const { lifecycle } = this
if (!lifecycle.active)
return { reset() {} }
const { query, clamp, setStyle } = this.utils
setStyle(container, 'touch-action', 'none')
container.innerHTML = `
<div class="apd-slider-line">
<div class="apd-slider-points">
${steps.map(() => `<div class="apd-slider-point"></div>`).join('')}
</div>
<div class="apd-slider-progress"></div>
</div>
<div class="apd-slider-dot"></div>
<div class="apd-slider-steps">
${steps.map(step => (step.hide ? '' : `<div class="apd-slider-step">${step.name}</div>`)).join('')}
</div>
`
const $dot = query('.apd-slider-dot', container)
const $progress = query('.apd-slider-progress', container)
let isDroging = false
lifecycle.own(() => isDroging = false)
function reset(index) {
if (!lifecycle.active)
return
if (index === undefined)
index = findIndex()
if (index < min || index > max)
return
const percentage = (index - min) / (max - min)
$dot.style.left = `${percentage * 100}%`
if (steps.length === 0) {
$progress.style.width = $dot.style.left
}
onChange(index)
}
function updateLeft(event) {
const { top, height, left, width } = container.getBoundingClientRect()
if (this.art.isRotate) {
const value = clamp(event.clientY - top, 0, height)
const index = Math.round((value / height) * (max - min) + min)
reset(index)
}
else {
const value = clamp(event.clientX - left, 0, width)
const index = Math.round((value / width) * (max - min) + min)
reset(index)
}
}
lifecycle.proxy(container, 'click', (event) => {
updateLeft.call(this, event)
})
lifecycle.proxy(container, 'pointerdown', (event) => {
isDroging = event.button === 0
})
lifecycle.on('document:pointermove', (event) => {
if (isDroging) {
updateLeft.call(this, event)
}
})
lifecycle.on('document:pointerup', (event) => {
if (isDroging) {
isDroging = false
updateLeft.call(this, event)
}
})
return { reset }
}
@@ -0,0 +1,29 @@
import style from './style.less?inline'
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-danmuku'
const pending = Symbol.for('artplayer-plugin-danmuku.pending-style')
let $style = document.getElementById(id) || document[pending]
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document[pending] = $style
const ready = () => {
document.removeEventListener('DOMContentLoaded', ready)
if (document[pending] === $style)
delete document[pending]
const existing = document.getElementById(id)
if (existing)
existing.textContent = $style.textContent
else
(document.head || document.documentElement).appendChild($style)
}
document.addEventListener('DOMContentLoaded', ready, { once: true })
}
else {
(document.head || document.documentElement).appendChild($style)
}
}
$style.textContent = style
}
@@ -0,0 +1,126 @@
import $check_off from './img/check_off.svg?raw'
import $check_on from './img/check_on.svg?raw'
import $config from './img/config.svg?raw'
import $mode_0_off from './img/mode_0_off.svg?raw'
import $mode_0_on from './img/mode_0_on.svg?raw'
import $mode_1_off from './img/mode_1_off.svg?raw'
import $mode_1_on from './img/mode_1_on.svg?raw'
import $mode_2_off from './img/mode_2_off.svg?raw'
import $mode_2_on from './img/mode_2_on.svg?raw'
import $off from './img/off.svg?raw'
import $on from './img/on.svg?raw'
import $style from './img/style.svg?raw'
export function settingIcons() {
return {
$on,
$off,
$config,
$style,
$mode_0_off,
$mode_0_on,
$mode_1_off,
$mode_1_on,
$mode_2_off,
$mode_2_on,
$check_on,
$check_off,
}
}
export function settingTemplate() {
const { option } = this
return `
<div class="apd-toggle">
${$on}${$off}
</div>
<div class="apd-config">
${$config}
<div class="apd-config-panel">
<div class="apd-config-panel-inner">
<div class="apd-config-mode">
按类型屏蔽
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_off}${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_off}${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_off}${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-config-other">
<div class="apd-other apd-anti-overlap">
${$check_on}${$check_off}
防止弹幕重叠
</div>
<div class="apd-other apd-sync-video">
${$check_on}${$check_off}
同步视频速度
</div>
</div>
<div class="apd-config-slider apd-config-opacity">
不透明度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-margin">
显示区域
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-fontSize">
弹幕字号
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-speed">
弹幕速度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
</div>
</div>
</div>
<div class="apd-emitter">
<div class="apd-style">
${$style}
<div class="apd-style-panel">
<div class="apd-style-panel-inner">
<div class="apd-style-mode">
模式
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-style-color">
颜色
<div class="apd-colors">
${this.COLOR.map(color => `<div data-color="${color}" class="apd-color" style="background-color: ${color}"></div>`).join('')}
</div>
</div>
</div>
</div>
</div>
<input class="apd-input" placeholder="发个友善的弹幕见证当下" autocomplete="off" maxLength="${option.maxLength}" />
<div class="apd-send">发送</div>
</div>
`
}
+99 -307
View File
@@ -1,16 +1,8 @@
import $check_off from './img/check_off.svg?raw'
import $check_on from './img/check_on.svg?raw'
import $config from './img/config.svg?raw'
import $mode_0_off from './img/mode_0_off.svg?raw'
import $mode_0_on from './img/mode_0_on.svg?raw'
import $mode_1_off from './img/mode_1_off.svg?raw'
import $mode_1_on from './img/mode_1_on.svg?raw'
import $mode_2_off from './img/mode_2_off.svg?raw'
import $mode_2_on from './img/mode_2_on.svg?raw'
import $off from './img/off.svg?raw'
import $on from './img/on.svg?raw'
import $style from './img/style.svg?raw'
import style from './style.less?inline'
import SettingLifecycle from './setting-lifecycle'
import { emitSetting, lockSetting, unlockSetting } from './setting-send'
import createSlider from './setting-slider'
import { settingIcons, settingTemplate } from './setting-template'
import './setting-style'
export default class Setting {
constructor(art, danmuku) {
@@ -20,7 +12,6 @@ export default class Setting {
const { setStyle } = this.utils
const { $controlsCenter } = art.template
setStyle($controlsCenter, 'display', 'flex')
this.template = {
$controlsCenter,
@@ -57,46 +48,59 @@ export default class Setting {
this.isLock = false
this.timer = null
this.createTemplate()
this.createSliders()
this.createEvents()
this.lifecycle = new SettingLifecycle(art)
this.destroy = this.destroy.bind(this)
this.lifecycle.own(() => art.off('destroy', this.destroy))
this.mount(this.option.mount)
try {
art.on('destroy', this.destroy)
if (!this.lifecycle.active)
return
this.lifecycle.write($controlsCenter.style, 'display', 'flex', value => setStyle($controlsCenter, 'display', value))
art.on('resize', () => this.resize())
art.on('fullscreen', state => this.onFullscreen(state))
art.on('fullscreenWeb', state => this.onFullscreen(state))
this.createTemplate()
if (!this.lifecycle.active)
return
this.createSliders()
if (!this.lifecycle.active)
return
this.createEvents()
art.proxy(this.template.$config, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$config,
$panel: this.template.$configPanel,
this.mount(this.option.mount)
if (!this.lifecycle.active)
return
this.lifecycle.on('resize', () => this.resize())
this.lifecycle.on('fullscreen', state => this.onFullscreen(state))
this.lifecycle.on('fullscreenWeb', state => this.onFullscreen(state))
this.lifecycle.proxy(this.template.$config, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$config,
$panel: this.template.$configPanel,
})
})
})
art.proxy(this.template.$style, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$style,
$panel: this.template.$stylePanel,
this.lifecycle.proxy(this.template.$style, 'mouseenter', () => {
this.onMouseEnter({
$control: this.template.$style,
$panel: this.template.$stylePanel,
})
})
})
}
catch (error) {
try {
this.destroy()
}
catch (cleanupError) {
console.warn('Failed to roll back danmuku setting:', cleanupError)
}
throw error
}
}
static get icons() {
return {
$on,
$off,
$config,
$style,
$mode_0_off,
$mode_0_on,
$mode_1_off,
$mode_1_on,
$mode_2_off,
$mode_2_on,
$check_on,
$check_off,
}
return settingIcons()
}
get option() {
@@ -108,100 +112,7 @@ export default class Setting {
}
get TEMPLATE() {
const { option } = this
return `
<div class="apd-toggle">
${$on}${$off}
</div>
<div class="apd-config">
${$config}
<div class="apd-config-panel">
<div class="apd-config-panel-inner">
<div class="apd-config-mode">
按类型屏蔽
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_off}${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_off}${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_off}${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-config-other">
<div class="apd-other apd-anti-overlap">
${$check_on}${$check_off}
防止弹幕重叠
</div>
<div class="apd-other apd-sync-video">
${$check_on}${$check_off}
同步视频速度
</div>
</div>
<div class="apd-config-slider apd-config-opacity">
不透明度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-margin">
显示区域
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-fontSize">
弹幕字号
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
<div class="apd-config-slider apd-config-speed">
弹幕速度
<div class="apd-slider"></div>
<div class="apd-value">未知</div>
</div>
</div>
</div>
</div>
<div class="apd-emitter">
<div class="apd-style">
${$style}
<div class="apd-style-panel">
<div class="apd-style-panel-inner">
<div class="apd-style-mode">
模式
<div class="apd-modes">
<div data-mode="0" class="apd-mode">
${$mode_0_on}
<div>滚动</div>
</div>
<div data-mode="1" class="apd-mode">
${$mode_1_on}
<div>顶部</div>
</div>
<div data-mode="2" class="apd-mode">
${$mode_2_on}
<div>底部</div>
</div>
</div>
</div>
<div class="apd-style-color">
颜色
<div class="apd-colors">
${this.COLOR.map(color => `<div data-color="${color}" class="apd-color" style="background-color: ${color}"></div>`).join('')}
</div>
</div>
</div>
</div>
</div>
<input class="apd-input" placeholder="发个友善的弹幕见证当下" autocomplete="off" maxLength="${option.maxLength}" />
<div class="apd-send">发送</div>
</div>
`
return settingTemplate.call(this)
}
get OPACITY() {
@@ -308,6 +219,8 @@ export default class Setting {
}
append(el, target) {
if (!this.lifecycle.active)
return
const { append } = this.utils
const children = [...el.children]
if (children.includes(target))
@@ -316,11 +229,13 @@ export default class Setting {
}
setData(key, value) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
const { $mount } = this.template
$player.dataset[key] = value
this.lifecycle.write($player.dataset, key, value)
if (this.outside) {
$mount.dataset[key] = value
this.lifecycle.write($mount.dataset, key, value)
}
}
@@ -328,10 +243,10 @@ export default class Setting {
const { createElement, tooltip } = this.utils
const $danmuku = createElement('div')
this.template.$danmuku = $danmuku
$danmuku.className = 'artplayer-plugin-danmuku'
$danmuku.innerHTML = this.TEMPLATE
this.template.$danmuku = $danmuku
this.template.$toggle = this.query('.apd-toggle')
this.template.$config = this.query('.apd-config')
this.template.$configPanel = this.query('.apd-config-panel')
@@ -355,11 +270,11 @@ export default class Setting {
const { $toggle } = this.template
this.art.on('artplayerPluginDanmuku:show', () => {
this.lifecycle.on('artplayerPluginDanmuku:show', () => {
tooltip($toggle, '关闭弹幕')
})
this.art.on('artplayerPluginDanmuku:hide', () => {
this.lifecycle.on('artplayerPluginDanmuku:hide', () => {
tooltip($toggle, '打开弹幕')
})
}
@@ -367,14 +282,14 @@ export default class Setting {
createEvents() {
const { $toggle, $configModes, $styleModes, $colors, $antiOverlap, $syncVideo, $send, $input } = this.template
this.art.proxy($toggle, 'click', () => {
this.lifecycle.proxy($toggle, 'click', () => {
this.danmuku.config({
visible: !this.option.visible,
})
this.reset()
})
this.art.proxy($configModes, 'click', (event) => {
this.lifecycle.proxy($configModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
if (!$mode)
return
@@ -392,21 +307,21 @@ export default class Setting {
this.reset()
})
this.art.proxy($antiOverlap, 'click', () => {
this.lifecycle.proxy($antiOverlap, 'click', () => {
this.danmuku.config({
antiOverlap: !this.option.antiOverlap,
})
this.reset()
})
this.art.proxy($syncVideo, 'click', () => {
this.lifecycle.proxy($syncVideo, 'click', () => {
this.danmuku.config({
synchronousPlayback: !this.option.synchronousPlayback,
})
this.reset()
})
this.art.proxy($styleModes, 'click', (event) => {
this.lifecycle.proxy($styleModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
if (!$mode)
return
@@ -417,7 +332,7 @@ export default class Setting {
this.reset()
})
this.art.proxy($colors, 'click', (event) => {
this.lifecycle.proxy($colors, 'click', (event) => {
const $color = event.target.closest('.apd-color')
if (!$color)
return
@@ -427,9 +342,9 @@ export default class Setting {
this.reset()
})
this.art.proxy($send, 'click', () => this.emit())
this.lifecycle.proxy($send, 'click', () => this.emit())
this.art.proxy($input, 'keypress', (event) => {
this.lifecycle.proxy($input, 'keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault()
this.emit()
@@ -509,79 +424,13 @@ export default class Setting {
})
}
createSlider({ min, max, container, findIndex, onChange, steps = [] }) {
const { query, clamp, setStyle } = this.utils
setStyle(container, 'touch-action', 'none')
container.innerHTML = `
<div class="apd-slider-line">
<div class="apd-slider-points">
${steps.map(() => `<div class="apd-slider-point"></div>`).join('')}
</div>
<div class="apd-slider-progress"></div>
</div>
<div class="apd-slider-dot"></div>
<div class="apd-slider-steps">
${steps.map(step => (step.hide ? '' : `<div class="apd-slider-step">${step.name}</div>`)).join('')}
</div>
`
const $dot = query('.apd-slider-dot', container)
const $progress = query('.apd-slider-progress', container)
let isDroging = false
function reset(index = findIndex()) {
if (index < min || index > max)
return
const percentage = (index - min) / (max - min)
$dot.style.left = `${percentage * 100}%`
if (steps.length === 0) {
$progress.style.width = $dot.style.left
}
onChange(index)
}
function updateLeft(event) {
const { top, height, left, width } = container.getBoundingClientRect()
if (this.art.isRotate) {
const value = clamp(event.clientY - top, 0, height)
const index = Math.round((value / height) * (max - min) + min)
reset(index)
}
else {
const value = clamp(event.clientX - left, 0, width)
const index = Math.round((value / width) * (max - min) + min)
reset(index)
}
}
this.art.proxy(container, 'click', (event) => {
updateLeft.call(this, event)
})
this.art.proxy(container, 'pointerdown', (event) => {
isDroging = event.button === 0
})
this.art.on('document:pointermove', (event) => {
if (isDroging) {
updateLeft.call(this, event)
}
})
this.art.on('document:pointerup', (event) => {
if (isDroging) {
isDroging = false
updateLeft.call(this, event)
}
})
return { reset }
createSlider(option) {
return createSlider.call(this, option)
}
onFullscreen(state) {
if (!this.lifecycle.active)
return
const { $danmuku, $controlsCenter, $mount } = this.template
if (this.outside) {
if (state) {
@@ -597,6 +446,8 @@ export default class Setting {
}
onMouseEnter({ $control, $panel }) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
const controlRect = $control.getBoundingClientRect()
const panelRect = $panel.getBoundingClientRect()
@@ -617,80 +468,21 @@ export default class Setting {
}
}
async emit() {
const { $input } = this.template
const text = $input.value.trim()
if (!text.length)
return
if (this.isLock)
return
if (this.emitting)
return
const danmu = {
text,
mode: this.option.mode,
color: this.option.color,
time: this.art.currentTime,
}
try {
this.emitting = true
const state = await this.option.beforeEmit(danmu)
this.emitting = false
if (state !== true)
return
danmu.border = true
delete danmu.time
this.danmuku.emit(danmu)
$input.value = ''
this.lock()
}
catch (error) {
console.error('Error emitting danmuku:', error)
this.emitting = false
}
emit() {
return emitSetting(this)
}
lock() {
const { addClass } = this.utils
const { $send } = this.template
this.isLock = true
let time = this.option.lockTime
$send.textContent = time
addClass($send, 'apd-lock')
const loop = () => {
this.timer = setTimeout(() => {
if (time === 0) {
this.unlock()
}
else {
time -= 1
$send.textContent = time
loop()
}
}, 1000)
}
loop()
lockSetting(this)
}
unlock() {
const { removeClass } = this.utils
const { $send } = this.template
clearTimeout(this.timer)
this.isLock = false
$send.textContent = '发送'
removeClass($send, 'apd-lock')
unlockSetting(this)
}
resize() {
if (!this.lifecycle.active)
return
if (this.outside)
return
if (this.art.fullscreen)
@@ -708,13 +500,16 @@ export default class Setting {
}
reset() {
if (!this.lifecycle.active)
return
const { inverseClass, tooltip } = this.utils
const { $toggle, $colors } = this.template
this.slider.opacity.reset()
this.slider.margin.reset()
this.slider.fontSize.reset()
this.slider.speed.reset()
for (const slider of Object.values(this.slider)) {
slider.reset()
if (!this.lifecycle.active)
return
}
this.setData('danmukuVisible', this.option.visible)
this.setData('danmukuMode', this.option.mode)
@@ -737,6 +532,8 @@ export default class Setting {
}
mount(target) {
if (!this.lifecycle.active)
return
const { errorHandle } = this.utils
const $el = typeof target === 'string' ? document.querySelector(target) : target
errorHandle($el, `Can not find the mount point: ${target}`)
@@ -744,22 +541,17 @@ export default class Setting {
this.template.$mount = $el
this.reset()
}
}
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-danmuku'
let $style = document.getElementById(id)
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild($style)
})
}
else {
(document.head || document.documentElement).appendChild($style)
}
destroy() {
if (this.lifecycle.closed)
return
this.lifecycle.close()
clearTimeout(this.timer)
this.timer = null
this.emitting = false
this.isLock = false
const $danmuku = this.template.$danmuku
if ($danmuku?.parentElement)
$danmuku.parentElement.removeChild($danmuku)
}
$style.textContent = style
}