2026-01-26 23:09:57 +08:00
|
|
|
// 弹幕功能封装
|
|
|
|
|
(function() {
|
2026-01-29 21:05:27 +08:00
|
|
|
let barrageInstance = null; // 单例实例引用
|
|
|
|
|
|
|
|
|
|
// 获取 Artalk 评论数据
|
|
|
|
|
function getArtalkComments() {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
let isResolved = false;
|
2026-01-31 22:52:19 +08:00
|
|
|
let interval = null;
|
2026-01-29 21:05:27 +08:00
|
|
|
|
2026-01-30 19:22:18 +08:00
|
|
|
// 安全获取评论列表的辅助函数
|
|
|
|
|
const safeGetComments = () => {
|
|
|
|
|
if (!window.artalk) return null;
|
|
|
|
|
if (typeof window.artalk.getComments === 'function') return window.artalk.getComments();
|
|
|
|
|
if (typeof window.artalk.getCommentList === 'function') return window.artalk.getCommentList();
|
|
|
|
|
// 尝试访问内部数据 (Artalk v2 hack)
|
|
|
|
|
if (window.artalk.ctx && window.artalk.ctx.get('list') && typeof window.artalk.ctx.get('list').getComments === 'function') {
|
|
|
|
|
return window.artalk.ctx.get('list').getComments();
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
const tryResolve = (data, source) => {
|
|
|
|
|
if (isResolved) return;
|
|
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
if (Array.isArray(data)) {
|
|
|
|
|
// 如果是事件触发(说明已加载完成),或者是轮询且有数据,则认为成功
|
|
|
|
|
if (source === 'event' || data.length > 0) {
|
|
|
|
|
console.log(`[Barrage] 数据获取成功 (${source}): ${data.length} 条`);
|
|
|
|
|
isResolved = true;
|
|
|
|
|
if (interval) clearInterval(interval);
|
|
|
|
|
resolve(data);
|
|
|
|
|
}
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
// 检查 Artalk 实例并尝试获取数据
|
|
|
|
|
const checkArtalk = () => {
|
|
|
|
|
if (isResolved) return;
|
2026-01-31 23:01:35 +08:00
|
|
|
|
|
|
|
|
// 如果当前页面没有评论区,不要等待 Artalk 初始化,直接返回空
|
|
|
|
|
// 这样可以避免在无评论页(如首页)长时间等待超时
|
|
|
|
|
const hasCommentSection = document.getElementById('artalk') ||
|
|
|
|
|
document.getElementById('Comments') ||
|
|
|
|
|
document.querySelector('.atk-comment-wrap');
|
|
|
|
|
if (!hasCommentSection) {
|
|
|
|
|
// 延迟一点点返回,确保逻辑统一
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
tryResolve([], 'no-comment-section');
|
|
|
|
|
}, 100);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-26 23:09:57 +08:00
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
if (window.artalk) {
|
|
|
|
|
// 1. 尝试绑定事件 (只绑定一次)
|
|
|
|
|
if (!window.artalk._barrageListenerAttached && typeof window.artalk.on === 'function') {
|
|
|
|
|
window.artalk.on('list-loaded', (data) => {
|
|
|
|
|
tryResolve(data, 'event');
|
|
|
|
|
});
|
|
|
|
|
window.artalk._barrageListenerAttached = true;
|
|
|
|
|
console.log('[Barrage] Artalk 事件监听已绑定');
|
|
|
|
|
}
|
2026-01-29 21:05:27 +08:00
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
// 2. 尝试直接获取数据
|
|
|
|
|
const data = safeGetComments();
|
|
|
|
|
if (data) {
|
|
|
|
|
tryResolve(data, 'poll');
|
|
|
|
|
}
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
2026-01-31 22:52:19 +08:00
|
|
|
};
|
2026-01-29 21:05:27 +08:00
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
// 立即检查
|
|
|
|
|
checkArtalk();
|
|
|
|
|
|
|
|
|
|
// 轮询检查 (直到获取数据或超时)
|
|
|
|
|
// 增加轮询频率,以便更快响应 Artalk 初始化
|
|
|
|
|
interval = setInterval(checkArtalk, 200);
|
|
|
|
|
|
|
|
|
|
// 超时处理 (8秒)
|
2026-01-29 21:05:27 +08:00
|
|
|
setTimeout(() => {
|
|
|
|
|
if (!isResolved) {
|
2026-01-31 22:52:19 +08:00
|
|
|
if (interval) clearInterval(interval);
|
|
|
|
|
console.warn('[Barrage] 获取数据超时');
|
2026-01-29 21:05:27 +08:00
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
// 最后的尝试:DOM 兜底 (适用于静态页面或 API 失败但 DOM 已渲染的情况)
|
2026-01-29 21:05:27 +08:00
|
|
|
const domComments = document.querySelectorAll('.atk-comment-wrap');
|
|
|
|
|
if (domComments.length > 0) {
|
|
|
|
|
console.log(`[Barrage] 从 DOM 挽救数据: ${domComments.length} 条`);
|
|
|
|
|
const fallbackData = Array.from(domComments).map(el => {
|
2026-01-31 22:52:19 +08:00
|
|
|
const idStr = el.id || '';
|
2026-01-29 21:05:27 +08:00
|
|
|
const id = idStr.replace('atk-comment-', '');
|
|
|
|
|
const contentEl = el.querySelector('.atk-content');
|
|
|
|
|
const avatarEl = el.querySelector('.atk-avatar img');
|
|
|
|
|
|
|
|
|
|
if (!id || !contentEl) return null;
|
2026-02-01 17:00:25 +08:00
|
|
|
|
|
|
|
|
// 优先获取 data-src (Artalk 懒加载),否则获取 src
|
|
|
|
|
const avatarSrc = avatarEl ? (avatarEl.getAttribute('data-src') || avatarEl.src) : '';
|
2026-01-29 21:05:27 +08:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: id,
|
|
|
|
|
content: contentEl.innerText || contentEl.textContent,
|
2026-02-01 17:00:25 +08:00
|
|
|
avatar: avatarSrc,
|
2026-01-29 21:05:27 +08:00
|
|
|
ua: null
|
|
|
|
|
};
|
|
|
|
|
}).filter(Boolean);
|
|
|
|
|
|
|
|
|
|
if (fallbackData.length > 0) {
|
|
|
|
|
resolve(fallbackData);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
resolve([]);
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
2026-01-31 22:52:19 +08:00
|
|
|
}, 8000);
|
2026-01-26 23:09:57 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
// 获取 Hitokoto 一言
|
|
|
|
|
async function fetchHitokoto(url) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
|
if (!res.ok) throw new Error('Network response was not ok');
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
return {
|
|
|
|
|
id: `hitokoto-${data.id}`,
|
|
|
|
|
content: data.hitokoto,
|
|
|
|
|
from: data.from
|
|
|
|
|
};
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('[Barrage] Hitokoto fetch failed:', e);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 23:09:57 +08:00
|
|
|
class BarrageEngine {
|
|
|
|
|
constructor(options = {}) {
|
|
|
|
|
this.container = typeof options.container === 'string'
|
|
|
|
|
? document.querySelector(options.container)
|
|
|
|
|
: options.container;
|
|
|
|
|
|
|
|
|
|
if (!this.container) {
|
|
|
|
|
console.warn('[Barrage] 错误: 找不到弹幕容器');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.data = [];
|
|
|
|
|
this.index = 0;
|
|
|
|
|
this.timer = null;
|
|
|
|
|
this.isPaused = false;
|
2026-01-29 20:33:30 +08:00
|
|
|
this.isDestroyed = false;
|
2026-01-29 21:05:27 +08:00
|
|
|
|
|
|
|
|
// 追踪当前屏幕上显示的弹幕 ID
|
|
|
|
|
this.activeIds = new Set();
|
2026-02-03 13:18:03 +08:00
|
|
|
// 最大同屏数量限制
|
2026-01-29 21:19:49 +08:00
|
|
|
this.maxActiveItems = 30;
|
|
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
// 对象池
|
|
|
|
|
this.pool = [];
|
|
|
|
|
this.initPool();
|
|
|
|
|
|
2026-01-29 21:19:49 +08:00
|
|
|
// 绑定事件委托
|
|
|
|
|
this.initEventDelegation();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
initPool() {
|
|
|
|
|
// 初始化对象池,预创建 DOM
|
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
|
for (let i = 0; i < this.maxActiveItems; i++) {
|
|
|
|
|
const item = document.createElement('div');
|
|
|
|
|
item.className = 'barrage-item';
|
|
|
|
|
item.style.display = 'none'; // 初始隐藏
|
|
|
|
|
item.style.willChange = 'transform'; // 提示浏览器优化
|
|
|
|
|
|
|
|
|
|
// 预构建结构
|
|
|
|
|
const avatar = document.createElement('img');
|
|
|
|
|
avatar.className = 'barrage-avatar';
|
|
|
|
|
avatar.draggable = false;
|
|
|
|
|
|
|
|
|
|
const bubble = document.createElement('div');
|
|
|
|
|
bubble.className = 'barrage-bubble';
|
|
|
|
|
const textSpan = document.createElement('span');
|
|
|
|
|
textSpan.className = 'barrage-text';
|
|
|
|
|
bubble.appendChild(textSpan);
|
|
|
|
|
|
|
|
|
|
item.appendChild(avatar);
|
|
|
|
|
item.appendChild(bubble);
|
|
|
|
|
|
|
|
|
|
// 绑定动画结束事件(只需绑定一次)
|
|
|
|
|
item.addEventListener('animationend', () => {
|
|
|
|
|
this.recycle(item);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.pool.push({
|
|
|
|
|
el: item,
|
|
|
|
|
avatarEl: avatar,
|
|
|
|
|
textEl: textSpan,
|
|
|
|
|
busy: false,
|
|
|
|
|
id: null
|
|
|
|
|
});
|
|
|
|
|
fragment.appendChild(item);
|
|
|
|
|
}
|
|
|
|
|
this.container.appendChild(fragment);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
recycle(itemEl) {
|
|
|
|
|
const poolItem = this.pool.find(p => p.el === itemEl);
|
|
|
|
|
if (poolItem) {
|
|
|
|
|
poolItem.busy = false;
|
|
|
|
|
poolItem.el.style.display = 'none';
|
|
|
|
|
if (poolItem.id) {
|
|
|
|
|
this.activeIds.delete(poolItem.id);
|
|
|
|
|
poolItem.id = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 21:19:49 +08:00
|
|
|
initEventDelegation() {
|
|
|
|
|
// 鼠标进入容器暂停
|
|
|
|
|
this.container.addEventListener('mouseover', (e) => {
|
|
|
|
|
const item = e.target.closest('.barrage-item');
|
|
|
|
|
if (item) {
|
|
|
|
|
item.style.animationPlayState = 'paused';
|
|
|
|
|
item.style.zIndex = '9999';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 鼠标离开恢复
|
|
|
|
|
this.container.addEventListener('mouseout', (e) => {
|
|
|
|
|
const item = e.target.closest('.barrage-item');
|
|
|
|
|
if (item) {
|
|
|
|
|
item.style.animationPlayState = 'running';
|
|
|
|
|
item.style.zIndex = '';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 点击事件
|
|
|
|
|
this.container.addEventListener('click', (e) => {
|
|
|
|
|
const item = e.target.closest('.barrage-item');
|
|
|
|
|
if (item) {
|
|
|
|
|
const url = item.dataset.url;
|
|
|
|
|
if (url && url !== '#') {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
const targetId = url.split('#')[1];
|
|
|
|
|
const targetEl = document.getElementById(targetId);
|
|
|
|
|
|
|
|
|
|
if (targetEl) {
|
|
|
|
|
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
|
targetEl.classList.add('atk-flash');
|
|
|
|
|
setTimeout(() => targetEl.classList.remove('atk-flash'), 2000);
|
|
|
|
|
} else {
|
|
|
|
|
window.location.href = url;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start(data = []) {
|
2026-01-29 21:05:27 +08:00
|
|
|
this.stop();
|
2026-02-03 13:18:03 +08:00
|
|
|
// 不再清除 DOM,而是重置池子
|
|
|
|
|
this.resetPool();
|
2026-01-29 21:05:27 +08:00
|
|
|
this.isDestroyed = false;
|
2026-01-31 22:52:19 +08:00
|
|
|
this.updateData(data);
|
|
|
|
|
}
|
2026-02-03 13:18:03 +08:00
|
|
|
|
|
|
|
|
resetPool() {
|
|
|
|
|
this.pool.forEach(item => {
|
|
|
|
|
item.busy = false;
|
|
|
|
|
item.el.style.display = 'none';
|
|
|
|
|
item.id = null;
|
|
|
|
|
if (item.animation) {
|
|
|
|
|
item.animation.cancel();
|
|
|
|
|
item.animation = null;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
this.activeIds.clear();
|
|
|
|
|
}
|
2026-01-29 21:05:27 +08:00
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
updateData(data = []) {
|
2026-01-29 21:05:27 +08:00
|
|
|
// 数据预处理:去重 & ID 规范化
|
|
|
|
|
const uniqueData = [];
|
|
|
|
|
const seenIds = new Set();
|
|
|
|
|
|
|
|
|
|
data.forEach(item => {
|
|
|
|
|
const id = String(item.id);
|
|
|
|
|
if (!seenIds.has(id)) {
|
|
|
|
|
seenIds.add(id);
|
|
|
|
|
uniqueData.push({ ...item, id: id });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.data = uniqueData;
|
2026-01-31 22:52:19 +08:00
|
|
|
// 如果数据更新时列表变短了,重置索引以防越界
|
|
|
|
|
if (this.index >= this.data.length) {
|
|
|
|
|
this.index = 0;
|
|
|
|
|
}
|
2026-01-29 21:05:27 +08:00
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
console.log(`[Barrage] 数据更新: ${this.data.length} 条`);
|
2026-01-26 23:09:57 +08:00
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
if (this.data.length > 0 && !this.timer && !this.isPaused) {
|
|
|
|
|
this.loop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hide() {
|
|
|
|
|
if (this.container) {
|
|
|
|
|
this.container.style.opacity = '0';
|
|
|
|
|
this.container.style.pointerEvents = 'none';
|
|
|
|
|
}
|
|
|
|
|
this.isPaused = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
show() {
|
|
|
|
|
if (this.container) {
|
|
|
|
|
this.container.style.opacity = '1';
|
|
|
|
|
this.container.style.pointerEvents = 'auto';
|
|
|
|
|
}
|
|
|
|
|
this.isPaused = false;
|
|
|
|
|
if (!this.timer) this.loop();
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
stop() {
|
|
|
|
|
if (this.timer) {
|
|
|
|
|
clearTimeout(this.timer);
|
|
|
|
|
this.timer = null;
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearDOM() {
|
2026-02-03 13:18:03 +08:00
|
|
|
// 兼容旧接口:实际上就是 resetPool
|
|
|
|
|
this.resetPool();
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
destroy() {
|
|
|
|
|
this.isDestroyed = true;
|
|
|
|
|
this.stop();
|
2026-02-03 13:18:03 +08:00
|
|
|
this.resetPool();
|
|
|
|
|
// 彻底销毁时才移除 DOM
|
|
|
|
|
if (this.container) {
|
|
|
|
|
this.container.innerHTML = '';
|
|
|
|
|
}
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loop() {
|
2026-01-31 22:52:19 +08:00
|
|
|
if (this.isDestroyed || this.isPaused) return;
|
2026-01-29 20:33:30 +08:00
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
// 容器检测
|
2026-01-26 23:09:57 +08:00
|
|
|
if (!document.body.contains(this.container)) {
|
2026-01-29 21:05:27 +08:00
|
|
|
this.destroy();
|
2026-01-29 20:33:30 +08:00
|
|
|
return;
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
if (this.data.length > 0) {
|
2026-01-29 21:19:49 +08:00
|
|
|
// 只有当前屏幕数量未满时才发射
|
|
|
|
|
if (this.activeIds.size < this.maxActiveItems) {
|
|
|
|
|
this.tryShoot();
|
|
|
|
|
}
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
// 随机间隔 2s - 4s
|
|
|
|
|
const interval = 2000 + Math.random() * 2000;
|
2026-01-26 23:09:57 +08:00
|
|
|
this.timer = setTimeout(() => this.loop(), interval);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
tryShoot() {
|
2026-01-31 22:52:19 +08:00
|
|
|
if (this.data.length === 0) return;
|
|
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
let attempts = 0;
|
|
|
|
|
const maxAttempts = this.data.length;
|
|
|
|
|
|
|
|
|
|
while (attempts < maxAttempts) {
|
|
|
|
|
const item = this.data[this.index];
|
|
|
|
|
this.index = (this.index + 1) % this.data.length;
|
|
|
|
|
|
|
|
|
|
if (!this.activeIds.has(item.id)) {
|
|
|
|
|
this.shoot(item);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
attempts++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 23:09:57 +08:00
|
|
|
shoot(itemData) {
|
|
|
|
|
const { text, avatar, url, id } = itemData;
|
|
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
// 查找空闲的池对象
|
|
|
|
|
const poolItem = this.pool.find(p => !p.busy);
|
|
|
|
|
if (!poolItem) return; // 没有空闲对象,放弃本次发射
|
|
|
|
|
|
|
|
|
|
poolItem.busy = true;
|
|
|
|
|
poolItem.id = id;
|
2026-01-29 21:05:27 +08:00
|
|
|
this.activeIds.add(id);
|
|
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
const { el, avatarEl, textEl } = poolItem;
|
|
|
|
|
|
|
|
|
|
// 更新数据
|
2026-01-29 21:19:49 +08:00
|
|
|
if (url && url !== '#') {
|
2026-02-03 13:18:03 +08:00
|
|
|
el.dataset.url = url;
|
|
|
|
|
el.style.cursor = 'pointer';
|
|
|
|
|
} else {
|
|
|
|
|
el.removeAttribute('data-url');
|
|
|
|
|
el.style.cursor = 'default';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 只有当有头像时才显示 img
|
|
|
|
|
if (avatar) {
|
|
|
|
|
avatarEl.src = avatar;
|
|
|
|
|
avatarEl.style.display = 'block';
|
|
|
|
|
// 错误处理
|
|
|
|
|
avatarEl.onerror = function() {
|
|
|
|
|
this.src = 'https://cravatar.cn/avatar/default?d=mp';
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
avatarEl.style.display = 'none';
|
2026-01-29 21:19:49 +08:00
|
|
|
}
|
2026-01-26 23:09:57 +08:00
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
textEl.innerText = text;
|
2026-01-26 23:09:57 +08:00
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
// 计算轨道
|
2026-01-29 21:05:27 +08:00
|
|
|
const tracks = 10;
|
2026-01-26 23:09:57 +08:00
|
|
|
const trackIndex = Math.floor(Math.random() * tracks);
|
2026-01-29 21:05:27 +08:00
|
|
|
const top = 5 + (trackIndex * (90 / tracks));
|
2026-02-03 13:18:03 +08:00
|
|
|
el.style.top = `${top}%`;
|
2026-01-26 23:09:57 +08:00
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
// 显示
|
|
|
|
|
el.style.display = 'inline-flex';
|
2026-01-29 21:05:27 +08:00
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
// 使用 Web Animations API (WAAPI) 替代 CSS Animation
|
|
|
|
|
// 优势:无需触发 offsetHeight 重绘,控制更精准
|
|
|
|
|
const duration = (18 + Math.random() * 7) * 1000; // ms
|
|
|
|
|
|
|
|
|
|
const keyframes = [
|
|
|
|
|
{ transform: 'translateX(-100%)', opacity: 0, offset: 0 },
|
|
|
|
|
{ opacity: 1, offset: 0.05 },
|
|
|
|
|
{ opacity: 1, offset: 0.95 },
|
|
|
|
|
{ transform: 'translateX(100vw)', opacity: 0, offset: 1 }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const animation = el.animate(keyframes, {
|
|
|
|
|
duration: duration,
|
|
|
|
|
easing: 'linear',
|
|
|
|
|
fill: 'forwards'
|
2026-01-26 23:09:57 +08:00
|
|
|
});
|
|
|
|
|
|
2026-02-03 13:18:03 +08:00
|
|
|
animation.onfinish = () => {
|
|
|
|
|
this.recycle(el);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
poolItem.animation = animation;
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
// 辅助:判断移动端
|
2026-01-26 23:29:29 +08:00
|
|
|
function isMobile() {
|
2026-01-29 21:05:27 +08:00
|
|
|
return window.innerWidth < 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
2026-01-26 23:29:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
// 主加载函数
|
2026-01-26 23:09:57 +08:00
|
|
|
async function loadBarrager() {
|
2026-01-29 21:05:27 +08:00
|
|
|
// 移动端禁用
|
2026-01-26 23:29:29 +08:00
|
|
|
if (isMobile()) {
|
2026-01-29 21:05:27 +08:00
|
|
|
console.log('[Barrage] 移动端禁用');
|
|
|
|
|
if (barrageInstance) barrageInstance.destroy();
|
2026-01-26 23:29:29 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
// 检测是否存在评论区
|
|
|
|
|
const hasCommentSection = document.getElementById('artalk') ||
|
|
|
|
|
document.getElementById('Comments') ||
|
|
|
|
|
document.querySelector('.atk-comment-wrap');
|
|
|
|
|
|
2026-01-31 23:01:35 +08:00
|
|
|
// 即使没有评论区,也不要隐藏弹幕,允许 Hitokoto 或上一页的数据继续播放
|
|
|
|
|
// if (!hasCommentSection) {
|
|
|
|
|
// // console.log('[Barrage] 当前页面无评论区,隐藏弹幕');
|
|
|
|
|
// if (barrageInstance) {
|
|
|
|
|
// barrageInstance.hide();
|
|
|
|
|
// }
|
|
|
|
|
// return;
|
|
|
|
|
// }
|
2026-01-31 22:52:19 +08:00
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
// 1. 获取容器,如果没有则动态创建
|
|
|
|
|
let container = document.querySelector('.barrager');
|
|
|
|
|
if (!container) {
|
|
|
|
|
console.warn('[Barrage] 未找到 .barrager 容器,尝试动态创建');
|
|
|
|
|
container = document.createElement('div');
|
|
|
|
|
container.className = 'barrager';
|
|
|
|
|
document.body.appendChild(container);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
// 确保显示(无论是否有评论区,都允许弹幕存在,可能显示的是上一页的数据或 Hitokoto)
|
2026-01-29 21:05:27 +08:00
|
|
|
if (barrageInstance) {
|
2026-01-31 22:52:19 +08:00
|
|
|
barrageInstance.show();
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-26 23:09:57 +08:00
|
|
|
try {
|
2026-01-29 21:05:27 +08:00
|
|
|
const config = window.barrageConfig || {};
|
|
|
|
|
const promises = [getArtalkComments()];
|
|
|
|
|
|
|
|
|
|
// 如果开启了 Hitokoto,则并发获取
|
|
|
|
|
if (config.enableHitokoto && config.hitokotoUrl) {
|
|
|
|
|
promises.push(fetchHitokoto(config.hitokotoUrl));
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
2026-01-29 21:05:27 +08:00
|
|
|
|
|
|
|
|
// 3. 获取数据 (支持事件监听 + 轮询 + Hitokoto)
|
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
|
|
|
|
|
|
|
|
const barrageList = [];
|
|
|
|
|
|
|
|
|
|
// 处理 Artalk 评论
|
|
|
|
|
if (results[0].status === 'fulfilled' && Array.isArray(results[0].value)) {
|
|
|
|
|
const comments = results[0].value;
|
|
|
|
|
const processedComments = comments.map(c => {
|
|
|
|
|
const text = c.content.replace(/<[^>]+>/g, "").substring(0, 60);
|
|
|
|
|
if (!text.trim()) return null;
|
|
|
|
|
|
|
|
|
|
let avatar = '';
|
2026-01-31 22:52:19 +08:00
|
|
|
|
|
|
|
|
// 1. 优先尝试使用 email_encrypted 构造 Gravatar (适用于跨页面无 DOM 的情况)
|
|
|
|
|
if (c.email_encrypted) {
|
|
|
|
|
avatar = `https://cravatar.cn/avatar/${c.email_encrypted}?d=mp&s=240`;
|
|
|
|
|
} else if (c.email_hash) {
|
|
|
|
|
avatar = `https://cravatar.cn/avatar/${c.email_hash}?d=mp&s=240`;
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
2026-01-31 22:52:19 +08:00
|
|
|
|
|
|
|
|
// 2. 如果没有 hash,尝试从 API 对象直接获取
|
|
|
|
|
if (!avatar && c.avatar) {
|
|
|
|
|
avatar = c.avatar;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. 尝试从 UA 获取
|
|
|
|
|
if (!avatar && c.ua && c.ua.avatar) {
|
|
|
|
|
avatar = c.ua.avatar;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. 兜底默认头像
|
2026-01-29 21:05:27 +08:00
|
|
|
if (!avatar) {
|
2026-01-31 22:52:19 +08:00
|
|
|
avatar = 'https://cravatar.cn/avatar/default?d=mp';
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: c.id,
|
|
|
|
|
text: text,
|
|
|
|
|
avatar: avatar,
|
|
|
|
|
url: `#atk-comment-${c.id}`
|
|
|
|
|
};
|
|
|
|
|
}).filter(Boolean);
|
|
|
|
|
barrageList.push(...processedComments);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 处理 Hitokoto
|
|
|
|
|
if (results[1] && results[1].status === 'fulfilled' && results[1].value) {
|
|
|
|
|
const h = results[1].value;
|
|
|
|
|
barrageList.push({
|
|
|
|
|
id: h.id,
|
|
|
|
|
text: `${h.content}`,
|
2026-01-30 19:52:13 +08:00
|
|
|
avatar: config.hitokotoAvatar || 'https://cravatar.cn/avatar/default?d=mp',
|
2026-01-29 21:05:27 +08:00
|
|
|
url: '#'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (barrageList.length === 0) {
|
|
|
|
|
console.log('[Barrage] 无弹幕数据');
|
2026-01-31 22:52:19 +08:00
|
|
|
// 即使无数据,也不要销毁实例,保持空转即可
|
2026-01-29 21:05:27 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 22:52:19 +08:00
|
|
|
// 更新实例或创建新实例
|
2026-01-29 21:05:27 +08:00
|
|
|
if (barrageInstance) {
|
2026-01-31 22:52:19 +08:00
|
|
|
barrageInstance.updateData(barrageList);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(`[Barrage] 初始化: ${barrageList.length} 条`);
|
|
|
|
|
barrageInstance = new BarrageEngine({ container });
|
|
|
|
|
barrageInstance.start(barrageList);
|
2026-01-29 21:05:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('[Barrage] 加载异常:', e);
|
2026-01-26 23:09:57 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-29 21:05:27 +08:00
|
|
|
|
|
|
|
|
// 暴露全局
|
2026-01-29 20:33:30 +08:00
|
|
|
window.loadBarrager = loadBarrager;
|
2026-01-26 23:09:57 +08:00
|
|
|
|
2026-01-29 21:05:27 +08:00
|
|
|
// 初始化
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
setTimeout(loadBarrager, 500);
|
2026-01-26 23:09:57 +08:00
|
|
|
});
|
2026-01-31 22:52:19 +08:00
|
|
|
|
|
|
|
|
// PJAX 适配
|
|
|
|
|
document.addEventListener('pjax:complete', () => {
|
|
|
|
|
setTimeout(loadBarrager, 500);
|
|
|
|
|
});
|
2026-01-26 23:09:57 +08:00
|
|
|
|
|
|
|
|
})();
|