danmu
Version:
Flexible, cross-platform, powerful danmu library.
1,718 lines (1,706 loc) • 47.8 kB
JavaScript
/*!
* 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;