live页面节目单增加timeline视图
This commit is contained in:
@@ -141,34 +141,43 @@ function LivePageClient() {
|
||||
},
|
||||
});
|
||||
|
||||
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目
|
||||
// EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,显示今日节目(18点后包含明天10点前的节目)
|
||||
const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => {
|
||||
if (!programs || programs.length === 0) return programs;
|
||||
|
||||
// 获取当前时间
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
|
||||
// 获取今日日期(只考虑年月日,忽略时间)
|
||||
const today = new Date();
|
||||
const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const todayEnd = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
||||
|
||||
// 首先过滤出今日的节目(包括跨天节目)
|
||||
const todayPrograms = programs.filter(program => {
|
||||
// 如果当前时间超过18点,扩展到明天10点
|
||||
let endTime = todayEnd;
|
||||
if (currentHour >= 18) {
|
||||
// 明天10点
|
||||
endTime = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1, 10, 0, 0);
|
||||
}
|
||||
|
||||
// 首先过滤出符合时间范围的节目(包括跨天节目)
|
||||
const filteredPrograms = programs.filter(program => {
|
||||
const programStart = parseCustomTimeFormat(program.start);
|
||||
const programEnd = parseCustomTimeFormat(program.end);
|
||||
|
||||
// 获取节目的日期范围
|
||||
const programStartDate = new Date(programStart.getFullYear(), programStart.getMonth(), programStart.getDate());
|
||||
const programEndDate = new Date(programEnd.getFullYear(), programEnd.getMonth(), programEnd.getDate());
|
||||
// 使用时间戳进行比较
|
||||
const programStartTime = programStart.getTime();
|
||||
const programEndTime = programEnd.getTime();
|
||||
const todayStartTime = todayStart.getTime();
|
||||
const endTimeValue = endTime.getTime();
|
||||
|
||||
// 如果节目的开始时间或结束时间在今天,或者节目跨越今天,都算作今天的节目
|
||||
return (
|
||||
(programStartDate >= todayStart && programStartDate < todayEnd) || // 开始时间在今天
|
||||
(programEndDate >= todayStart && programEndDate < todayEnd) || // 结束时间在今天
|
||||
(programStartDate < todayStart && programEndDate >= todayEnd) // 节目跨越今天(跨天节目)
|
||||
);
|
||||
// 节目的开始时间在范围内,或者节目在范围内播放(开始时间早于范围开始,但结束时间在范围内)
|
||||
return programStartTime < endTimeValue && programEndTime > todayStartTime;
|
||||
});
|
||||
|
||||
// 按开始时间排序
|
||||
const sortedPrograms = [...todayPrograms].sort((a, b) => {
|
||||
const sortedPrograms = [...filteredPrograms].sort((a, b) => {
|
||||
const startA = parseCustomTimeFormat(a.start).getTime();
|
||||
const startB = parseCustomTimeFormat(b.start).getTime();
|
||||
return startA - startB;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
import { Clock, Target, Tv } from 'lucide-react';
|
||||
import { Clock, Target, Tv, List, BarChart3 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { formatTimeToHHMM, parseCustomTimeFormat } from '@/lib/time';
|
||||
@@ -17,14 +17,19 @@ interface EpgScrollableRowProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = 'list' | 'timeline';
|
||||
|
||||
export default function EpgScrollableRow({
|
||||
programs,
|
||||
currentTime = new Date(),
|
||||
isLoading = false,
|
||||
}: EpgScrollableRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const timelineHorizontalRef = useRef<HTMLDivElement>(null);
|
||||
const timelineVerticalRef = useRef<HTMLDivElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [currentPlayingIndex, setCurrentPlayingIndex] = useState<number>(-1);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
|
||||
// 处理滚轮事件,实现横向滚动
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
@@ -49,7 +54,7 @@ export default function EpgScrollableRow({
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到正在播放的节目
|
||||
// 自动滚动到正在播放的节目(列表视图)
|
||||
const scrollToCurrentProgram = () => {
|
||||
if (containerRef.current) {
|
||||
const currentProgramIndex = programs.findIndex(program => isCurrentlyPlaying(program));
|
||||
@@ -73,6 +78,50 @@ export default function EpgScrollableRow({
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到正在播放的节目(时间线视图)
|
||||
const scrollToCurrentProgramTimeline = () => {
|
||||
const currentProgramIndex = programs.findIndex(program => isCurrentlyPlaying(program));
|
||||
if (currentProgramIndex === -1) return;
|
||||
|
||||
// 横向时间线
|
||||
if (timelineHorizontalRef.current && window.innerWidth >= 768) {
|
||||
const programElement = timelineHorizontalRef.current.children[currentProgramIndex] as HTMLElement;
|
||||
if (programElement) {
|
||||
const container = timelineHorizontalRef.current;
|
||||
const programLeft = programElement.offsetLeft;
|
||||
const containerWidth = container.clientWidth;
|
||||
const programWidth = programElement.offsetWidth;
|
||||
|
||||
const scrollLeft = programLeft - (containerWidth / 2) + (programWidth / 2);
|
||||
|
||||
container.scrollTo({
|
||||
left: Math.max(0, scrollLeft),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
// 竖向时间线
|
||||
else if (timelineVerticalRef.current) {
|
||||
const programElement = timelineVerticalRef.current.children[currentProgramIndex] as HTMLElement;
|
||||
if (programElement) {
|
||||
// 找到包含滚动的父容器
|
||||
const scrollContainer = timelineVerticalRef.current.parentElement;
|
||||
if (scrollContainer) {
|
||||
const programTop = programElement.offsetTop;
|
||||
const containerHeight = scrollContainer.clientHeight;
|
||||
const programHeight = programElement.offsetHeight;
|
||||
|
||||
const scrollTop = programTop - (containerHeight / 2) + (programHeight / 2);
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, scrollTop),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovered) {
|
||||
// 鼠标悬停时阻止页面滚动
|
||||
@@ -128,6 +177,20 @@ export default function EpgScrollableRow({
|
||||
return () => clearInterval(interval);
|
||||
}, [programs, currentTime, currentPlayingIndex]);
|
||||
|
||||
// 切换视图时自动跳转到当前播放位置
|
||||
useEffect(() => {
|
||||
// 延迟执行,确保DOM完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (viewMode === 'list') {
|
||||
scrollToCurrentProgram();
|
||||
} else if (viewMode === 'timeline') {
|
||||
scrollToCurrentProgramTimeline();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [viewMode]);
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (timeString: string) => {
|
||||
return formatTimeToHHMM(timeString);
|
||||
@@ -144,6 +207,37 @@ export default function EpgScrollableRow({
|
||||
}
|
||||
};
|
||||
|
||||
// 计算节目时长(分钟)
|
||||
const getProgramDuration = (program: EpgProgram) => {
|
||||
try {
|
||||
const start = parseCustomTimeFormat(program.start);
|
||||
const end = parseCustomTimeFormat(program.end);
|
||||
return (end.getTime() - start.getTime()) / (1000 * 60); // 转换为分钟
|
||||
} catch {
|
||||
return 30; // 默认30分钟
|
||||
}
|
||||
};
|
||||
|
||||
// 计算当前时间在时间线上的位置百分比
|
||||
const getCurrentTimePosition = () => {
|
||||
if (programs.length === 0) return 0;
|
||||
|
||||
try {
|
||||
const firstProgram = programs[0];
|
||||
const lastProgram = programs[programs.length - 1];
|
||||
const startTime = parseCustomTimeFormat(firstProgram.start).getTime();
|
||||
const endTime = parseCustomTimeFormat(lastProgram.end).getTime();
|
||||
const currentTimeMs = currentTime.getTime();
|
||||
|
||||
if (currentTimeMs < startTime) return 0;
|
||||
if (currentTimeMs > endTime) return 100;
|
||||
|
||||
return ((currentTimeMs - startTime) / (endTime - startTime)) * 100;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载中状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -193,29 +287,62 @@ export default function EpgScrollableRow({
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
今日节目单
|
||||
</h4>
|
||||
{currentPlayingIndex !== -1 && (
|
||||
<button
|
||||
onClick={scrollToCurrentProgram}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-2.5 py-1.5 sm:py-2 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 bg-gray-300/50 dark:bg-gray-800 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-300 dark:hover:border-green-700 transition-all duration-200"
|
||||
title="滚动到当前播放位置"
|
||||
>
|
||||
<Target className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
|
||||
<span className="hidden sm:inline">当前播放</span>
|
||||
<span className="sm:hidden">当前</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 视图切换按钮 */}
|
||||
<div className="flex items-center gap-1 bg-gray-200 dark:bg-gray-800 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-all duration-200 ${
|
||||
viewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
title="列表视图"
|
||||
>
|
||||
<List className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">列表</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('timeline')}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-all duration-200 ${
|
||||
viewMode === 'timeline'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
title="时间线视图"
|
||||
>
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">时间线</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 当前播放按钮 */}
|
||||
{currentPlayingIndex !== -1 && (
|
||||
<button
|
||||
onClick={viewMode === 'list' ? scrollToCurrentProgram : scrollToCurrentProgramTimeline}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-2.5 py-1.5 sm:py-2 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 bg-gray-300/50 dark:bg-gray-800 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-300 dark:hover:border-green-700 transition-all duration-200"
|
||||
title="滚动到当前播放位置"
|
||||
>
|
||||
<Target className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
|
||||
<span className="hidden sm:inline">当前播放</span>
|
||||
<span className="sm:hidden">当前</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='relative'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 列表视图 */}
|
||||
{viewMode === 'list' && (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex overflow-x-auto scrollbar-hide py-2 pb-4 px-2 sm:px-4 min-h-[100px] sm:min-h-[120px]'
|
||||
className='relative'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{programs.map((program, index) => {
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex overflow-x-auto scrollbar-hide py-2 pb-4 px-2 sm:px-4 min-h-[100px] sm:min-h-[120px]'
|
||||
>
|
||||
{programs.map((program, index) => {
|
||||
// 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新
|
||||
const isPlaying = index === currentPlayingIndex;
|
||||
const isFinishedProgram = index < currentPlayingIndex;
|
||||
@@ -288,6 +415,221 @@ export default function EpgScrollableRow({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 时间线视图 */}
|
||||
{viewMode === 'timeline' && (
|
||||
<div className='relative'>
|
||||
{/* 电脑端:横向时间线 */}
|
||||
<div className='hidden md:block'>
|
||||
<div className='bg-gray-100 dark:bg-gray-800 rounded-lg p-4'>
|
||||
{/* 时间线容器 - 可横向滚动 */}
|
||||
<div
|
||||
className='relative'
|
||||
onMouseEnter={(e) => {
|
||||
const container = timelineHorizontalRef.current;
|
||||
if (container) {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (container.scrollWidth > container.clientWidth) {
|
||||
e.preventDefault();
|
||||
container.scrollLeft += e.deltaY * 4;
|
||||
}
|
||||
};
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
(container as any)._wheelHandler = handleWheel;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const container = timelineHorizontalRef.current;
|
||||
if (container && (container as any)._wheelHandler) {
|
||||
container.removeEventListener('wheel', (container as any)._wheelHandler);
|
||||
delete (container as any)._wheelHandler;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={timelineHorizontalRef}
|
||||
className='flex overflow-x-auto scrollbar-hide pb-2 px-2 sm:px-4 max-h-[400px]'
|
||||
>
|
||||
{programs.map((program, index) => {
|
||||
const isPlaying = index === currentPlayingIndex;
|
||||
const isFinished = index < currentPlayingIndex;
|
||||
const duration = getProgramDuration(program);
|
||||
|
||||
return (
|
||||
<div key={index} className='flex flex-col items-center flex-shrink-0'>
|
||||
{/* 节目信息卡片 */}
|
||||
<div className={`w-48 p-3 rounded-lg border transition-all duration-200 mb-3 h-[110px] flex flex-col ${
|
||||
isPlaying
|
||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30'
|
||||
: isFinished
|
||||
? 'bg-gray-300/50 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
|
||||
: 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30'
|
||||
}`}>
|
||||
<div className='flex items-start justify-between mb-2 flex-shrink-0'>
|
||||
<span className={`text-xs font-medium ${
|
||||
isPlaying
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: isFinished
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}>
|
||||
{formatTime(program.start)}
|
||||
</span>
|
||||
<span className='text-xs text-gray-400 dark:text-gray-500'>
|
||||
{Math.round(duration)}分钟
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-medium flex-1 ${
|
||||
isPlaying
|
||||
? 'text-green-900 dark:text-green-100'
|
||||
: isFinished
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: 'text-blue-900 dark:text-blue-100'
|
||||
}`}
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
lineHeight: '1.4'
|
||||
}}
|
||||
>
|
||||
{program.title}
|
||||
</div>
|
||||
{isPlaying && (
|
||||
<div className='mt-auto pt-2 flex items-center gap-1.5 flex-shrink-0'>
|
||||
<div className='w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse'></div>
|
||||
<span className='text-xs text-green-600 dark:text-green-400 font-medium'>
|
||||
正在播放
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 时间线轴 */}
|
||||
<div className='flex items-center flex-shrink-0'>
|
||||
{/* 时间点 */}
|
||||
<div className={`w-3 h-3 rounded-full border-2 flex-shrink-0 ${
|
||||
isPlaying
|
||||
? 'bg-green-500 border-green-500 animate-pulse'
|
||||
: isFinished
|
||||
? 'bg-gray-400 border-gray-400'
|
||||
: 'bg-blue-500 border-blue-500'
|
||||
}`}></div>
|
||||
|
||||
{/* 右侧连接线 */}
|
||||
{index < programs.length - 1 && (
|
||||
<div className={`h-0.5 w-48 ${
|
||||
isFinished
|
||||
? 'bg-gray-300 dark:bg-gray-600'
|
||||
: isPlaying
|
||||
? 'bg-green-300 dark:bg-green-700'
|
||||
: 'bg-blue-300 dark:bg-blue-700'
|
||||
}`}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手机端:竖向时间线 */}
|
||||
<div className='md:hidden'>
|
||||
<div className='relative bg-gray-100 dark:bg-gray-800 rounded-lg p-4 max-h-[500px] overflow-y-auto'>
|
||||
{/* 时间线容器 */}
|
||||
<div ref={timelineVerticalRef} className='relative'>
|
||||
{programs.map((program, index) => {
|
||||
const isPlaying = index === currentPlayingIndex;
|
||||
const isFinished = index < currentPlayingIndex;
|
||||
const duration = getProgramDuration(program);
|
||||
|
||||
return (
|
||||
<div key={index} className='relative flex gap-3 mb-3 last:mb-0'>
|
||||
{/* 时间线轴 */}
|
||||
<div className='relative flex flex-col items-center flex-shrink-0' style={{ paddingTop: '0.375rem' }}>
|
||||
{/* 时间点 */}
|
||||
<div className={`w-3 h-3 rounded-full border-2 z-10 ${
|
||||
isPlaying
|
||||
? 'bg-green-500 border-green-500 animate-pulse'
|
||||
: isFinished
|
||||
? 'bg-gray-400 border-gray-400'
|
||||
: 'bg-blue-500 border-blue-500'
|
||||
}`}></div>
|
||||
|
||||
{/* 连接线 - 根据状态显示不同颜色 */}
|
||||
{index < programs.length - 1 && (
|
||||
<div
|
||||
className={`absolute w-0.5 ${
|
||||
isFinished
|
||||
? 'bg-gray-300 dark:bg-gray-600'
|
||||
: isPlaying
|
||||
? 'bg-green-300 dark:bg-green-700'
|
||||
: 'bg-blue-300 dark:bg-blue-700'
|
||||
}`}
|
||||
style={{
|
||||
top: '0.375rem',
|
||||
bottom: 'calc(-0.75rem - 100%)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 节目信息 */}
|
||||
<div className={`flex-1 p-3 rounded-lg border transition-all duration-200 ${
|
||||
isPlaying
|
||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30'
|
||||
: isFinished
|
||||
? 'bg-gray-300/50 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
|
||||
: 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30'
|
||||
}`}>
|
||||
<div className='flex items-start justify-between mb-2'>
|
||||
<span className={`text-xs font-medium ${
|
||||
isPlaying
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: isFinished
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}>
|
||||
{formatTime(program.start)}
|
||||
</span>
|
||||
<span className='text-xs text-gray-400 dark:text-gray-500'>
|
||||
{Math.round(duration)}分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${
|
||||
isPlaying
|
||||
? 'text-green-900 dark:text-green-100'
|
||||
: isFinished
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: 'text-blue-900 dark:text-blue-100'
|
||||
}`}>
|
||||
{program.title}
|
||||
</div>
|
||||
{isPlaying && (
|
||||
<div className='mt-2 flex items-center gap-1.5'>
|
||||
<div className='w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse'></div>
|
||||
<span className='text-xs text-green-600 dark:text-green-400 font-medium'>
|
||||
正在播放
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user