diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
index a10fed1..5f05588 100644
--- a/src/app/search/page.tsx
+++ b/src/app/search/page.tsx
@@ -22,6 +22,7 @@ import PansouSearch from '@/components/PansouSearch';
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
import SearchSuggestions from '@/components/SearchSuggestions';
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
+import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
function SearchPageClient() {
// 搜索历史
@@ -518,6 +519,11 @@ function SearchPageClient() {
});
}, [aggregatedResults, filterAgg, searchQuery]);
+ const useVirtualGrid = useMemo(() => {
+ const cardCount = viewMode === 'agg' ? filteredAggResults.length : filteredAllResults.length;
+ return cardCount >= 100;
+ }, [viewMode, filteredAggResults.length, filteredAllResults.length]);
+
// 监听选项卡切换,自动执行搜索
useEffect(() => {
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
@@ -1194,77 +1200,98 @@ function SearchPageClient() {
)
) : (
-
- {viewMode === 'agg'
- ? filteredAggResults.map(([mapKey, group]) => {
- const title = group[0]?.title || '';
- const poster = group[0]?.poster || '';
- const year = group[0]?.year || 'unknown';
- const { episodes, source_names, douban_id } = computeGroupStats(group);
+ (() => {
+ const gridClassName =
+ 'justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8';
- // 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year)
- // 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
- const lastDashIndex = mapKey.lastIndexOf('-');
- const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
- const type = secondLastDashIndex > 0
- ? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
- : (episodes === 1 ? 'movie' : 'tv'); // 兜底
+ const gridChildren =
+ viewMode === 'agg'
+ ? filteredAggResults.map(([mapKey, group]) => {
+ const title = group[0]?.title || '';
+ const poster = group[0]?.poster || '';
+ const year = group[0]?.year || 'unknown';
+ const { episodes, source_names, douban_id } = computeGroupStats(group);
- // 如果该聚合第一次出现,写入初始统计
- if (!groupStatsRef.current.has(mapKey)) {
- groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
- }
+ // 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year)
+ // 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
+ const lastDashIndex = mapKey.lastIndexOf('-');
+ const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
+ const type = secondLastDashIndex > 0
+ ? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
+ : (episodes === 1 ? 'movie' : 'tv'); // 兜底
- return (
-
+ // 如果该聚合第一次出现,写入初始统计
+ if (!groupStatsRef.current.has(mapKey)) {
+ groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
+ }
+
+ return (
+
+
+
+ );
+ })
+ : filteredAllResults.map((item) => (
+
1 ? 'tv' : 'movie'}
/>
- );
- })
- : filteredAllResults.map((item) => (
-
- 1 ? 'tv' : 'movie'}
- />
-
- ))}
-
+ {gridChildren}
+
+ );
+ }
+
+ return (
+
+ {gridChildren}
+
+ );
+ })()
)}
>
) : activeTab === 'pansou' ? (
diff --git a/src/components/VirtualScrollableGrid.tsx b/src/components/VirtualScrollableGrid.tsx
new file mode 100644
index 0000000..dd0ab2c
--- /dev/null
+++ b/src/components/VirtualScrollableGrid.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+
+interface VirtualScrollableGridProps {
+ children: React.ReactNode[];
+ gridClassName: string;
+ /** extra rows rendered above/below viewport */
+ overscanRows?: number;
+ /** < 640px columns */
+ mobileColumns?: number;
+ /** >= 640px min card width (px) to derive columns */
+ minItemWidth?: number;
+ /** >= 640px max content width (px) to derive columns */
+ maxContentWidth?: number;
+}
+
+const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
+
+export default function VirtualScrollableGrid({
+ children,
+ gridClassName,
+ overscanRows = 3,
+ mobileColumns = 3,
+ minItemWidth = 176,
+ maxContentWidth = 1400,
+}: VirtualScrollableGridProps) {
+ const containerRef = useRef
(null);
+ const measureRef = useRef(null);
+
+ const columnsRef = useRef(mobileColumns);
+ const rowHeightRef = useRef(320);
+ const totalRowsRef = useRef(0);
+ const rafRef = useRef(null);
+
+ const [range, setRange] = useState({ startRow: 0, endRow: 0 });
+
+ const computeColumns = () => {
+ if (typeof window === 'undefined') return mobileColumns;
+ const width = window.innerWidth;
+ if (width < 640) return mobileColumns;
+ const containerWidth = Math.min(width - 32, maxContentWidth);
+ return Math.max(mobileColumns, Math.floor(containerWidth / minItemWidth));
+ };
+
+ const updateLayout = () => {
+ const gapY = window.innerWidth >= 640 ? 80 : 56; // gap-y-14 / sm:gap-y-20
+ const columns = computeColumns();
+ columnsRef.current = columns;
+ totalRowsRef.current = Math.ceil(children.length / Math.max(1, columns));
+
+ // Measure a single item height (wrapper div around VideoCard) and add vertical gap.
+ const measureEl = measureRef.current;
+ const firstItem = measureEl?.querySelector('[data-virtual-measure-item]');
+ const itemH = firstItem?.getBoundingClientRect().height;
+ if (itemH && Number.isFinite(itemH) && itemH > 0) {
+ rowHeightRef.current = Math.max(120, Math.round(itemH + gapY));
+ }
+ };
+
+ const updateRange = () => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const totalRows = totalRowsRef.current;
+ if (totalRows <= 0) return;
+
+ const rowHeight = rowHeightRef.current;
+ if (!rowHeight || rowHeight <= 0) return;
+
+ // This app uses `document.body` as the actual scroll container (see search page back-to-top logic).
+ const scrollTop = document.body.scrollTop || 0;
+ const viewportBottom = scrollTop + window.innerHeight;
+ const containerTop = el.getBoundingClientRect().top + scrollTop;
+
+ const startRow = Math.floor((scrollTop - containerTop) / rowHeight) - overscanRows;
+ const endRow = Math.ceil((viewportBottom - containerTop) / rowHeight) + overscanRows;
+
+ const clampedStart = clamp(startRow, 0, Math.max(0, totalRows - 1));
+ const clampedEnd = clamp(endRow, clampedStart, Math.max(0, totalRows - 1));
+
+ setRange((prev) => {
+ if (prev.startRow === clampedStart && prev.endRow === clampedEnd) return prev;
+ return { startRow: clampedStart, endRow: clampedEnd };
+ });
+ };
+
+ const scheduleUpdate = () => {
+ if (rafRef.current != null) return;
+ rafRef.current = window.requestAnimationFrame(() => {
+ rafRef.current = null;
+ updateLayout();
+ updateRange();
+ });
+ };
+
+ useEffect(() => {
+ updateLayout();
+ updateRange();
+
+ let isRunning = true;
+ const rafLoop = () => {
+ if (!isRunning) return;
+ scheduleUpdate();
+ window.requestAnimationFrame(rafLoop);
+ };
+ rafLoop();
+
+ document.body.addEventListener('scroll', scheduleUpdate, { passive: true });
+ window.addEventListener('resize', scheduleUpdate);
+
+ return () => {
+ isRunning = false;
+ document.body.removeEventListener('scroll', scheduleUpdate);
+ window.removeEventListener('resize', scheduleUpdate);
+ if (rafRef.current != null) window.cancelAnimationFrame(rafRef.current);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [children.length, overscanRows, mobileColumns, minItemWidth, maxContentWidth]);
+
+ const columns = columnsRef.current;
+ const totalRows = totalRowsRef.current;
+ const rowHeight = rowHeightRef.current;
+
+ const startIndex = range.startRow * columns;
+ const endIndexExclusive = Math.min(children.length, (range.endRow + 1) * columns);
+ const visibleChildren = children.slice(startIndex, endIndexExclusive);
+
+ const topSpacerHeight = range.startRow * rowHeight;
+ const bottomSpacerHeight = Math.max(0, (totalRows - range.endRow - 1) * rowHeight);
+
+ return (
+
+ {/* hidden measuring row (first visible row) */}
+
+
+ {children.slice(0, Math.max(1, columns)).map((child, idx) => (
+
+ {child}
+
+ ))}
+
+
+
+
+
{visibleChildren}
+
+
+ );
+}