继续观看列表使用虚拟滚动
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/lib/db.client';
|
||||
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import VirtualScrollableRow from '@/components/VirtualScrollableRow';
|
||||
|
||||
interface ContinueWatchingProps {
|
||||
className?: string;
|
||||
@@ -103,56 +103,60 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
{loading ? (
|
||||
// 加载状态显示灰色占位数据(使用原始 ScrollableRow)
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||
>
|
||||
<div className='relative aspect-[3/2] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-1 h-1 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800 w-3/4'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 使用虚拟滚动显示真实数据
|
||||
<VirtualScrollableRow>
|
||||
{playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={record.key}
|
||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||
>
|
||||
<div className='relative aspect-[3/2] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-1 h-1 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800 w-3/4'></div>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
poster={record.cover}
|
||||
year={record.year}
|
||||
source={source}
|
||||
source_name={record.source_name}
|
||||
progress={getProgress(record)}
|
||||
episodes={record.total_episodes}
|
||||
currentEpisode={record.index}
|
||||
query={record.search_title}
|
||||
from='playrecord'
|
||||
onDelete={() =>
|
||||
setPlayRecords((prev) =>
|
||||
prev.filter((r) => r.key !== record.key)
|
||||
)
|
||||
}
|
||||
type={record.total_episodes > 1 ? 'tv' : ''}
|
||||
origin={record.origin}
|
||||
orientation='horizontal'
|
||||
playTime={record.play_time}
|
||||
totalTime={record.total_time}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
return (
|
||||
<div
|
||||
key={record.key}
|
||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||
>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
poster={record.cover}
|
||||
year={record.year}
|
||||
source={source}
|
||||
source_name={record.source_name}
|
||||
progress={getProgress(record)}
|
||||
episodes={record.total_episodes}
|
||||
currentEpisode={record.index}
|
||||
query={record.search_title}
|
||||
from='playrecord'
|
||||
onDelete={() =>
|
||||
setPlayRecords((prev) =>
|
||||
prev.filter((r) => r.key !== record.key)
|
||||
)
|
||||
}
|
||||
type={record.total_episodes > 1 ? 'tv' : ''}
|
||||
origin={record.origin}
|
||||
orientation='horizontal'
|
||||
playTime={record.play_time}
|
||||
totalTime={record.total_time}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollableRow>
|
||||
);
|
||||
})}
|
||||
</VirtualScrollableRow>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
126
src/components/VirtualScrollableRow.tsx
Normal file
126
src/components/VirtualScrollableRow.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface VirtualScrollableRowProps {
|
||||
children: React.ReactNode[];
|
||||
maxVisible?: number; // 最大可见数量
|
||||
}
|
||||
|
||||
export default function VirtualScrollableRow({
|
||||
children,
|
||||
maxVisible = 30, // 默认最多显示 30 个项目
|
||||
}: VirtualScrollableRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftScroll, setShowLeftScroll] = useState(false);
|
||||
const [showRightScroll, setShowRightScroll] = useState(false);
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: maxVisible });
|
||||
|
||||
// 检查滚动状态
|
||||
const checkScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = containerRef.current;
|
||||
const canScrollLeft = scrollLeft > 0;
|
||||
const canScrollRight = scrollLeft + clientWidth < scrollWidth - 10;
|
||||
|
||||
setShowLeftScroll(canScrollLeft);
|
||||
setShowRightScroll(canScrollRight);
|
||||
|
||||
// 计算可见范围(基于滚动位置)
|
||||
const itemWidth = 208; // 每个项目约 200px + 8px gap
|
||||
const scrolledItems = Math.floor(scrollLeft / itemWidth);
|
||||
const visibleItems = Math.ceil(clientWidth / itemWidth);
|
||||
|
||||
// 扩展渲染范围(当前可见 + 前后缓冲)
|
||||
const bufferSize = 5;
|
||||
const newStart = Math.max(0, scrolledItems - bufferSize);
|
||||
const newEnd = Math.min(children.length, scrolledItems + visibleItems + bufferSize);
|
||||
|
||||
setVisibleRange({ start: newStart, end: newEnd });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const container = containerRef.current;
|
||||
|
||||
if (container) {
|
||||
container.addEventListener('scroll', checkScroll);
|
||||
return () => container.removeEventListener('scroll', checkScroll);
|
||||
}
|
||||
}, [children.length]);
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', checkScroll);
|
||||
return () => window.removeEventListener('resize', checkScroll);
|
||||
}, []);
|
||||
|
||||
const scrollLeft = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollBy({
|
||||
left: -400,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scrollRight = () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollBy({
|
||||
left: 400,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染可见项目
|
||||
const visibleChildren = children.slice(visibleRange.start, visibleRange.end);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
{/* 左侧滚动按钮 */}
|
||||
{showLeftScroll && (
|
||||
<button
|
||||
onClick={scrollLeft}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-[600] bg-white/90 dark:bg-gray-800/90 p-2 rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-700"
|
||||
aria-label="向左滚动"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-gray-700 dark:text-gray-200" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 滚动容器 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex gap-2 overflow-x-auto scrollbar-hide scroll-smooth"
|
||||
style={{ scrollBehavior: 'smooth' }}
|
||||
>
|
||||
{/* 左侧占位符(用于保持滚动位置) */}
|
||||
{visibleRange.start > 0 && (
|
||||
<div style={{ minWidth: visibleRange.start * 208, flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
{/* 渲染可见项目 */}
|
||||
{visibleChildren}
|
||||
|
||||
{/* 右侧占位符 */}
|
||||
{visibleRange.end < children.length && (
|
||||
<div style={{ minWidth: (children.length - visibleRange.end) * 208, flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧滚动按钮 */}
|
||||
{showRightScroll && (
|
||||
<button
|
||||
onClick={scrollRight}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-[600] bg-white/90 dark:bg-gray-800/90 p-2 rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-700"
|
||||
aria-label="向右滚动"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-gray-700 dark:text-gray-200" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user