fixed 重写弹幕逻辑
This commit is contained in:
1 parent
7de8d0e253
commit
5b7569fe40
2 files changed
+298
-200
No files matched your search
+291
-198
@@ -1,51 +1,121 @@
|
||||
// 弹幕功能封装
|
||||
(function() {
|
||||
// 异步初始化 Artalk 并获取评论数据
|
||||
function initArtalkAndGetComments() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 检查 Artalk 是否存在,增加轮询等待机制
|
||||
if (typeof Artalk === 'undefined') {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 最多等待 5 秒
|
||||
const interval = setInterval(() => {
|
||||
attempts++;
|
||||
if (typeof Artalk !== 'undefined') {
|
||||
clearInterval(interval);
|
||||
Artalk.use((ctx) => {
|
||||
ctx.on('list-loaded', () => {
|
||||
console.log('评论列表加载完毕');
|
||||
const commentsData = ctx.getComments();
|
||||
if (commentsData) {
|
||||
resolve(commentsData);
|
||||
} else {
|
||||
reject('获取评论数据失败');
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
console.warn('Artalk 加载超时');
|
||||
reject('Artalk 未定义 (加载超时)');
|
||||
}
|
||||
}, 100);
|
||||
return;
|
||||
let barrageInstance = null; // 单例实例引用
|
||||
|
||||
// 获取 Artalk 评论数据
|
||||
function getArtalkComments() {
|
||||
return new Promise((resolve) => {
|
||||
let isResolved = false;
|
||||
|
||||
const tryResolve = (data, source) => {
|
||||
if (isResolved) return;
|
||||
|
||||
// 调试日志
|
||||
if (data && Array.isArray(data)) {
|
||||
console.log(`[Barrage] 数据检查 (${source}): ${data.length} 条`);
|
||||
} else {
|
||||
console.log(`[Barrage] 数据检查 (${source}): 无效数据`, data);
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
isResolved = true;
|
||||
resolve(data);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. 立即检查
|
||||
if (window.artalk && typeof window.artalk.getComments === 'function') {
|
||||
tryResolve(window.artalk.getComments(), 'immediate');
|
||||
} else {
|
||||
console.log('[Barrage] Artalk 实例未就绪 (Immediate)');
|
||||
}
|
||||
|
||||
Artalk.use((ctx) => {
|
||||
ctx.on('list-loaded', () => {
|
||||
console.log('评论列表加载完毕');
|
||||
const commentsData = ctx.getComments();
|
||||
if (commentsData) {
|
||||
resolve(commentsData);
|
||||
} else {
|
||||
reject('获取评论数据失败');
|
||||
}
|
||||
if (isResolved) return;
|
||||
|
||||
// 2. 监听加载完成事件 (推荐)
|
||||
if (window.artalk && typeof window.artalk.on === 'function') {
|
||||
window.artalk.on('list-loaded', (data) => {
|
||||
console.log('[Barrage] list-loaded 事件触发');
|
||||
// 优先使用事件传递的数据(如果有),否则调用 getComments
|
||||
const comments = (Array.isArray(data) && data.length > 0) ? data : window.artalk.getComments();
|
||||
tryResolve(comments, 'event');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 轮询 (后备)
|
||||
let pollCount = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (isResolved) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
pollCount++;
|
||||
if (window.artalk && typeof window.artalk.getComments === 'function') {
|
||||
// 仅在有数据时才 resolve,避免轮询到空数据提前结束
|
||||
const data = window.artalk.getComments();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
tryResolve(data, `poll-${pollCount}`);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 4. 超时处理 (5秒)
|
||||
setTimeout(() => {
|
||||
if (!isResolved) {
|
||||
clearInterval(interval);
|
||||
console.warn('[Barrage] 等待评论数据超时 (5s)');
|
||||
|
||||
// 尝试从 DOM 获取作为最后兜底
|
||||
const domComments = document.querySelectorAll('.atk-comment-wrap');
|
||||
if (domComments.length > 0) {
|
||||
console.log(`[Barrage] 从 DOM 挽救数据: ${domComments.length} 条`);
|
||||
const fallbackData = Array.from(domComments).map(el => {
|
||||
const idStr = el.id || ''; // atk-comment-123
|
||||
const id = idStr.replace('atk-comment-', '');
|
||||
const contentEl = el.querySelector('.atk-content');
|
||||
const avatarEl = el.querySelector('.atk-avatar img');
|
||||
|
||||
if (!id || !contentEl) return null;
|
||||
|
||||
return {
|
||||
id: id,
|
||||
content: contentEl.innerText || contentEl.textContent,
|
||||
avatar: avatarEl ? avatarEl.src : '',
|
||||
// 模拟 Artalk 数据结构
|
||||
ua: null
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
if (fallbackData.length > 0) {
|
||||
isResolved = true;
|
||||
resolve(fallbackData);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
resolve([]); // 真的没有数据
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// 弹幕类 (基于 min-barrage 逻辑)
|
||||
// 获取 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;
|
||||
}
|
||||
}
|
||||
|
||||
class BarrageEngine {
|
||||
constructor(options = {}) {
|
||||
this.container = typeof options.container === 'string'
|
||||
@@ -62,238 +132,261 @@
|
||||
this.timer = null;
|
||||
this.isPaused = false;
|
||||
this.isDestroyed = false;
|
||||
|
||||
// 追踪当前屏幕上显示的弹幕 ID
|
||||
this.activeIds = new Set();
|
||||
}
|
||||
|
||||
// 开始发送
|
||||
start(data = []) {
|
||||
console.log(`[Barrage] 启动弹幕,共 ${data.length} 条`);
|
||||
this.data = data;
|
||||
this.stop();
|
||||
this.clearDOM();
|
||||
this.isDestroyed = false;
|
||||
|
||||
// 数据预处理:去重 & 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;
|
||||
console.log(`[Barrage] 启动: 原始 ${data.length}, 去重后 ${this.data.length}`);
|
||||
|
||||
if (this.data.length === 0) return;
|
||||
|
||||
this.clear();
|
||||
this.isDestroyed = false;
|
||||
this.loop();
|
||||
}
|
||||
|
||||
// 清空
|
||||
clear() {
|
||||
this.isDestroyed = true;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
// 清除容器内所有弹幕元素
|
||||
if (this.container) {
|
||||
const items = this.container.querySelectorAll('.barrage-item');
|
||||
items.forEach(item => item.remove());
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.index = 0;
|
||||
this.activeIds.clear();
|
||||
}
|
||||
|
||||
clearDOM() {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.isDestroyed = true;
|
||||
this.stop();
|
||||
this.clearDOM();
|
||||
}
|
||||
|
||||
// 循环发射
|
||||
loop() {
|
||||
if (this.isDestroyed) return;
|
||||
|
||||
// 如果容器不在文档中(PJAX切换后),停止播放
|
||||
// 容器检测
|
||||
if (!document.body.contains(this.container)) {
|
||||
console.warn('[Barrage] 容器失效,停止播放');
|
||||
// 尝试重新获取容器 (可能被 PJAX 替换但 ID 没变?)
|
||||
// 这里简单处理:如果失效则停止
|
||||
// console.warn('[Barrage] 容器失效,停止');
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 发射逻辑
|
||||
if (!this.isPaused) {
|
||||
const itemData = this.data[this.index];
|
||||
this.shoot(itemData);
|
||||
this.index = (this.index + 1) % this.data.length;
|
||||
if (!this.isPaused && this.data.length > 0) {
|
||||
this.tryShoot();
|
||||
}
|
||||
|
||||
// 随机间隔 1s - 3s
|
||||
const interval = 1000 + Math.random() * 2000;
|
||||
// 随机间隔 2s - 4s
|
||||
const interval = 2000 + Math.random() * 2000;
|
||||
this.timer = setTimeout(() => this.loop(), interval);
|
||||
}
|
||||
|
||||
// 发射单条
|
||||
tryShoot() {
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
shoot(itemData) {
|
||||
const { text, avatar, url, id } = itemData;
|
||||
|
||||
this.activeIds.add(id);
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'barrage-item';
|
||||
|
||||
// 构造内部 HTML 结构:头像 + 气泡
|
||||
let html = '';
|
||||
|
||||
// 1. 头像
|
||||
if (avatar) {
|
||||
html += `<img src="${avatar}" class="barrage-avatar" draggable="false">`;
|
||||
}
|
||||
|
||||
// 2. 气泡 (包裹文本)
|
||||
html += `<div class="barrage-bubble"><span class="barrage-text">${text}</span></div>`;
|
||||
|
||||
item.innerHTML = html;
|
||||
|
||||
// 点击跳转 (通用处理)
|
||||
const jumpHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
console.log(`[Barrage] 点击跳转: ${url}`);
|
||||
|
||||
// 检查目标评论是否在当前页面
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
if (url && url !== '#') {
|
||||
item.addEventListener('click', jumpHandler);
|
||||
item.style.cursor = 'pointer';
|
||||
item.addEventListener('click', (e) => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 随机轨道 (Top 5% - 95%) - 避免太靠边
|
||||
const tracks = 15;
|
||||
const trackHeight = 100 / tracks;
|
||||
// 样式与动画
|
||||
const tracks = 10;
|
||||
const trackIndex = Math.floor(Math.random() * tracks);
|
||||
const top = trackIndex * trackHeight + (Math.random() * 2);
|
||||
const top = 5 + (trackIndex * (90 / tracks));
|
||||
|
||||
item.style.top = `${top}%`;
|
||||
|
||||
// 随机速度 (18s - 25s) - 更慢一点,更自然
|
||||
|
||||
const duration = 18 + Math.random() * 7;
|
||||
|
||||
item.style.animation = `barrage-slide-right ${duration}s linear forwards`;
|
||||
|
||||
// 鼠标悬停暂停该弹幕
|
||||
|
||||
item.addEventListener('mouseenter', () => {
|
||||
item.style.animationPlayState = 'paused';
|
||||
item.style.zIndex = '99999';
|
||||
item.style.zIndex = '9999';
|
||||
});
|
||||
item.addEventListener('mouseleave', () => {
|
||||
item.style.animationPlayState = 'running';
|
||||
item.style.zIndex = '10';
|
||||
item.style.zIndex = '';
|
||||
});
|
||||
|
||||
// 动画结束移除
|
||||
item.addEventListener('animationend', () => {
|
||||
item.remove();
|
||||
this.activeIds.delete(id);
|
||||
});
|
||||
|
||||
this.container.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载到 window,使用 BarrageEngine 避免冲突
|
||||
window.BarrageEngine = BarrageEngine;
|
||||
|
||||
// 从 Artalk 评论数据生成弹幕
|
||||
async function createBarragerFromArtalk(commentsData) {
|
||||
const barragerContainer = document.querySelector('.barrager');
|
||||
if (!barragerContainer) {
|
||||
console.warn('[Barrage] 未找到弹幕容器 .barrager');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 数据预处理
|
||||
const barrageList = commentsData.map(comment => {
|
||||
// 清理 HTML 标签
|
||||
const contentText = comment.content.replace(/<[^>]+>/g, "").substring(0, 50);
|
||||
|
||||
// 获取头像逻辑优化: 优先从 DOM 获取 Artalk 已渲染的头像,其次使用 API 数据
|
||||
let avatarUrl = '';
|
||||
|
||||
// 尝试从 DOM 获取 (最准确,因为是 Artalk 自己渲染的)
|
||||
const commentEl = document.getElementById(`atk-comment-${comment.id}`);
|
||||
if (commentEl) {
|
||||
const imgEl = commentEl.querySelector('.atk-avatar img');
|
||||
if (imgEl && imgEl.src) {
|
||||
avatarUrl = imgEl.src;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 DOM 没取到,尝试从数据获取
|
||||
if (!avatarUrl) {
|
||||
if (comment.avatar) {
|
||||
avatarUrl = comment.avatar;
|
||||
} else if (comment.ua && comment.ua.avatar) {
|
||||
avatarUrl = comment.ua.avatar;
|
||||
} else {
|
||||
avatarUrl = 'https://cravatar.cn/avatar/default?d=mp';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: contentText,
|
||||
avatar: avatarUrl,
|
||||
url: `#atk-comment-${comment.id}`, // 跳转锚点
|
||||
id: comment.id
|
||||
};
|
||||
}).filter(item => item.text.trim() !== "");
|
||||
|
||||
if (barrageList.length === 0) {
|
||||
console.log('[Barrage] 没有有效的评论弹幕数据');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 初始化 Barrage 实例
|
||||
// 挂载到 window 以便调试或清理
|
||||
if (window.myBarrage) {
|
||||
window.myBarrage.clear();
|
||||
}
|
||||
|
||||
// 使用 window.BarrageEngine
|
||||
if (window.BarrageEngine) {
|
||||
window.myBarrage = new window.BarrageEngine({
|
||||
container: barragerContainer
|
||||
});
|
||||
window.myBarrage.start(barrageList);
|
||||
} else {
|
||||
console.error('[Barrage] window.BarrageEngine 未定义');
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为移动端
|
||||
// 辅助:判断移动端
|
||||
function isMobile() {
|
||||
if (window.innerWidth < 768) {
|
||||
return true;
|
||||
}
|
||||
return window.navigator.userAgent.match(/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i);
|
||||
return window.innerWidth < 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
// 加载弹幕主函数
|
||||
// 主加载函数
|
||||
async function loadBarrager() {
|
||||
// 移动端不加载弹幕
|
||||
// 移动端禁用
|
||||
if (isMobile()) {
|
||||
console.log('[Barrage] 移动端已禁用弹幕');
|
||||
console.log('[Barrage] 移动端禁用');
|
||||
if (barrageInstance) barrageInstance.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 获取容器,如果没有则动态创建
|
||||
let container = document.querySelector('.barrager');
|
||||
if (!container) {
|
||||
console.warn('[Barrage] 未找到 .barrager 容器,尝试动态创建');
|
||||
container = document.createElement('div');
|
||||
container.className = 'barrager';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// 2. 清理旧实例
|
||||
if (barrageInstance) {
|
||||
barrageInstance.destroy();
|
||||
barrageInstance = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const commentsData = await initArtalkAndGetComments();
|
||||
if (commentsData) {
|
||||
await createBarragerFromArtalk(commentsData);
|
||||
const config = window.barrageConfig || {};
|
||||
const promises = [getArtalkComments()];
|
||||
|
||||
// 如果开启了 Hitokoto,则并发获取
|
||||
if (config.enableHitokoto && config.hitokotoUrl) {
|
||||
promises.push(fetchHitokoto(config.hitokotoUrl));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载弹幕时发生错误:', error);
|
||||
|
||||
// 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 = '';
|
||||
const domComment = document.getElementById(`atk-comment-${c.id}`);
|
||||
if (domComment) {
|
||||
const img = domComment.querySelector('.atk-avatar img');
|
||||
if (img && img.src) avatar = img.src;
|
||||
}
|
||||
if (!avatar) {
|
||||
avatar = c.avatar || (c.ua && c.ua.avatar) || 'https://cravatar.cn/avatar/default?d=mp';
|
||||
}
|
||||
|
||||
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}`,
|
||||
avatar: 'https://cravatar.cn/avatar/default?d=mp',
|
||||
url: '#'
|
||||
});
|
||||
}
|
||||
|
||||
if (barrageList.length === 0) {
|
||||
console.log('[Barrage] 无弹幕数据');
|
||||
return;
|
||||
}
|
||||
|
||||
// 再次检查实例 (竞态保护)
|
||||
if (barrageInstance) {
|
||||
barrageInstance.destroy();
|
||||
}
|
||||
|
||||
console.log(`[Barrage] 最终加载: ${barrageList.length} 条`);
|
||||
barrageInstance = new BarrageEngine({ container });
|
||||
barrageInstance.start(barrageList);
|
||||
|
||||
} catch (e) {
|
||||
console.warn('[Barrage] 加载异常:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给全局,供 mypjax.js 调用
|
||||
|
||||
// 暴露全局
|
||||
window.loadBarrager = loadBarrager;
|
||||
|
||||
// 初始化监听
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadBarrager(); // 页面首次加载时启动弹幕
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(loadBarrager, 500);
|
||||
});
|
||||
|
||||
// 移除内部的 pjax:complete 监听,统一由 mypjax.js 控制调用时机
|
||||
// document.addEventListener('pjax:complete', function() {
|
||||
// loadBarrager();
|
||||
// });
|
||||
|
||||
})();
|
||||
@@ -1,4 +1,9 @@
|
||||
{{ if .Site.Params.barrage.enable }}
|
||||
<!-- 弹幕容器 (body 标签下) -->
|
||||
<script>
|
||||
window.barrageConfig = {
|
||||
enableHitokoto: {{ .Site.Params.barrage.enableHitokoto | default false }},
|
||||
hitokotoUrl: "{{ .Site.Params.barrage.hitokotoUrl | default "https://v1.hitokoto.cn" }}"
|
||||
};
|
||||
</script>
|
||||
<div class="barrager"></div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
Reference in new issue
Block a user