热力图改为自定义

This commit is contained in:
mtvpls
2026-01-02 15:24:48 +08:00
parent a1ef59454f
commit 435d3a0f36
3 changed files with 559 additions and 74 deletions

View File

@@ -50,6 +50,7 @@ import Toast, { ToastProps } from '@/components/Toast';
import AIChatPanel from '@/components/AIChatPanel';
import { useEnableComments } from '@/hooks/useEnableComments';
import PansouSearch from '@/components/PansouSearch';
import CustomHeatmap from '@/components/CustomHeatmap';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
declare global {
@@ -2834,17 +2835,6 @@ function PlayPageClient() {
setDanmakuCount(0);
} finally {
setDanmakuLoading(false);
// 弹幕加载完成后,根据用户设置显示或隐藏热力图(仅在未禁用热力图时)
if (!danmakuHeatmapDisabledRef.current) {
const heatmapElement = document.querySelector('.art-control-heatmap') as HTMLElement;
if (heatmapElement) {
const isEnabled = danmakuHeatmapEnabledRef.current;
heatmapElement.style.opacity = isEnabled ? '1' : '0';
heatmapElement.style.pointerEvents = isEnabled ? 'auto' : 'none';
console.log('弹幕加载完成,热力图状态:', isEnabled ? '显示' : '隐藏');
}
}
}
};
@@ -3700,7 +3690,7 @@ function PlayPageClient() {
antiOverlap: true,
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
emitter: false,
heatmap: !danmakuHeatmapDisabledRef.current, // 根据禁用状态决定是否创建热力图
heatmap: false, // 禁用 artplayer 自带热力图,使用自定义热力图
// 主题
theme: 'dark',
filter: (danmu: any) => {
@@ -3781,8 +3771,8 @@ function PlayPageClient() {
return '打开设置';
},
},
// 只有在未禁用热力图时才显示热力图开关
...(!danmakuHeatmapDisabledRef.current ? [{
// 热力图开关(始终显示,不再依赖 danmakuHeatmapDisabled
{
name: '弹幕热力',
html: '弹幕热力',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" fill="#ffffff"/></svg>',
@@ -3792,22 +3782,13 @@ function PlayPageClient() {
try {
localStorage.setItem('danmaku_heatmap_enabled', String(newVal));
setDanmakuHeatmapEnabled(newVal);
// 使用 opacity 控制热力图显示/隐藏
const heatmapElement = document.querySelector('.art-control-heatmap') as HTMLElement;
if (heatmapElement) {
heatmapElement.style.opacity = newVal ? '1' : '0';
heatmapElement.style.pointerEvents = newVal ? 'auto' : 'none';
console.log('弹幕热力已', newVal ? '开启' : '关闭');
} else {
console.warn('未找到热力图元素');
}
console.log('弹幕热力已', newVal ? '开启' : '关闭');
} catch (err) {
console.error('切换弹幕热力失败:', err);
}
return newVal;
},
}] : []),
},
...(webGPUSupported ? [
{
name: 'Anime4K超分',
@@ -4609,16 +4590,6 @@ function PlayPageClient() {
danmakuPluginRef.current.hide();
}
// 初始隐藏热力图,等待弹幕加载完成后再显示(仅在未禁用热力图时)
if (!danmakuHeatmapDisabledRef.current) {
const heatmapElement = document.querySelector('.art-control-heatmap') as HTMLElement;
if (heatmapElement) {
heatmapElement.style.opacity = '0';
heatmapElement.style.pointerEvents = 'none';
console.log('热力图初始状态: 隐藏(等待弹幕加载)');
}
}
// 自动搜索并加载弹幕
await autoSearchDanmaku();
}
@@ -4661,6 +4632,339 @@ function PlayPageClient() {
setIsWebFullscreen(isFullscreen);
});
// 添加自定义热力图到播放器控制层
if (!danmakuHeatmapDisabledRef.current) {
artPlayerRef.current.controls.add({
name: 'custom-heatmap',
position: 'top',
html: '<canvas id="custom-heatmap-canvas" style="width: 100%; height: 100%; display: block;"></canvas>',
style: {
position: 'absolute',
bottom: '16px',
left: '10px',
right: '10px',
height: '30px',
pointerEvents: 'none',
zIndex: '30',
display: danmakuHeatmapEnabledRef.current ? 'block' : 'none',
},
mounted: ($el: HTMLElement) => {
// 动态获取进度条的实际位置并调整热力图
const adjustHeatmapPosition = () => {
// 尝试查找进度条的内部元素
const progressInner = document.querySelector('.art-control-progress-inner') as HTMLElement;
const progressBar = progressInner || document.querySelector('.art-control-progress') as HTMLElement;
if (progressBar) {
const rect = progressBar.getBoundingClientRect();
const parentRect = $el.parentElement?.getBoundingClientRect();
if (parentRect) {
const leftOffset = rect.left - parentRect.left;
// 调整热力图位置以匹配进度条
$el.style.left = `${leftOffset}px`;
$el.style.right = 'auto';
$el.style.width = `${rect.width}px`;
}
}
};
// 初始调整
setTimeout(adjustHeatmapPosition, 500);
// 监听进度条尺寸变化
const progressBar = document.querySelector('.art-control-progress') as HTMLElement;
let progressResizeObserver: ResizeObserver | null = null;
if (progressBar && typeof ResizeObserver !== 'undefined') {
progressResizeObserver = new ResizeObserver(() => {
adjustHeatmapPosition();
// 进度条长度变化时也需要重新计算和绘制热力图
setTimeout(updateHeatmapData, 100);
});
progressResizeObserver.observe(progressBar);
}
// 监听全屏状态变化
if (artPlayerRef.current) {
artPlayerRef.current.on('fullscreen', () => {
setTimeout(adjustHeatmapPosition, 300);
});
artPlayerRef.current.on('fullscreenWeb', () => {
setTimeout(adjustHeatmapPosition, 300);
});
}
// 监听窗口大小变化
const resizeHandler = () => {
adjustHeatmapPosition();
};
window.addEventListener('resize', resizeHandler);
const canvas = $el.querySelector('#custom-heatmap-canvas') as HTMLCanvasElement;
if (!canvas) {
return;
}
canvas.width = 1000;
canvas.height = 30;
let heatmapData: number[] = [];
let isHovering = false;
let hoverTime = 0;
let tooltipEl: HTMLElement | null = null;
// 监听热力图开关状态变化
const updateVisibility = () => {
const enabled = localStorage.getItem('danmaku_heatmap_enabled');
$el.style.display = enabled === 'true' ? 'block' : 'none';
};
// 定期检查开关状态
const visibilityInterval = setInterval(updateVisibility, 500);
// 计算热力图数据按视频长度的5%分段,使热力图更平滑)
const calculateHeatmapData = (danmakuList: any[], duration: number) => {
if (!duration || duration <= 0 || danmakuList.length === 0) {
return [];
}
// 按视频长度的5%分段最少20段
const segments = Math.max(20, Math.ceil(duration * 0.05));
const segmentDuration = duration / segments;
const heatData = new Array(segments).fill(0);
danmakuList.forEach((danmaku: any) => {
const segmentIndex = Math.floor(danmaku.time / segmentDuration);
if (segmentIndex >= 0 && segmentIndex < segments) {
heatData[segmentIndex]++;
}
});
const maxCount = Math.max(...heatData, 1);
return heatData.map((count: number) => count / maxCount);
};
// 绘制热力图
const drawHeatmap = () => {
if (!artPlayerRef.current || heatmapData.length === 0) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
const duration = artPlayerRef.current.duration || 0;
const currentTime = artPlayerRef.current.currentTime || 0;
ctx.clearRect(0, 0, width, height);
const progressRatio = duration > 0 ? currentTime / duration : 0;
const progressX = progressRatio * width;
// 绘制未播放部分的曲线
ctx.beginPath();
ctx.moveTo(0, height);
heatmapData.forEach((value: number, index: number) => {
const x = (index / heatmapData.length) * width;
const y = height - (value * height);
if (index === 0) {
ctx.lineTo(x, y);
} else {
// 使用二次贝塞尔曲线使线条平滑
const prevX = ((index - 1) / heatmapData.length) * width;
const prevY = height - (heatmapData[index - 1] * height);
const cpX = (prevX + x) / 2;
const cpY = (prevY + y) / 2;
ctx.quadraticCurveTo(prevX, prevY, cpX, cpY);
ctx.lineTo(x, y);
}
});
ctx.lineTo(width, height);
ctx.closePath();
ctx.fillStyle = 'rgba(128, 128, 128, 0.3)';
ctx.fill();
// 绘制已播放部分的曲线(深色)
if (progressRatio > 0) {
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, progressX, height);
ctx.clip();
ctx.beginPath();
ctx.moveTo(0, height);
heatmapData.forEach((value: number, index: number) => {
const x = (index / heatmapData.length) * width;
const y = height - (value * height);
if (index === 0) {
ctx.lineTo(x, y);
} else {
const prevX = ((index - 1) / heatmapData.length) * width;
const prevY = height - (heatmapData[index - 1] * height);
const cpX = (prevX + x) / 2;
const cpY = (prevY + y) / 2;
ctx.quadraticCurveTo(prevX, prevY, cpX, cpY);
ctx.lineTo(x, y);
}
});
ctx.lineTo(width, height);
ctx.closePath();
ctx.fillStyle = 'rgba(128, 128, 128, 0.6)';
ctx.fill();
ctx.restore();
}
};
// 格式化时间
const formatTime = (seconds: number): string => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
};
// 获取弹幕密度
const getDensity = (time: number): string => {
if (heatmapData.length === 0 || !artPlayerRef.current) return '';
const duration = artPlayerRef.current.duration || 0;
if (duration <= 0) return '';
// 按视频长度的5%分段
const segments = Math.max(20, Math.ceil(duration * 0.05));
const segmentDuration = duration / segments;
const segmentIndex = Math.floor(time / segmentDuration);
if (segmentIndex >= 0 && segmentIndex < heatmapData.length) {
const density = heatmapData[segmentIndex];
if (density < 0.2) return '低';
if (density < 0.5) return '中';
if (density < 0.8) return '高';
return '极高';
}
return '';
};
// 鼠标移动事件
canvas.addEventListener('mousemove', (e: MouseEvent) => {
if (!artPlayerRef.current) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const duration = artPlayerRef.current.duration || 0;
hoverTime = percentage * duration;
isHovering = true;
// 创建或更新提示框
if (!tooltipEl) {
tooltipEl = document.createElement('div');
tooltipEl.style.cssText = `
position: absolute;
bottom: 100%;
transform: translateX(-50%);
margin-bottom: 8px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 12px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
z-index: 30;
`;
$el.appendChild(tooltipEl);
}
tooltipEl.textContent = `${formatTime(hoverTime)} - 弹幕密度: ${getDensity(hoverTime)}`;
tooltipEl.style.left = `${percentage * 100}%`;
tooltipEl.style.display = 'block';
});
// 鼠标离开事件
canvas.addEventListener('mouseleave', () => {
isHovering = false;
if (tooltipEl) {
tooltipEl.style.display = 'none';
}
});
// 点击跳转
canvas.addEventListener('click', (e: MouseEvent) => {
if (!artPlayerRef.current) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const duration = artPlayerRef.current.duration || 0;
const time = percentage * duration;
artPlayerRef.current.currentTime = time;
});
// 监听时间更新
artPlayerRef.current.on('video:timeupdate', drawHeatmap);
// 监听弹幕数据更新
const updateHeatmapData = () => {
if (!artPlayerRef.current || !danmakuPluginRef.current) return;
const duration = artPlayerRef.current.duration || 0;
// 直接从弹幕插件获取弹幕数据
const danmakuList = danmakuPluginRef.current.option?.danmuku || [];
if (danmakuList.length > 0 && duration > 0) {
heatmapData = calculateHeatmapData(danmakuList, duration);
// 立即绘制热力图
drawHeatmap();
// 强制再次绘制,确保显示
setTimeout(drawHeatmap, 100);
}
};
artPlayerRef.current.on('video:loadedmetadata', updateHeatmapData);
// 监听弹幕插件的配置变化
if (danmakuPluginRef.current) {
const originalConfig = danmakuPluginRef.current.config;
danmakuPluginRef.current.config = function(...args: any[]) {
const result = originalConfig.apply(this, args);
setTimeout(updateHeatmapData, 100);
return result;
};
}
// 初始尝试加载
setTimeout(updateHeatmapData, 500);
setTimeout(updateHeatmapData, 1500);
setTimeout(updateHeatmapData, 3000);
// 清理
return () => {
clearInterval(visibilityInterval);
window.removeEventListener('resize', resizeHandler);
if (progressResizeObserver) {
progressResizeObserver.disconnect();
}
if (tooltipEl && tooltipEl.parentNode) {
tooltipEl.parentNode.removeChild(tooltipEl);
}
};
},
});
}
// 添加全屏快进快退按钮
artPlayerRef.current.layers.add({
name: 'seek-buttons',
@@ -5501,7 +5805,6 @@ function PlayPageClient() {
</div>
)}
</div>
{/* 第三方应用打开按钮 - 观影室同步状态下隐藏 */}

View File

@@ -0,0 +1,220 @@
'use client';
import React, { useEffect, useRef, useState, useCallback } from 'react';
interface DanmakuData {
time: number;
text: string;
[key: string]: any;
}
interface CustomHeatmapProps {
danmakuList: DanmakuData[];
duration: number;
currentTime: number;
enabled: boolean;
onSeek?: (time: number) => void;
className?: string;
}
const CustomHeatmap: React.FC<CustomHeatmapProps> = ({
danmakuList,
duration,
currentTime,
enabled,
onSeek,
className = '',
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [heatmapData, setHeatmapData] = useState<number[]>([]);
const [isHovering, setIsHovering] = useState(false);
const [hoverTime, setHoverTime] = useState(0);
// 计算热力图数据
const calculateHeatmapData = useCallback(() => {
if (!duration || duration <= 0 || danmakuList.length === 0) {
return [];
}
// 将视频时长分成若干个时间段(每秒一个)
const segments = Math.ceil(duration);
const heatData = new Array(segments).fill(0);
// 统计每个时间段的弹幕数量
danmakuList.forEach((danmaku) => {
const segmentIndex = Math.floor(danmaku.time);
if (segmentIndex >= 0 && segmentIndex < segments) {
heatData[segmentIndex]++;
}
});
// 归一化数据到 0-1 范围
const maxCount = Math.max(...heatData, 1);
return heatData.map((count) => count / maxCount);
}, [danmakuList, duration]);
// 当弹幕列表或时长变化时重新计算热力图数据
useEffect(() => {
const data = calculateHeatmapData();
setHeatmapData(data);
}, [calculateHeatmapData]);
// 绘制热力图
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || heatmapData.length === 0) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// 清空画布
ctx.clearRect(0, 0, width, height);
// 计算每个柱子的宽度
const barWidth = width / heatmapData.length;
const progressRatio = duration > 0 ? currentTime / duration : 0;
// 绘制热力图柱状图
heatmapData.forEach((value, index) => {
const x = index * barWidth;
const barHeight = value * height;
const y = height - barHeight;
// 判断是否已播放
const isPlayed = (index / heatmapData.length) <= progressRatio;
// 使用灰色透明,已播放的部分深色一点
const opacity = isPlayed ? 0.5 + value * 0.3 : 0.2 + value * 0.3;
const color = `rgba(128, 128, 128, ${opacity})`;
ctx.fillStyle = color;
ctx.fillRect(x, y, Math.ceil(barWidth) + 1, barHeight);
});
// 绘制当前播放位置指示器
if (duration > 0) {
const progressX = (currentTime / duration) * width;
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.fillRect(progressX - 1, 0, 2, height);
}
}, [heatmapData, currentTime, duration]);
// 处理鼠标移动
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container || !duration) return;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const time = percentage * duration;
setHoverTime(time);
setIsHovering(true);
};
// 处理鼠标离开
const handleMouseLeave = () => {
setIsHovering(false);
};
// 处理点击跳转
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container || !duration || !onSeek) return;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = x / rect.width;
const time = percentage * duration;
onSeek(time);
};
// 格式化时间显示
const formatTime = (seconds: number): string => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
};
// 获取悬停位置的弹幕密度
const getHoverDensity = (): string => {
if (!isHovering || heatmapData.length === 0) return '';
const segmentIndex = Math.floor(hoverTime);
if (segmentIndex >= 0 && segmentIndex < heatmapData.length) {
const density = heatmapData[segmentIndex];
if (density < 0.2) return '低';
if (density < 0.5) return '中';
if (density < 0.8) return '高';
return '极高';
}
return '';
};
if (!enabled) {
return null;
}
return (
<div
ref={containerRef}
className={`custom-heatmap ${className}`}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
style={{
position: 'relative',
width: '100%',
height: '100%',
cursor: 'pointer',
}}
>
<canvas
ref={canvasRef}
width={1000}
height={30}
style={{
width: '100%',
height: '100%',
display: 'block',
}}
/>
{/* 悬停提示 */}
{isHovering && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: `${(hoverTime / duration) * 100}%`,
transform: 'translateX(-50%)',
marginBottom: '8px',
padding: '4px 8px',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
fontSize: '12px',
borderRadius: '4px',
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 10,
}}
>
{formatTime(hoverTime)} - : {getHoverDensity()}
</div>
)}
</div>
);
};
export default CustomHeatmap;

