refactor(danmuku): [PKG-DANMUKU-04] own scheduler and worker request lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-13 19:19:25 +08:00
1 parent 0b2efb07f1
commit 12332df221
24 files changed
+23441 -461

No files matched your search

@@ -10,17 +10,21 @@ repository root. Do not infer runtime contracts from the old declaration file.
| Module | Responsibility |
| --- | --- |
| `src/index.js` | Plugin factory, facade getters, bound methods, setting and heatmap composition |
| `src/danmuku.js` | Internal instance, public method return identity, queue commits and events; scheduling is still here pending PKG-DANMUKU-04 |
| `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; DOM preparation moves to a renderer in PKG-DANMUKU-05 |
| `src/queue.js` | Ordered state pools, eligibility and state transitions |
| `src/worker-client.js` | Track Worker lifecycle, a shared response dispatcher, unique request IDs and pending requests |
| `src/worker.js` | Track placement Worker; a separate protocol from the XML parser Worker |
| `src/setting.js` | Settings and emitter UI; lifecycle restructuring follows in PKG-DANMUKU-05 |
| `src/heatmap.js` | Heatmap rendering; restructuring follows in PKG-DANMUKU-05 |
The package is undergoing staged migration. PKG-DANMUKU-03 separates input and
configuration responsibilities in JavaScript; PKG-DANMUKU-06 converts all owned
configuration responsibilities in JavaScript; PKG-DANMUKU-04 separates scheduling
and track requests; 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.
@@ -47,8 +51,10 @@ This intermediate structure is not the final TypeScript or release acceptance.
callbacks that start another replacement.
- Keep input normalization observable: accepted objects receive missing mode,
style and color before filtering, and the queue receives a shallow copy.
Explicit time zero is preserved; missing time defaults to current time + 0.5,
and negative time is clamped to zero.
Explicit time zero is preserved; missing time and `NaN` default to current
time + 0.5. The historical validator accepts `NaN` as a number, so preserving
zero must not accidentally remove that fallback. Positive infinity remains
positive infinity; negative values, including negative infinity, clamp to zero.
- Function and Promise configuration changes use identity. Structurally equal
ordinary values retain the historical JSON comparison behavior. Validate and
normalize a prospective option before replacing the current option; invalid
@@ -58,20 +64,77 @@ This intermediate structure is not the final TypeScript or release acceptance.
empty result. Fetch/text failures must reach the public load's single error
outlet and reject it; automatic initial loading observes that rejection.
## Scheduling ownership
Keep at most one pending RAF or asynchronous preparation frame. Native `play`
and `playing` both invoke the existing start path and retain its events, but
must not create competing loops. Ready items precede wait items; wait selection
retains the current-time +/- 0.1 second window and state-pool order. Keep the
existing track algorithm and geometry fields until separately reviewed.
Pause, reset, successful replacement input commit, hide, seeking, destroy and replacing the
`beforeVisible` callback invalidate unfinished preparation. Cancellation races
the user Promise so an unresolved callback cannot prevent later work. A frame
owns its allocated node; check that node identity before recycling it, and check
the generation after each await or callback. Existing emit/stop nodes survive
hide, seeking and callback changes; reset retains its explicit reset behavior.
Seek does not implicitly reset already displayed or paused comments. Date.now
still measures wall-clock lifetime, excluding paused time. With synchronous
playback enabled, new comments sample speed/playbackRate when allocated; active
comments retain their assigned remaining seconds. Do not change these timing
rules as incidental cleanup.
A rejected `beforeVisible` reports its original error once and excludes that
item from further attempts in the current run. Explicit start, reset, an
invalidation or replacement callback permits another attempt if the item is
still eligible. False results retain their ordinary next-frame evaluation.
Other items can continue after a rejection. Error-listener failures are observed
locally and logged instead of creating a detached rejected RAF Promise.
A track Worker fault rejects its pending requests with the original error,
disposes that Worker and reports one error, then halts internal scheduling.
It does not fabricate a public stop event. A later start/reset rebuilds the
Worker; repeated failure waits for another explicit recovery opportunity.
Cancelled internal requests resolve `{ id, result: undefined }` instead of
remaining pending forever. Normal responses preserve `{ id, result }`.
Recovery error listeners run synchronously and can call destroy, stop, reset,
hide or start again. After recovery, the outer start must recheck destruction,
stop and fault state, its cancellation generation and its start-attempt identity
before continuing paused rows, scheduling or emitting start. Generation checks
protect reset/hide and other invalidation; the attempt identity also protects a
nested start that succeeds without changing the generation. Do not let the outer
call revive stopped rows or add a second start event after a nested recovery.
Ordinary repeated starts still retain their historical events and internal
return identity. Recovery tests restore a working Worker factory before a
single nested action; they do not manufacture an infinite retry loop.
Install the shared handler before sending requests. IDs are unique across
requests and Worker replacement, including multiple requests in one millisecond.
Late, unknown and duplicate replies do not settle a different request.
## Verification and remaining work
`yarn test:danmuku` runs frozen historical contracts/failures and candidate input
and parser regressions. The historical helper and archives must remain frozen;
`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
artifacts through `ARTPLAYER_DANMUKU_ARTIFACT`.
The input tests compare `NaN` and both infinities against frozen source and real
npm main/legacy artifacts. Preserve those historical assertions alongside the
explicit-zero correction when changing numeric normalization.
`test/browser/danmuku-input.spec.js` uses native video and Blob Workers against
published and candidate cores. Run its source, main and legacy variants after
the normal package build. Browser evidence distinguishes local XML/fault
fixtures from the real Bilibili service, and Windows WebKit from Safari devices.
The older native track-overlap observation remains open until scheduler and
rendering work is verified. A passing input test is not evidence that duplicate
RAF loops, visibility continuations, settings cleanup, heatmap sampling or
long-run load behavior have been fixed. See PKG-DANMUKU-04/05/07 and the risk
register before changing completion or release status.
`test/browser/danmuku-scheduler.spec.js` verifies native callback cancellation,
three modes, strict dense geometry, one request per prepared item, native
pause/seek/rate and visible-node reuse. Preserve both geometric and protocol
assertions; unique IDs alone do not prove non-overlapping tracks.
Settings cleanup, heatmap sampling and long-run load behavior still require
PKG-DANMUKU-05/07. Check task evidence and the risk register before changing
completion or release status; short native regressions are not package-wide
stability acceptance.
+22 -2
View File
@@ -20,11 +20,31 @@ reject the corresponding public `load()` Promise. Initial automatic loading
observes its rejection and logs a warning. XML Worker startup/runtime failures
use the existing parser locally and release Worker/Blob resources.
Explicit `time: 0` is preserved. A replacement callback passed to `config()`
takes effect, and invalid configuration leaves the current option intact.
Explicit `time: 0` is preserved. Missing time and `NaN` retain the historical
default of the current playback time plus 0.5 seconds. Positive infinity remains
unchanged; negative values, including negative infinity, are clamped to zero.
A replacement callback passed to `config()` takes effect, and invalid
configuration leaves the current option intact.
Existing method return identities, getters, input normalization and XML fields
remain compatible. Configuration changes do not automatically reload data.
## Scheduling failures and recovery
Playback uses one scheduling loop. Pausing, seeking, hiding, resetting or
destroying the player cancels unfinished visibility preparation. Updating
`beforeVisible` also releases an unfinished old callback; seeking and callback
changes preserve already displayed comments.
A rejected `beforeVisible` emits the original `artplayerPluginDanmuku:error`
once for that item in the current run, while other items continue. It is not
retried every frame. Pause/resume, reset or a replacement callback permits a
retry if the item still matches the display-time rules. A track Worker failure
reports once and halts scheduling until pause/resume or reset rebuilds it.
If an error listener synchronously stops, destroys, resets or hides the instance,
the interrupted recovery respects that action. A nested start can recover once
without the outer call resuming it again or emitting another start event.
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module ownership, compatibility
constraints, tests and the remaining staged TypeScript migration.
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDanmuku=e(),define(function(){return t.artplayerPluginDanmuku})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDanmuku=e()}(this,function(){"use strict";function t(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function e(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function i(t){if("string"!==typeof t)return[];const i=t.matchAll(/<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\/d>/gs);return Array.from(i).map(t=>{const i=t.groups.p.split(",");if(i.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const h='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switch(t.mode){case 0:{const e=a.find(e=>e.every(e=>{if(i<e.distance)return!1;if(t.speed<e.speed)return!0;return e.right/(t.speed-e.speed)>e.time}));return e&&e[0]?e[0].top:void 0}case 1:case 2:return}}onmessage=e=>{const{data:i}=e;if(!i.id||!i.type)return;const h=(0,{getDanmuTop:t}[i.type])(i);globalThis.postMessage({result:h,id:i.id})};\n',u="undefined"!==typeof self&&self.Blob&&new Blob(["URL.revokeObjectURL(import.meta.url);",h],{type:"text/javascript;charset=utf-8"});function c(t){let e;try{if(e=u&&(self.URL||self.webkitURL).createObjectURL(u),!e)throw"";const i=new Worker(e,{type:"module",name:t?.name});return i.addEventListener("error",()=>{(self.URL||self.webkitURL).revokeObjectURL(e)}),i}catch(i){return new Worker("data:text/javascript;charset=utf-8,"+encodeURIComponent(h),{type:"mLine 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 a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const a=t.states[e]||[];for(let n=0;n<a.length;n++)i(a[n]);return a}const u=Symbol("cancelled danmuku frame");class c{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}this.owner.workerClient?.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{this.owner.workerClient?.dispose(),this.owner.createWorker(),this.fault=!1}catch(t){this.report(t)}}schedule(){const t=this.owner;!this.running||this.closed||this.fault||t.art.isDestroy||t.isStop||null!==this.frame||this.operation||(this.frame=window.requestAnimationFrame(()=>{if(this.frame=null,t.timer=null,this.closed||this.fault||t.isStop||t.art.isDestroy)return;let e;const i=new Promise(t=>e=()=>t(u)),a={generation:this.generation,cancel:e,wait:t=>Promise.race([t,i]),ref:null};return this.operation=a,this.run(a).catch(t=>{this.active(a)&&this.fail(t)}).finally(()=>{this.release(a),this.operation===a&&(this.operation=null,this.schedule())})}),t.timer=this.frame)}async run(t){const e=this.owner,{setStyles:i}=e.utils;if(!e.art.playing||!this.active(t))return;e.filter("emit",t=>{const i=(Date.now()-t.$lastStartTime)/1e3;t.$restTimeLine truncated
@@ -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,a=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,s=Object.prototype.propertyIsEnumerable,o=Math.pow,r=(e,i,a)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:a}):e[i]=a,l=(t,e)=>{for(var i in e||(e={}))n.call(e,i)&&r(t,i,e[i]);if(a)for(var i of a(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,a)=>e(t,i(a)),p=(t,e,i)=>new Promise((a,n)=>{var s=t=>{try{r(i.next(t))}catch(e){n(e)}},o=t=>{try{r(i.throw(t))}catch(e){n(e)}},r=t=>t.done?a(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});function h(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,a=c(e);globalThis.postMessage({danmus:a,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((a,n)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const a=o;o=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?n(i):a(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const a=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(a,t.error||t)},s.onmessageerror=t=>v(a,t.error||t),s.postMessage({xml:a,id:d})}catch(n){v(a,n)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function x(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function $(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}const w='/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction t({target:t,visibles:e,clientWidth:i,clientHeight:h,marginBottom:n,marginTop:o,antiOverlap:s}){const r=h-n,p=e.filter(e=>e.mode===t.mode&&e.top<=r).sort((t,e)=>t.top-e.top);if(0===p.length)return 2===t.mode?r-t.height:o;if(p.unshift({type:"top",top:0,left:0,right:0,height:o,width:i,speed:0,distance:i}),p.push({type:"bottom",top:r,left:0,right:0,height:n,width:i,speed:0,distance:i}),2===t.mode)for(let g=p.length-2;g>=0;g-=1){const e=p[g],i=p[g+1],h=e.top+e.height;if(i.top-h>=t.height)return i.top-t.height}else for(let g=1;g<p.length;g+=1){const e=p[g],i=p[g-1],h=i.top+i.height;if(e.top-h>=t.height)return h}const a=[];for(let g=1;g<p.length-1;g+=1){const t=p[g];if(a.length){const e=a[a.length-1];e[0].top===t.top?e.push(t):a.push([t])}else a.push([t])}if(!s){switch(t.mode){case 0:a.sort((t,e)=>{const i=Math.min(...e.map(t=>t.right)),h=Math.min(...t.map(t=>t.right));return i*e.length-h*t.length});break;case 1:case 2:a.sort((t,e)=>{const i=Math.max(...e.map(t=>t.width));return Math.max(...t.map(t=>t.width))*t.length-i*e.length})}return a[0][0].top}switcLine 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,o=Math.pow,r=(e,i,n)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[i]=n,l=(t,e)=>{for(var i in e||(e={}))a.call(e,i)&&r(t,i,e[i]);if(n)for(var i of n(e))s.call(e,i)&&r(t,i,e[i]);return t},d=(t,n)=>e(t,i(n)),p=(t,e,i)=>new Promise((n,a)=>{var s=t=>{try{r(i.next(t))}catch(e){a(e)}},o=t=>{try{r(i.throw(t))}catch(e){a(e)}},r=t=>t.done?n(t.value):Promise.resolve(t.value).then(s,o);r((i=i.apply(t,e)).next())});function h(t){return t instanceof Promise||"[object Promise]"===Object.prototype.toString.call(t)}function u(t){switch(t){case 1:case 2:case 3:default:return 0;case 4:return 2;case 5:return 1}}function c(t){if("string"!==typeof t)return[];const e=new RegExp('<d[^>]*?p="(?<p>[^"]+)"[^>]*>(?<text>.*?)<\\/d>',"gs"),i=t.matchAll(e);return Array.from(i).map(t=>{const e=t.groups.p.split(",");if(e.length>=8){return{text:t.groups.text.trim().replaceAll("&quot;",'"').replaceAll("&apos;","'").replaceAll("&lt;","<").replaceAll("&gt;",">").replaceAll("&amp;","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
@@ -133,7 +133,7 @@ function onmessage({ data }) {
const danmus = bilibiliDanmuParseFromXml(xml);
globalThis.postMessage({ danmus, id });
}
let nextRequest = 0;
let nextRequest$1 = 0;
function abortError(signal) {
if (signal?.reason !== void 0)
return signal.reason;
@@ -147,7 +147,7 @@ function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
let worker;
let workerUrl;
let releaseCancel;
const id = ++nextRequest;
const id = ++nextRequest$1;
const cancel = () => finish(void 0, abortError(signal));
function releaseWorker() {
let error;
@@ -264,7 +264,7 @@ function bilibiliDanmuParseFromUrl(url, { signal, onCancel } = {}) {
});
}
const inputs = /* @__PURE__ */ new WeakMap();
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku input");
const cancelled$1 = /* @__PURE__ */ Symbol("cancelled danmuku input");
function stateFor(owner) {
if (!inputs.has(owner))
inputs.set(owner, { closed: false, tasks: /* @__PURE__ */ new Set(), emitting: [] });
@@ -305,7 +305,7 @@ function beginInput(owner, replace) {
if (stopped)
return;
stopped = true;
resolveCancel(cancelled);
resolveCancel(cancelled$1);
controller?.abort();
for (const callback of [...handlers]) callback();
handlers.clear();
@@ -325,7 +325,7 @@ function beginInput(owner, replace) {
}
};
if (stopped)
resolveCancel(cancelled);
resolveCancel(cancelled$1);
else state.tasks.add(task);
return task;
}
@@ -343,6 +343,301 @@ function readInput(target, task) {
return { asynchronous: true, value: bilibiliDanmuParseFromUrl(target, task) };
return { asynchronous: false, value: Array.isArray(target) ? target : [] };
}
function filterState(owner, state, callback) {
const danmus = owner.states[state] || [];
for (let index = 0; index < danmus.length; index++) callback(danmus[index]);
return danmus;
}
function readyItems(owner) {
const { currentTime } = owner.art;
const result = [];
filterState(owner, "ready", (danmu) => result.push(danmu));
filterState(owner, "wait", (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1)
result.push(danmu);
});
return result;
}
function setItemState(owner, danmu, state) {
owner.states[danmu.$state] = owner.states[danmu.$state].filter((item) => item !== danmu);
danmu.$state = state;
if (danmu.$ref)
danmu.$ref.dataset.state = state;
owner.states[state].push(danmu);
}
const cancelled = /* @__PURE__ */ Symbol("cancelled danmuku frame");
class Scheduler {
constructor(owner) {
this.owner = owner;
this.generation = 0;
this.starts = 0;
this.frame = null;
this.operation = null;
this.closed = false;
this.running = false;
this.fault = false;
this.failedItems = /* @__PURE__ */ new Set();
}
active(operation) {
const owner = this.owner;
return !this.closed && !this.fault && !owner.art.isDestroy && !owner.isStop && !owner.isHide && operation.generation === this.generation;
}
release(operation) {
const { danmu, ref } = operation;
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText;
ref.style.visibility = "hidden";
this.owner.$refs.push(ref);
danmu.$ref = null;
}
operation.ref = null;
}
invalidate() {
this.generation++;
if (this.frame !== null)
window.cancelAnimationFrame(this.frame);
this.frame = null;
this.owner.timer = null;
if (this.operation) {
const operation = this.operation;
this.operation = null;
operation.cancel();
this.release(operation);
}
this.owner.workerClient?.cancel();
this.failedItems.clear();
}
report(error) {
try {
this.owner.art.emit("artplayerPluginDanmuku:error", error);
} catch (listenerError) {
console.warn("Failed to report danmuku scheduling error:", listenerError);
}
}
fail(error) {
if (this.closed || this.fault)
return;
this.fault = true;
this.invalidate();
this.report(error);
}
recover() {
if (this.closed || this.owner.art.isDestroy)
return;
this.failedItems.clear();
if (this.fault) {
try {
this.owner.workerClient?.dispose();
this.owner.createWorker();
this.fault = false;
} catch (error) {
this.report(error);
}
}
}
schedule() {
const owner = this.owner;
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
return;
this.frame = window.requestAnimationFrame(() => {
this.frame = null;
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 };
this.operation = operation;
return this.run(operation).catch((error) => {
if (this.active(operation))
this.fail(error);
}).finally(() => {
this.release(operation);
if (this.operation === operation) {
this.operation = null;
this.schedule();
}
});
});
owner.timer = this.frame;
}
async run(operation) {
const owner = this.owner;
const { setStyles } = owner.utils;
if (!owner.art.playing || !this.active(operation))
return;
owner.filter("emit", (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1e3;
danmu.$restTime -= emitTime;
danmu.$lastStartTime = Date.now();
if (danmu.$restTime <= 0)
owner.makeWait(danmu);
});
const readys = owner.readys;
for (const danmu of readys) {
if (!this.active(operation))
return;
if (this.failedItems.has(danmu))
continue;
let state;
try {
state = await operation.wait(owner.option.beforeVisible(danmu));
} catch (error) {
if (!this.active(operation))
return;
this.failedItems.add(danmu);
this.report(error);
continue;
}
if (!this.active(operation))
return;
if (!state)
continue;
const { clientWidth, clientHeight } = owner.$player;
const ref = owner.$ref;
operation.danmu = danmu;
operation.ref = ref;
danmu.$ref = ref;
ref.textContent = danmu.text;
owner.$danmuku.appendChild(ref);
ref.style.opacity = owner.option.opacity;
ref.style.fontSize = `${owner.fontSize}px`;
ref.style.color = danmu.color;
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null;
ref.style.backgroundColor = danmu.border ? "rgb(0 0 0 / 50%)" : null;
setStyles(ref, danmu.style);
if (!this.active(operation))
return;
danmu.$lastStartTime = Date.now();
danmu.$restTime = owner.speed;
const distance = clientWidth + ref.clientWidth;
const { result: top } = 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,
marginTop: owner.marginTop
}));
if (!this.active(operation) || danmu.$ref !== ref)
return;
if (top !== void 0) {
owner.setState(danmu, "emit");
ref.style.top = `${top}px`;
ref.style.visibility = "visible";
ref.dataset.mode = danmu.mode;
ref.dataset.id = danmu.id || "";
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`;
ref.style.marginLeft = "0px";
ref.style.transform = `translateX(${-distance}px)`;
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`;
break;
case 1:
case 2:
ref.style.left = "50%";
ref.style.marginLeft = `-${ref.clientWidth / 2}px`;
break;
}
operation.ref = null;
owner.art.emit("artplayerPluginDanmuku:visible", danmu);
} else {
owner.setState(danmu, "ready");
this.release(operation);
}
}
}
destroy() {
this.closed = true;
this.invalidate();
}
}
let nextRequest = 0;
class WorkerClient {
constructor(createWorker, onFailure) {
this.pending = /* @__PURE__ */ new Map();
this.closed = false;
this.failed = false;
this.onFailure = onFailure;
this.worker = createWorker();
try {
this.worker.onmessage = (event) => {
const request = this.pending.get(event.data?.id);
if (!request)
return;
this.pending.delete(event.data.id);
request.resolve(event.data);
};
this.worker.onerror = (event) => {
event.preventDefault?.();
this.fail(event.error || event);
};
this.worker.onmessageerror = (event) => this.fail(event.error || event);
} catch (error) {
try {
this.dispose();
} catch {
}
throw error;
}
}
request(message) {
message.id = ++nextRequest;
const { id } = message;
if (this.closed || this.failed)
return Promise.resolve({ id, result: void 0 });
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try {
this.worker.postMessage(message);
} catch (error) {
this.fail(error);
}
});
}
fail(error) {
if (this.closed || this.failed)
return;
this.failed = true;
for (const request of this.pending.values()) request.reject(error);
this.pending.clear();
try {
this.dispose();
} catch {
}
this.onFailure(error);
}
cancel() {
for (const [id, request] of this.pending) request.resolve({ id, result: void 0 });
this.pending.clear();
}
dispose() {
if (this.closed)
return;
this.closed = true;
this.cancel();
let failed = false;
let error;
const attempt = (callback) => {
try {
callback();
} catch (failure) {
if (!failed)
error = failure;
failed = true;
}
};
for (const name of ["onmessage", "onerror", "onmessageerror"]) {
attempt(() => this.worker[name] = null);
}
attempt(() => this.worker.terminate());
if (failed)
throw error;
}
}
const jsContent = '/*!\n * artplayer-plugin-danmuku.js v5.3.0\n * Github: https://github.com/zhw2590582/ArtPlayer\n * (c) 2017-2026 Harvey Zhao\n * Released under the MIT License.\n */\nfunction getDanmuTop({ target, visibles, clientWidth, clientHeight, marginBottom, marginTop, antiOverlap }) {\n const maxTop = clientHeight - marginBottom;\n const danmus = visibles.filter((item) => item.mode === target.mode && item.top <= maxTop).sort((prev, next) => prev.top - next.top);\n if (danmus.length === 0) {\n if (target.mode === 2) {\n return maxTop - target.height;\n } else {\n return marginTop;\n }\n }\n danmus.unshift({\n type: "top",\n top: 0,\n left: 0,\n right: 0,\n height: marginTop,\n width: clientWidth,\n speed: 0,\n distance: clientWidth\n });\n danmus.push({\n type: "bottom",\n top: maxTop,\n left: 0,\n right: 0,\n height: marginBottom,\n width: clientWidth,\n speed: 0,\n distance: clientWidth\n });\n if (target.mode === 2) {\n for (let index = danmus.length - 2; index >= 0; index -= 1) {\n const item = danmus[index];\n const prev = danmus[index + 1];\n const itemBottom = item.top + item.height;\n const diff = prev.top - itemBottom;\n if (diff >= target.height) {\n return prev.top - target.height;\n }\n }\n } else {\n for (let index = 1; index < danmus.length; index += 1) {\n const item = danmus[index];\n const prev = danmus[index - 1];\n const prevBottom = prev.top + prev.height;\n const diff = item.top - prevBottom;\n if (diff >= target.height) {\n return prevBottom;\n }\n }\n }\n const topMap = [];\n for (let index = 1; index < danmus.length - 1; index += 1) {\n const item = danmus[index];\n if (topMap.length) {\n const last = topMap[topMap.length - 1];\n if (last[0].top === item.top) {\n last.push(item);\n } else {\n topMap.push([item]);\n }\n } else {\n topMap.push([item]);\n }\n }\n if (antiOverlap) {\n switch (target.mode) {\n case 0: {\n const result = topMap.find((list) => {\n return list.every((danmu) => {\n if (clientWidth < danmu.distance)\n return false;\n if (target.speed < danmu.speed)\n return true;\n const overlapTime = danmu.right / (target.speed - danmu.speed);\n if (overlapTime > danmu.time)\n return true;\n return false;\n });\n });\n return result && result[0] ? result[0].top : void 0;\n }\n // 静止弹幕没有重叠问题\n case 1:\n case 2:\n return void 0;\n }\n } else {\n switch (target.mode) {\n case 0:\n topMap.sort((prev, next) => {\n const nextMinRight = Math.min(...next.map((item) => item.right));\n const prevMinRight = Math.min(...prev.map((item) => item.right));\n return nextMinRight * next.length - prevMinRight * prev.length;\n });\n break;\n case 1:\n case 2:\n topMap.sort((prev, next) => {\n const nextMaxWidth = Math.max(...next.map((item) => item.width));\n const prevMaxWidth = Math.max(...prev.map((item) => item.width));\n return prevMaxWidth * prev.length - nextMaxWidth * next.length;\n });\n break;\n }\n return topMap[0][0].top;\n }\n}\nonmessage = (event) => {\n const { data } = event;\n if (!data.id || !data.type)\n return;\n const fns = { getDanmuTop };\n const fn = fns[data.type];\n const result = fn(data);\n globalThis.postMessage({\n result,\n id: data.id\n });\n};\n';
const blob = typeof self !== "undefined" && self.Blob && new Blob(["URL.revokeObjectURL(import.meta.url);", jsContent], { type: "text/javascript;charset=utf-8" });
function WorkerWrapper(options) {
@@ -383,6 +678,8 @@ class Danmuku {
this.timer = null;
this.index = 0;
this.worker = null;
this.workerClient = null;
this.scheduler = new Scheduler(this);
this.option = Danmuku.option;
this.states = { wait: [], ready: [], emit: [], stop: [] };
this.start = this.start.bind(this);
@@ -390,16 +687,18 @@ class Danmuku {
this.reset = this.reset.bind(this);
this.resize = this.resize.bind(this);
this.destroy = this.destroy.bind(this);
this.seek = this.seek.bind(this);
inputActive(this);
art.on("destroy", this.destroy);
try {
this.config(option, true);
if (!inputActive(this))
return;
this.worker = new WorkerWrapper();
this.createWorker();
} catch (error) {
art.off("destroy", this.destroy);
cancelInputs(this);
this.scheduler.destroy();
throw error;
}
art.on("video:play", this.start);
@@ -407,6 +706,7 @@ class Danmuku {
art.on("video:pause", this.stop);
art.on("video:waiting", this.stop);
art.on("resize", this.resize);
art.on("video:seeking", this.seek);
this.load().catch((error) => console.warn("Failed to load initial danmuku:", error));
}
// 默认配置
@@ -491,15 +791,7 @@ class Danmuku {
}
// 获取准备好发送的弹幕
get readys() {
const { currentTime } = this.art;
const result = [];
this.filter("ready", (danmu) => result.push(danmu));
this.filter("wait", (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1) {
result.push(danmu);
}
});
return result;
return readyItems(this);
}
// 可见的弹幕的数据,用于计算下一个弹幕的top值
get visibles() {
@@ -595,7 +887,7 @@ class Danmuku {
});
if (!danmu.text.trim())
return this;
if (danmu.time !== void 0) {
if (danmu.time || danmu.time === 0) {
danmu.time = clamp(danmu.time, 0, Infinity);
} else {
danmu.time = this.art.currentTime + 0.5;
@@ -647,7 +939,12 @@ class Danmuku {
});
if (!inputActive(this))
return this;
const beforeVisibleChanged = this.option.beforeVisible !== next.beforeVisible;
this.option = next;
if (beforeVisibleChanged) {
this.scheduler.invalidate();
this.scheduler.schedule();
}
if (option.fontSize) {
this.reset();
if (!inputActive(this))
@@ -669,33 +966,19 @@ class Danmuku {
}
// 复杂运算交给 Web Worker 处理
postMessage(message = {}) {
return new Promise((resolve) => {
message.id = Date.now();
this.worker.postMessage(message);
this.worker.onmessage = (event) => {
const { data } = event;
if (data.id === message.id) {
resolve(data);
}
};
});
return this.workerClient.request(message);
}
createWorker() {
this.workerClient = new WorkerClient(() => new WorkerWrapper(), (error) => this.scheduler.fail(error));
this.worker = this.workerClient.worker;
}
// 根据状态获取弹幕
filter(state, callback) {
const danmus = this.states[state] || [];
for (let index = 0; index < danmus.length; index++) {
callback(danmus[index]);
}
return danmus;
return filterState(this, state, callback);
}
// 设置弹幕状态
setState(danmu, state) {
this.states[danmu.$state] = this.states[danmu.$state].filter((item) => item !== danmu);
danmu.$state = state;
if (danmu.$ref) {
danmu.$ref.dataset.state = state;
}
this.states[state].push(danmu);
setItemState(this, danmu, state);
}
// 重置弹幕到wait状态,回收弹幕DOM节点
makeWait(danmu) {
@@ -712,88 +995,8 @@ class Danmuku {
}
// 实时更新弹幕
update() {
const { setStyles } = this.utils;
this.timer = window.requestAnimationFrame(async () => {
if (this.art.playing && !this.isHide) {
this.filter("emit", (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1e3;
danmu.$restTime -= emitTime;
danmu.$lastStartTime = Date.now();
if (danmu.$restTime <= 0) {
this.makeWait(danmu);
}
});
const readys = this.readys;
for (let index = 0; index < readys.length; index++) {
const danmu = readys[index];
const state = await this.option.beforeVisible(danmu);
if (state) {
const { clientWidth, clientHeight } = this.$player;
danmu.$ref = this.$ref;
danmu.$ref.textContent = danmu.text;
this.$danmuku.appendChild(danmu.$ref);
danmu.$ref.style.opacity = this.option.opacity;
danmu.$ref.style.fontSize = `${this.fontSize}px`;
danmu.$ref.style.color = danmu.color;
danmu.$ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null;
danmu.$ref.style.backgroundColor = danmu.border ? "rgb(0 0 0 / 50%)" : null;
setStyles(danmu.$ref, danmu.style);
danmu.$lastStartTime = Date.now();
danmu.$restTime = this.speed;
const distance = clientWidth + danmu.$ref.clientWidth;
const { result: top } = await this.postMessage({
type: "getDanmuTop",
target: {
mode: danmu.mode,
height: danmu.$ref.clientHeight,
speed: distance / danmu.$restTime
},
// 当前弹幕信息
visibles: this.visibles,
// 可见的弹幕的数据
antiOverlap: this.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: this.marginBottom,
marginTop: this.marginTop
});
if (danmu.$ref) {
if (!this.isStop && top !== void 0) {
this.setState(danmu, "emit");
danmu.$ref.style.top = `${top}px`;
danmu.$ref.style.visibility = "visible";
danmu.$ref.dataset.mode = danmu.mode;
danmu.$ref.dataset.id = danmu.id || "";
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
danmu.$ref.style.left = `${clientWidth}px`;
danmu.$ref.style.marginLeft = "0px";
danmu.$ref.style.transform = `translateX(${-distance}px)`;
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`;
break;
}
case 1:
// falls through
case 2:
danmu.$ref.style.left = "50%";
danmu.$ref.style.marginLeft = `-${danmu.$ref.clientWidth / 2}px`;
break;
}
this.art.emit("artplayerPluginDanmuku:visible", danmu);
} else {
this.setState(danmu, "ready");
this.$refs.push(danmu.$ref);
danmu.$ref = null;
}
}
}
}
}
if (!this.isStop) {
this.update();
}
});
this.scheduler.running = true;
this.scheduler.schedule();
return this;
}
// 重置正在显示的弹幕: stop/emit 状态的弹幕
@@ -858,23 +1061,39 @@ class Danmuku {
}
stop() {
this.isStop = true;
this.scheduler.running = false;
this.scheduler.invalidate();
this.suspend();
window.cancelAnimationFrame(this.timer);
this.art.emit("artplayerPluginDanmuku:stop");
return this;
}
start() {
if (this.scheduler.closed || this.art.isDestroy)
return this;
const start = ++this.scheduler.starts;
const generation = this.scheduler.generation;
this.isStop = false;
this.scheduler.recover();
const obsolete = this.scheduler.closed || this.art.isDestroy || this.isStop || this.scheduler.fault || generation !== this.scheduler.generation || start !== this.scheduler.starts;
if (obsolete)
return this;
this.continue();
this.update();
this.art.emit("artplayerPluginDanmuku:start");
return this;
}
reset() {
this.scheduler.invalidate();
this.queue.forEach((danmu) => this.makeWait(danmu));
this.art.emit("artplayerPluginDanmuku:reset");
this.scheduler.recover();
this.scheduler.schedule();
return this;
}
seek() {
this.scheduler.invalidate();
this.scheduler.schedule();
}
show() {
this.isHide = false;
this.$danmuku.style.opacity = 1;
@@ -884,22 +1103,32 @@ class Danmuku {
}
hide() {
this.isHide = true;
this.scheduler.invalidate();
this.$danmuku.style.opacity = 0;
this.option.visible = false;
this.art.emit("artplayerPluginDanmuku:hide");
this.scheduler.schedule();
return this;
}
destroy() {
cancelInputs(this);
this.stop();
this.worker?.terminate();
this.art.off("video:play", this.start);
this.art.off("video:playing", this.start);
this.art.off("video:pause", this.stop);
this.art.off("video:waiting", this.stop);
this.art.off("resize", this.resize);
this.art.off("destroy", this.destroy);
this.art.emit("artplayerPluginDanmuku:destroy");
this.scheduler.destroy();
try {
try {
this.stop();
} finally {
this.workerClient?.dispose();
}
} finally {
this.art.off("video:play", this.start);
this.art.off("video:playing", this.start);
this.art.off("video:pause", this.stop);
this.art.off("video:waiting", this.stop);
this.art.off("resize", this.resize);
this.art.off("video:seeking", this.seek);
this.art.off("destroy", this.destroy);
this.art.emit("artplayerPluginDanmuku:destroy");
}
}
}
const lib = {
+66 -168
View File
@@ -1,5 +1,8 @@
import { defaultOption, normalizeOption, optionChanged, optionScheme } from './config'
import { beginInput, cancelInputs, inputActive, readInput } from './input'
import { filterState, readyItems, setItemState } from './queue'
import Scheduler from './scheduler'
import WorkerClient from './worker-client'
import DanmuWorker from './worker.js?worker&inline'
export default class Danmuku {
@@ -19,6 +22,8 @@ export default class Danmuku {
this.timer = null // 定时器
this.index = 0 // 弹幕索引
this.worker = null
this.workerClient = null
this.scheduler = new Scheduler(this)
// 格式化后的配置项
this.option = Danmuku.option
@@ -32,6 +37,7 @@ export default class Danmuku {
this.reset = this.reset.bind(this)
this.resize = this.resize.bind(this)
this.destroy = this.destroy.bind(this)
this.seek = this.seek.bind(this)
// 配置事件可同步销毁播放器,先建立输入和销毁边界。
inputActive(this)
@@ -42,11 +48,12 @@ export default class Danmuku {
return
// 创建 Web Worker, 用于计算弹幕的 top 值
this.worker = new DanmuWorker()
this.createWorker()
}
catch (error) {
art.off('destroy', this.destroy)
cancelInputs(this)
this.scheduler.destroy()
throw error
}
// 监听事件
@@ -55,6 +62,7 @@ export default class Danmuku {
art.on('video:pause', this.stop)
art.on('video:waiting', this.stop)
art.on('resize', this.resize)
art.on('video:seeking', this.seek)
// 开始加载弹幕
this.load().catch(error => console.warn('Failed to load initial danmuku:', error))
@@ -160,21 +168,7 @@ export default class Danmuku {
// 获取准备好发送的弹幕
get readys() {
const { currentTime } = this.art
const result = []
// 有的是ready状态:之前因为弹幕太多而暂停发送的弹幕
this.filter('ready', danmu => result.push(danmu))
// 有的是wait状态:符合时间范围的弹幕
this.filter('wait', (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1) {
result.push(danmu)
}
})
return result
return readyItems(this)
}
// 可见的弹幕的数据,用于计算下一个弹幕的top值
@@ -289,7 +283,7 @@ export default class Danmuku {
return this
// 设置弹幕时间,如果没有则默认为当前时间加 0.5 秒
if (danmu.time !== undefined) {
if (danmu.time || danmu.time === 0) {
danmu.time = clamp(danmu.time, 0, Infinity)
}
else {
@@ -367,7 +361,12 @@ export default class Danmuku {
})
if (!inputActive(this))
return this
const beforeVisibleChanged = this.option.beforeVisible !== next.beforeVisible
this.option = next
if (beforeVisibleChanged) {
this.scheduler.invalidate()
this.scheduler.schedule()
}
// 动态配置有字体大小,需要重新渲染
if (option.fontSize) {
@@ -398,43 +397,22 @@ export default class Danmuku {
// 复杂运算交给 Web Worker 处理
postMessage(message = {}) {
return new Promise((resolve) => {
message.id = Date.now() // 生成唯一标识
this.worker.postMessage(message)
this.worker.onmessage = (event) => {
const { data } = event
// 判断是否是当前的消息
if (data.id === message.id) {
resolve(data)
}
}
})
return this.workerClient.request(message)
}
createWorker() {
this.workerClient = new WorkerClient(() => new DanmuWorker(), error => this.scheduler.fail(error))
this.worker = this.workerClient.worker
}
// 根据状态获取弹幕
filter(state, callback) {
const danmus = this.states[state] || []
for (let index = 0; index < danmus.length; index++) {
callback(danmus[index])
}
return danmus
return filterState(this, state, callback)
}
// 设置弹幕状态
setState(danmu, state) {
// 从原状态池中删除
this.states[danmu.$state] = this.states[danmu.$state].filter(item => item !== danmu)
// 设置新状态
danmu.$state = state
// 设置DOM节点状态
if (danmu.$ref) {
danmu.$ref.dataset.state = state
}
// 添加到新状态池中
this.states[state].push(danmu)
setItemState(this, danmu, state)
}
// 重置弹幕到wait状态,回收弹幕DOM节点
@@ -453,118 +431,8 @@ export default class Danmuku {
// 实时更新弹幕
update() {
const { setStyles } = this.utils
this.timer = window.requestAnimationFrame(async () => {
if (this.art.playing && !this.isHide) {
// 实时计算弹幕的剩余显示时间
this.filter('emit', (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1000
danmu.$restTime -= emitTime
danmu.$lastStartTime = Date.now()
// 超过时间即重置弹幕
if (danmu.$restTime <= 0) {
this.makeWait(danmu)
}
})
// 获取准备好发送的弹幕,可能包含ready和wait状态的弹幕
const readys = this.readys
for (let index = 0; index < readys.length; index++) {
const danmu = readys[index]
// 弹幕发送前的过滤器
const state = await this.option.beforeVisible(danmu)
if (state) {
const { clientWidth, clientHeight } = this.$player
danmu.$ref = this.$ref // 获取弹幕DOM节点
danmu.$ref.textContent = danmu.text // 设置弹幕文本
// 提前添加到弹幕层中,用于计算top值
this.$danmuku.appendChild(danmu.$ref)
// 设置初始弹幕样式
danmu.$ref.style.opacity = this.option.opacity
danmu.$ref.style.fontSize = `${this.fontSize}px`
danmu.$ref.style.color = danmu.color
danmu.$ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
danmu.$ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
// 设置单独弹幕样式
setStyles(danmu.$ref, danmu.style)
// 记录弹幕时间戳
danmu.$lastStartTime = Date.now()
// 计算弹幕剩余时间
danmu.$restTime = this.speed
// 计算弹幕滚动的距离
const distance = clientWidth + danmu.$ref.clientWidth
// 计算弹幕的top值
const { result: top } = await this.postMessage({
type: 'getDanmuTop',
target: {
mode: danmu.mode,
height: danmu.$ref.clientHeight,
speed: distance / danmu.$restTime,
}, // 当前弹幕信息
visibles: this.visibles, // 可见的弹幕的数据
antiOverlap: this.option.antiOverlap,
clientWidth,
clientHeight,
marginBottom: this.marginBottom,
marginTop: this.marginTop,
})
if (danmu.$ref) {
if (!this.isStop && top !== undefined) {
this.setState(danmu, 'emit') // 转换为emit状态
danmu.$ref.style.top = `${top}px`
danmu.$ref.style.visibility = 'visible'
danmu.$ref.dataset.mode = danmu.mode // CSS控制模式的显示和隐藏
danmu.$ref.dataset.id = danmu.id || '' // 用于悬停的唯一标识
switch (danmu.mode) {
// 滚动的弹幕
case 0: {
danmu.$ref.style.left = `${clientWidth}px`
danmu.$ref.style.marginLeft = '0px'
danmu.$ref.style.transform = `translateX(${-distance}px)`
danmu.$ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
}
case 1:
// falls through
case 2:
danmu.$ref.style.left = '50%'
danmu.$ref.style.marginLeft = `-${danmu.$ref.clientWidth / 2}px`
break
default:
break
}
this.art.emit('artplayerPluginDanmuku:visible', danmu)
}
else {
// 假如弹幕已经停止或者没有 top 值,则重置弹幕为ready状态,回收弹幕DOM节点,等待下次发送
this.setState(danmu, 'ready')
this.$refs.push(danmu.$ref)
danmu.$ref = null
}
}
}
}
}
// 递归调用
if (!this.isStop) {
this.update()
}
})
this.scheduler.running = true
this.scheduler.schedule()
return this
}
@@ -645,14 +513,24 @@ export default class Danmuku {
stop() {
this.isStop = true
this.scheduler.running = false
this.scheduler.invalidate()
this.suspend()
window.cancelAnimationFrame(this.timer)
this.art.emit('artplayerPluginDanmuku:stop')
return this
}
start() {
if (this.scheduler.closed || this.art.isDestroy)
return this
const start = ++this.scheduler.starts
const generation = this.scheduler.generation
this.isStop = false
this.scheduler.recover()
const obsolete = this.scheduler.closed || this.art.isDestroy || this.isStop || this.scheduler.fault
|| generation !== this.scheduler.generation || start !== this.scheduler.starts
if (obsolete)
return this
this.continue()
this.update()
this.art.emit('artplayerPluginDanmuku:start')
@@ -660,11 +538,19 @@ export default class Danmuku {
}
reset() {
this.scheduler.invalidate()
this.queue.forEach(danmu => this.makeWait(danmu))
this.art.emit('artplayerPluginDanmuku:reset')
this.scheduler.recover()
this.scheduler.schedule()
return this
}
seek() {
this.scheduler.invalidate()
this.scheduler.schedule()
}
show() {
this.isHide = false
this.$danmuku.style.opacity = 1
@@ -675,22 +561,34 @@ export default class Danmuku {
hide() {
this.isHide = true
this.scheduler.invalidate()
this.$danmuku.style.opacity = 0
this.option.visible = false
this.art.emit('artplayerPluginDanmuku:hide')
this.scheduler.schedule()
return this
}
destroy() {
cancelInputs(this)
this.stop()
this.worker?.terminate()
this.art.off('video:play', this.start)
this.art.off('video:playing', this.start)
this.art.off('video:pause', this.stop)
this.art.off('video:waiting', this.stop)
this.art.off('resize', this.resize)
this.art.off('destroy', this.destroy)
this.art.emit('artplayerPluginDanmuku:destroy')
this.scheduler.destroy()
try {
try {
this.stop()
}
finally {
this.workerClient?.dispose()
}
}
finally {
this.art.off('video:play', this.start)
this.art.off('video:playing', this.start)
this.art.off('video:pause', this.stop)
this.art.off('video:waiting', this.stop)
this.art.off('resize', this.resize)
this.art.off('video:seeking', this.seek)
this.art.off('destroy', this.destroy)
this.art.emit('artplayerPluginDanmuku:destroy')
}
}
}
@@ -0,0 +1,24 @@
export function filterState(owner, state, callback) {
const danmus = owner.states[state] || []
for (let index = 0; index < danmus.length; index++) callback(danmus[index])
return danmus
}
export function readyItems(owner) {
const { currentTime } = owner.art
const result = []
filterState(owner, 'ready', danmu => result.push(danmu))
filterState(owner, 'wait', (danmu) => {
if (currentTime + 0.1 >= danmu.time && danmu.time >= currentTime - 0.1)
result.push(danmu)
})
return result
}
export function setItemState(owner, danmu, state) {
owner.states[danmu.$state] = owner.states[danmu.$state].filter(item => item !== danmu)
danmu.$state = state
if (danmu.$ref)
danmu.$ref.dataset.state = state
owner.states[state].push(danmu)
}
@@ -0,0 +1,208 @@
const cancelled = Symbol('cancelled danmuku frame')
export default class Scheduler {
constructor(owner) {
this.owner = owner
this.generation = 0
this.starts = 0
this.frame = null
this.operation = null
this.closed = false
this.running = false
this.fault = false
this.failedItems = new Set()
}
active(operation) {
const owner = this.owner
return !this.closed && !this.fault && !owner.art.isDestroy && !owner.isStop
&& !owner.isHide && operation.generation === this.generation
}
release(operation) {
const { danmu, ref } = operation
if (ref && danmu.$ref === ref) {
ref.style.cssText = this.owner.constructor.cssText
ref.style.visibility = 'hidden'
this.owner.$refs.push(ref)
danmu.$ref = null
}
operation.ref = null
}
invalidate() {
this.generation++
if (this.frame !== null)
window.cancelAnimationFrame(this.frame)
this.frame = null
this.owner.timer = null
if (this.operation) {
const operation = this.operation
this.operation = null
operation.cancel()
this.release(operation)
}
this.owner.workerClient?.cancel()
this.failedItems.clear()
}
report(error) {
try {
this.owner.art.emit('artplayerPluginDanmuku:error', error)
}
catch (listenerError) {
console.warn('Failed to report danmuku scheduling error:', listenerError)
}
}
fail(error) {
if (this.closed || this.fault)
return
this.fault = true
this.invalidate()
this.report(error)
}
recover() {
if (this.closed || this.owner.art.isDestroy)
return
this.failedItems.clear()
if (this.fault) {
try {
this.owner.workerClient?.dispose()
this.owner.createWorker()
this.fault = false
}
catch (error) {
this.report(error)
}
}
}
schedule() {
const owner = this.owner
if (!this.running || this.closed || this.fault || owner.art.isDestroy || owner.isStop || this.frame !== null || this.operation)
return
this.frame = window.requestAnimationFrame(() => {
this.frame = null
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 }
this.operation = operation
return this.run(operation).catch((error) => {
if (this.active(operation))
this.fail(error)
}).finally(() => {
this.release(operation)
if (this.operation === operation) {
this.operation = null
this.schedule()
}
})
})
owner.timer = this.frame
}
async run(operation) {
const owner = this.owner
const { setStyles } = owner.utils
if (!owner.art.playing || !this.active(operation))
return
owner.filter('emit', (danmu) => {
const emitTime = (Date.now() - danmu.$lastStartTime) / 1000
danmu.$restTime -= emitTime
danmu.$lastStartTime = Date.now()
if (danmu.$restTime <= 0)
owner.makeWait(danmu)
})
const readys = owner.readys
for (const danmu of readys) {
if (!this.active(operation))
return
if (this.failedItems.has(danmu))
continue
let state
try {
state = await operation.wait(owner.option.beforeVisible(danmu))
}
catch (error) {
if (!this.active(operation))
return
this.failedItems.add(danmu)
this.report(error)
continue
}
if (!this.active(operation))
return
if (!state)
continue
const { clientWidth, clientHeight } = owner.$player
const ref = owner.$ref
operation.danmu = danmu
operation.ref = ref
danmu.$ref = ref
ref.textContent = danmu.text
owner.$danmuku.appendChild(ref)
ref.style.opacity = owner.option.opacity
ref.style.fontSize = `${owner.fontSize}px`
ref.style.color = danmu.color
ref.style.border = danmu.border ? `1px solid ${danmu.color}` : null
ref.style.backgroundColor = danmu.border ? 'rgb(0 0 0 / 50%)' : null
setStyles(ref, danmu.style)
if (!this.active(operation))
return
danmu.$lastStartTime = Date.now()
danmu.$restTime = owner.speed
const distance = clientWidth + ref.clientWidth
const { result: top } = 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,
marginTop: owner.marginTop,
}))
if (!this.active(operation) || danmu.$ref !== ref)
return
if (top !== undefined) {
owner.setState(danmu, 'emit')
ref.style.top = `${top}px`
ref.style.visibility = 'visible'
ref.dataset.mode = danmu.mode
ref.dataset.id = danmu.id || ''
switch (danmu.mode) {
case 0:
ref.style.left = `${clientWidth}px`
ref.style.marginLeft = '0px'
ref.style.transform = `translateX(${-distance}px)`
ref.style.transition = `transform ${danmu.$restTime}s linear 0s`
break
case 1:
case 2:
ref.style.left = '50%'
ref.style.marginLeft = `-${ref.clientWidth / 2}px`
break
default:
break
}
operation.ref = null
owner.art.emit('artplayerPluginDanmuku:visible', danmu)
}
else {
owner.setState(danmu, 'ready')
this.release(operation)
}
}
}
destroy() {
this.closed = true
this.invalidate()
}
}
@@ -0,0 +1,91 @@
let nextRequest = 0
export default class WorkerClient {
constructor(createWorker, onFailure) {
this.pending = new Map()
this.closed = false
this.failed = false
this.onFailure = onFailure
this.worker = createWorker()
try {
this.worker.onmessage = (event) => {
const request = this.pending.get(event.data?.id)
if (!request)
return
this.pending.delete(event.data.id)
request.resolve(event.data)
}
this.worker.onerror = (event) => {
event.preventDefault?.()
this.fail(event.error || event)
}
this.worker.onmessageerror = event => this.fail(event.error || event)
}
catch (error) {
try {
this.dispose()
}
catch {}
throw error
}
}
request(message) {
message.id = ++nextRequest
const { id } = message
if (this.closed || this.failed)
return Promise.resolve({ id, result: undefined })
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
try {
this.worker.postMessage(message)
}
catch (error) {
this.fail(error)
}
})
}
fail(error) {
if (this.closed || this.failed)
return
this.failed = true
for (const request of this.pending.values()) request.reject(error)
this.pending.clear()
try {
this.dispose()
}
catch {}
this.onFailure(error)
}
cancel() {
for (const [id, request] of this.pending) request.resolve({ id, result: undefined })
this.pending.clear()
}
dispose() {
if (this.closed)
return
this.closed = true
this.cancel()
let failed = false
let error
const attempt = (callback) => {
try {
callback()
}
catch (failure) {
if (!failed)
error = failure
failed = true
}
}
for (const name of ['onmessage', 'onerror', 'onmessageerror']) {
attempt(() => this.worker[name] = null)
}
attempt(() => this.worker.terminate())
if (failed)
throw error
}
}