UNPKG

danmu

Version:

Flexible, cross-platform, powerful danmu library.

1,718 lines (1,706 loc) 47.8 kB
/*! * danmu.js * (c) 2024-2025 Imtaotao * Released under the MIT License. */ 'use strict'; var aidly = require('aidly'); var hooksPlugin = require('hooks-plugin'); class Track { _container; isLock = false; index; location; list; constructor({ index, list, location, container }) { this.list = list; this.index = index; this.location = location; this._container = container; } get width() { return this._container.width; } get height() { return this.location.bottom - this.location.top; } // We have to make a copy. // During the loop, there are too many factors that change danmaku, // which makes it impossible to guarantee the stability of the list. each(fn) { for (const dm of Array.from(this.list)) { if (fn(dm) === false) break; } } lock() { this.isLock = true; } unlock() { this.isLock = false; } clear() { this.each((dm) => dm.destroy()); } /** * @internal */ _add(dm) { this.list.push(dm); } /** * @internal */ _remove(dm) { aidly.remove(this.list, dm); } /** * @internal */ _updateLocation(location) { this.location = location; } /** * @internal */ _last(li) { for (let i = this.list.length - 1; i >= 0; i--) { const dm = this.list[i - li]; if (dm && !dm.paused && dm.loops === 0 && dm.type === 'facile') { return dm; } } return null; } } const INTERNAL_FLAG = Symbol(); const ids = { danmu: 1, bridge: 1, runtime: 1, container: 1, }; const nextFrame = (fn) => aidly.raf(() => aidly.raf(fn)); const randomIdx = (founds, rows) => { const n = Math.floor(Math.random() * rows); return founds.has(n) ? randomIdx(founds, rows) : n; }; const toNumber = (val, all) => { return aidly.mathExprEvaluate(val, { units: { px: (n) => n, '%': (n) => (Number(n) / 100) * all, }, }); }; const whenTransitionEnds = (node) => { return new Promise((resolve) => { const onEnd = aidly.once(() => { node.removeEventListener('transitionend', onEnd); resolve(); }); node.addEventListener('transitionend', onEnd); }); }; class Container { width = 0; height = 0; node; parentNode = null; _parentWidth = 0; _parentHeight = 0; _size = { x: { start: 0, end: '100%' }, y: { start: 0, end: '100%' }, }; constructor() { this.node = document.createElement('div'); this.node.setAttribute('data-danmu-container', String(ids.container++)); this.setStyle('overflow', 'hidden'); this.setStyle('position', 'relative'); this.setStyle('top', '0'); this.setStyle('left', '0'); } /** * @internal */ _sizeToNumber() { const size = Object.create(null); const transform = (v, all) => { return typeof v === 'string' ? (v ? toNumber(v, all) : 0) : v; }; size.x = aidly.map(this._size.x, (v) => transform(v, this._parentWidth)); size.y = aidly.map(this._size.y, (v) => transform(v, this._parentHeight)); return size; } /** * @internal */ _mount(node) { this._unmount(); this.parentNode = node; this._format(); this.parentNode.appendChild(this.node); } /** * @internal */ _unmount() { this.parentNode = null; if (this.node.parentNode) { this.node.parentNode.removeChild(this.node); } } /** * @internal */ _updateSize({ x, y }) { const isLegal = (v) => { return typeof v === 'string' || typeof v === 'number'; }; if (x) { if (isLegal(x.end)) this._size.x.end = x.end; if (isLegal(x.start)) this._size.x.start = x.start; } if (y) { if (isLegal(y.end)) this._size.y.end = y.end; if (isLegal(y.start)) this._size.y.start = y.start; } } /** * @internal */ _toNumber(p, val) { let n = typeof val === 'number' ? val : toNumber(val, this[p]); if (n > this[p]) n = this[p]; aidly.assert(!Number.isNaN(n), `${val} is not a number`); return n; } /** * @internal */ _format() { if (this.parentNode) { const styles = getComputedStyle(this.parentNode); this._parentWidth = Number(styles.width.replace('px', '')); this._parentHeight = Number(styles.height.replace('px', '')); } const { x, y } = this._sizeToNumber(); this.width = x.end - x.start; this.height = y.end - y.start; this.setStyle('left', `${x.start}px`); this.setStyle('top', `${y.start}px`); this.setStyle('width', `${this.width}px`); this.setStyle('height', `${this.height}px`); } setStyle(key, val) { this.node.style[key] = val; } } function createDanmakuLifeCycle() { return new hooksPlugin.PluginSystem({ hide: new hooksPlugin.SyncHook(), show: new hooksPlugin.SyncHook(), pause: new hooksPlugin.SyncHook(), resume: new hooksPlugin.SyncHook(), beforeMove: new hooksPlugin.SyncHook(), moved: new hooksPlugin.SyncHook(), createNode: new hooksPlugin.SyncHook(), appendNode: new hooksPlugin.SyncHook(), removeNode: new hooksPlugin.SyncHook(), beforeDestroy: new hooksPlugin.AsyncHook(), destroyed: new hooksPlugin.SyncHook(), }); } function createManagerLifeCycle() { const { lifecycle } = createDanmakuLifeCycle(); return new hooksPlugin.PluginSystem({ // Danmaku hooks $show: lifecycle.show, $hide: lifecycle.hide, $pause: lifecycle.pause, $resume: lifecycle.resume, $beforeMove: lifecycle.beforeMove, $moved: lifecycle.moved, $createNode: lifecycle.createNode, $appendNode: lifecycle.appendNode, $removeNode: lifecycle.removeNode, $beforeDestroy: lifecycle.beforeDestroy, $destroyed: lifecycle.destroyed, // Global hooks format: new hooksPlugin.SyncHook(), start: new hooksPlugin.SyncHook(), stop: new hooksPlugin.SyncHook(), show: new hooksPlugin.SyncHook(), hide: new hooksPlugin.SyncHook(), freeze: new hooksPlugin.SyncHook(), unfreeze: new hooksPlugin.SyncHook(), finished: new hooksPlugin.SyncHook(), clear: new hooksPlugin.SyncHook(), mount: new hooksPlugin.SyncHook(), unmount: new hooksPlugin.SyncHook(), init: new hooksPlugin.SyncHook(), limitWarning: new hooksPlugin.SyncHook(), push: new hooksPlugin.SyncHook(), render: new hooksPlugin.SyncHook(), updateOptions: new hooksPlugin.SyncHook(), willRender: new hooksPlugin.SyncWaterfallHook(), }); } const scope = '$'; const cache = []; function createDanmakuPlugin(plSys) { const plugin = { name: `__danmaku_plugin_${ids.bridge++}__`, }; if (cache.length) { for (const [k, nk] of cache) { plugin[nk] = (...args) => { return plSys.lifecycle[k].emit(...args); }; } } else { const keys = Object.keys(plSys.lifecycle); for (const k of keys) { if (k.startsWith(scope)) { const nk = k.replace(scope, ''); cache.push([k, nk]); plugin[nk] = (...args) => { return plSys.lifecycle[k].emit(...args); }; } } } return plugin; } class FacileDanmaku { _options; data; loops = 0; isLoop = false; paused = false; moving = false; isEnded = false; isFixedDuration = false; rate; duration; recorder; nextFrame = nextFrame; type = 'facile'; track = null; node = null; moveTimer = null; position = { x: 0, y: 0 }; pluginSystem = createDanmakuLifeCycle(); _internalStatuses; _initData; constructor(_options) { this._options = _options; this.data = _options.data; this.rate = _options.rate; this.duration = _options.duration; this._internalStatuses = _options.internalStatuses; this._initData = { duration: _options.duration, width: _options.container.width, }; this.recorder = { pauseTime: 0, startTime: 0, prevPauseTime: 0, }; } /** * @internal */ _delInTrack() { this._options.delInTrack(this); if (this.track) { this.track._remove(this); } } /** * @internal */ _summaryWidth() { return this._options.container.width + this.getWidth(); } /** * @internal */ _getMovePercent() { const { pauseTime, startTime, prevPauseTime } = this.recorder; const ct = this.paused ? prevPauseTime : aidly.now(); const movePercent = (ct - startTime - pauseTime) / this.actualDuration(); if (this._options.progress && this._options.progress > 0) { return movePercent + this._options.progress; } return movePercent; } /** * @internal */ _getMoveDistance() { if (!this.moving) return 0; return this._getMovePercent() * this._summaryWidth(); } /** * @internal */ _getSpeed() { const cw = this._summaryWidth(); if (cw == null) return 0; return cw / this.actualDuration(); } /** * @internal */ _createNode() { if (this.node) return; this.node = document.createElement('div'); this._setStartStatus(); this.node.__danmaku__ = this; this.pluginSystem.lifecycle.createNode.emit(this, this.node); } /** * @internal */ _appendNode(container) { if (!this.node || this.node.parentNode === container) return; container.appendChild(this.node); this.pluginSystem.lifecycle.appendNode.emit(this, this.node); } /** * @internal */ _removeNode(_flag) { if (!this.node) return; const parentNode = this.node.parentNode; if (!parentNode) return; parentNode.removeChild(this.node); if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.removeNode.emit(this, this.node); } } /** * @internal */ _setOff() { return new Promise((resolve) => { if (!this.node) { this.moving = false; this.isEnded = true; resolve(); return; } for (const key in this._internalStatuses.styles) { this.setStyle(key, this._internalStatuses.styles[key]); } const w = this.getWidth(); const cw = this._options.container.width + w; const negative = this.direction === 'left' ? 1 : -1; this._internalStatuses.viewStatus === 'hide' ? this.hide(INTERNAL_FLAG) : this.show(INTERNAL_FLAG); const actualDuration = this.actualDuration(); this.setStyle('transform', `translateX(${negative * cw}px)`); this.setStyle('transition', `transform linear ${actualDuration}ms`); if (this._options.progress && this._options.progress > 0) { const remainingTime = this._options.progress * actualDuration; this.setStyle('transitionDelay', `${-1 * remainingTime}ms`); } if (this.direction !== 'none') { this.setStyle(this.direction, `-${w}px`); } this.moving = true; this.recorder.startTime = aidly.now(); this.pluginSystem.lifecycle.beforeMove.emit(this); whenTransitionEnds(this.node).then(() => { this.loops++; this.moving = false; this.isEnded = true; this.pluginSystem.lifecycle.moved.emit(this); resolve(); }); }); } /** * @internal */ _setStartStatus() { this._internalStatuses.viewStatus === 'hide' ? this.hide(INTERNAL_FLAG) : this.show(INTERNAL_FLAG); this.setStyle('zIndex', '0'); this.setStyle('opacity', '0'); this.setStyle('transform', ''); this.setStyle('transition', ''); this.setStyle('position', 'absolute'); this.setStyle('top', `${this.position.y}px`); if (this.direction !== 'none') { this.setStyle(this.direction, '0'); } } /** * @internal */ _updatePosition(p) { if (typeof p.x === 'number') { this.position.x = p.x; } if (typeof p.y === 'number') { this.position.y = p.y; this.setStyle('top', `${p.y}px`); } } /** * @internal */ _updateTrack(track) { this.track = track; if (track) { track._add(this); } } /** * @internal */ _updateDuration(duration, updateInitData = true) { this.isFixedDuration = true; this.duration = duration; if (updateInitData) { this._initData.duration = duration; } } /** * @internal */ _format(oldWidth, oldHeight, newTrack) { if (this.isEnded) { this.destroy(); return; } // Don't let the rendering of danmaku exceed the container if ( this._options.container.height !== oldHeight && this.getHeight() + newTrack.location.bottom > this._options.container.height ) { this.destroy(); return; } // As the x-axis varies, the motion area of danmu also changes if (this._options.container.width !== oldWidth) { const { width, duration } = this._initData; const speed = (width + this.getWidth()) / duration; this._updateDuration(this._summaryWidth() / speed, false); if (!this.paused) { this.pause(INTERNAL_FLAG); this.resume(INTERNAL_FLAG); } } } /** * @internal */ _reset() { this.loops = 0; this.paused = false; this.moving = false; this.position = { x: 0, y: 0 }; this._removeNode(); this._delInTrack(); this._setStartStatus(); this._updateTrack(null); this.setStyle('top', ''); if (this.moveTimer) { this.moveTimer.clear(); this.moveTimer = null; } this.recorder = { pauseTime: 0, startTime: 0, prevPauseTime: 0, }; this._initData = { duration: this._options.duration, width: this._options.container.width, }; } get direction() { return this._options.direction; } // When our total distance remains constant, // acceleration is inversely proportional to time. actualDuration() { return this.duration / this.rate; } setloop() { this.isLoop = true; } unloop() { this.isLoop = false; } getHeight() { return (this.node && this.node.clientHeight) || 0; } getWidth() { return (this.node && this.node.clientWidth) || 0; } pause(_flag) { if (!this.moving || this.paused) return; let d = this._getMoveDistance(); if (Number.isNaN(d)) return; const negative = this.direction === 'left' ? 1 : -1; this.paused = true; this.recorder.prevPauseTime = aidly.now(); this.setStyle('zIndex', '2'); this.setStyle('transitionDuration', '0ms'); this.setStyle('transform', `translateX(${d * negative}px)`); if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.pause.emit(this); } } resume(_flag) { if (!this.moving || !this.paused) return; const cw = this._summaryWidth(); const negative = this.direction === 'left' ? 1 : -1; const remainingTime = (1 - this._getMovePercent()) * this.actualDuration(); this.paused = false; this.recorder.pauseTime += aidly.now() - this.recorder.prevPauseTime; this.recorder.prevPauseTime = 0; this.setStyle('zIndex', '0'); this.setStyle('transitionDuration', `${remainingTime}ms`); this.setStyle('transform', `translateX(${cw * negative}px)`); this.setStyle('transitionDelay', ''); if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.resume.emit(this); } } hide(_flag) { this.setStyle('visibility', 'hidden'); this.setStyle('pointerEvents', 'none'); if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.hide.emit(this); } } show(_flag) { this.setStyle('visibility', 'visible'); this.setStyle('pointerEvents', 'auto'); if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.show.emit(this); } } async destroy(mark) { await this.pluginSystem.lifecycle.beforeDestroy.emit(this, mark); this.moving = false; this._delInTrack(); this._removeNode(); if (this.moveTimer) { this.moveTimer.clear(); this.moveTimer = null; } this.pluginSystem.lifecycle.destroyed.emit(this, mark); this.node = null; } setStyle(key, val) { if (!this.node) return; this.node.style[key] = val; } remove(pluginName) { this.pluginSystem.remove(pluginName); } use(plugin) { if (typeof plugin === 'function') plugin = plugin(this); if (!plugin.name) { plugin.name = `__facile_danmaku_plugin_${ids.danmu++}__`; } this.pluginSystem.useRefine(plugin); return plugin; } } class FlexibleDanmaku extends FacileDanmaku { _options; position; type = 'flexible'; constructor(_options) { super(_options); this._options = _options; this.position = _options.position || { x: 0, y: 0 }; } /** * @internal */ _getSpeed() { if (this.direction === 'none') return 0; const { duration } = this._initData; const cw = this.direction === 'right' ? this.position.x + this.getWidth() : this.position.x; return cw / duration; } /** * @internal */ _setOff() { return new Promise((resolve) => { if (!this.node) { this.moving = false; this.isEnded = true; resolve(); return; } const onEnd = () => { this.loops++; this.moving = false; this.isEnded = true; if (this.moveTimer) { this.moveTimer.clear(); this.moveTimer = null; } this.pluginSystem.lifecycle.moved.emit(this); resolve(); }; for (const key in this._internalStatuses.styles) { this.setStyle(key, this._internalStatuses.styles[key]); } this.moving = true; this.recorder.startTime = aidly.now(); this.pluginSystem.lifecycle.beforeMove.emit(this); if (this.direction === 'none') { let timer = setTimeout(onEnd, this.actualDuration()); this.moveTimer = { cb: onEnd, clear() { clearTimeout(timer); timer = null; }, }; } else { const ex = this.direction === 'left' ? this._options.container.width : -this.getWidth(); this.setStyle( 'transition', `transform linear ${this.actualDuration()}ms`, ); this.setStyle( 'transform', `translateX(${ex}px) translateY(${this.position.y}px)`, ); whenTransitionEnds(this.node).then(onEnd); } }); } /** * @internal */ _setStartStatus() { this.setStyle('zIndex', '1'); this.setStyle('transform', ''); this.setStyle('transition', ''); this.setStyle('position', 'absolute'); this.setStyle( 'transform', `translateX(${this.position.x}px) translateY(${this.position.y}px)`, ); this._internalStatuses.viewStatus === 'hide' ? this.hide(INTERNAL_FLAG) : this.show(INTERNAL_FLAG); } /** * @internal */ _updatePosition(p) { let needUpdateStyle = false; if (typeof p.x === 'number') { this.position.x = p.x; needUpdateStyle = true; } if (typeof p.y === 'number') { this.position.y = p.y; needUpdateStyle = true; } if (needUpdateStyle) { this.setStyle( 'transform', `translateX(${this.position.x}px) translateY(${this.position.y}px)`, ); } } /** * @internal */ _getMovePercent(useInitData) { const { pauseTime, startTime, prevPauseTime } = this.recorder; const ct = this.paused ? prevPauseTime : aidly.now(); const moveTime = ct - startTime - pauseTime; return ( moveTime / (useInitData ? this._initData.duration / this.rate : this.actualDuration()) ); } /** * @internal */ _getMoveDistance() { if (!this.moving) return 0; let d; let { x } = this.position; const diff = this._initData.width - this._options.container.width; if (this.direction === 'none') { d = x - diff; } else { const percent = this._getMovePercent(true); if (this.direction === 'left') { // When the container changes and the direction of movement is to the right, // there is no need for any changes d = x + (this._options.container.width - x) * percent; } else { d = x - (x + this.getWidth()) * percent - diff; } } return d; } /** * @internal */ _format() { if (this.direction === 'left') return; if (this.direction === 'none') { this.setStyle( 'transform', `translateX(${this._getMoveDistance()}px) translateY(${this.position.y}px)`, ); return; } const diff = this._initData.width - this._options.container.width; const cw = this.position.x + this.getWidth(); this._updateDuration((cw - diff) / this._getSpeed(), false); if (this.paused) { this.resume(INTERNAL_FLAG); this.pause(INTERNAL_FLAG); } else { this.pause(INTERNAL_FLAG); this.resume(INTERNAL_FLAG); } } pause(_flag) { if (!this.moving || this.paused) return; this.paused = true; this.recorder.prevPauseTime = aidly.now(); if (this.direction === 'none') { if (this.moveTimer) this.moveTimer.clear(); } else { this.setStyle('zIndex', '3'); this.setStyle('transitionDuration', '0ms'); this.setStyle( 'transform', `translateX(${this._getMoveDistance()}px) translateY(${this.position.y}px)`, ); } if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.pause.emit(this); } } resume(_flag) { if (!this.moving || !this.paused) return; this.paused = false; this.recorder.pauseTime += aidly.now() - this.recorder.prevPauseTime; this.recorder.prevPauseTime = 0; const remainingTime = (1 - this._getMovePercent()) * this.actualDuration(); if (this.direction === 'none') { if (this.moveTimer) { let timer = setTimeout(this.moveTimer.cb || (() => {}), remainingTime); this.moveTimer.clear = () => { clearTimeout(timer); timer = null; }; } } else { const ex = this.direction === 'left' ? this._options.container.width : -this.getWidth(); this.setStyle('zIndex', '1'); this.setStyle('transitionDuration', `${remainingTime}ms`); this.setStyle( 'transform', `translateX(${ex}px) translateY(${this.position.y}px)`, ); } if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.resume.emit(this); } } remove(pluginName) { this.pluginSystem.remove(pluginName); } use(plugin) { if (typeof plugin === 'function') plugin = plugin(this); if (!plugin.name) { plugin.name = `__flexible_danmaku_plugin_${ids.danmu++}__`; } this.pluginSystem.useRefine(plugin); return plugin; } } class Engine { _options; rows = 0; container = new Container(); tracks = []; _fx = new aidly.Queue(); _sets = { view: new Set(), flexible: new Set(), stash: [], }; // Avoid frequent deletion of danmaku. // collect the danmaku that need to be deleted within 2 seconds and delete them together. _addDestroyQueue = aidly.batchProcess({ ms: 3000, processor: (ls) => ls.forEach((dm) => dm.destroy()), }); constructor(_options) { this._options = _options; } len() { const { stash, view, flexible } = this._sets; return { stash: stash.length, flexible: flexible.size, view: view.size + flexible.size, all: view.size + flexible.size + stash.length, }; } add(data, options, isUnshift) { const val = data instanceof FacileDanmaku ? data : { data, options }; this._sets.stash[isUnshift ? 'unshift' : 'push'](val); } updateOptions(newOptions) { this._options = Object.assign(this._options, newOptions); if (aidly.hasOwn(newOptions, 'gap')) { this._options.gap = this.container._toNumber('width', this._options.gap); } if (aidly.hasOwn(newOptions, 'trackHeight')) { this.format(); } } clear(type) { if (!type || type === 'facile') { for (let i = 0; i < this.tracks.length; i++) { this.tracks[i].clear(); } this._sets.view.clear(); this._sets.stash.length = 0; } if (!type || type === 'flexible') { for (const dm of this._sets.flexible) { dm.destroy(); } this._sets.flexible.clear(); } } // `flexible` and `view` are both xx, // so deleting some of them in the loop will not affect each(fn) { for (const dm of this._sets.flexible) { if (!dm.isEnded) { if (fn(dm) === false) return; } } for (const dm of this._sets.view) { if (!dm.isEnded) { if (fn(dm) === false) return; } } } // Because there are copies brought by `Array.from`, // deleting it in all loops will not affect asyncEach(fn) { let stop = false; const arr = Array.from(this._sets.flexible); return aidly .loopSlice( arr.length, (i) => { if (!arr[i].isEnded) { if (fn(arr[i]) === false) { stop = true; return false; } } }, 17, ) .then(() => { if (stop) return; const arr = Array.from(this._sets.view); return aidly.loopSlice( arr.length, (i) => { if (!arr[i].isEnded) { return fn(arr[i]); } }, 17, ); }); } format() { const { width, height } = this.container; this.container._format(); const { gap, trackHeight } = this._options; this._options.gap = this.container._toNumber('width', gap); const h = this.container._toNumber('height', trackHeight); if (h <= 0) { for (let i = 0; i < this.tracks.length; i++) { this.tracks[i].clear(); } return; } const rows = (this.rows = +(this.container.height / h).toFixed(0)); for (let i = 0; i < rows; i++) { const track = this.tracks[i]; const top = h * i; const bottom = h * (i + 1) - 1; const middle = (bottom - top) / 2 + top; const location = { top, middle, bottom }; if (bottom > this.container.height) { this.rows--; if (track) { this.tracks[i].clear(); this.tracks.splice(i, 1); } } else if (track) { // If the reused track is larger than the container height, // the overflow needs to be deleted. if (track.location.middle > this.container.height) { this.tracks[i].clear(); } else { track.each((dm) => { dm._format(width, height, track); }); } track._updateLocation(location); } else { const track = new Track({ index: i, list: [], location, container: this.container, }); this.tracks.push(track); } } // Delete the extra tracks and the danmaku inside if (this.tracks.length > this.rows) { for (let i = this.rows; i < this.tracks.length; i++) { this.tracks[i].clear(); } this.tracks.splice(this.rows, this.tracks.length - this.rows); } // If `flexible` danmaku is also outside the view, it also needs to be deleted for (const dm of this._sets.flexible) { if (dm.position.y > this.container.height) { dm.destroy(); } else if (width !== this.container.width) { dm._format(); } } } renderFlexibleDanmaku(data, options, { hooks, statuses, danmakuPlugin }) { aidly.assert(this.container, 'Container not formatted'); hooks.render.call(null, 'flexible'); const dm = this._create('flexible', data, options, statuses); if (dm.position.x > this.container.width) return false; if (dm.position.y > this.container.height) return false; if (options.plugin) dm.use(options.plugin); dm.use(danmakuPlugin); const { prevent } = hooks.willRender.call(null, { type: 'flexible', danmaku: dm, prevent: false, trackIndex: null, }); if (this._options.rate > 0 && prevent !== true) { const setup = () => { dm._createNode(); this._sets.flexible.add(dm); this._setAction(dm, statuses).then((isFreeze) => { if (isFreeze) { console.error( 'Currently in a freeze state, unable to render "FlexibleDanmaku"', ); return; } if (dm.isLoop) { dm._setStartStatus(); setup(); return; } dm.destroy(); if (this.len().all === 0) { hooks.finished.call(null); } }); }; setup(); return true; } return false; } renderFacileDanmaku({ hooks, statuses, danmakuPlugin }) { const { mode, limits } = this._options; const launch = () => { const num = this.len(); let l = num.stash; if (typeof limits.view === 'number') { const max = limits.view - num.view; if (l > max) l = max; } if (mode === 'strict' && l > this.rows) { l = this.rows; } if (l <= 0) return; hooks.render.call(null, 'facile'); return aidly.loopSlice(l, () => this._consumeFacileDanmaku(statuses, danmakuPlugin, hooks), ); }; if (mode === 'strict') { this._fx.add((next) => { const p = launch(); p ? p.then(next) : next(); }); } else { launch(); } } _consumeFacileDanmaku(statuses, danmakuPlugin, hooks) { let dm; const layer = this._sets.stash.shift(); if (!layer) return; const track = this._getTrack(); if (!track) { this._sets.stash.unshift(layer); // If there is nothing to render, return `false` to stop the loop. return false; } if (layer instanceof FacileDanmaku) { dm = layer; } else { dm = this._create('facile', layer.data, layer.options, statuses); if (layer.options.plugin) { dm.use(layer.options.plugin); } dm.use(danmakuPlugin); } const { prevent } = hooks.willRender.call(null, { type: 'facile', danmaku: dm, prevent: false, trackIndex: track.index, }); // When the rate is less than or equal to 0, // the danmaku will never move, but it will be rendered, // so just don't render it here. if (this._options.rate > 0 && prevent !== true) { // First createNode, users may add styles dm._createNode(); dm._appendNode(this.container.node); dm._updateTrack(track); const setup = () => { this._sets.view.add(dm); this._setAction(dm, statuses).then((isStash) => { if (isStash) { dm._reset(); this._sets.view.delete(dm); this._sets.stash.unshift(dm); return; } if (dm.isLoop) { dm._setStartStatus(); setup(); return; } this._addDestroyQueue(dm); if (this.len().all === 0) { hooks.finished.call(null); } }); }; // Waiting for the style to take effect, // we need to get the danmaku screen height. let i = 0; const triggerSetup = () => { nextFrame(() => { const height = dm.getHeight(); if (height === 0 && ++i < 20) { triggerSetup(); } else { const y = track.location.middle - height / 2; if (y + height > this.container.height) return; dm._updatePosition({ y }); setup(); } }); }; triggerSetup(); } } _setAction(cur, internalStatuses) { return new Promise((resolve) => { nextFrame(() => { if (internalStatuses.freeze === true) { resolve(true); return; } const { mode, durationRange } = this._options; if (cur.type === 'facile') { const speed = this._calculateSpeed(cur._options.speed); if (speed) { cur._updateDuration(cur._summaryWidth() / speed, true); } else if (mode === 'strict' || mode === 'adaptive') { aidly.assert(cur.track, 'Track not found'); const prev = cur.track._last(1); if (prev && cur.loops === 0) { const fixTime = this._collisionPrediction(prev, cur); if (fixTime !== null) { if (aidly.isInBounds(durationRange, fixTime)) { cur._updateDuration(fixTime, true); } else if (mode === 'strict') { resolve(true); return; } } } } } else if (cur.type === 'flexible') { cur.use({ appendNode: () => { const speed = this._calculateSpeed(cur._options.speed); if (speed) { cur._updateDuration( (cur.position.x + cur.getWidth()) / speed, true, ); } }, }); } cur._appendNode(this.container.node); nextFrame(() => { if (internalStatuses.freeze === true) { cur._removeNode(INTERNAL_FLAG); resolve(true); } else { cur._setOff().then(() => resolve(false)); } }); }); }); } _create(type, data, options, internalStatuses) { aidly.assert(this.container, 'Container not formatted'); const config = { data, internalStatuses, rate: options.rate, speed: options.speed, container: this.container, duration: options.duration, direction: options.direction, progress: options.progress, delInTrack: (b) => { aidly.remove(this._sets.view, b); type === 'facile' ? aidly.remove(this._sets.stash, b) : aidly.remove(this._sets.flexible, b); }, }; if (type === 'facile') { // Create FacileDanmaku return new FacileDanmaku(config); } else { // Create FlexibleDanmaku const dm = new FlexibleDanmaku(config); const { position } = options; // If it is a function, the position will be updated after the node is created, // so that the function can get accurate danmaku data. if (typeof position === 'function') { dm.use({ appendNode: () => { let { x, y } = position(dm, this.container); x = this.container._toNumber('width', x); y = this.container._toNumber('height', y); dm._updatePosition({ x, y }); }, }); } else { const x = this.container._toNumber('width', position.x); const y = this.container._toNumber('height', position.y); dm._updatePosition({ x, y }); } return dm; } } _calculateSpeed(s) { if (s && typeof s === 'string') { s = this.container._toNumber('width', s); aidly.assert( aidly.isNumber(s) && s > 0, `The speed must > 0, but the current value is "${s}"`, ); } return s; } _getTrack(founds = new Set(), prev) { if (this.rows === 0) return null; const { gap, mode } = this._options; if (founds.size === this.tracks.length) { return mode === 'adaptive' ? prev || null : null; } const i = randomIdx(founds, this.rows); const track = this.tracks[i]; if (!track.isLock) { if (mode === 'none') { return track; } const last = track._last(0); if (!last) { return track; } const lastWidth = last.getWidth(); if (lastWidth > 0 && last._getMoveDistance() >= gap + lastWidth) { return track; } } founds.add(i); return this._getTrack(founds, track); } _collisionPrediction(prv, cur) { const cs = cur._getSpeed(); const ps = prv._getSpeed(); const acceleration = cs - ps; if (acceleration <= 0) return null; const cw = cur.getWidth(); const pw = prv.getWidth(); const { gap } = this._options; const distance = prv._getMoveDistance() - cw - pw - gap; const collisionTime = distance / acceleration; if (collisionTime >= cur.duration) return null; aidly.assert(this.container, 'Container not formatted'); const remainingTime = (1 - prv._getMovePercent()) * prv.duration; const currentFixTime = ((cw + gap) * remainingTime) / this.container.width; return remainingTime + currentFixTime; } } class Manager { options; version = '0.17.1'; nextFrame = nextFrame; statuses = Object.create(null); pluginSystem = createManagerLifeCycle(); _engine; _renderTimer = null; _internalStatuses = Object.create(null); constructor(options) { this.options = options; this._engine = new Engine(options); this._internalStatuses.freeze = false; this._internalStatuses.viewStatus = 'show'; this._internalStatuses.styles = Object.create(null); this._internalStatuses.styles.opacity = ''; this.pluginSystem.lifecycle.init.emit(this); } /** * @internal */ _mergeOptions(pushOptions) { const options = pushOptions ? pushOptions : Object.create(null); if (!('rate' in options)) { options.rate = this.options.rate; } if (!('speed' in options)) { options.speed = this.options.speed; } if (!('direction' in options)) { options.direction = this.options.direction; } if (!('duration' in options)) { const duration = aidly.random(...this.options.durationRange); aidly.assert(duration > 0, `Invalid duration "${duration}"`); options.duration = duration; } return options; } /** * @internal */ _setViewStatus(status, filter) { return new Promise((resolve) => { if (this._internalStatuses.viewStatus === status) { resolve(); return; } this._internalStatuses.viewStatus = status; this.pluginSystem.lifecycle[status].emit(); this._engine .asyncEach((b) => { if (this._internalStatuses.viewStatus === status) { if (!filter || filter(b) !== true) { b[status](); } } else { return false; } }) .then(resolve); }); } get container() { return this._engine.container; } get trackCount() { return this._engine.tracks.length; } len() { return this._engine.len(); } isShow() { return this._internalStatuses.viewStatus === 'show'; } isFreeze() { return this._internalStatuses.freeze === true; } isPlaying() { return this._renderTimer !== null; } isDanmaku(dm) { return dm instanceof FacileDanmaku || dm instanceof FlexibleDanmaku; } each(fn) { this._engine.each(fn); } asyncEach(fn) { return this._engine.asyncEach(fn); } getTrack(i) { i = i >= 0 ? i : this.trackCount + i; return this._engine.tracks[i]; } freeze({ preventEvents = [] } = {}) { let stopFlag; let pauseFlag; if (preventEvents.includes('stop')) { stopFlag = INTERNAL_FLAG; } if (preventEvents.includes('pause')) { pauseFlag = INTERNAL_FLAG; } this.stopPlaying(stopFlag); this.each((dm) => dm.pause(pauseFlag)); this._internalStatuses.freeze = true; this.pluginSystem.lifecycle.freeze.emit(); } unfreeze({ preventEvents = [] } = {}) { let startFlag; let resumeFlag; if (preventEvents.includes('start')) { startFlag = INTERNAL_FLAG; } if (preventEvents.includes('resume')) { resumeFlag = INTERNAL_FLAG; } this.each((dm) => dm.resume(resumeFlag)); this.startPlaying(startFlag); this._internalStatuses.freeze = false; this.pluginSystem.lifecycle.unfreeze.emit(); } format() { this._engine.format(); this.pluginSystem.lifecycle.format.emit(); } mount(parentNode, { clear = true } = {}) { if (parentNode) { if (typeof parentNode === 'string') { const res = document.querySelector(parentNode); aidly.assert(res, `Invalid selector "${parentNode}"`); parentNode = res; } if (this.isPlaying()) { clear && this.clear(null, INTERNAL_FLAG); } this._engine.container._mount(parentNode); this.format(); this.pluginSystem.lifecycle.mount.emit(parentNode); } } unmount() { const { parentNode } = this.container; this.container._unmount(); this.pluginSystem.lifecycle.unmount.emit(parentNode); } clear(type, _flag) { this._engine.clear(type); if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.clear.emit(type); } } updateOptions(newOptions, key) { this._engine.updateOptions(newOptions); this.options = Object.assign(this.options, newOptions); if (aidly.hasOwn(newOptions, 'interval')) { this.stopPlaying(INTERNAL_FLAG); this.startPlaying(INTERNAL_FLAG); } this.pluginSystem.lifecycle.updateOptions.emit(newOptions, key); } startPlaying(_flag) { if (this.isPlaying()) return; if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.start.emit(); } const cycle = () => { this._renderTimer = setTimeout(cycle, this.options.interval); this.render(); }; cycle(); } stopPlaying(_flag) { if (!this.isPlaying()) return; if (this._renderTimer) { clearTimeout(this._renderTimer); } this._renderTimer = null; if (_flag !== INTERNAL_FLAG) { this.pluginSystem.lifecycle.stop.emit(); } } show(filter) { return this._setViewStatus('show', filter); } hide(filter) { return this._setViewStatus('hide', filter); } canPush(type) { let res = true; const isFacile = type === 'facile'; const { limits } = this.options; const { stash, view } = this._engine.len(); if (isFacile) { res = stash < limits.stash; } else if (typeof limits.view === 'number') { res = view < limits.view; } return res; } unshift(data, options) { return this.push(data, options, INTERNAL_FLAG); } push(data, options, _unshift) { if (!this.canPush('facile')) { const { stash } = this.options.limits; const hook = this.pluginSystem.lifecycle.limitWarning; !hook.isEmpty() ? hook.emit('facile', stash) : console.warn( `The number of danmu in temporary storage exceeds the limit (${stash})`, ); return false; } const isUnshift = _unshift === INTERNAL_FLAG; if (!this.isDanmaku(data)) { options = this._mergeOptions(options); } this._engine.add(data, options, isUnshift); this.pluginSystem.lifecycle.push.emit(data, 'facile', isUnshift); return true; } pushFlexibleDanmaku(data, options) { if (!this.isPlaying()) return false; if (typeof options.duration === 'number' && options.duration < 0) { return false; } if (!this.canPush('flexible')) { const { view } = this.options.limits; const hook = this.pluginSystem.lifecycle.limitWarning; !hook.isEmpty() ? hook.emit('flexible', view || 0) : console.warn( `The number of danmu in view exceeds the limit (${view})`, ); return false; } const res = this._engine.renderFlexibleDanmaku( data, this._mergeOptions(options), { statuses: this._internalStatuses, danmakuPlugin: createDanmakuPlugin(this.pluginSystem), hooks: { finished: () => this.pluginSystem.lifecycle.finished.emit(), render: (val) => this.pluginSystem.lifecycle.render.emit(val), willRender: (val) => this.pluginSystem.lifecycle.willRender.emit(val), }, }, ); if (res) { this.pluginSystem.lifecycle.push.emit(data, 'flexible', false); return true; } return false; } updateOccludedUrl(url, el) { let set; if (el) { if (typeof el === 'string') { const res = document.querySelector(el); aidly.assert(res, `Invalid selector "${el}"`); el = res; } set = (key, val) => (el.style[key] = val); } else { set = (key, val) => this.container.setStyle(key, val); } if (url) { aidly.assert(typeof url === 'string', `Invalid url "${url}"`); set('maskImage', `url("${url}")`); set('webkitMaskImage', `url("${url}")`); set('maskSize', 'cover'); set('webkitMaskSize', 'cover'); } else { set('maskImage', 'none'); set('webkitMaskImage', 'none'); } } render() { this._engine.renderFacileDanmaku({ statuses: this._internalStatuses, danmakuPlugin: createDanmakuPlugin(this.pluginSystem), hooks: { finished: () => this.pluginSystem.lifecycle.finished.emit(), render: (val) => this.pluginSystem.lifecycle.render.emit(val), willRender: (val) => this.pluginSystem.lifecycle.willRender.emit(val), }, }); } remove(pluginName) { this.pluginSystem.remove(pluginName); } use(plugin) { if (typeof plugin === 'function') plugin = plugin(this); if (!plugin.name) { plugin.name = `__runtime_plugin_${ids.runtime++}__`; } this.pluginSystem.useRefine(plugin); return plugin; } setStyle(key, val) { const { styles } = this._internalStatuses; if (styles[key] !== val) { styles[key] = val; this._engine.asyncEach((dm) => { if (dm.moving) { dm.setStyle(key, val); } }); } } setOpacity(opacity) { if (typeof opacity === 'string') { opacity = Number(opacity); } if (opacity < 0) { opacity = 0; } else if (opacity > 1) { opacity = 1; } this.setStyle('opacity', String(opacity)); } setArea(size) { if (!aidly.isEmptyObject(size)) { this._engine.container._updateSize(size); this.format(); } } setGap(gap) { this.updateOptions({ gap }, 'gap'); } setMode(mode) { this.updateOptions({ mode }, 'mode'); } setSpeed(speed) { this.updateOptions({ speed }, 'speed'); } setRate(rate) { if (rate !== this.options.rate) { this.updateOptions({ rate }, 'rate'); } } setInterval(interval) { this.updateOptions({ interval }, 'interval'); } setTrackHeight(trackHeight) { this.updateOptions({ trackHeight }, 'trackHeight'); } setDurationRange(durationRange) { this.updateOptions({ durationRange }, 'durationRange'); } setDirection(direction) { this.updateOptions({ direction }, 'direction'); } setLimits({ view, stash }) { let needUpdate = false; const limits = Object.assign({}, this.options.limits); if (typeof view === 'number') { needUpdate = true; limits.view = view; } if (typeof stash === 'number') { needUpdate = true; limits.stash = stash; } if (needUpdate) { this.updateOptions({ limits }, 'limits'); } } } const formatOptions = (options) => { const newOptions = Object.assign( { gap: 0, rate: 1, limits: {}, interval: 500, mode: 'strict', direction: 'right', trackHeight: '20%', durationRange: [4000, 6000], }, options, ); aidly.assert(newOptions.gap >= 0, 'Gap must be greater than or equal to 0'); if (typeof newOptions.limits.stash !== 'number') { newOptions.limits.stash = Infinity; } return newOptions; }; function create(options) { const opts = formatOptions(options); const manager = new Manager(opts); if (opts.plugin) { const plugins = Array.isArray(opts.plugin) ? opts.plugin : [opts.plugin]; for (const plugin of plugins) { manager.use(plugin); } manager.pluginSystem.lifecycle.init.emit(manager); } return manager; } exports.create = create;