';
animes.forEach(anime => {
- const img = anime.imageUrl || 'https://placehold.co/150x200?text=No+Image';
- html += '
';
+ const animeId = Number.parseInt(anime.animeId, 10);
+ if (!Number.isFinite(animeId)) return;
+ const img = escapeHtml(anime.imageUrl || 'https://placehold.co/150x200?text=No+Image');
+ html += '
';
html += '

';
html += '
' + escapeHtml(anime.animeTitle) + ' - 共' + (anime.episodeCount || '?') + '集
';
html += '
';
@@ -655,26 +875,59 @@ function displayManualAnimeList(animes) {
}
async function manualGetBangumi(animeId) {
+ const detailRequestId = startManualBangumiRequest();
+ const requestTrace = danmuTestState.currentManualSearchCallTraceBase
+ ? cloneDanmuCallTrace(danmuTestState.currentManualSearchCallTraceBase)
+ : createDanmuCallTrace('manual', String(animeId || ''));
+ danmuTestState.currentCallTrace = requestTrace;
addLog('获取番剧详情: ' + animeId, 'info');
// 隐藏搜索结果,显示剧集列表区域
showDanmuView(['manual-episode-list'], ['manual-anime-list', 'danmu-result-area']);
+ let bangumiStartedAt = 0;
+ const bangumiUrl = '/api/v2/bangumi/' + animeId;
try {
- const resp = await fetch(buildApiUrl('/api/v2/bangumi/' + animeId));
+ bangumiStartedAt = performance.now();
+ const resp = await fetch(buildApiUrl(bangumiUrl));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
+ if (!isCurrentManualBangumiRequest(detailRequestId)) return;
- if (data.success && data.bangumi && data.bangumi.episodes) {
- displayManualEpisodeList(data.bangumi.animeTitle, data.bangumi.episodes);
- addLog('获取到 ' + data.bangumi.episodes.length + ' 个剧集', 'success');
+ const episodes = data && data.bangumi && Array.isArray(data.bangumi.episodes) ? data.bangumi.episodes : [];
+ if (data.success && data.bangumi && episodes.length > 0) {
+ finishDanmuCallStep(requestTrace, bangumiStartedAt, {
+ name: '番剧详情',
+ method: 'GET',
+ url: bangumiUrl,
+ params: 'animeId: ' + animeId,
+ result: '获取到 ' + episodes.length + ' 个剧集'
+ });
+ danmuTestState.currentManualEpisodeCallTraceBase = cloneDanmuCallTrace(requestTrace);
+ displayManualEpisodeList(data.bangumi.animeTitle, episodes);
+ addLog('获取到 ' + episodes.length + ' 个剧集', 'success');
} else {
+ finishDanmuCallEmpty(requestTrace, bangumiStartedAt, {
+ name: '番剧详情',
+ method: 'GET',
+ url: bangumiUrl,
+ params: 'animeId: ' + animeId
+ }, '该动漫暂无剧集信息');
customAlert('该动漫暂无剧集信息');
+ showDanmuCallTraceFailure(requestTrace, '番剧详情无剧集', '该动漫暂无剧集信息', 'manual-anime');
// 恢复搜索结果
showDanmuView(['manual-anime-list'], ['manual-episode-list']);
}
} catch (e) {
+ if (!isCurrentManualBangumiRequest(detailRequestId)) return;
+ finishDanmuCallFailure(requestTrace, bangumiStartedAt || performance.now(), {
+ name: '番剧详情',
+ method: 'GET',
+ url: bangumiUrl,
+ params: 'animeId: ' + animeId
+ }, e);
customAlert('获取番剧详情失败: ' + e.message);
addLog('获取番剧详情失败: ' + e.message, 'error');
+ showDanmuCallTraceFailure(requestTrace, '获取番剧详情失败', e.message, 'manual-anime');
showDanmuView(['manual-anime-list'], ['manual-episode-list']);
}
}
@@ -682,25 +935,28 @@ async function manualGetBangumi(animeId) {
function displayManualEpisodeList(animeTitle, episodes) {
const container = document.getElementById('manual-episode-list');
let html = backBtnHtml('返回搜索结果', 'backToManualSearch()');
- html += '
剧集列表
';
+ html += '
剧集列表
';
html += '
' + escapeHtml(animeTitle) + '
';
// 添加跳转到指定集数的功能
html += \`
-
+
跳转到第
-
+
集
-
- 共\${episodes.length}集
+
+ 共\${episodes.length}集
\`;
html += '
';
episodes.forEach(ep => {
- const title = escapeHtml(animeTitle) + ' 第' + ep.episodeNumber + '集';
- html += '
';
- html += '
第' + ep.episodeNumber + '集 - ' + escapeHtml(ep.episodeTitle || '无标题') + '
';
- html += '
';
+ const episodeId = Number.parseInt(ep.episodeId, 10);
+ const episodeNumber = Number.parseInt(ep.episodeNumber, 10);
+ if (!Number.isFinite(episodeId) || !Number.isFinite(episodeNumber)) return;
+ const title = String(animeTitle || '') + ' 第' + episodeNumber + '集';
+ html += '
';
+ html += '
第' + episodeNumber + '集 - ' + escapeHtml(ep.episodeTitle || '无标题') + '
';
+ html += '
';
html += '
';
});
html += '
';
@@ -735,20 +991,37 @@ function jumpToEpisode() {
// 滚动到指定元素位置
episodeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 添加高亮效果以便识别
- episodeElement.style.backgroundColor = '#fff3cd'; // 黄色背景高亮
+ episodeElement.classList.add('episode-item-highlight');
setTimeout(() => {
- episodeElement.style.backgroundColor = ''; // 恢复原色
+ episodeElement.classList.remove('episode-item-highlight');
}, 2000);
} else {
customAlert('找不到第' + episodeNumber + '集');
}
}
+function isCurrentDanmuRequest(requestId) {
+ return danmuTestState.activeDanmuRequestId === requestId;
+}
+
// =====================
// 获取弹幕并展示结果
// =====================
-async function fetchDanmuForTest(episodeId, title, source) {
+async function fetchDanmuForTest(episodeId, title, source, traceBase) {
addLog('获取弹幕: ' + episodeId + ' (' + title + ')', 'info');
+ const requestId = ++danmuTestState.nextDanmuRequestId;
+ danmuTestState.activeDanmuRequestId = requestId;
+ let requestTrace = null;
+ if (traceBase) {
+ requestTrace = cloneDanmuCallTrace(traceBase);
+ danmuTestState.currentCallTrace = requestTrace;
+ } else if (source === 'manual' && danmuTestState.currentManualEpisodeCallTraceBase) {
+ danmuTestState.currentCallTrace = cloneDanmuCallTrace(danmuTestState.currentManualEpisodeCallTraceBase);
+ requestTrace = danmuTestState.currentCallTrace;
+ } else {
+ requestTrace = cloneDanmuCallTrace(danmuTestState.currentCallTrace);
+ danmuTestState.currentCallTrace = requestTrace;
+ }
danmuTestState.currentEpisodeId = episodeId;
danmuTestState.currentTitle = title;
danmuTestState.currentDuration = 0;
@@ -767,22 +1040,39 @@ async function fetchDanmuForTest(episodeId, title, source) {
const startTime = performance.now();
try {
- const data = await fetch(buildApiUrl('/api/v2/comment/' + episodeId + "?format=json&duration=true"))
+ const commentUrl = '/api/v2/comment/' + episodeId + '?format=json&duration=true';
+ const data = await fetch(buildApiUrl(commentUrl))
.then(resp => {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
});
+ if (!isCurrentDanmuRequest(requestId)) return;
const elapsed = ((performance.now() - startTime) / 1000).toFixed(2);
const durationSeconds = Number((data && data.videoDuration) || 0);
if (!data.comments || data.comments.length === 0) {
+ finishDanmuCallEmpty(requestTrace, startTime, {
+ name: '弹幕',
+ method: 'GET',
+ url: commentUrl,
+ params: 'episodeId: ' + episodeId + ',format=json,duration=true'
+ }, '该剧集暂无弹幕数据');
customAlert('该剧集暂无弹幕数据');
addLog('无弹幕数据', 'warn');
- resultArea.style.display = 'none';
- if (source === 'manual') backToEpisodeList();
+ const failureSource = source === 'manual' ? 'manual-episode' : source;
+ resultArea.innerHTML = renderDanmuCallFailure(requestTrace, '无弹幕数据', '该剧集暂无弹幕数据', failureSource);
+ resultArea.style.display = 'block';
return;
}
+ finishDanmuCallStep(requestTrace, startTime, {
+ name: '弹幕',
+ method: 'GET',
+ url: commentUrl,
+ params: 'episodeId: ' + episodeId + ',format=json,duration=true',
+ result: data.comments.length + ' 条弹幕'
+ });
+
addLog('获取到 ' + data.comments.length + ' 条弹幕, 耗时 ' + elapsed + 's', 'success');
danmuTestState.allComments = data.comments;
@@ -800,32 +1090,38 @@ async function fetchDanmuForTest(episodeId, title, source) {
'
' +
'
';
+ const filterCounts = getDanmuFilterCounts(data.comments);
resultArea.innerHTML =
toolbarHtml +
'
' +
- '
' +
- '
弹幕列表
' +
- '
' +
- '' +
- '' +
- '' +
- '' +
- '
' +
+ renderDanmuCallTrace(requestTrace) +
+ '
' +
+ '
弹幕列表
' +
+ renderDanmuFilterTabs(filterCounts) +
'
' +
- '
' +
+ '
' +
'
';
applyDanmuFilter();
- renderDanmuStats(data, elapsed, title, durationSeconds);
+ renderDanmuStats(data, elapsed, title, durationSeconds, filterCounts);
renderDanmuHeatmap(data.comments, durationSeconds);
renderDanmuList();
setTimeout(() => resultArea.scrollIntoView({ behavior: 'smooth', block: 'start' }), 10);
} catch (e) {
+ if (!isCurrentDanmuRequest(requestId)) return;
+ const commentUrl = '/api/v2/comment/' + episodeId + '?format=json&duration=true';
+ finishDanmuCallFailure(requestTrace, startTime, {
+ name: '弹幕',
+ method: 'GET',
+ url: commentUrl,
+ params: 'episodeId: ' + episodeId + ',format=json,duration=true'
+ }, e);
customAlert('获取弹幕失败: ' + e.message);
addLog('获取弹幕失败: ' + e.message, 'error');
- resultArea.style.display = 'none';
- if (source === 'manual') backToEpisodeList();
+ const failureSource = source === 'manual' ? 'manual-episode' : source;
+ resultArea.innerHTML = renderDanmuCallFailure(requestTrace, '获取弹幕失败', e.message, failureSource);
+ resultArea.style.display = 'block';
}
}
@@ -874,7 +1170,7 @@ async function exportDanmu(format) {
// =====================
// 弹幕统计
// =====================
-function renderDanmuStats(data, elapsed, title, durationSeconds) {
+function renderDanmuStats(data, elapsed, title, durationSeconds, filterCounts) {
const comments = data.comments;
const count = comments.length;
@@ -901,14 +1197,12 @@ function renderDanmuStats(data, elapsed, title, durationSeconds) {
const hotStart = hotIdx * segLen;
const hotMoment = formatDuration(hotStart) + ' - ' + formatDuration(hotStart + segLen);
- // 统计各类型数量
- let scrollCount = 0, topCount = 0, bottomCount = 0;
- comments.forEach(c => {
- const mode = parseDanmuMode(c.p);
- if (mode === 5) topCount++;
- else if (mode === 4) bottomCount++;
- else scrollCount++;
- });
+ const coloredCount = comments.reduce((sum, comment) => {
+ if (!comment || !comment.p) return sum;
+ const color = parseInt(String(comment.p).split(',')[2], 10);
+ return Number.isFinite(color) && color !== 16777215 ? sum + 1 : sum;
+ }, 0);
+ const coloredRatio = count > 0 ? ((coloredCount / count) * 100).toFixed(1) + '%' : '--';
const container = document.getElementById('danmu-stats');
container.innerHTML =
@@ -918,8 +1212,8 @@ function renderDanmuStats(data, elapsed, title, durationSeconds) {
'
' + formatDuration(maxTime) + '
时长
' +
'
' +
'
' + avgDensity + ' 条/分
平均密度
' +
- '
' +
- '
' + scrollCount + ' / ' + topCount + ' / ' + bottomCount + '
滚动 / 顶部 / 底部
' +
+ '
' +
+ '
' +
'
';
}
@@ -929,7 +1223,7 @@ function renderDanmuStats(data, elapsed, title, durationSeconds) {
function renderDanmuHeatmap(comments, durationSeconds) {
const container = document.getElementById('danmu-heatmap');
const maxTime = getEffectiveDuration(comments, durationSeconds);
- if (maxTime === 0) { container.innerHTML = '
无数据
'; return; }
+ if (maxTime === 0) { container.innerHTML = '
无数据
'; return; }
const barCount = Math.min(60, Math.max(20, Math.ceil(maxTime / 30)));
const segLen = maxTime / barCount;
@@ -942,7 +1236,8 @@ function renderDanmuHeatmap(comments, durationSeconds) {
});
const maxSeg = Math.max(...segs, 1);
- let html = '
';
+ let html = '
';
+ html += '
';
for (let i = 0; i < barCount; i++) {
const pct = Math.max(2, (segs[i] / maxSeg) * 100);
const intensity = segs[i] / maxSeg;
@@ -950,12 +1245,121 @@ function renderDanmuHeatmap(comments, durationSeconds) {
const r = Math.round(66 + intensity * 189);
const g = Math.round(126 - intensity * 80);
const b = Math.round(234 - intensity * 180);
- const timeLabel = formatDuration(i * segLen);
- html += '
';
+ const start = i * segLen;
+ const end = Math.min((i + 1) * segLen, maxTime);
+ const timeLabel = formatDuration(start) + ' - ' + formatDuration(end);
+ html += '
';
}
html += '
';
+ html += '
';
+ html += '
';
html += '
00:00' + formatDuration(maxTime / 2) + '' + formatDuration(maxTime) + '
';
container.innerHTML = html;
+ bindDanmuHeatmapScrub(container, segs, segLen, maxTime);
+}
+
+function bindDanmuHeatmapScrub(container, segs, segLen, maxTime) {
+ const interactive = container.querySelector('.danmu-heatmap-interactive');
+ const bars = container.querySelector('.heatmap-bars');
+ const tooltip = container.querySelector('.danmu-heatmap-tooltip');
+ const indicator = container.querySelector('.danmu-heatmap-indicator');
+ if (!interactive || !bars || !tooltip || !indicator) return;
+
+ let rafId = null;
+ let latestEvent = null;
+ const scheduleUpdate = event => {
+ latestEvent = event;
+ if (rafId) return;
+ rafId = requestAnimationFrame(() => {
+ rafId = null;
+ if (!latestEvent) return;
+ updateDanmuHeatmapTooltip(latestEvent, interactive, bars, tooltip, indicator, segs, segLen, maxTime);
+ });
+ };
+
+ const hideTooltip = () => hideDanmuHeatmapTooltip(bars, tooltip, indicator, () => {
+ if (rafId) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ latestEvent = null;
+ });
+
+ interactive.addEventListener('pointermove', scheduleUpdate, { passive: true });
+ interactive.addEventListener('pointerdown', scheduleUpdate, { passive: true });
+ interactive.addEventListener('pointerup', hideTooltip, { passive: true });
+ interactive.addEventListener('pointercancel', hideTooltip, { passive: true });
+ interactive.addEventListener('pointerleave', hideTooltip);
+}
+
+function hideDanmuHeatmapTooltip(bars, tooltip, indicator, beforeHide) {
+ if (beforeHide) beforeHide();
+ tooltip.classList.remove('active');
+ indicator.classList.remove('active');
+ const activeBar = bars.querySelector('.heatmap-bar.active');
+ if (activeBar) activeBar.classList.remove('active');
+}
+
+function updateDanmuHeatmapTooltip(event, interactive, bars, tooltip, indicator, segs, segLen, maxTime) {
+ if (!event) return;
+ const rect = bars.getBoundingClientRect();
+ if (!rect.width) return;
+ const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
+ const index = Math.max(0, Math.min(segs.length - 1, Math.floor(ratio * segs.length)));
+ const start = index * segLen;
+ const end = Math.min((index + 1) * segLen, maxTime);
+ const leftPct = ((index + 0.5) / segs.length) * 100;
+
+ const activeBar = bars.querySelector('.heatmap-bar.active');
+ if (activeBar) activeBar.classList.remove('active');
+ const currentBar = bars.querySelector('.heatmap-bar[data-index="' + index + '"]');
+ if (currentBar) currentBar.classList.add('active');
+
+ tooltip.innerHTML = '
' + segs[index] + '条弹幕' + formatDuration(start) + ' - ' + formatDuration(end) + '';
+ tooltip.classList.add('active');
+ indicator.classList.add('active');
+
+ const clampedLeft = clampHeatmapTooltipLeft(interactive, tooltip, leftPct);
+ tooltip.style.left = clampedLeft + 'px';
+ indicator.style.left = leftPct + '%';
+}
+
+function clampHeatmapTooltipLeft(interactive, tooltip, leftPct) {
+ const width = interactive.clientWidth || 0;
+ const tooltipWidth = tooltip.offsetWidth || 0;
+ const padding = 8;
+ const rawLeft = (leftPct / 100) * width;
+ const minLeft = (tooltipWidth / 2) + padding;
+ const maxLeft = width - (tooltipWidth / 2) - padding;
+ if (width <= tooltipWidth + padding * 2) return width / 2;
+ return Math.max(minLeft, Math.min(maxLeft, rawLeft));
+}
+
+function getDanmuFilterCounts(comments) {
+ const counts = { all: comments.length, scroll: 0, top: 0, bottom: 0 };
+ comments.forEach(c => {
+ const mode = parseDanmuMode(c.p);
+ if (mode === 5) counts.top++;
+ else if (mode === 4) counts.bottom++;
+ else counts.scroll++;
+ });
+ return counts;
+}
+
+function renderDanmuFilterTabs(counts) {
+ const items = [
+ { key: 'all', label: '全部' },
+ { key: 'scroll', label: '滚动' },
+ { key: 'top', label: '顶部' },
+ { key: 'bottom', label: '底部' }
+ ];
+ let html = '
';
+ items.forEach(item => {
+ const active = item.key === 'all' ? ' active' : '';
+ html += '';
+ });
+ html += '
';
+ return html;
}
// =====================
@@ -979,7 +1383,8 @@ function applyDanmuFilter() {
function filterDanmuList(type, event) {
document.querySelectorAll('.danmu-filter-tab').forEach(btn => btn.classList.remove('active'));
- event.target.classList.add('active');
+ const tab = event.currentTarget || event.target.closest('.danmu-filter-tab');
+ if (tab) tab.classList.add('active');
danmuTestState.currentFilter = type;
applyDanmuFilter();
renderDanmuList();
@@ -1000,7 +1405,7 @@ function renderDanmuList() {
html += '
' +
'' + formatDuration(time) + '' +
- '' +
+ '' +
'' + modeLabel + '' +
'' + escapeHtml(c.m) + '' +
'
';
@@ -1008,7 +1413,7 @@ function renderDanmuList() {
const container = document.getElementById('danmu-list');
if (danmuTestState.displayedCount === 0) {
- container.innerHTML = html || '
无弹幕数据
';
+ container.innerHTML = html || '
无弹幕数据
';
} else {
container.insertAdjacentHTML('beforeend', html);
}