热力图改为自定义
This commit is contained in:
220
src/components/CustomHeatmap.tsx
Normal file
220
src/components/CustomHeatmap.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user