refactor(danmuku): [PKG-DANMUKU-06] migrate runtime and preserve public types

This commit is contained in:
Harvey Zhao committed 2026-09-13 22:06:00 +08:00
1 parent abc8008da7
commit 24b883d862
64 files changed
+14020 -633

No files matched your search

+2 -1
View File
@@ -2,4 +2,5 @@ node_modules
.DS_Store
yarn.lock
package-lock.json
src
src
tsconfig.json
+112 -31
View File
@@ -2,40 +2,100 @@
The compatibility reference is the actual npm 5.3.0 package. Its archive,
historical declarations, earlier exports and failure evidence are indexed in
`refactor/baselines/danmuku-release.json` and `danmuku-contract.md` at the
repository root. Do not infer runtime contracts from the old declaration file.
`refactor/baselines/danmuku-release.json` and
`refactor/baselines/danmuku-contract.md` relative to the repository root. Do not infer runtime contracts from the old declaration file.
## Module responsibilities
| Module | Responsibility |
| --- | --- |
| `src/index.js` | Plugin factory, facade getters, bound methods, setting and heatmap composition |
| `src/danmuku.js` | Internal instance, method return identity, configuration/load commits, event and lifecycle wiring |
| `src/config.js` | Fresh defaults, validation schema, configuration comparison and normalization |
| `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 |
| `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 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 |
| `src/index.ts` | Plugin factory, facade getters, bound methods, setting and heatmap composition |
| `src/danmuku.ts` | Internal instance, method return identity, configuration/load commits, event and lifecycle wiring |
| `src/config.ts` | Fresh defaults, validation schema, configuration comparison and normalization |
| `src/input.ts` | Input forms, replacement ownership, independent append operations, cancellation and synchronous callback reentry |
| `src/bilibili-parser.ts` | Pure legacy-compatible XML fields, mode mapping and entity decoding |
| `src/bilibili.ts` | Fetch and response text, one parser Worker per request, fallback, settlement and Blob URL cleanup |
| `src/scheduler.ts` | One RAF or asynchronous frame, cancellation generations, preparation ownership and failure recovery |
| `src/renderer.ts` | Owned node allocation, preparation, geometry snapshots, placement, pause/resume styling and disposal |
| `src/queue.ts` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.ts` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
| `src/worker.ts` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/setting.ts` | Settings coordinator, configuration controls, mount and fullscreen layout |
| `src/setting-template.ts` | Existing template HTML and static icon values |
| `src/setting-slider.ts` | Slider index, pointer and rotation behavior |
| `src/setting-send.ts` | Pending send, original error outlet and lock countdown |
| `src/setting-lifecycle.ts` | Exact subscription/proxy disposal, cancellation and conditional host-property restoration |
| `src/setting-style.ts` | Shared document style and one pending DOMContentLoaded installation |
| `src/heatmap.ts` | Owned control, progress stops, subscriptions and per-instance gradient |
| `src/heatmap-sampling.ts` | Sorted-time bin counts with historical numeric boundaries |
| `src/heatmap-geometry.ts` | 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-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.
All 20 owned runtime modules use TypeScript. Their responsibilities remain
separate from public declaration compatibility and from release acceptance.
The following files contain types only:
| Module | Responsibility |
| --- | --- |
| `src/types.ts` | Reexports public runtime data types and defines the internal Artplayer host adapter |
| `src/worker-types.ts` | Placement requests, replies, visible rows and virtual boundary rows |
| `src/parser-types.ts` | XML parser results and its independent Worker protocol |
| `src/setting-types.ts` | Settings template slots and slider integration contracts |
| `src/worker-assets.d.ts` | Inline placement Worker module declaration |
| `types/runtime-shared.d.ts` | Single source for accurate option, callback, input, queue, facade, owner and event payload types |
`src/types.ts` imports/reexports `types/runtime-shared.d.ts` with type-only
syntax. Keep shared option/data definitions there; do not create a second copy
in production source. Host, DOM and Worker adapter types remain internal. The
factory composes Danmuku, Setting and heatmap. Danmuku owns queue/state pools and
coordinates input, renderer and scheduler. Scheduler/renderer refer back to the
owner with type-only imports; these references add no runtime module cycle.
The XML parser and heatmap sampling/geometry remain independently testable.
## Declaration entrypoints and type boundaries
The root and `/legacy` declarations retain the exact actual npm 5.3.0 type
source, including historical inaccuracies and extraction behavior. Edit neither
to make runtime implementation checks pass. The additive `/runtime` entrypoint
selects the existing ESM or CommonJS factory with accurate declarations. Its
`runtime.d.mts` entry exposes ESM type exports; `runtime.d.cts` and the
`typesVersions` fallback `runtime.d.ts` expose the callable CommonJS factory
and its type namespace. CommonJS returns the function itself; importing it does
not add a `factory.default` property.
The argument object is required, while its fields are optional. The registrar
returns `RuntimeResult` synchronously. Command methods resolve/return `Owner`,
which is a different object; `emit` is asynchronous even though its initial
insertion work is synchronous. The facade retains its three live getters and
requires a valid target for `mount`. Public data includes mutable point tuples,
typed static icons, normalized filter inputs and queue entries for visibility
callbacks. Filters and visibility callbacks accept truthy results; sending
requires strict `true`. Their `this` is the current normalized option. Loader
functions retain their receiver-free call. `EventMap` supplies named payload
tuples explicitly; importing `/runtime` does not augment old core event types.
It contains types only, with no new event bus or runtime export.
Keep these narrow implementation assertions documented when editing them:
- Native style/dataset writes retain their historical numeric and null values.
WebIDL performs the conversion; changing source assignments to satisfy DOM
declarations could change controlled hosts or observable setters.
- Emit/stop state pools own allocated nodes. A frame with a non-null reference
has its associated item, and release checks node identity. Non-null assertions
depend on those invariants; they do not replace lifecycle validation.
- The legacy public core type describes `constructor` as `Function`. The
factory's host adapter narrows it to the existing Artplayer static utilities
and validator. It introduces no new core capability or minimum core version.
- An omitted internal `postMessage` keeps its old empty-message behavior;
unsupported margin strings retain the raw historical fallback at the Worker
boundary. Placement types describe valid scheduler requests, with explicit
compatibility assertions for those exceptional paths. These internal message
helpers are not additional methods promised by the public `Owner` interface.
The strict package config disables JavaScript input. Its `ES2021.String` type
library describes the existing XML `replaceAll` calls; it neither introduces a
new runtime dependency nor raises the existing browser requirement. Modern and
legacy build targets remain es2020 and es2015; syntax lowering does not polyfill
`replaceAll` or other browser APIs.
## Contracts to retain
@@ -125,6 +185,25 @@ Late, unknown and duplicate replies do not settle a different request.
## Verification and remaining work
Run package commands from the repository root with the pinned Node version and
Yarn Classic 1.22.22:
| Command | What it checks or produces |
| --- | --- |
| `yarn test:danmuku` | Historical and candidate input, parser, Worker, scheduling, rendering, settings and heatmap regressions |
| `yarn test:danmuku-types` | Frozen root declarations, current/legacy compiler consumers, negative cases, implementation assignability and editor types |
| `yarn test:danmuku-types-package` | Packed isolated installs, real entrypoints and consumer module/compiler modes; creates local evidence, does not publish |
| `yarn typecheck` | Strict production sources and the repository consumer type matrix |
| `yarn build artplayer-plugin-danmuku` | Normal package outputs and copied docs artifacts, including the inline TS Worker |
| `yarn dev artplayer-plugin-danmuku` | Local demo rebuild for the package |
Edit `src/` for runtime changes, `types/runtime-shared.d.ts` for shared accurate
data contracts and the corresponding `runtime.d.*` entry for module shape.
Rebuild artifacts through the package script. The frozen root declaration and
historical test helpers are compatibility evidence, not generated repair targets.
Use `refactor/fixtures/implementation/danmuku.ts` when a source/declaration change must
prove assignability to the actual implementation.
`yarn test:danmuku` runs frozen historical contracts/failures and candidate input,
parser, scheduler and Worker regressions. The historical helper and archives must remain frozen;
candidate tests use `test/helpers/danmuku-candidate.js` and may select built
@@ -143,9 +222,11 @@ 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.
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.
Long-run native load behavior still requires PKG-DANMUKU-07; cross-plugin/core
combination coverage and final distribution acceptance remain PKG-DANMUKU-08/09.
Check task evidence and the risk register before changing completion or release
status. These commands and short native regressions do not establish npm release
readiness; final checks and release reviews remain separate gates.
## Rendering and UI resources
+36 -1
View File
@@ -6,6 +6,38 @@ Danmuku plugin for ArtPlayer
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-danmuku/index.js&example=danmuku)
## TypeScript entrypoints
Existing imports from `artplayer-plugin-danmuku` and `/legacy` retain the npm
5.3.0 declarations. Use `/runtime` for declarations that describe the actual
factory, asynchronous commands and returned internal owner:
```ts
import danmuku from 'artplayer-plugin-danmuku/runtime'
import type { Point, RuntimeOption } from 'artplayer-plugin-danmuku/runtime'
const option: RuntimeOption = { danmuku: [], heatmap: true }
const points: Point[] = [[0, 10], [200, 20]]
```
The option object is required; each field is optional. Registration returns the
plugin facade immediately. `await plugin.emit(row)` and `await plugin.load()`
return the internal owner, as do synchronous `config/hide/show/reset` commands.
That owner differs from the registered facade. `mount(target)` returns undefined
and requires a valid element or selector. The facade's option/state getters
remain live.
The accurate entrypoint also exposes icon types, callback receivers and an
explicit `EventMap` of payload tuples. Filters and visibility callbacks receive
the current option as `this`; `beforeEmit` does too and accepts only strict
`true`. Importing these types does not automatically alter Artplayer's historical
event declarations. Custom points use mutable `[number, number]` tuples and
retain the existing inner-array modifications during rendering.
ESM imports select the existing ESM factory; CommonJS
`require('artplayer-plugin-danmuku/runtime')` returns the existing callable
factory directly. No additional runtime implementation is loaded.
## Loading and configuration
`plugin.load()` reloads the configured input and replaces the queue after input
@@ -66,7 +98,10 @@ finite `heatmap.yMin` or `heatmap.yMax`, and custom points events, retain their
original coordinate mapping; use these when deliberately controlling the scale.
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module ownership, compatibility
constraints, tests and the remaining staged TypeScript migration.
constraints and editing/verification commands. All owned runtime modules now use
TypeScript, with shared public runtime data types maintained in
`types/runtime-shared.d.ts`. Long-duration load tests, combination/distribution
acceptance and release reviews remain separate unfinished stages.
## License
@@ -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 n({data:t}){const{xml:e,id:n}=t,a=i(e);globalThis.postMessage({danmus:a,id:n})}let a=0;function s(t,{signal:s,onCancel:r}={}){return new Promise((o,l)=>{let d,c,p,h=!1;const u=++a,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==c){const n=c;c=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(h)return;h=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),p?.();try{f()}catch(n){i||(e=n),i=!0}i?l(e):o(t)}function y(t,e){if(!h){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(n){g(void 0,n)}}}s?.aborted?m():(s?.addEventListener("abort",m),p=r?.(m),h?p?.():async function(){const a=s?await fetch(t,{signal:s}):await fetch(t);if(h)return;const r=await a.text();if(!h)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${n.toString()}\n `;if(c=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),h)return void f();if(d=new Worker(c),h)return void f();d.onmessage=t=>{if(h||t.data?.id!==u)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(r,t.error||t)},d.onmessageerror=t=>y(r,t.error||t),d.postMessage({xml:r,id:u})}catch(o){y(r,o)}}().catch(t=>g(void 0,t)))})}const r=new WeakMap,o=Symbol("cancelled danmuku input");function l(t){return r.has(t)||r.set(t,{closed:!1,tasks:new Set,emitting:[]}),r.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function c(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function p(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}class h{constructor(t){this.owner=t,this.nodes=new Set}acquire(){const t=this.owner.$refs.pop()||document.createElement("div");return t.style.cssText=this.owner.constructor.cssText,t.dataset.mode="",t.dataset.id="",t.className="",this.nodes.add(t),t}get visibles(){const t=[],{clientWidth:e}=this.owner.$player,i=this.owner.getLeft(this.owner.$player);return this.owner.filter("emit",n=>{const a=n.$ref.offsetTop,s=this.owner.getLeft(n.$ref)-i,r=n.$ref.clientHeight,o=n.$ref.clientWidth,l=s+o,d=e-l,c=l/n.$restTime,p={};p.top=a,p.left=s,p.height=r,p.width=o,p.right=d,p.speed=c,p.distance=l,p.time=n.$restTime,p.mode=n.mode,t.push(p)}),t}left(t){const e=t.getBoundingClientRect();return this.owner.isRotate?e.top:e.left}makeWait(t){this.owner.setState(t,"wait"),t.$ref&&(t.$ref.style.cssText=this.owner.constructor.cssText,t.$ref.style.visibility="hidden",t.$ref.style.marginLeft="0px",t.$ref.style.transform="translateX(0px)",t.$ref.style.transition="transform 0s linear 0s",this.owner.$refs.push(t.$ref),t.$ref=null)}resize(){const{clientWidth:t}=this.owner.$player;this.owner.filter("stop",e=>{if(0===e.mode)e.$ref.style.left=`${t}px`}),this.owner.filter("emit",e=>{switch(e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.left=`${t}px`,e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}})}continue(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("stop",e=>{switch(this.owner.setState(e,"emit"),e.$lastStartTime=Date.now(),e.mode){case 0:{const i=t+e.$ref.clientWidth;e.$ref.style.transform=`translateX(${-i}px)`,e.$ref.style.transition=`transform ${e.$restTime}s linear 0s`;break}}}),this}suspend(){const{clientWidth:t}=this.owner.$player;return this.owner.filter("emit",e=>{switch(this.owner.setState(e,"stop"),e.mode){case 0:{const i=t-(this.owner.getLeft(e.$ref)-this.owner.getLeft(this.owner.$player));e.$ref.style.transform=`translLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&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,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
!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
@@ -665,16 +665,18 @@ class Scheduler {
danmu.$lastStartTime = Date.now();
danmu.$restTime = owner.speed;
const distance = clientWidth + ref.clientWidth;
const { result: top } = await operation.wait(owner.postMessage({
const reply = await operation.wait(owner.postMessage({
type: "getDanmuTop",
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
antiOverlap: owner.option.antiOverlap,
clientWidth,
clientHeight,
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom,
marginTop: owner.marginTop
}));
const top = typeof reply === "symbol" ? void 0 : reply.result;
if (!this.active(operation) || danmu.$ref !== ref)
return;
if (top !== void 0) {
@@ -948,6 +950,7 @@ class Danmuku {
if (!task.active())
return this;
errorHandle(Array.isArray(danmus), "Danmuku need return an array as result");
const rows = danmus;
if (danmuku === void 0) {
this.reset();
if (!task.active())
@@ -956,10 +959,10 @@ class Danmuku {
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.renderer.clear();
}
for (let index = 0; index < danmus.length; index++) {
for (let index = 0; index < rows.length; index++) {
if (!task.active())
return this;
const danmu = danmus[index];
const danmu = rows[index];
await task.emit(danmu);
}
if (task.active())
@@ -1074,6 +1077,7 @@ class Danmuku {
return this.renderer.left($ref);
}
// 复杂运算交给 Web Worker 处理
// Keep the historical empty default; unknown message types receive no reply.
postMessage(message = {}) {
return this.workerClient.request(message);
}
@@ -1312,8 +1316,9 @@ function heatmap(art, danmuku, option) {
let closed = false;
const active = () => !closed && !art.isDestroy;
function listen(name, callback) {
subscriptions.push([name, callback]);
art.on(name, callback);
const listener = callback;
subscriptions.push([name, listener]);
art.on(name, listener);
}
function dispose(removeControl) {
if (closed)
@@ -1351,18 +1356,19 @@ function heatmap(art, danmuku, option) {
return;
start = null;
stop = null;
element.innerHTML = "";
const target = element;
target.innerHTML = "";
if (art.option.isLive)
return;
const shape = heatmapGeometry({ width: element.offsetWidth, height: element.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points });
const shape = heatmapGeometry({ width: target.offsetWidth, height: target.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points });
if (!shape || !active())
return;
const document2 = element.ownerDocument;
const document2 = target.ownerDocument;
if (!gradient)
gradient = document2 ? "heatmap-solids" : `heatmap-solids-${++nextGradient}`;
while (document2?.getElementById(gradient))
gradient = `heatmap-solids-${++nextGradient}`;
element.innerHTML = `
target.innerHTML = `
<svg viewBox="0 0 ${shape.width} ${shape.height}">
<defs>
<linearGradient id="${gradient}" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -1375,8 +1381,8 @@ function heatmap(art, danmuku, option) {
<path fill="url(#${gradient})" d="${shape.path}"></path>
</svg>
`;
start = query("#heatmap-start", element);
stop = query("#heatmap-stop", element);
start = query("#heatmap-start", target);
stop = query("#heatmap-stop", target);
progress(art.played);
}
function update(points) {
@@ -1456,7 +1462,8 @@ class SettingLifecycle {
dispose();
else this.disposers.add(dispose);
}
write(target, key, value, apply = (value2) => target[key] = value2) {
write(host, key, value, apply = (value2) => host[key] = value2) {
const target = host;
if (!this.active)
return;
const priority = () => typeof target.getPropertyPriority === "function" ? target.getPropertyPriority(key) : void 0;
@@ -1847,16 +1854,17 @@ const style = ".artplayer-plugin-danmuku {\n display: flex;\n position: relati
if (typeof document !== "undefined") {
const id = "artplayer-plugin-danmuku";
const pending = /* @__PURE__ */ Symbol.for("artplayer-plugin-danmuku.pending-style");
let $style2 = document.getElementById(id) || document[pending];
const styleDocument = document;
let $style2 = document.getElementById(id) || styleDocument[pending];
if (!$style2) {
$style2 = document.createElement("style");
$style2.id = id;
if (document.readyState === "loading") {
document[pending] = $style2;
styleDocument[pending] = $style2;
const ready = () => {
document.removeEventListener("DOMContentLoaded", ready);
if (document[pending] === $style2)
delete document[pending];
if (styleDocument[pending] === $style2)
delete styleDocument[pending];
const existing = document.getElementById(id);
if (existing)
existing.textContent = $style2.textContent;
+21 -1
View File
@@ -27,11 +27,31 @@
"types": "./types/artplayer-plugin-danmuku.d.ts",
"import": "./dist/artplayer-plugin-danmuku.legacy.js",
"require": "./dist/artplayer-plugin-danmuku.legacy.js"
},
"./runtime": {
"import": {
"types": "./types/runtime.d.mts",
"default": "./dist/artplayer-plugin-danmuku.mjs"
},
"require": {
"types": "./types/runtime.d.cts",
"default": "./dist/artplayer-plugin-danmuku.js"
}
}
},
"main": "./dist/artplayer-plugin-danmuku.js",
"module": "./dist/artplayer-plugin-danmuku.mjs",
"types": "./types/artplayer-plugin-danmuku.d.ts",
"legacy": "./dist/artplayer-plugin-danmuku.legacy.js",
"browserslist": "last 1 Chrome version"
"browserslist": "last 1 Chrome version",
"typesVersions": {
"*": {
"legacy": [
"types/artplayer-plugin-danmuku.d.ts"
],
"runtime": [
"types/runtime.d.ts"
]
}
}
}
@@ -1,4 +1,6 @@
export function getMode(key) {
import type { ParsedDanmu, ParserReply, ParserRequest } from './parser-types'
export function getMode(key: number): number {
switch (key) {
case 1:
case 2:
@@ -13,16 +15,16 @@ export function getMode(key) {
}
}
export function bilibiliDanmuParseFromXml(xmlString) {
export function bilibiliDanmuParseFromXml(xmlString: unknown): ParsedDanmu[] {
if (typeof xmlString !== 'string')
return []
const reg = /<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs
const matches = xmlString.matchAll(reg)
return Array.from(matches)
.map((match) => {
const attr = match.groups.p.split(',')
const attr = match.groups!.p!.split(',')
if (attr.length >= 8) {
const text = match.groups.text
const text = match.groups!.text!
.trim()
.replaceAll('&quot;', '"')
.replaceAll('&apos;', '\'')
@@ -38,7 +40,7 @@ export function bilibiliDanmuParseFromXml(xmlString) {
color: `#${Number(attr[3]).toString(16)}`,
timestamp: Number(attr[4]),
pool: Number(attr[5]),
userID: attr[6],
userID: attr[6]!,
rowID: Number(attr[7]),
}
}
@@ -46,11 +48,11 @@ export function bilibiliDanmuParseFromXml(xmlString) {
return null
}
})
.filter(Boolean)
.filter(Boolean) as ParsedDanmu[]
}
export function onmessage({ data }) {
export function onmessage({ data }: MessageEvent<ParserRequest>) {
const { xml, id } = data
const danmus = bilibiliDanmuParseFromXml(xml)
globalThis.postMessage({ danmus, id })
globalThis.postMessage({ danmus, id } satisfies ParserReply)
}
@@ -1,8 +1,9 @@
import type { ParsedDanmu, ParserCancellation, ParserReply, ParserRequest } from './parser-types'
import { bilibiliDanmuParseFromXml, getMode, onmessage } from './bilibili-parser'
let nextRequest = 0
function abortError(signal) {
function abortError(signal?: AbortSignal): unknown {
if (signal?.reason !== undefined)
return signal.reason
const error = new Error('Bilibili Danmu loading cancelled')
@@ -10,17 +11,17 @@ function abortError(signal) {
return error
}
export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
export function bilibiliDanmuParseFromUrl(url: string, { signal, onCancel }: ParserCancellation = {}): Promise<ParsedDanmu[]> {
return new Promise((resolve, reject) => {
let settled = false
let worker
let workerUrl
let releaseCancel
let worker: Worker | undefined
let workerUrl: string | undefined
let releaseCancel: (() => void) | undefined
const id = ++nextRequest
const cancel = () => finish(undefined, abortError(signal))
function releaseWorker() {
let error
let error: unknown
let failed = false
try {
if (worker) {
@@ -52,7 +53,9 @@ export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
throw error
}
function finish(value, error) {
function finish(value: ParsedDanmu[]): void
function finish(value: undefined, error: unknown): void
function finish(value: ParsedDanmu[] | undefined, error?: unknown) {
if (settled)
return
settled = true
@@ -69,10 +72,10 @@ export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
}
if (failed)
reject(error)
else resolve(value)
else resolve(value!)
}
function fallback(xml, error) {
function fallback(xml: string, error: unknown) {
if (settled)
return
console.error('Error parsing Bilibili Danmu:', error)
@@ -107,7 +110,7 @@ export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
releaseWorker()
return
}
worker.onmessage = (event) => {
worker.onmessage = (event: MessageEvent<ParserReply>) => {
if (settled || event.data?.id !== id)
return
const { danmus } = event.data
@@ -121,8 +124,8 @@ export function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
event.preventDefault?.()
fallback(xml, event.error || event)
}
worker.onmessageerror = event => fallback(xml, event.error || event)
worker.postMessage({ xml, id })
worker.onmessageerror = event => fallback(xml, (event as MessageEvent & { error?: unknown }).error || event)
worker.postMessage({ xml, id } satisfies ParserRequest)
}
catch (error) {
fallback(xml, error)
@@ -1,4 +1,6 @@
export function defaultOption() {
import type { DanmukuOption, NormalizedOption } from './types'
export function defaultOption(): NormalizedOption & { fontSize: number, margin: [number, string] } {
return {
danmuku: [],
speed: 5,
@@ -62,21 +64,21 @@ export function optionScheme() {
}
}
export function isPromiseInput(value) {
export function isPromiseInput(value: unknown): value is Promise<unknown> {
return value instanceof Promise || Object.prototype.toString.call(value) === '[object Promise]'
}
export function optionChanged(previous, update) {
export function optionChanged(previous: NormalizedOption, update: DanmukuOption) {
return Object.keys(update).some((key) => {
const before = previous[key]
const after = update[key]
const before = previous[key as keyof NormalizedOption]
const after = update[key as keyof NormalizedOption]
if (typeof before === 'function' || typeof after === 'function' || isPromiseInput(before) || isPromiseInput(after))
return before !== after
return JSON.stringify(before) !== JSON.stringify(after)
})
}
export function normalizeOption(previous, update, { defaults, validate, clamp, mount }) {
export function normalizeOption(previous: NormalizedOption, update: DanmukuOption, { defaults, validate, clamp, mount }: { defaults: NormalizedOption, validate: (value: NormalizedOption) => unknown, clamp: (value: number, min: number, max: number) => number, mount: HTMLDivElement }): NormalizedOption {
const next = Object.assign({}, defaults, previous, update)
validate(next)
next.mode = clamp(next.mode, 0, 2)
@@ -1,13 +1,34 @@
import type { DanmuInput, DanmuItem, DanmukuArt, DanmukuInput, DanmukuOption, DanmuState, NormalizedDanmu, NormalizedOption } from './types'
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'
import DanmuWorker from './worker.ts?worker&inline'
export default class Danmuku {
constructor(art, option) {
declare ['constructor']: typeof Danmuku
declare art: DanmukuArt
declare utils: DanmukuArt['constructor']['utils']
declare validator: DanmukuArt['constructor']['validator']
declare $danmuku: HTMLDivElement
declare $player: HTMLDivElement
declare queue: DanmuItem[]
declare $refs: HTMLDivElement[]
declare isStop: boolean
declare isHide: boolean
declare timer: number | null
declare index: number
declare worker: Worker | null
declare workerClient: WorkerClient | null
declare renderer: Renderer
declare scheduler: Scheduler
declare option: NormalizedOption
declare states: Record<DanmuState, DanmuItem[]>
constructor(art: DanmukuArt, option: DanmukuOption) {
const { constructor, template } = art
this.utils = constructor.utils // 工具库
@@ -99,8 +120,8 @@ export default class Danmuku {
}
// 是否在移动端使用了自动旋屏,会影响弹幕的left和top值
get isRotate() {
return this.art.plugins?.autoOrientation?.state
get isRotate(): boolean | undefined {
return (this.art.plugins?.autoOrientation as { state?: boolean } | undefined)?.state
}
// 计算上空白边距
@@ -181,7 +202,7 @@ export default class Danmuku {
}
// 加载弹幕
async load(danmuku) {
async load(danmuku?: DanmukuInput) {
const { errorHandle } = this.utils
const task = beginInput(this, danmuku === undefined)
@@ -196,6 +217,8 @@ export default class Danmuku {
return this
errorHandle(Array.isArray(danmus), 'Danmuku need return an array as result')
// Active input and the runtime validator exclude the cancellation sentinel.
const rows = danmus as DanmuInput[]
// 假如没有传入弹幕参数,则清空弹幕,否则追加弹幕
if (danmuku === undefined) {
@@ -208,10 +231,10 @@ export default class Danmuku {
}
// 逐个验证原始弹幕并转换到弹幕队列
for (let index = 0; index < danmus.length; index++) {
for (let index = 0; index < rows.length; index++) {
if (!task.active())
return this
const danmu = danmus[index]
const danmu = rows[index]!
await task.emit(danmu)
}
@@ -232,7 +255,7 @@ export default class Danmuku {
}
// 把原始弹幕转换到弹幕队列
async emit(danmu) {
async emit(danmu: DanmuInput) {
const { clamp } = this.utils
if (!inputActive(this))
return this
@@ -279,7 +302,8 @@ export default class Danmuku {
return this
// 自定义弹幕过滤函数
if (!this.option.filter(danmu))
// Validation/defaulting above fills these fields before the public callback.
if (!this.option.filter(danmu as NormalizedDanmu))
return this
if (!inputActive(this))
return this
@@ -287,7 +311,7 @@ export default class Danmuku {
// 添加自定义属性
const item = {
...danmu,
$state: 'wait', // 弹幕初始状态
$state: 'wait' as const, // 弹幕初始状态
$index: this.index++, // 弹幕索引
$ref: null, // 弹幕 DOM 节点
$restTime: 0, // 弹幕剩余时间
@@ -295,10 +319,10 @@ export default class Danmuku {
}
// 转换为wait状态
this.setState(item, 'wait')
this.setState(item as DanmuItem, 'wait')
// 添加到实际弹幕队列
this.queue.push(item)
this.queue.push(item as DanmuItem)
// 弹幕有四个状态:
// - wait: 弹幕还未开始显示,没有被添加到 DOM 中
@@ -310,7 +334,7 @@ export default class Danmuku {
}
// 动态配置
config(option, isInit = false) {
config(option: DanmukuOption, isInit = false) {
const { clamp } = this.utils
const { $controlsCenter } = this.art.template
@@ -359,13 +383,14 @@ export default class Danmuku {
}
// 计算DOM的left值,受到旋屏影响
getLeft($ref) {
getLeft($ref: HTMLElement): number {
return this.renderer.left($ref)
}
// 复杂运算交给 Web Worker 处理
postMessage(message = {}) {
return this.workerClient.request(message)
// Keep the historical empty default; unknown message types receive no reply.
postMessage(message: Parameters<WorkerClient['request']>[0] = {} as Parameters<WorkerClient['request']>[0]) {
return this.workerClient!.request(message)
}
createWorker() {
@@ -374,17 +399,17 @@ export default class Danmuku {
}
// 根据状态获取弹幕
filter(state, callback) {
filter(state: DanmuState, callback: (danmu: DanmuItem) => void) {
return filterState(this, state, callback)
}
// 设置弹幕状态
setState(danmu, state) {
setState(danmu: DanmuItem, state: DanmuState) {
setItemState(this, danmu, state)
}
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu) {
makeWait(danmu: DanmuItem) {
this.renderer.makeWait(danmu)
}
@@ -454,7 +479,7 @@ export default class Danmuku {
show() {
this.isHide = false
this.$danmuku.style.opacity = 1
this.$danmuku.style.opacity = 1 as unknown as string
this.option.visible = true
this.art.emit('artplayerPluginDanmuku:show')
return this
@@ -463,7 +488,7 @@ export default class Danmuku {
hide() {
this.isHide = true
this.scheduler.invalidate()
this.$danmuku.style.opacity = 0
this.$danmuku.style.opacity = 0 as unknown as string
this.option.visible = false
this.art.emit('artplayerPluginDanmuku:hide')
this.scheduler.schedule()
@@ -1,19 +1,36 @@
import type { DanmuItem, HeatmapOptions, HeatmapPoint } from './types'
import { MAX_HEATMAP_POINTS, sampleHeatmap } from './heatmap-sampling'
function map(value, inMin, inMax, outMin, outMax) {
function map(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
}
function line(pointA, pointB) {
function line(pointA: HeatmapPoint, pointB: HeatmapPoint) {
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 = [] }) {
interface GeometryInput {
width: number
height: number
duration: number
queue: readonly Pick<DanmuItem, 'time'>[]
option: boolean | HeatmapOptions
points?: HeatmapPoint[]
}
export interface HeatmapGeometry {
path: string
opacity: number
width: number
height: number
}
export function heatmapGeometry({ width, height, duration, queue, option, points: input = [] }: GeometryInput): HeatmapGeometry | null {
if (![width, height, duration].every(value => Number.isFinite(value) && value > 0))
return null
const defaults = {
const defaults: Required<HeatmapOptions> = {
xMin: 0,
xMax: width,
yMin: 0,
@@ -25,9 +42,9 @@ export function heatmapGeometry({ width, height, duration, queue, option, points
smoothing: 0.2,
flattening: 0.2,
}
const configured = Object.assign({}, typeof option === 'object' ? option : {})
const configured: HeatmapOptions = Object.assign({}, typeof option === 'object' ? option : {})
const options = Object.assign({}, defaults, configured)
for (const key of Object.keys(defaults)) {
for (const key of Object.keys(defaults) as (keyof HeatmapOptions)[]) {
if (!Number.isFinite(options[key]))
options[key] = defaults[key]
}
@@ -39,7 +56,8 @@ export function heatmapGeometry({ width, height, duration, queue, option, points
const points = hasCustomPoints ? [...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]
// The nonempty check above guarantees both endpoints used below.
const lastPoint = points[points.length - 1]!
if (lastPoint[0] !== width) {
if (points.length === MAX_HEATMAP_POINTS)
return null
@@ -65,8 +83,8 @@ export function heatmapGeometry({ width, height, duration, queue, option, points
const fitted = automatic && peak > defaults.yMax / 4
if (fitted)
options.yMax = peak * 4
const boundY = value => fitted ? Math.min(height, Math.max(height * 0.75, value)) : value
const controlPoint = (current, previous, next, reverse) => {
const boundY = (value: number) => fitted ? Math.min(height, Math.max(height * 0.75, value)) : value
const controlPoint = (current: HeatmapPoint, previous: HeatmapPoint | undefined, next: HeatmapPoint | undefined, reverse?: boolean): HeatmapPoint => {
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)
@@ -74,13 +92,14 @@ export function heatmapGeometry({ width, height, duration, queue, option, points
// A cubic stays inside the convex hull of its endpoints and controls.
return [current[0] + Math.cos(angle) * length, boundY(current[1] + Math.sin(angle) * length)]
}
const positions = points.map(point => [map(point[0], options.xMin, options.xMax, 0, width), boundY(map(point[1], options.yMin, options.yMax, height, 0))])
const positions = points.map<HeatmapPoint>(point => [map(point[0], options.xMin, options.xMax, 0, width), boundY(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)
return `M ${all[all.length - 1]![0]},${height} L ${point[0]},${height} L ${point[0]},${point[1]}`
// Every noninitial point has its immediately preceding control point.
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(' ')
@@ -1,22 +1,25 @@
import type { DanmuItem, HeatmapPoint } from './types'
export const MAX_HEATMAP_POINTS = 0xFFFFFFFF
function upperBound(values, value) {
function upperBound(values: readonly number[], value: number): number {
let start = 0
let end = values.length
while (start < end) {
const middle = Math.floor((start + end) / 2)
if (values[middle] <= value)
// The binary-search interval guarantees middle is an existing index.
if (values[middle]! <= value)
start = middle + 1
else end = middle
}
return start
}
export function sampleHeatmap(queue, width, duration, sampling) {
export function sampleHeatmap(queue: readonly Pick<DanmuItem, 'time'>[], width: number, duration: number, sampling: number): HeatmapPoint[] {
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 points: HeatmapPoint[] = []
const gap = duration / width
if (!Number.isFinite(gap))
return []
@@ -1,32 +1,47 @@
import type { DanmuItem, DanmukuArt, HeatmapOptions, HeatmapPoint } from './types'
import { heatmapGeometry } from './heatmap-geometry'
let nextGradient = 0
export default function heatmap(art, danmuku, option) {
type HeatmapArt = Pick<DanmukuArt, 'isDestroy' | 'constructor' | 'on' | 'off' | 'controls' | 'option' | 'duration' | 'played'>
interface HeatmapEvents {
'destroy': []
'video:timeupdate': []
'setBar': [type: string, percentage: number]
'ready': []
'resize': []
'artplayerPluginDanmuku:loaded': []
'artplayerPluginDanmuku:points': [points: HeatmapPoint[]]
}
type HeatmapCallback = (...args: unknown[]) => unknown
export default function heatmap(art: HeatmapArt, danmuku: { queue: readonly Pick<DanmuItem, 'time'>[] }, option: boolean | HeatmapOptions): (() => void) | undefined {
if (art.isDestroy)
return
const { query } = art.constructor.utils
let gradient
const subscriptions = []
let element
let start
let stop
let gradient: string | undefined
const subscriptions: [keyof HeatmapEvents, HeatmapCallback][] = []
let element: HTMLElement | undefined
let start: Element | null | undefined
let stop: Element | null | undefined
let closed = false
const active = () => !closed && !art.isDestroy
function listen(name, callback) {
subscriptions.push([name, callback])
art.on(name, callback)
function listen<Name extends keyof HeatmapEvents>(name: Name, callback: (...args: HeatmapEvents[Name]) => unknown) {
// Core's string-event overload erases payloads; this local map checks each subscription.
const listener = callback as HeatmapCallback
subscriptions.push([name, listener])
art.on(name, listener)
}
function dispose(removeControl) {
function dispose(removeControl: boolean) {
if (closed)
return
closed = true
start = null
stop = null
let failure
let failure: unknown
let failed = false
const attempt = (callback) => {
const attempt = (callback: () => unknown) => {
try {
callback()
}
@@ -44,29 +59,31 @@ export default function heatmap(art, danmuku, option) {
if (failed)
throw failure
}
function progress(value) {
function progress(value: number) {
if (active() && start && stop) {
start.setAttribute('offset', `${value * 100}%`)
stop.setAttribute('offset', `${value * 100}%`)
}
}
function draw(points = []) {
function draw(points: HeatmapPoint[] = []) {
if (!active())
return
start = null
stop = null
element.innerHTML = ''
// Drawing listeners are registered only after mounted assigns element; dispose closes first.
const target = element!
target.innerHTML = ''
if (art.option.isLive)
return
const shape = heatmapGeometry({ width: element.offsetWidth, height: element.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points })
const shape = heatmapGeometry({ width: target.offsetWidth, height: target.offsetHeight, duration: art.duration, queue: danmuku.queue, option, points })
if (!shape || !active())
return
const document = element.ownerDocument
const document = target.ownerDocument
if (!gradient)
gradient = document ? 'heatmap-solids' : `heatmap-solids-${++nextGradient}`
while (document?.getElementById(gradient))
gradient = `heatmap-solids-${++nextGradient}`
element.innerHTML = `
target.innerHTML = `
<svg viewBox="0 0 ${shape.width} ${shape.height}">
<defs>
<linearGradient id="${gradient}" x1="0%" y1="0%" x2="100%" y2="0%">
@@ -79,11 +96,11 @@ export default function heatmap(art, danmuku, option) {
<path fill="url(#${gradient})" d="${shape.path}"></path>
</svg>
`
start = query('#heatmap-start', element)
stop = query('#heatmap-stop', element)
start = query('#heatmap-start', target)
stop = query('#heatmap-stop', target)
progress(art.played)
}
function update(points) {
function update(points?: HeatmapPoint[]) {
try {
draw(points)
}
@@ -1,15 +1,19 @@
import type Artplayer from 'artplayer'
import type { DanmukuArt, DanmukuOption } from './types'
import Danmuku from './danmuku'
import heatmap from './heatmap'
import Setting from './setting'
export default function artplayerPluginDanmuku(option) {
return (art) => {
const danmuku = new Danmuku(art, option)
let setting
export default function artplayerPluginDanmuku(option: DanmukuOption) {
return (art: Artplayer) => {
// Old declarations type instance.constructor as Function; both supported cores expose these statics.
const danmuku = new Danmuku(art as DanmukuArt, option)
let setting: Setting | undefined
try {
setting = art.isDestroy ? undefined : new Setting(art, danmuku)
setting = art.isDestroy ? undefined : new Setting(art as DanmukuArt, danmuku)
if (!art.isDestroy && danmuku.option.heatmap) {
heatmap(art, danmuku, danmuku.option.heatmap)
heatmap(art as DanmukuArt, danmuku, danmuku.option.heatmap)
}
}
catch (error) {
@@ -25,7 +29,7 @@ export default function artplayerPluginDanmuku(option) {
}
return {
name: 'artplayerPluginDanmuku',
name: 'artplayerPluginDanmuku' as const,
emit: danmuku.emit.bind(danmuku),
load: danmuku.load.bind(danmuku),
config: danmuku.config.bind(danmuku),
@@ -1,22 +1,45 @@
import type { DanmuInput, DanmukuInput } from './types'
import { bilibiliDanmuParseFromUrl } from './bilibili'
import { isPromiseInput } from './config'
const inputs = new WeakMap()
const cancelled = Symbol('cancelled danmuku input')
function stateFor(owner) {
if (!inputs.has(owner))
inputs.set(owner, { closed: false, tasks: new Set(), emitting: [] })
return inputs.get(owner)
interface InputOwner {
art: { isDestroy: boolean }
emit: (danmu: DanmuInput) => unknown
}
export function inputActive(owner) {
export interface InputTask {
replace: boolean
signal: AbortSignal | undefined
active: () => boolean
onCancel: (callback: () => void) => () => void
cancel: () => void
wait: <T>(value: T) => Promise<Awaited<T> | typeof cancelled>
emit: (danmu: DanmuInput) => unknown
finish: () => void
}
interface InputState {
closed: boolean
tasks: Set<InputTask>
emitting: InputTask[]
}
const inputs = new WeakMap<InputOwner, InputState>()
export const cancelled = Symbol('cancelled danmuku input')
function stateFor(owner: InputOwner): InputState {
if (!inputs.has(owner))
inputs.set(owner, { closed: false, tasks: new Set(), emitting: [] })
return inputs.get(owner)!
}
export function inputActive(owner: InputOwner) {
const state = stateFor(owner)
const task = state.emitting[state.emitting.length - 1]
return !state.closed && !owner.art.isDestroy && (!task || task.active())
}
export function beginInput(owner, replace) {
export function beginInput(owner: InputOwner, replace: boolean): InputTask {
const state = stateFor(owner)
if (replace) {
for (const task of [...state.tasks]) {
@@ -25,11 +48,11 @@ export function beginInput(owner, replace) {
}
}
let stopped = state.closed || owner.art.isDestroy
let resolveCancel
const cancellation = new Promise(resolve => resolveCancel = resolve)
const handlers = new Set()
let resolveCancel!: (value: typeof cancelled) => void
const cancellation = new Promise<typeof cancelled>(resolve => resolveCancel = resolve)
const handlers = new Set<() => void>()
const controller = typeof AbortController === 'function' ? new AbortController() : undefined
const task = {
const task: InputTask = {
replace,
signal: controller?.signal,
active: () => !stopped && !state.closed && !owner.art.isDestroy,
@@ -71,13 +94,15 @@ export function beginInput(owner, replace) {
return task
}
export function cancelInputs(owner) {
export function cancelInputs(owner: InputOwner) {
const state = stateFor(owner)
state.closed = true
for (const task of [...state.tasks]) task.cancel()
}
export function readInput(target, task) {
export function readInput(target: DanmukuInput, task: InputTask):
| { asynchronous: true, value: DanmuInput[] | PromiseLike<DanmuInput[]> }
| { asynchronous: false, value: DanmuInput[] } {
if (typeof target === 'function')
return { asynchronous: true, value: target() }
if (isPromiseInput(target))
@@ -0,0 +1,20 @@
import type { DanmuInput } from './types'
export interface ParsedDanmu extends DanmuInput {
time: number
mode: number
fontSize: number
color: string
timestamp: number
pool: number
userID: string
rowID: number
}
export interface ParserRequest { xml: unknown, id: number }
export interface ParserReply { danmus: ParsedDanmu[], id: number }
export interface ParserCancellation {
signal?: AbortSignal
onCancel?: (callback: () => void) => () => void
}
@@ -1,12 +1,16 @@
export function filterState(owner, state, callback) {
import type { DanmuItem, DanmuState } from './types'
interface QueueOwner { states: Record<DanmuState, DanmuItem[]>, art: { currentTime: number } }
export function filterState(owner: QueueOwner, state: DanmuState, callback: (danmu: DanmuItem) => void) {
const danmus = owner.states[state] || []
for (let index = 0; index < danmus.length; index++) callback(danmus[index])
for (let index = 0; index < danmus.length; index++) callback(danmus[index]!)
return danmus
}
export function readyItems(owner) {
export function readyItems(owner: QueueOwner) {
const { currentTime } = owner.art
const result = []
const result: DanmuItem[] = []
filterState(owner, 'ready', danmu => result.push(danmu))
filterState(owner, 'wait', (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1)
@@ -15,7 +19,7 @@ export function readyItems(owner) {
return result
}
export function setItemState(owner, danmu, state) {
export function setItemState(owner: QueueOwner, danmu: DanmuItem, state: DanmuState) {
owner.states[danmu.$state] = owner.states[danmu.$state].filter(item => item !== danmu)
danmu.$state = state
if (danmu.$ref)
@@ -1,5 +1,12 @@
import type Danmuku from './danmuku'
import type { FrameOperation } from './scheduler'
import type { DanmuItem } from './types'
export default class Renderer {
constructor(owner) {
declare owner: Danmuku
declare nodes: Set<HTMLDivElement>
constructor(owner: Danmuku) {
this.owner = owner
this.nodes = new Set()
}
@@ -14,21 +21,21 @@ export default class Renderer {
return $ref
}
get visibles() {
const result = []
get visibles(): { top: number, left: number, height: number, width: number, right: number, speed: number, distance: number, time: number, mode: number }[] {
const result: Renderer['visibles'] = []
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 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 = {}
const emit = {} as Renderer['visibles'][number]
emit.top = top
emit.left = left
emit.height = height
@@ -45,21 +52,21 @@ export default class Renderer {
return result
}
left($ref) {
left($ref: HTMLElement): number {
const rect = $ref.getBoundingClientRect()
return this.owner.isRotate ? rect.top : rect.left
}
makeWait(danmu) {
makeWait(danmu: DanmuItem) {
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'
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
danmu!.$ref = null
}
}
@@ -70,7 +77,7 @@ export default class Renderer {
switch (danmu.mode) {
// 滚动的弹幕
case 0:
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref!.style.left = `${clientWidth}px`
break
default:
break
@@ -82,10 +89,10 @@ export default class Renderer {
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`
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:
@@ -102,9 +109,9 @@ export default class Renderer {
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`
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:
@@ -122,9 +129,9 @@ export default class Renderer {
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'
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:
@@ -135,7 +142,7 @@ export default class Renderer {
return this
}
prepare(danmu, operation) {
prepare(danmu: DanmuItem, operation: FrameOperation) {
const owner = this.owner
const ref = owner.$ref
operation.danmu = danmu
@@ -143,19 +150,19 @@ export default class Renderer {
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
ref.style.opacity = owner.option.opacity as unknown as string
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
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null as unknown as string
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null as unknown as string
owner.utils.setStyles(ref, danmu.style)
return ref
}
place(danmu, ref, top, distance, clientWidth) {
place(danmu: DanmuItem, ref: HTMLDivElement, top: number, distance: number, clientWidth: number) {
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.mode = danmu.mode as unknown as string
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
@@ -174,13 +181,13 @@ export default class Renderer {
}
}
release(operation) {
release(operation: FrameOperation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
if (ref && danmu!.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
danmu!.$ref = null
}
operation.ref = null
}
@@ -1,7 +1,28 @@
import type Danmuku from './danmuku'
import type { DanmuItem } from './types'
const cancelled = Symbol('cancelled danmuku frame')
export interface FrameOperation {
generation: number
cancel: () => void
wait: <T>(value: T | PromiseLike<T>) => Promise<T | typeof cancelled>
danmu?: DanmuItem
ref: HTMLDivElement | null
}
export default class Scheduler {
constructor(owner) {
declare owner: Danmuku
declare generation: number
declare starts: number
declare frame: number | null
declare operation: FrameOperation | null
declare closed: boolean
declare running: boolean
declare fault: boolean
declare failedItems: Set<DanmuItem>
constructor(owner: Danmuku) {
this.owner = owner
this.generation = 0
this.starts = 0
@@ -13,13 +34,13 @@ export default class Scheduler {
this.failedItems = new Set()
}
active(operation) {
active(operation: FrameOperation) {
const owner = this.owner
return !this.closed && !this.fault && !owner.art.isDestroy && !owner.isStop
&& !owner.isHide && operation.generation === this.generation
}
release(operation) {
release(operation: FrameOperation) {
this.owner.renderer.release(operation)
}
@@ -39,7 +60,7 @@ export default class Scheduler {
this.failedItems.clear()
}
report(error) {
report(error: unknown) {
try {
this.owner.art.emit('artplayerPluginDanmuku:error', error)
}
@@ -48,7 +69,7 @@ export default class Scheduler {
}
}
fail(error) {
fail(error: unknown) {
if (this.closed || this.fault)
return
this.fault = true
@@ -81,9 +102,9 @@ export default class Scheduler {
owner.timer = null
if (this.closed || this.fault || owner.isStop || owner.art.isDestroy)
return
let cancel
const cancellation = new Promise(resolve => cancel = () => resolve(cancelled))
const operation = { generation: this.generation, cancel, wait: value => Promise.race([value, cancellation]), ref: null }
let cancel!: () => void
const cancellation = new Promise<typeof cancelled>(resolve => cancel = () => resolve(cancelled))
const operation: FrameOperation = { generation: this.generation, cancel, wait: value => Promise.race([value, cancellation]), ref: null }
this.operation = operation
return this.run(operation).catch((error) => {
if (this.active(operation))
@@ -99,7 +120,7 @@ export default class Scheduler {
owner.timer = this.frame
}
async run(operation) {
async run(operation: FrameOperation) {
const owner = this.owner
if (!owner.art.playing || !this.active(operation))
return
@@ -139,16 +160,18 @@ export default class Scheduler {
danmu.$lastStartTime = Date.now()
danmu.$restTime = owner.speed
const distance = clientWidth + ref.clientWidth
const { result: top } = await operation.wait(owner.postMessage({
const reply = await operation.wait(owner.postMessage({
type: 'getDanmuTop',
target: { mode: danmu.mode, height: ref.clientHeight, speed: distance / danmu.$restTime },
visibles: owner.visibles,
antiOverlap: owner.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: owner.marginBottom,
// Valid margins are numeric. Preserve the old raw fallback for unsupported strings.
marginBottom: owner.marginBottom as number,
marginTop: owner.marginTop,
}))
const top = typeof reply === 'symbol' ? undefined : reply.result
if (!this.active(operation) || danmu.$ref !== ref)
return
if (top !== undefined) {
@@ -1,7 +1,35 @@
const properties = new WeakMap()
import type { DanmukuArt } from './types'
type Disposer = () => unknown
type PropertyValue = string | number | boolean | undefined
interface PropertyEntry {
original: PropertyValue
present: boolean
priority: string | undefined
owners: Map<SettingLifecycle, PropertyValue>
apply: (value: PropertyValue) => unknown
last?: PropertyValue
lastPriority?: string
}
interface SettingEvents {
'resize': []
'fullscreen': [state: boolean]
'fullscreenWeb': [state: boolean]
'artplayerPluginDanmuku:show': []
'artplayerPluginDanmuku:hide': []
'document:pointermove': [event: PointerEvent]
'document:pointerup': [event: PointerEvent]
}
const properties = new WeakMap<object, Map<string, PropertyEntry>>()
export default class SettingLifecycle {
constructor(art) {
declare art: DanmukuArt
declare closed: boolean
declare disposers: Set<Disposer>
declare cancelled: Promise<void>
declare cancel: () => void
constructor(art: DanmukuArt) {
this.art = art
this.closed = false
this.disposers = new Set()
@@ -12,13 +40,15 @@ export default class SettingLifecycle {
return !this.closed && !this.art.isDestroy
}
own(dispose) {
own(dispose: Disposer) {
if (this.closed)
dispose()
else this.disposers.add(dispose)
}
write(target, key, value, apply = value => target[key] = value) {
write(host: DOMStringMap | CSSStyleDeclaration, key: string, value: PropertyValue, apply: (value: PropertyValue) => unknown = value => (host as unknown as Record<string, PropertyValue>)[key] = value) {
// This adapter retains native dataset/style assignment and its WebIDL coercion.
const target = host as unknown as Record<string, PropertyValue> & Partial<Pick<CSSStyleDeclaration, 'getPropertyPriority' | 'setProperty'>>
if (!this.active)
return
const priority = () => typeof target.getPropertyPriority === 'function' ? target.getPropertyPriority(key) : undefined
@@ -52,7 +82,7 @@ export default class SettingLifecycle {
entry.lastPriority = priority()
}
else if (entry.priority !== undefined) {
target.setProperty(key, entry.original, entry.priority)
target.setProperty!(key, entry.original as string, entry.priority)
}
else if (entry.present) {
entry.apply(entry.original)
@@ -75,13 +105,15 @@ export default class SettingLifecycle {
entry.lastPriority = priority()
}
on(name, callback) {
on<Name extends keyof SettingEvents>(name: Name, callback: (this: unknown, ...args: SettingEvents[Name]) => unknown) {
if (!this.active)
return
// Keep the emitter/native callback receiver while consulting this owner.
// eslint-disable-next-line ts/no-this-alias
const lifecycle = this
function listener(...args) {
function listener(this: unknown, ...args: unknown[]) {
if (lifecycle.active)
return callback.apply(this, args)
return callback.apply(this, args as SettingEvents[Name])
}
const dispose = () => this.art.off(name, listener)
this.own(dispose)
@@ -96,18 +128,20 @@ export default class SettingLifecycle {
}
}
proxy(target, name, callback) {
proxy<Name extends keyof HTMLElementEventMap>(target: HTMLElement, name: Name, callback: (this: unknown, event: HTMLElementEventMap[Name]) => unknown) {
if (!this.active)
return
// Keep the emitter/native callback receiver while consulting this owner.
// eslint-disable-next-line ts/no-this-alias
const lifecycle = this
function listener(...args) {
function listener(this: unknown, ...args: unknown[]) {
if (lifecycle.active)
return callback.apply(this, args)
return callback.apply(this, args as [HTMLElementEventMap[Name]])
}
let remove
let remove: (() => void) | undefined
const dispose = () => {
if (typeof remove === 'function') {
const registry = this.art.events
const registry = this.art.events as DanmukuArt['events'] & { destroyEvents?: Set<() => void> }
if (typeof registry?.remove === 'function' && registry.destroyEvents?.has(remove))
registry.remove(remove)
else remove()
@@ -128,11 +162,11 @@ export default class SettingLifecycle {
}
}
wait(value) {
wait<Value>(value: Value | PromiseLike<Value>) {
return Promise.race([value, this.cancelled])
}
release(dispose) {
release(dispose: Disposer) {
try {
dispose()
}
@@ -1,17 +1,20 @@
export async function emitSetting(setting) {
import type Setting from './setting'
import type { DanmuInput } from './types'
export async function emitSetting(setting: 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 = {
const danmu: DanmuInput = {
text,
mode: setting.option.mode,
color: setting.option.color,
time: setting.art.currentTime,
}
const report = (error) => {
const report = (error: unknown) => {
if (lifecycle.active)
console.error('Error emitting danmuku:', error)
}
@@ -39,15 +42,15 @@ export async function emitSetting(setting) {
}
}
export function lockSetting(setting) {
export function lockSetting(setting: Setting) {
if (!setting.lifecycle.active)
return
const { addClass } = setting.utils
const { $send } = setting.template
clearTimeout(setting.timer)
clearTimeout(setting.timer as number | undefined)
setting.isLock = true
let time = setting.option.lockTime
$send.textContent = time
$send.textContent = time as unknown as string
addClass($send, 'apd-lock')
const loop = () => {
setting.timer = setTimeout(() => {
@@ -58,7 +61,7 @@ export function lockSetting(setting) {
}
else {
time -= 1
$send.textContent = time
$send.textContent = time as unknown as string
loop()
}
}, 1000)
@@ -66,8 +69,8 @@ export function lockSetting(setting) {
loop()
}
export function unlockSetting(setting) {
clearTimeout(setting.timer)
export function unlockSetting(setting: Setting) {
clearTimeout(setting.timer as number | undefined)
setting.timer = null
setting.isLock = false
if (!setting.lifecycle.active)
@@ -1,10 +1,13 @@
export default function createSlider({ min, max, container, findIndex, onChange, steps = [] }) {
import type Setting from './setting'
import type { SliderConfig } from './setting-types'
export default function createSlider(this: Setting, { min, max, container, findIndex, onChange, steps = [] }: SliderConfig) {
const { lifecycle } = this
if (!lifecycle.active)
return { reset() {} }
const { query, clamp, setStyle } = this.utils
setStyle(container, 'touch-action', 'none')
setStyle(container, 'touch-action' as keyof CSSStyleDeclaration, 'none')
container.innerHTML = `
<div class="apd-slider-line">
@@ -19,14 +22,14 @@ export default function createSlider({ min, max, container, findIndex, onChange,
</div>
`
const $dot = query('.apd-slider-dot', container)
const $progress = query('.apd-slider-progress', container)
const $dot = query<HTMLDivElement>('.apd-slider-dot', container)!
const $progress = query<HTMLDivElement>('.apd-slider-progress', container)!
let isDroging = false
lifecycle.own(() => isDroging = false)
function reset(index) {
function reset(index?: number) {
if (!lifecycle.active)
return
if (index === undefined)
@@ -41,7 +44,7 @@ export default function createSlider({ min, max, container, findIndex, onChange,
onChange(index)
}
function updateLeft(event) {
function updateLeft(this: Setting, event: MouseEvent) {
const { top, height, left, width } = container.getBoundingClientRect()
if (this.art.isRotate) {
const value = clamp(event.clientY - top, 0, height)
@@ -3,26 +3,27 @@ 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]
const styleDocument = document as Document & { [key: symbol]: HTMLElement | undefined }
let $style = document.getElementById(id) || styleDocument[pending]
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document[pending] = $style
styleDocument[pending] = $style
const ready = () => {
document.removeEventListener('DOMContentLoaded', ready)
if (document[pending] === $style)
delete document[pending]
if (styleDocument[pending] === $style)
delete styleDocument[pending]
const existing = document.getElementById(id)
if (existing)
existing.textContent = $style.textContent
existing.textContent = $style!.textContent
else
(document.head || document.documentElement).appendChild($style)
(document.head || document.documentElement).appendChild($style!)
}
document.addEventListener('DOMContentLoaded', ready, { once: true })
}
else {
(document.head || document.documentElement).appendChild($style)
(document.head || document.documentElement).appendChild($style!)
}
}
$style.textContent = style
@@ -1,3 +1,4 @@
import type Setting from './setting'
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'
@@ -28,7 +29,7 @@ export function settingIcons() {
}
}
export function settingTemplate() {
export function settingTemplate(this: Setting) {
const { option } = this
return `
<div class="apd-toggle">
@@ -0,0 +1,39 @@
import type { SliderStep } from './types'
// createTemplate/createSliders fill these slots before registering active UI callbacks.
// The initial null values and absent antiOverlap/syncVideo keys are kept for compatibility.
export interface SettingTemplate {
$controlsCenter: HTMLDivElement
$mount: HTMLElement
$danmuku: HTMLDivElement
$toggle: HTMLDivElement
$config: HTMLDivElement
$configPanel: HTMLDivElement
$configModes: HTMLDivElement
$style: HTMLDivElement
$stylePanel: HTMLDivElement
$styleModes: HTMLDivElement
$colors: HTMLDivElement
$antiOverlap?: HTMLDivElement
$syncVideo?: HTMLDivElement
$opacitySlider: HTMLDivElement
$opacityValue: HTMLDivElement
$marginSlider: HTMLDivElement
$marginValue: HTMLDivElement
$fontSizeSlider: HTMLDivElement
$fontSizeValue: HTMLDivElement
$speedSlider: HTMLDivElement
$speedValue: HTMLDivElement
$input: HTMLInputElement
$send: HTMLDivElement
}
export interface SettingSlider { reset: (index?: number) => void }
export interface SliderConfig {
min: number
max: number
container: HTMLElement
findIndex: () => number
onChange: (index: number) => void
steps?: SliderStep<unknown>[]
}
@@ -1,3 +1,6 @@
import type Danmuku from './danmuku'
import type { SettingSlider, SettingTemplate, SliderConfig } from './setting-types'
import type { DanmukuArt, NormalizedOption, SliderOption } from './types'
import SettingLifecycle from './setting-lifecycle'
import { emitSetting, lockSetting, unlockSetting } from './setting-send'
import createSlider from './setting-slider'
@@ -5,7 +8,17 @@ import { settingIcons, settingTemplate } from './setting-template'
import './setting-style'
export default class Setting {
constructor(art, danmuku) {
declare art: DanmukuArt
declare danmuku: Danmuku
declare utils: DanmukuArt['constructor']['utils']
declare template: SettingTemplate
declare slider: Record<'opacity' | 'margin' | 'fontSize' | 'speed', SettingSlider>
declare emitting: boolean
declare isLock: boolean
declare timer: ReturnType<typeof setTimeout> | null
declare lifecycle: SettingLifecycle
constructor(art: DanmukuArt, danmuku: Danmuku) {
this.art = art
this.danmuku = danmuku
this.utils = art.constructor.utils
@@ -16,32 +29,32 @@ export default class Setting {
this.template = {
$controlsCenter,
$mount: $controlsCenter,
$danmuku: null,
$toggle: null,
$config: null,
$configPanel: null,
$configModes: null,
$style: null,
$stylePanel: null,
$styleModes: null,
$colors: null,
$opacitySlider: null,
$opacityValue: null,
$marginSlider: null,
$marginValue: null,
$fontSizeSlider: null,
$fontSizeValue: null,
$speedSlider: null,
$speedValue: null,
$input: null,
$send: null,
$danmuku: null!,
$toggle: null!,
$config: null!,
$configPanel: null!,
$configModes: null!,
$style: null!,
$stylePanel: null!,
$styleModes: null!,
$colors: null!,
$opacitySlider: null!,
$opacityValue: null!,
$marginSlider: null!,
$marginValue: null!,
$fontSizeSlider: null!,
$fontSizeValue: null!,
$speedSlider: null!,
$speedValue: null!,
$input: null!,
$send: null!,
}
this.slider = {
opacity: null,
margin: null,
fontSize: null,
speed: null,
opacity: null!,
margin: null!,
fontSize: null!,
speed: null!,
}
this.emitting = false
@@ -56,7 +69,7 @@ export default class Setting {
art.on('destroy', this.destroy)
if (!this.lifecycle.active)
return
this.lifecycle.write($controlsCenter.style, 'display', 'flex', value => setStyle($controlsCenter, 'display', value))
this.lifecycle.write($controlsCenter.style, 'display', 'flex', value => setStyle($controlsCenter, 'display', value as string))
this.createTemplate()
if (!this.lifecycle.active)
@@ -103,7 +116,7 @@ export default class Setting {
return settingIcons()
}
get option() {
get option(): NormalizedOption {
return this.danmuku.option
}
@@ -115,7 +128,7 @@ export default class Setting {
return settingTemplate.call(this)
}
get OPACITY() {
get OPACITY(): Required<SliderOption> {
return {
min: 0,
max: 100,
@@ -124,7 +137,7 @@ export default class Setting {
}
}
get FONT_SIZE() {
get FONT_SIZE(): Required<SliderOption> {
return {
min: 12,
max: 120,
@@ -133,7 +146,7 @@ export default class Setting {
}
}
get MARGIN() {
get MARGIN(): Required<SliderOption<NormalizedOption['margin']>> {
return {
min: 0,
max: 3,
@@ -159,7 +172,7 @@ export default class Setting {
}
}
get SPEED() {
get SPEED(): Required<SliderOption> {
return {
min: 0,
max: 4,
@@ -212,13 +225,13 @@ export default class Setting {
]
}
query(selector) {
query<T extends HTMLElement = HTMLDivElement>(selector: string): T {
const { query } = this.utils
const { $danmuku } = this.template
return query(selector, $danmuku)
return query(selector, $danmuku) as T
}
append(el, target) {
append(el: HTMLElement, target: HTMLElement) {
if (!this.lifecycle.active)
return
const { append } = this.utils
@@ -228,7 +241,7 @@ export default class Setting {
append(el, target)
}
setData(key, value) {
setData(key: string, value: string | number | boolean) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
@@ -265,7 +278,7 @@ export default class Setting {
this.template.$fontSizeValue = this.query('.apd-config-fontSize .apd-value')
this.template.$speedSlider = this.query('.apd-config-speed .apd-slider')
this.template.$speedValue = this.query('.apd-config-speed .apd-value')
this.template.$input = this.query('.apd-input')
this.template.$input = this.query<HTMLInputElement>('.apd-input')
this.template.$send = this.query('.apd-send')
const { $toggle } = this.template
@@ -290,7 +303,7 @@ export default class Setting {
})
this.lifecycle.proxy($configModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
const $mode = (event.target as HTMLElement).closest<HTMLElement>('.apd-mode')
if (!$mode)
return
const mode = Number($mode.dataset.mode)
@@ -307,14 +320,14 @@ export default class Setting {
this.reset()
})
this.lifecycle.proxy($antiOverlap, 'click', () => {
this.lifecycle.proxy($antiOverlap!, 'click', () => {
this.danmuku.config({
antiOverlap: !this.option.antiOverlap,
})
this.reset()
})
this.lifecycle.proxy($syncVideo, 'click', () => {
this.lifecycle.proxy($syncVideo!, 'click', () => {
this.danmuku.config({
synchronousPlayback: !this.option.synchronousPlayback,
})
@@ -322,7 +335,7 @@ export default class Setting {
})
this.lifecycle.proxy($styleModes, 'click', (event) => {
const $mode = event.target.closest('.apd-mode')
const $mode = (event.target as HTMLElement).closest<HTMLElement>('.apd-mode')
if (!$mode)
return
const mode = Number($mode.dataset.mode)
@@ -333,7 +346,7 @@ export default class Setting {
})
this.lifecycle.proxy($colors, 'click', (event) => {
const $color = event.target.closest('.apd-color')
const $color = (event.target as HTMLElement).closest<HTMLElement>('.apd-color')
if (!$color)
return
this.danmuku.config({
@@ -373,7 +386,7 @@ export default class Setting {
container: this.template.$marginSlider,
findIndex: () => {
return this.MARGIN.steps.findIndex(
item => item.value[0] === this.option.margin[0] && item.value[1] === this.option.margin[1],
item => item.value![0] === this.option.margin[0] && item.value![1] === this.option.margin[1],
)
},
onChange: (index) => {
@@ -381,7 +394,7 @@ export default class Setting {
if (!margin)
return
const { $marginValue } = this.template
$marginValue.textContent = margin.name
$marginValue.textContent = margin.name as string
this.danmuku.config({
margin: margin.value,
})
@@ -416,7 +429,7 @@ export default class Setting {
if (!speed)
return
const { $speedValue } = this.template
$speedValue.textContent = speed.name
$speedValue.textContent = speed.name as string
this.danmuku.config({
speed: speed.value,
})
@@ -424,11 +437,11 @@ export default class Setting {
})
}
createSlider(option) {
createSlider(option: SliderConfig) {
return createSlider.call(this, option)
}
onFullscreen(state) {
onFullscreen(state: boolean) {
if (!this.lifecycle.active)
return
const { $danmuku, $controlsCenter, $mount } = this.template
@@ -445,7 +458,7 @@ export default class Setting {
}
}
onMouseEnter({ $control, $panel }) {
onMouseEnter({ $control, $panel }: { $control: HTMLElement, $panel: HTMLElement }) {
if (!this.lifecycle.active)
return
const { $player } = this.art.template
@@ -522,7 +535,7 @@ export default class Setting {
this.setData('danmukuTheme', this.option.theme)
this.setData('danmukuEmitter', this.option.emitter)
const colors = $colors.children
const colors = $colors.children as HTMLCollectionOf<HTMLElement>
const $color = Array.from(colors).find(item => item.dataset.color === this.option.color.toUpperCase())
$color && inverseClass($color, 'apd-active')
@@ -531,14 +544,14 @@ export default class Setting {
this.resize()
}
mount(target) {
mount(target: NormalizedOption['mount']) {
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}`)
this.append($el, this.template.$danmuku)
this.template.$mount = $el
const $el = typeof target === 'string' ? document.querySelector<HTMLElement>(target) : target
errorHandle($el as unknown as boolean, `Can not find the mount point: ${target}`)
this.append($el!, this.template.$danmuku)
this.template.$mount = $el!
this.reset()
}
@@ -546,7 +559,7 @@ export default class Setting {
if (this.lifecycle.closed)
return
this.lifecycle.close()
clearTimeout(this.timer)
clearTimeout(this.timer as number | undefined)
this.timer = null
this.emitting = false
this.isLock = false
@@ -0,0 +1,19 @@
import type Artplayer from 'artplayer'
// Public runtime data has one declaration source; host and Worker adapters stay internal.
export type {
Danmu as DanmuInput,
Item as DanmuItem,
Input as DanmukuInput,
RuntimeOption as DanmukuOption,
State as DanmuState,
Heatmap as HeatmapOptions,
Point as HeatmapPoint,
NormalizedDanmu,
NormalizedOption,
Slider as SliderOption,
SliderStep,
} from '../types/runtime-shared'
export type DanmukuArt = Artplayer & { constructor: typeof Artplayer, isRotate?: boolean }
export type DanmuStyle = Partial<CSSStyleDeclaration>
@@ -0,0 +1,4 @@
declare module '*?worker&inline' {
const WorkerConstructor: new (options?: WorkerOptions) => Worker
export default WorkerConstructor
}
@@ -1,14 +1,22 @@
import type { PlacementReply, PlacementRequest } from './worker-types'
let nextRequest = 0
export default class WorkerClient {
constructor(createWorker, onFailure) {
declare pending: Map<number, { resolve: (value: PlacementReply) => void, reject: (error: unknown) => void }>
declare closed: boolean
declare failed: boolean
declare onFailure: (error: unknown) => void
declare worker: Worker
constructor(createWorker: () => Worker, onFailure: (error: unknown) => void) {
this.pending = new Map()
this.closed = false
this.failed = false
this.onFailure = onFailure
this.worker = createWorker()
try {
this.worker.onmessage = (event) => {
this.worker.onmessage = (event: MessageEvent<PlacementReply>) => {
const request = this.pending.get(event.data?.id)
if (!request)
return
@@ -19,7 +27,7 @@ export default class WorkerClient {
event.preventDefault?.()
this.fail(event.error || event)
}
this.worker.onmessageerror = event => this.fail(event.error || event)
this.worker.onmessageerror = event => this.fail((event as MessageEvent & { error?: unknown }).error || event)
}
catch (error) {
try {
@@ -30,7 +38,7 @@ export default class WorkerClient {
}
}
request(message) {
request(message: PlacementRequest): Promise<PlacementReply> {
message.id = ++nextRequest
const { id } = message
if (this.closed || this.failed)
@@ -46,7 +54,7 @@ export default class WorkerClient {
})
}
fail(error) {
fail(error: unknown) {
if (this.closed || this.failed)
return
this.failed = true
@@ -70,8 +78,8 @@ export default class WorkerClient {
this.closed = true
this.cancel()
let failed = false
let error
const attempt = (callback) => {
let error: unknown
const attempt = (callback: () => void) => {
try {
callback()
}
@@ -81,7 +89,7 @@ export default class WorkerClient {
failed = true
}
}
for (const name of ['onmessage', 'onerror', 'onmessageerror']) {
for (const name of ['onmessage', 'onerror', 'onmessageerror'] as const) {
attempt(() => this.worker[name] = null)
}
attempt(() => this.worker.terminate())
@@ -0,0 +1,32 @@
export interface VisibleDanmu {
mode: number
top: number
left: number
right: number
height: number
width: number
speed: number
distance: number
time: number
}
export interface PlacementRequest {
id?: number
type: 'getDanmuTop'
target: { mode: number, height: number, speed: number }
visibles: VisibleDanmu[]
clientWidth: number
clientHeight: number
marginBottom: number
marginTop: number
antiOverlap: boolean
}
export interface PlacementMessage extends PlacementRequest { id: number }
export interface PlacementReply { id: number, result: number | undefined }
export interface PlacementBoundary extends Omit<VisibleDanmu, 'mode' | 'time'> {
type: 'top' | 'bottom'
}
export type PlacementRow = VisibleDanmu | PlacementBoundary
@@ -1,9 +1,11 @@
function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }) {
import type { PlacementMessage, PlacementReply, PlacementRequest, PlacementRow, VisibleDanmu } from './worker-types'
function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }: PlacementRequest): number | undefined {
// 弹幕最大高度
const maxTop = clientHeight - marginBottom
// 过滤同模式的弹幕,即每种模式各不影响
const danmus = visibles
const danmus: PlacementRow[] = visibles
.filter(item => item.mode === target.mode && item.top <= maxTop)
.sort((prev, next) => prev.top - next.top)
@@ -44,8 +46,8 @@ function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom
if (target.mode === 2) {
// 倒序查找
for (let index = danmus.length - 2; index >= 0; index -= 1) {
const item = danmus[index]
const prev = danmus[index + 1]
const item = danmus[index]!
const prev = danmus[index + 1]!
const itemBottom = item.top + item.height
const diff = prev.top - itemBottom
if (diff >= target.height) {
@@ -56,8 +58,8 @@ function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom
else {
// 顺序查找
for (let index = 1; index < danmus.length; index += 1) {
const item = danmus[index]
const prev = danmus[index - 1]
const item = danmus[index]!
const prev = danmus[index - 1]!
const prevBottom = prev.top + prev.height
const diff = item.top - prevBottom
if (diff >= target.height) {
@@ -66,12 +68,13 @@ function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom
}
}
const topMap = []
const topMap: VisibleDanmu[][] = []
for (let index = 1; index < danmus.length - 1; index += 1) {
const item = danmus[index]
// The loop excludes the two virtual boundary rows inserted above.
const item = danmus[index] as VisibleDanmu
if (topMap.length) {
const last = topMap[topMap.length - 1]
if (last[0].top === item.top) {
const last = topMap[topMap.length - 1]!
if (last[0]!.top === item.top) {
last.push(item)
}
else {
@@ -131,11 +134,11 @@ function getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom
break
}
return topMap[0][0].top
return topMap[0]![0]!.top
}
}
onmessage = (event) => {
onmessage = (event: MessageEvent<PlacementMessage>) => {
const { data } = event
if (!data.id || !data.type)
return
@@ -147,5 +150,5 @@ onmessage = (event) => {
globalThis.postMessage({
result,
id: data.id,
})
} satisfies PlacementReply)
}
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": false,
"lib": ["ES2020", "ES2021.String", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "../../types/assets.d.ts", "../../types/option-validator.d.ts"]
}
@@ -0,0 +1,171 @@
import type Artplayer from 'artplayer'
export type Mode = 0 | 1 | 2
export type State = 'wait' | 'ready' | 'emit' | 'stop'
export type Margin = [number | string, number | string]
/** Mutable tuples: historical custom heatmap rendering adjusts the second coordinate. */
export type Point = [number, number]
export interface Danmu {
text: string
id?: string
mode?: number
color?: string
time?: number
border?: boolean
style?: Partial<CSSStyleDeclaration>
[extension: string]: unknown
}
/** Input fields have been filled before filter runs; queue fields do not exist yet. */
export interface NormalizedDanmu extends Danmu {
mode: number
color: string
time: number
style: Partial<CSSStyleDeclaration>
}
/** The actual mutable queue entry supplied to beforeVisible and visible/loaded events. */
export interface Item extends NormalizedDanmu {
$state: State
$index: number
$ref: HTMLDivElement | null
$restTime: number
$lastStartTime: number
}
/** Functions run without an option receiver. Arrays are also valid synchronous results. */
export type Input = Danmu[] | string | Promise<Danmu[]> | (() => Danmu[] | Promise<Danmu[]>)
export interface SliderStep<Value = number> { name?: string, value?: Value, hide?: boolean, show?: boolean }
export interface Slider<Value = number> { min?: number, max?: number, steps?: SliderStep<Value>[] }
export interface Heatmap {
xMin?: number
xMax?: number
yMin?: number
yMax?: number
scale?: number
opacity?: number
minHeight?: number
sampling?: number
smoothing?: number
flattening?: number
}
export interface NormalizedOption {
danmuku: Input
speed: number
margin: Margin
opacity: number
color: string
/** Numeric inputs are clamped; values other than 0, 1 and 2 are not emitted. */
mode: number
modes: number[]
fontSize: number | string
antiOverlap: boolean
synchronousPlayback: boolean
mount: HTMLDivElement | string | undefined
heatmap: boolean | Heatmap
width: number
/** Stored configuration; send the points event to draw custom points. */
points: Point[]
/** A truthy result accepts the row. */
filter: (this: NormalizedOption, danmu: NormalizedDanmu) => unknown
/** Only a result equal to true sends the row; callbacks may be asynchronous. */
beforeEmit: (this: NormalizedOption, danmu: Danmu) => unknown
/** A truthy result displays the queue entry; callbacks may be asynchronous. */
beforeVisible: (this: NormalizedOption, danmu: Item) => unknown
visible: boolean
emitter: boolean
maxLength: number
lockTime: number
theme: string
OPACITY: Slider
FONT_SIZE: Slider
MARGIN: Slider<Margin>
SPEED: Slider
COLOR: string[]
}
/** The argument object is required; every option inside it is optional. */
export type RuntimeOption = Partial<NormalizedOption>
/** The owner returned by commands is different from the registered plugin facade. */
export interface Owner {
art: Artplayer
option: NormalizedOption
isHide: boolean
isStop: boolean
queue: Item[]
states: Record<State, Item[]>
readonly readys: Item[]
readonly speed: number
readonly fontSize: number
readonly marginTop: number
readonly marginBottom: number | string
readonly isRotate: boolean | undefined
emit: (danmu: Danmu) => Promise<Owner>
/** No argument replaces the configured source; a provided argument appends rows. */
load: (danmuku?: Input) => Promise<Owner>
config: (option: RuntimeOption) => Owner
hide: () => Owner
show: () => Owner
reset: () => Owner
start: () => Owner
stop: () => Owner
continue: () => Owner
suspend: () => Owner
update: () => Owner
resize: () => void
seek: () => void
destroy: () => void
}
export interface RuntimeResult {
name: 'artplayerPluginDanmuku'
emit: (danmu: Danmu) => Promise<Owner>
load: (danmuku?: Input) => Promise<Owner>
config: (option: RuntimeOption) => Owner
hide: () => Owner
show: () => Owner
reset: () => Owner
/** The live setting requires a valid mount; omitting it does not select a default. */
mount: (target: HTMLDivElement | string) => void
readonly option: NormalizedOption
readonly isHide: boolean
readonly isStop: boolean
}
export interface Icons {
$on: string
$off: string
$config: string
$style: string
$mode_0_off: string
$mode_0_on: string
$mode_1_off: string
$mode_1_on: string
$mode_2_off: string
$mode_2_on: string
$check_on: string
$check_off: string
}
export interface RuntimeFactory {
(option: RuntimeOption): (art: Artplayer) => RuntimeResult
icons: Icons
}
/** Explicit payload map; importing /runtime does not augment historical Artplayer events. */
export interface EventMap {
'artplayerPluginDanmuku:points': [points: Point[]]
'artplayerPluginDanmuku:loaded': [queue: Item[]]
'artplayerPluginDanmuku:visible': [item: Item]
'artplayerPluginDanmuku:config': [option: NormalizedOption]
'artplayerPluginDanmuku:error': [error: unknown]
'artplayerPluginDanmuku:show': []
'artplayerPluginDanmuku:hide': []
'artplayerPluginDanmuku:start': []
'artplayerPluginDanmuku:stop': []
'artplayerPluginDanmuku:reset': []
'artplayerPluginDanmuku:destroy': []
}
@@ -0,0 +1,25 @@
import type { RuntimeFactory } from './runtime-shared.js'
declare const artplayerPluginDanmuku: RuntimeFactory
// eslint-disable-next-line ts/no-redeclare -- Ambient merge exposes types on the CommonJS factory.
declare namespace artplayerPluginDanmuku {
type Danmu = import('./runtime-shared.js').Danmu
type EventMap = import('./runtime-shared.js').EventMap
type Heatmap = import('./runtime-shared.js').Heatmap
type Icons = import('./runtime-shared.js').Icons
type Input = import('./runtime-shared.js').Input
type Item = import('./runtime-shared.js').Item
type Margin = import('./runtime-shared.js').Margin
type Mode = import('./runtime-shared.js').Mode
type NormalizedDanmu = import('./runtime-shared.js').NormalizedDanmu
type NormalizedOption = import('./runtime-shared.js').NormalizedOption
type Owner = import('./runtime-shared.js').Owner
type Point = import('./runtime-shared.js').Point
type RuntimeFactory = import('./runtime-shared.js').RuntimeFactory
type RuntimeOption = import('./runtime-shared.js').RuntimeOption
type RuntimeResult = import('./runtime-shared.js').RuntimeResult
type Slider<Value = number> = import('./runtime-shared.js').Slider<Value>
type SliderStep<Value = number> = import('./runtime-shared.js').SliderStep<Value>
type State = import('./runtime-shared.js').State
}
export = artplayerPluginDanmuku
@@ -0,0 +1,5 @@
import type { RuntimeFactory } from './runtime-shared.js'
declare const artplayerPluginDanmuku: RuntimeFactory
export default artplayerPluginDanmuku
export type { Danmu, EventMap, Heatmap, Icons, Input, Item, Margin, Mode, NormalizedDanmu, NormalizedOption, Owner, Point, RuntimeFactory, RuntimeOption, RuntimeResult, Slider, SliderStep, State } from './runtime-shared.js'
+25
View File
@@ -0,0 +1,25 @@
import type { RuntimeFactory } from './runtime-shared.js'
declare const artplayerPluginDanmuku: RuntimeFactory
// eslint-disable-next-line ts/no-redeclare -- Ambient merge exposes types on the CommonJS factory.
declare namespace artplayerPluginDanmuku {
type Danmu = import('./runtime-shared.js').Danmu
type EventMap = import('./runtime-shared.js').EventMap
type Heatmap = import('./runtime-shared.js').Heatmap
type Icons = import('./runtime-shared.js').Icons
type Input = import('./runtime-shared.js').Input
type Item = import('./runtime-shared.js').Item
type Margin = import('./runtime-shared.js').Margin
type Mode = import('./runtime-shared.js').Mode
type NormalizedDanmu = import('./runtime-shared.js').NormalizedDanmu
type NormalizedOption = import('./runtime-shared.js').NormalizedOption
type Owner = import('./runtime-shared.js').Owner
type Point = import('./runtime-shared.js').Point
type RuntimeFactory = import('./runtime-shared.js').RuntimeFactory
type RuntimeOption = import('./runtime-shared.js').RuntimeOption
type RuntimeResult = import('./runtime-shared.js').RuntimeResult
type Slider<Value = number> = import('./runtime-shared.js').Slider<Value>
type SliderStep<Value = number> = import('./runtime-shared.js').SliderStep<Value>
type State = import('./runtime-shared.js').State
}
export = artplayerPluginDanmuku