From cae84559ea093ab9808cd24b9f4ee8d1ebea8bbc Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 29 Dec 2025 21:05:51 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E8=A7=82=E7=9C=8B=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=BD=BF=E7=94=A8=E8=99=9A=E6=8B=9F=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 40 ++++++++ src/components/ContinueWatching.tsx | 98 +++++++++--------- src/components/VirtualScrollableRow.tsx | 126 ++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 47 deletions(-) create mode 100644 src/components/VirtualScrollableRow.tsx diff --git a/package.json b/package.json index d087269..052e121 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-dom": "^18.3.1", "react-icons": "^5.4.0", "react-markdown": "^10.1.0", + "react-window": "^2.2.3", "redis": "^4.6.7", "remark-gfm": "^4.0.1", "socket.io": "^4.8.1", @@ -76,6 +77,7 @@ "@types/node-fetch": "^2.6.13", "@types/react": "^18.3.18", "@types/react-dom": "^19.1.6", + "@types/react-window": "^2.0.0", "@types/testing-library__jest-dom": "^5.14.9", "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a86dc7..34bd01f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.23)(react@18.3.1) + react-window: + specifier: ^2.2.3 + version: 2.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redis: specifier: ^4.6.7 version: 4.7.1 @@ -171,6 +174,9 @@ importers: '@types/react-dom': specifier: ^19.1.6 version: 19.1.6(@types/react@18.3.23) + '@types/react-window': + specifier: ^2.0.0 + version: 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/testing-library__jest-dom': specifier: ^5.14.9 version: 5.14.9 @@ -1197,24 +1203,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -1630,6 +1640,10 @@ packages: peerDependencies: '@types/react': ^19.0.0 + '@types/react-window@2.0.0': + resolution: {integrity: sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ==} + deprecated: This is a stub types definition. react-window provides its own type definitions, so you do not need this installed. + '@types/react@18.3.23': resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==} @@ -1769,41 +1783,49 @@ packages: resolution: {integrity: sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.9.0': resolution: {integrity: sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.9.0': resolution: {integrity: sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.9.0': resolution: {integrity: sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.9.0': resolution: {integrity: sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.9.0': resolution: {integrity: sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.9.0': resolution: {integrity: sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.9.0': resolution: {integrity: sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.9.0': resolution: {integrity: sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==} @@ -4756,6 +4778,12 @@ packages: '@types/react': '>=18' react: '>=18' + react-window@2.2.3: + resolution: {integrity: sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -7623,6 +7651,13 @@ snapshots: dependencies: '@types/react': 18.3.23 + '@types/react-window@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + react-window: 2.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - react + - react-dom + '@types/react@18.3.23': dependencies: '@types/prop-types': 15.7.15 @@ -11429,6 +11464,11 @@ snapshots: transitivePeerDependencies: - supports-color + react-window@2.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/src/components/ContinueWatching.tsx b/src/components/ContinueWatching.tsx index 4b23c4b..e451d80 100644 --- a/src/components/ContinueWatching.tsx +++ b/src/components/ContinueWatching.tsx @@ -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) { )} - - {loading - ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( + {loading ? ( + // 加载状态显示灰色占位数据(使用原始 ScrollableRow) +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : ( + // 使用虚拟滚动显示真实数据 + + {playRecords.map((record) => { + const { source, id } = parseKey(record.key); + return (
-
-
-
-
-
+ + 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} + />
- )) - : // 显示真实数据 - playRecords.map((record) => { - const { source, id } = parseKey(record.key); - return ( -
- - 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} - /> -
- ); - })} -
+ ); + })} + + )} ); } diff --git a/src/components/VirtualScrollableRow.tsx b/src/components/VirtualScrollableRow.tsx new file mode 100644 index 0000000..d54417d --- /dev/null +++ b/src/components/VirtualScrollableRow.tsx @@ -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(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 ( +
+ {/* 左侧滚动按钮 */} + {showLeftScroll && ( + + )} + + {/* 滚动容器 */} +
+ {/* 左侧占位符(用于保持滚动位置) */} + {visibleRange.start > 0 && ( +
+ )} + + {/* 渲染可见项目 */} + {visibleChildren} + + {/* 右侧占位符 */} + {visibleRange.end < children.length && ( +
+ )} +
+ + {/* 右侧滚动按钮 */} + {showRightScroll && ( + + )} +
+ ); +}