mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-21 21:01:20 -08:00
refactor(danmuku): [PKG-DANMUKU-04] own scheduler and worker request lifecycle
This commit is contained in:
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.
|
||||
@@ -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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(i[0]),mode:e(Number(i[1])),fontSize:Number(i[2]),color:`#${Number(i[3]).toString(16)}`,timestamp:Number(i[4]),pool:Number(i[5]),userID:i[6],rowID:Number(i[7])}}return null}).filter(Boolean)}function a({data:t}){const{xml:e,id:a}=t,n=i(e);globalThis.postMessage({danmus:n,id:a})}let n=0;function s(t,{signal:s,onCancel:o}={}){return new Promise((r,l)=>{let d,p,h,u=!1;const c=++n,m=()=>g(void 0,function(t){if(void 0!==t?.reason)return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(s));function f(){let t,e=!1;try{if(d){const t=d;d=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==p){const a=p;p=void 0;try{URL.revokeObjectURL(a)}catch(i){e||(t=i),e=!0}}if(e)throw t}function g(t,e){if(u)return;u=!0;let i=arguments.length>1;s?.removeEventListener("abort",m),h?.();try{f()}catch(a){i||(e=a),i=!0}i?l(e):r(t)}function y(t,e){if(!u){console.error("Error parsing Bilibili Danmu:",e);try{g(i(t))}catch(a){g(void 0,a)}}}s?.aborted?m():(s?.addEventListener("abort",m),h=o?.(m),u?h?.():async function(){const n=s?await fetch(t,{signal:s}):await fetch(t);if(u)return;const o=await n.text();if(!u)try{const t=`\n ${e.toString()}\n ${i.toString()}\n onmessage = ${a.toString()}\n `;if(p=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),u)return void f();if(d=new Worker(p),u)return void f();d.onmessage=t=>{if(u||t.data?.id!==c)return;const{danmus:e}=t.data;Array.isArray(e)?g(e):g(void 0,new Error("Invalid Bilibili Danmu worker response"))},d.onerror=t=>{t.preventDefault?.(),y(o,t.error||t)},d.onmessageerror=t=>y(o,t.error||t),d.postMessage({xml:o,id:c})}catch(r){y(o,r)}}().catch(t=>g(void 0,t)))})}const o=new WeakMap,r=Symbol("cancelled danmuku input");function l(t){return o.has(t)||o.set(t,{closed:!1,tasks:new Set,emitting:[]}),o.get(t)}function d(t){const e=l(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function p(t){const e=l(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function h(t,e,i){const a=t.states[e]||[];for(let n=0;n<a.length;n++)i(a[n]);return a}const u=Symbol("cancelled danmuku frame");class c{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}this.owner.workerClient?.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{this.owner.workerClient?.dispose(),this.owner.createWorker(),this.fault=!1}catch(t){this.report(t)}}schedule(){const t=this.owner;!this.running||this.closed||this.fault||t.art.isDestroy||t.isStop||null!==this.frame||this.operation||(this.frame=window.requestAnimationFrame(()=>{if(this.frame=null,t.timer=null,this.closed||this.fault||t.isStop||t.art.isDestroy)return;let e;const i=new Promise(t=>e=()=>t(u)),a={generation:this.generation,cancel:e,wait:t=>Promise.race([t,i]),ref:null};return this.operation=a,this.run(a).catch(t=>{this.active(a)&&this.fail(t)}).finally(()=>{this.release(a),this.operation===a&&(this.operation=null,this.schedule())})}),t.timer=this.frame)}async run(t){const e=this.owner,{setStyles:i}=e.utils;if(!e.art.playing||!this.active(t))return;e.filter("emit",t=>{const i=(Date.now()-t.$lastStartTime)/1e3;t.$restTimeLine truncated
|
||||
@@ -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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,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(""",'"').replaceAll("'","'").replaceAll("<","<").replaceAll(">",">").replaceAll("&","&"),time:Number(e[0]),mode:u(Number(e[1])),fontSize:Number(e[2]),color:`#${Number(e[3]).toString(16)}`,timestamp:Number(e[4]),pool:Number(e[5]),userID:e[6],rowID:Number(e[7])}}return null}).filter(Boolean)}function m({data:t}){const{xml:e,id:i}=t,n=c(e);globalThis.postMessage({danmus:n,id:i})}let f=0;function g(t,{signal:e,onCancel:i}={}){return new Promise((n,a)=>{let s,o,r,l=!1;const d=++f,h=()=>y(void 0,function(t){if(void 0!==(null==t?void 0:t.reason))return t.reason;const e=new Error("Bilibili Danmu loading cancelled");return e.name="AbortError",e}(e));function g(){let t,e=!1;try{if(s){const t=s;s=void 0,t.onmessage=null,t.onerror=null,t.onmessageerror=null,t.terminate()}}catch(i){t=i,e=!0}if(void 0!==o){const n=o;o=void 0;try{URL.revokeObjectURL(n)}catch(i){e||(t=i),e=!0}}if(e)throw t}function y(t,i){if(l)return;l=!0;let s=arguments.length>1;null==e||e.removeEventListener("abort",h),null==r||r();try{g()}catch(o){s||(i=o),s=!0}s?a(i):n(t)}function v(t,e){if(!l){console.error("Error parsing Bilibili Danmu:",e);try{y(c(t))}catch(i){y(void 0,i)}}}(null==e?void 0:e.aborted)?h():(null==e||e.addEventListener("abort",h),r=null==i?void 0:i(h),l?null==r||r():function(){return p(this,null,function*(){const i=e?yield fetch(t,{signal:e}):yield fetch(t);if(l)return;const n=yield i.text();if(!l)try{const t=`\n ${u.toString()}\n ${c.toString()}\n onmessage = ${m.toString()}\n `;if(o=URL.createObjectURL(new Blob([t],{type:"application/javascript"})),l)return void g();if(s=new Worker(o),l)return void g();s.onmessage=t=>{var e;if(l||(null==(e=t.data)?void 0:e.id)!==d)return;const{danmus:i}=t.data;Array.isArray(i)?y(i):y(void 0,new Error("Invalid Bilibili Danmu worker response"))},s.onerror=t=>{var e;null==(e=t.preventDefault)||e.call(t),v(n,t.error||t)},s.onmessageerror=t=>v(n,t.error||t),s.postMessage({xml:n,id:d})}catch(a){v(n,a)}})}().catch(t=>y(void 0,t)))})}const y=new WeakMap,v=Symbol("cancelled danmuku input");function k(t){return y.has(t)||y.set(t,{closed:!1,tasks:new Set,emitting:[]}),y.get(t)}function w(t){const e=k(t),i=e.emitting[e.emitting.length-1];return!e.closed&&!t.art.isDestroy&&(!i||i.active())}function x(t){const e=k(t);e.closed=!0;for(const i of[...e.tasks])i.cancel()}function $(t,e,i){const n=t.states[e]||[];for(let a=0;a<n.length;a++)i(n[a]);return n}const b=Symbol("cancelled danmuku frame");class S{constructor(t){this.owner=t,this.generation=0,this.starts=0,this.frame=null,this.operation=null,this.closed=!1,this.running=!1,this.fault=!1,this.failedItems=new Set}active(t){const e=this.owner;return!this.closed&&!this.fault&&!e.art.isDestroy&&!e.isStop&&!e.isHide&&t.generation===this.generation}release(t){const{danmu:e,ref:i}=t;i&&e.$ref===i&&(i.style.cssText=this.owner.constructor.cssText,i.style.visibility="hidden",this.owner.$refs.push(i),e.$ref=null),t.ref=null}invalidate(){var t;if(this.generation++,null!==this.frame&&window.cancelAnimationFrame(this.frame),this.frame=null,this.owner.timer=null,this.operation){const t=this.operation;this.operation=null,t.cancel(),this.release(t)}null==(t=this.owner.workerClient)||t.cancel(),this.failedItems.clear()}report(t){try{this.owner.art.emit("artplayerPluginDanmuku:error",t)}catch(e){console.warn("Failed to report danmuku scheduling error:",e)}}fail(t){this.closed||this.fault||(this.fault=!0,this.invalidate(),this.report(t))}recover(){var t;if(!this.closed&&!this.owner.art.isDestroy&&(this.failedItems.clear(),this.fault))try{null==(t=this.owner.worLine truncated
|
||||
+358
-129
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user