View File

@@ -89,7 +89,6 @@ export const UserMenu: React.FC = () => {
const [enableOptimization, setEnableOptimization] = useState(true);
const [fluidSearch, setFluidSearch] = useState(true);
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false);
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
const [enableTrailers, setEnableTrailers] = useState(false);
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
@@ -318,11 +317,6 @@ export const UserMenu: React.FC = () => {
setLiveDirectConnect(JSON.parse(savedLiveDirectConnect));
}
const savedDanmakuHeatmapDisabled = localStorage.getItem('danmaku_heatmap_disabled');
if (savedDanmakuHeatmapDisabled !== null) {
setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true');
}
const savedTmdbBackdropDisabled = localStorage.getItem('tmdb_backdrop_disabled');
if (savedTmdbBackdropDisabled !== null) {
setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true');
@@ -556,13 +550,6 @@ export const UserMenu: React.FC = () => {
}
};
const handleDanmakuHeatmapDisabledToggle = (value: boolean) => {
setDanmakuHeatmapDisabled(value);
if (typeof window !== 'undefined') {
localStorage.setItem('danmaku_heatmap_disabled', String(value));
}
};
const handleTmdbBackdropDisabledToggle = (value: boolean) => {
setTmdbBackdropDisabled(value);
if (typeof window !== 'undefined') {
@@ -647,7 +634,6 @@ export const UserMenu: React.FC = () => {
setEnableOptimization(true);
setFluidSearch(defaultFluidSearch);
setLiveDirectConnect(false);
setDanmakuHeatmapDisabled(false);
setTmdbBackdropDisabled(false);
setEnableTrailers(false);
setDoubanProxyUrl(defaultDoubanProxy);
@@ -662,7 +648,6 @@ export const UserMenu: React.FC = () => {
localStorage.setItem('enableOptimization', JSON.stringify(true));
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
localStorage.setItem('liveDirectConnect', JSON.stringify(false));
localStorage.setItem('danmaku_heatmap_disabled', 'false');
localStorage.setItem('tmdb_backdrop_disabled', 'false');
localStorage.setItem('enableTrailers', 'false');
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
@@ -1267,29 +1252,6 @@ export const UserMenu: React.FC = () => {
</div>
{/* 禁用弹幕热力 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={danmakuHeatmapDisabled}
onChange={(e) => handleDanmakuHeatmapDisabledToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 禁用背景图渲染 */}
<div className='flex items-center justify-between'>
<div>