mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-27 09:33:21 -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user