diff --git a/backend/cmd/seed-videos/main.go b/backend/cmd/seed-videos/main.go new file mode 100644 index 00000000..a0139296 --- /dev/null +++ b/backend/cmd/seed-videos/main.go @@ -0,0 +1,93 @@ +// 本地开发用的一次性数据放大工具:以现有的某条视频为模板生成大量可列出的 +// 行,用来压测列表页的无限滚动 / 虚拟列表。只做 INSERT,不改动既有数据。 +package main + +import ( + "database/sql" + "flag" + "fmt" + "log" + "time" + + _ "modernc.org/sqlite" +) + +func main() { + dbPath := flag.String("db", "./data/video-site.db", "sqlite path") + count := flag.Int("count", 0, "how many synthetic rows to insert") + prefix := flag.String("prefix", "seed", "id prefix for generated rows") + purge := flag.Bool("purge", false, "delete previously generated rows with this prefix") + flag.Parse() + + db, err := sql.Open("sqlite", *dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + like := *prefix + "-%" + + if *purge { + res, err := db.Exec(`DELETE FROM videos WHERE id LIKE ?`, like) + if err != nil { + log.Fatal(err) + } + removed, _ := res.RowsAffected() + fmt.Printf("purged %d generated rows\n", removed) + } + + if *count > 0 { + now := time.Now().UnixMilli() + // content_hash / sampled_sha256 置空 + file_name 唯一,避免被去重触发器 + // 判成同一条视频的副本而丢掉 is_canonical。 + res, err := db.Exec(` +WITH RECURSIVE seq(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < ? +), +template AS ( + SELECT * FROM videos WHERE id NOT LIKE ? ORDER BY created_at LIMIT 1 +) +INSERT INTO videos ( + id, drive_id, file_id, file_name, content_hash, sampled_sha256, + fingerprint_status, parent_id, dir_name, title, author, tags, + duration_seconds, size_bytes, ext, quality, thumbnail_url, + thumbnail_updated_at, thumbnail_status, preview_local, preview_updated_at, + preview_status, views, favorites, comments, likes, dislikes, hidden, + is_canonical, badges, description, published_at, created_at, updated_at +) +SELECT + ? || '-' || printf('%05d', seq.n), + template.drive_id, + -- 复用模板行的真实 file_id,生成的视频才真的能播放和出封面。 + template.file_id, + ? || '-' || printf('%05d', seq.n) || '.mp4', + '', '', 'pending', + template.parent_id, template.dir_name, + '压测视频 ' || printf('%05d', seq.n), + template.author, template.tags, + template.duration_seconds, + COALESCE(template.size_bytes, 0) + seq.n, + template.ext, template.quality, template.thumbnail_url, + template.thumbnail_updated_at, template.thumbnail_status, + template.preview_local, template.preview_updated_at, template.preview_status, + seq.n % 97, 0, 0, seq.n % 53, 0, 0, 1, + template.badges, template.description, + ? - seq.n * 60000, ?, ? +FROM seq, template`, + *count, like, *prefix, *prefix, now, now, now) + if err != nil { + log.Fatal(err) + } + inserted, _ := res.RowsAffected() + fmt.Printf("inserted %d rows\n", inserted) + } + + var total, listable int + if err := db.QueryRow(`SELECT COUNT(*) FROM videos`).Scan(&total); err != nil { + log.Fatal(err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM videos WHERE COALESCE(hidden,0)=0 AND is_canonical=1`).Scan(&listable); err != nil { + log.Fatal(err) + } + fmt.Printf("total rows: %d, listable rows: %d\n", total, listable) +} diff --git a/package-lock.json b/package-lock.json index c6ac30eb..6be12cd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.7.1", "@noble/hashes": "^2.3.0", + "@tanstack/react-virtual": "^3.14.9", "@uiw/react-codemirror": "^4.25.11", "artplayer": "^5.4.0", "hls.js": "^1.6.16", @@ -914,6 +915,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", diff --git a/package.json b/package.json index 7a511899..f51c16ca 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.7.1", "@noble/hashes": "^2.3.0", + "@tanstack/react-virtual": "^3.14.9", "@uiw/react-codemirror": "^4.25.11", "artplayer": "^5.4.0", "hls.js": "^1.6.16", diff --git a/src/components/BackToTop.tsx b/src/components/BackToTop.tsx index 65887809..d9452a73 100644 --- a/src/components/BackToTop.tsx +++ b/src/components/BackToTop.tsx @@ -5,6 +5,14 @@ type Props = { onVisibilityChange?: (visible: boolean) => void; }; +/** + * 虚拟列表会在滚动过程中按实测行高做位置补偿,平滑滚动动画会被这些补偿 + * 打断、停在半路,所以返回顶部直接落到 0。 + */ +function scrollToTop() { + window.scrollTo({ top: 0, behavior: "auto" }); +} + export function BackToTop({ onVisibilityChange }: Props) { const [visible, setVisible] = useState(false); @@ -26,7 +34,7 @@ export function BackToTop({ onVisibilityChange }: Props) { return ( + ); + })} + + ); +} + +export { HOME_FEED_TABS }; diff --git a/src/components/VirtualVideoGrid.tsx b/src/components/VirtualVideoGrid.tsx new file mode 100644 index 00000000..721e54d0 --- /dev/null +++ b/src/components/VirtualVideoGrid.tsx @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; +import { + virtualGridColumns, + virtualRowCount, + virtualRowRange, +} from "@/lib/virtualGrid"; +import type { VideoItem } from "@/types"; +import { VideoCard } from "./VideoCard"; + +/** + * 虚拟滚动的视频网格:以"整行"为虚拟单元交给 @tanstack/react-virtual 的 + * window virtualizer 管理,只挂载可视区域附近的行,其余行由占位容器的 + * 总高度顶出来,滚动条因此与完整列表等高。 + * + * 每行本身仍是原来的 .video-grid,列数与列间距沿用既有 CSS;行高由 + * measureElement 实测,卡片高度随断点或标题变化都不需要额外配置。 + */ + +const DEFAULT_OVERSCAN_ROWS = 2; +const ESTIMATED_ROW_HEIGHT = 260; +const ESTIMATED_COMPACT_ROW_HEIGHT = 120; + +export type VirtualGridRange = { + startIndex: number; + endIndex: number; + columns: number; +}; + +type Props = { + videos: VideoItem[]; + compact?: boolean; + eagerCount?: number; + highPriorityCount?: number; + overscanRows?: number; + refreshMode?: "blocking" | "background"; + onRangeChange?: (range: VirtualGridRange) => void; +}; + +export function VirtualVideoGrid({ + videos, + compact, + eagerCount = 0, + highPriorityCount = 0, + overscanRows = DEFAULT_OVERSCAN_ROWS, + refreshMode, + onRangeChange, +}: Props) { + const containerRef = useRef(null); + const [columns, setColumns] = useState(1); + // 列表容器距文档顶部的距离:window virtualizer 用它把窗口滚动换算成列表内偏移。 + const [scrollMargin, setScrollMargin] = useState(0); + const [rowHeight, setRowHeight] = useState( + compact ? ESTIMATED_COMPACT_ROW_HEIGHT : ESTIMATED_ROW_HEIGHT + ); + const rowCount = virtualRowCount(videos.length, columns); + + const virtualizer = useWindowVirtualizer({ + count: rowCount, + estimateSize: () => rowHeight, + overscan: overscanRows, + scrollMargin, + getItemKey: (index) => videos[index * columns]?.id ?? index, + }); + + const measure = useCallback(() => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const nextMargin = rect.top + window.scrollY; + setScrollMargin((current) => + Math.abs(current - nextMargin) < 1 ? current : nextMargin + ); + + const row = container.querySelector(".video-grid--virtual-row"); + if (!row) return; + const nextColumns = virtualGridColumns(window.getComputedStyle(row)); + setColumns((current) => (current === nextColumns ? current : nextColumns)); + const nextRowHeight = row.getBoundingClientRect().height; + setRowHeight((current) => + nextRowHeight > 0 && Math.abs(current - nextRowHeight) >= 1 + ? nextRowHeight + : current + ); + }, []); + + // 布局阶段就要量出列数:首帧按单列渲染,浏览器绘制之前会被纠正过来。 + useLayoutEffect(() => { + measure(); + }); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); + observer?.observe(container); + window.addEventListener("resize", measure); + return () => { + observer?.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [measure]); + + // 列数变化会重排每一行的内容,之前测到的行高全部失效。 + useEffect(() => { + virtualizer.measure(); + }, [columns, compact, virtualizer]); + + const virtualRows = virtualizer.getVirtualItems(); + const firstRow = virtualRows[0]?.index ?? 0; + const lastRow = virtualRows[virtualRows.length - 1]?.index ?? -1; + + useEffect(() => { + if (lastRow < 0) return; + onRangeChange?.({ + startIndex: firstRow * columns, + endIndex: Math.min((lastRow + 1) * columns, videos.length), + columns, + }); + }, [columns, firstRow, lastRow, onRangeChange, videos.length]); + + const blockingRefresh = refreshMode === "blocking"; + const backgroundRefresh = refreshMode === "background"; + + return ( +
+
+ {virtualRows.map((virtualRow) => { + const { start, end } = virtualRowRange( + virtualRow.index, + columns, + videos.length + ); + return ( +
+ {videos.slice(start, end).map((video, offset) => { + const index = start + offset; + return ( + + ); + })} +
+ ); + })} +
+ {blockingRefresh && ( + + ); +} diff --git a/src/lib/infiniteFeedSource.ts b/src/lib/infiniteFeedSource.ts new file mode 100644 index 00000000..dd54dac9 --- /dev/null +++ b/src/lib/infiniteFeedSource.ts @@ -0,0 +1,114 @@ +import { fetchHomeVideos, fetchListing } from "@/data/videos"; +import { infiniteListingKey } from "@/lib/infiniteListing"; +import type { SortKey, VideoItem } from "@/types"; + +/** + * 无限滚动的数据来源。累积、去重、游标推进由 useInfiniteListing 统一负责, + * 各个 feed 只描述"第 N 批怎么取"。 + */ + +export type InfiniteFeedBatch = { items: VideoItem[]; total: number }; + +export type InfiniteFeedSource = { + /** 同一个 key 代表同一条累积会话,变了就重新开始。 */ + key: string; + /** 每批请求多少条。 */ + batchSize: number; + /** + * 服务端轮换 feed 既没有 total、也永远返回满批,只能靠"整批都是已看过的 + * 内容"判断转完了一圈。 + */ + stopOnDuplicateBatch?: boolean; + /** + * 是否可以用一次大请求把之前的进度补回来。轮换 feed 的游标在服务端且不 + * 幂等,补回来的不是原来那些视频,因此不支持。 + */ + supportsRestore?: boolean; + fetchBatch: ( + request: { offset: number; size: number }, + options: { signal: AbortSignal } + ) => Promise; +}; + +/** + * page/size 接口只能表达页边界上的区间,所以偏移量必须是批大小的整数倍; + * 恢复现场时的大请求也因此被约束成批大小的整数倍。 + */ +export function listingPageFromOffset( + offset: number, + size: number +): number | null { + if (!Number.isInteger(offset) || offset < 0) return null; + if (!Number.isInteger(size) || size <= 0) return null; + if (offset % size !== 0) return null; + return offset / size + 1; +} + +export type ListingFeedQuery = { + q: string; + tag: string; + sort: SortKey; + pageSize: number; +}; + +/** /api/list:真正的分页,有 total,可以恢复现场。 */ +export function listingFeedSource(query: ListingFeedQuery): InfiniteFeedSource { + return { + key: `listing:${infiniteListingKey(query)}`, + batchSize: query.pageSize, + supportsRestore: true, + fetchBatch: (request, options) => { + const page = listingPageFromOffset(request.offset, request.size); + if (page === null) { + return Promise.reject( + new Error( + `Listing offset ${request.offset} is not aligned to size ${request.size}` + ) + ); + } + return fetchListing( + page, + request.size, + { q: query.q, tag: query.tag, sort: query.sort }, + { signal: options.signal } + ); + }, + }; +} + +/** /api/home 单次上限,后端超过就直接 400。 */ +export const HOME_RECOMMENDATION_BATCH_SIZE = 12; + +/** + * /api/home:整库随机轮换,游标由服务端按会话维护,请求里带不了偏移量。 + * 每次拿一批新的随机视频接到列表尾部,转完一圈后开始重复,此时收尾。 + */ +export function homeRecommendationFeedSource(): InfiniteFeedSource { + return { + key: "home:recommend", + batchSize: HOME_RECOMMENDATION_BATCH_SIZE, + stopOnDuplicateBatch: true, + supportsRestore: false, + fetchBatch: async (request) => { + const items = await fetchHomeVideos( + Math.min(request.size, HOME_RECOMMENDATION_BATCH_SIZE) + ); + // 轮换 feed 不知道总量,交给"整批重复"和"返回不足一批"两个收尾条件。 + return { items, total: 0 }; + }, + }; +} + +/** + * 首页"最新视频"走 /api/list?sort=latest 而不是 /api/home/latest:后者单次 + * 上限 12 条且只在最新 96 条里绕圈,撑不起无限滚动。 + */ +export function homeLatestFeedSource(pageSize: number): InfiniteFeedSource { + const source = listingFeedSource({ + q: "", + tag: "", + sort: "latest", + pageSize, + }); + return { ...source, key: `home:latest:${pageSize}` }; +} diff --git a/src/lib/infiniteListing.ts b/src/lib/infiniteListing.ts new file mode 100644 index 00000000..0ff17be1 --- /dev/null +++ b/src/lib/infiniteListing.ts @@ -0,0 +1,212 @@ +import type { SortKey, VideoItem } from "@/types"; + +/** + * 无限滚动列表的纯状态层。分页游标按"已请求条目数"推进而不是按"已渲染条目数", + * 否则后端去重/隐藏行会让偏移量漂移,越翻越错位。 + */ + +export type InfiniteListingStatus = + | "idle" + | "initial-loading" + | "ready" + | "loading-more" + | "error"; + +export type InfiniteListingQuery = { + q: string; + tag: string; + sort: SortKey; + pageSize: number; +}; + +export type InfiniteListingState = { + key: string; + requestID: number; + pageSize: number; + items: VideoItem[]; + total: number; + /** 已经向后端请求过的条目数,等于下一批的偏移量。 */ + requestedCount: number; + /** 后端已经没有更多数据,停止再触发加载。 */ + exhausted: boolean; + status: InfiniteListingStatus; + error: Error | null; + /** 最近一次真实响应的时间,缓存新鲜度以它为准。 */ + receivedAt: number; +}; + +export type InfiniteListingAction = + | { type: "disable"; requestID: number } + | { type: "reset"; requestID: number; key: string; pageSize: number } + | { + type: "hydrate"; + requestID: number; + key: string; + pageSize: number; + items: VideoItem[]; + total: number; + requestedCount: number; + exhausted: boolean; + receivedAt: number; + } + | { type: "load-start"; requestID: number } + | { + type: "load-success"; + requestID: number; + /** 本批请求时的偏移量,用于丢弃与当前游标不衔接的响应。 */ + offset: number; + batchSize: number; + items: VideoItem[]; + total: number; + receivedAt: number; + /** 服务端轮换 feed 没有终点,整批都是重复内容时就收尾。 */ + stopOnDuplicateBatch?: boolean; + } + | { type: "load-failure"; requestID: number; error: Error }; + +export function infiniteListingKey(query: InfiniteListingQuery): string { + return JSON.stringify([ + query.q.trim(), + query.tag.trim(), + query.sort, + Number.isInteger(query.pageSize) && query.pageSize > 0 ? query.pageSize : 1, + ]); +} + +export function emptyInfiniteListingState( + key: string, + pageSize: number +): InfiniteListingState { + return { + key, + requestID: 0, + pageSize, + items: [], + total: 0, + requestedCount: 0, + exhausted: false, + status: "idle", + error: null, + receivedAt: 0, + }; +} + +/** + * 分页边界会随新入库/删除的视频移动,同一条视频可能出现在相邻两页里。 + * 追加时按 id 去重,既修掉重复卡片,也让 StrictMode 的重复请求幂等。 + */ +export function appendUniqueVideos( + previous: VideoItem[], + incoming: VideoItem[] +): VideoItem[] { + if (incoming.length === 0) return previous; + const seen = new Set(previous.map((item) => item.id)); + const fresh = incoming.filter((item) => { + if (!item || seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + if (fresh.length === 0) return previous; + return [...previous, ...fresh]; +} + +export function infiniteListingReducer( + state: InfiniteListingState, + action: InfiniteListingAction +): InfiniteListingState { + switch (action.type) { + case "disable": + return { + ...emptyInfiniteListingState(state.key, state.pageSize), + requestID: action.requestID, + }; + case "reset": + return { + ...emptyInfiniteListingState(action.key, action.pageSize), + requestID: action.requestID, + }; + case "hydrate": + return { + key: action.key, + requestID: action.requestID, + pageSize: action.pageSize, + items: action.items, + total: action.total, + requestedCount: action.requestedCount, + exhausted: action.exhausted, + status: "ready", + error: null, + receivedAt: action.receivedAt, + }; + case "load-start": + return { + ...state, + requestID: action.requestID, + status: state.items.length > 0 ? "loading-more" : "initial-loading", + error: null, + }; + case "load-success": { + if (action.requestID !== state.requestID) return state; + // 游标不衔接说明这批响应属于已经作废的加载序列。 + if (action.offset !== state.requestedCount) return state; + const items = appendUniqueVideos(state.items, action.items); + const requestedCount = action.offset + action.batchSize; + const total = action.total > 0 ? action.total : state.total; + return { + ...state, + items, + total, + requestedCount, + exhausted: isListingExhausted({ + received: action.items.length, + added: items.length - state.items.length, + batchSize: action.batchSize, + requestedCount, + total: action.total, + stopOnDuplicateBatch: action.stopOnDuplicateBatch, + }), + status: "ready", + error: null, + receivedAt: action.receivedAt, + }; + } + case "load-failure": + if (action.requestID !== state.requestID) return state; + return { ...state, status: "error", error: action.error }; + } +} + +/** + * 两个终止条件缺一不可:total 只是查询时刻的计数,删除/隐藏后会虚高, + * 只靠它会在尾部反复请求空页;只靠"返回不足一页"则会漏掉整除的边界。 + */ +export function isListingExhausted(input: { + received: number; + /** 去重后真正新增的条数。 */ + added: number; + batchSize: number; + requestedCount: number; + total: number; + /** 轮换 feed 没有 total,也永远返回满批,只能靠"整批都重复"收尾。 */ + stopOnDuplicateBatch?: boolean; +}): boolean { + if (input.received < input.batchSize) return true; + if (input.stopOnDuplicateBatch && input.added === 0) return true; + return input.total > 0 && input.requestedCount >= input.total; +} + +export type InfiniteListingRequest = { offset: number; size: number }; + +/** 下一批要取的区间;具体怎么翻页由各个 feed source 自己决定。 */ +export function nextListingRequest( + state: InfiniteListingState +): InfiniteListingRequest | null { + if (state.exhausted) return null; + const size = state.pageSize; + if (!Number.isInteger(size) || size <= 0) return null; + return { offset: state.requestedCount, size }; +} + +export function infiniteListingHasMore(state: InfiniteListingState): boolean { + return !state.exhausted && state.status !== "error"; +} diff --git a/src/lib/listingScrollRestore.ts b/src/lib/listingScrollRestore.ts new file mode 100644 index 00000000..f3abcb53 --- /dev/null +++ b/src/lib/listingScrollRestore.ts @@ -0,0 +1,151 @@ +/** + * 前进/后退时恢复无限滚动列表的现场。历史条目自己的 key 作为存储键, + * 所以"后退回列表"能拿回当时的滚动位置,而"重新点进列表"是干净的新会话。 + */ + +export const LISTING_SCROLL_STORAGE_PREFIX = "listing_scroll_v1:"; + +/** 恢复现场时一次最多补回多少条,避免深滚后的返回打出一个超大请求。 */ +export const MAX_RESTORE_ITEMS = 240; + +export type ListingScrollEntry = { + queryKey: string; + /** 保存现场时已经请求过的条目数。 */ + requestedCount: number; + scrollY: number; +}; + +export type ListingScrollStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +}; + +export function listingScrollStorageKey(historyKey: string): string { + return `${LISTING_SCROLL_STORAGE_PREFIX}${historyKey}`; +} + +export function parseListingScrollEntry( + raw: string | null +): ListingScrollEntry | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if ( + !parsed || + typeof parsed.queryKey !== "string" || + !Number.isInteger(parsed.requestedCount) || + parsed.requestedCount <= 0 || + !Number.isFinite(parsed.scrollY) || + parsed.scrollY < 0 + ) { + return null; + } + return { + queryKey: parsed.queryKey, + requestedCount: parsed.requestedCount, + scrollY: parsed.scrollY, + }; + } catch { + return null; + } +} + +export function readListingScrollEntry( + storage: ListingScrollStorage | null, + historyKey: string +): ListingScrollEntry | null { + if (!storage || !historyKey) return null; + try { + return parseListingScrollEntry(storage.getItem(listingScrollStorageKey(historyKey))); + } catch { + // 隐私模式下读写 sessionStorage 会抛错,恢复失败只是回到列表顶部。 + return null; + } +} + +export function writeListingScrollEntry( + storage: ListingScrollStorage | null, + historyKey: string, + entry: ListingScrollEntry +): void { + if (!storage || !historyKey) return; + try { + storage.setItem( + listingScrollStorageKey(historyKey), + JSON.stringify(entry) + ); + } catch { + // 同上:写不进去只影响返回时的位置恢复。 + } +} + +export function clearListingScrollEntry( + storage: ListingScrollStorage | null, + historyKey: string +): void { + if (!storage || !historyKey) return; + try { + storage.removeItem(listingScrollStorageKey(historyKey)); + } catch { + // ignore + } +} + +/** + * 恢复现场时首个请求要取多少条:把保存的进度向上取整到页大小的整数倍, + * 这样后续分页游标仍然落在页边界上;超过上限就只补到上限。 + * 返回 0 表示按普通首屏加载。 + */ +export function resolveRestoreCount(input: { + entry: ListingScrollEntry | null; + queryKey: string; + pageSize: number; + maxItems?: number; +}): number { + const { entry, queryKey } = input; + const pageSize = + Number.isInteger(input.pageSize) && input.pageSize > 0 ? input.pageSize : 0; + if (!entry || pageSize === 0) return 0; + if (entry.queryKey !== queryKey) return 0; + + const maxItems = input.maxItems ?? MAX_RESTORE_ITEMS; + const capped = Math.min(entry.requestedCount, Math.max(maxItems, pageSize)); + const rounded = Math.ceil(capped / pageSize) * pageSize; + return rounded > pageSize ? rounded : 0; +} + +export function resolveRestoreScrollY( + entry: ListingScrollEntry | null, + queryKey: string +): number { + if (!entry || entry.queryKey !== queryKey) return 0; + return entry.scrollY; +} + +/** + * 内容还没长到目标位置时滚过去只会停在底部,之后再补的内容也不会把 + * 视口推回原位。所以恢复动作要等文档高度够了才执行。 + */ +export function canRestoreScrollY(input: { + targetScrollY: number; + documentHeight: number; + viewportHeight: number; +}): boolean { + if (input.targetScrollY <= 0) return true; + return input.documentHeight - input.viewportHeight >= input.targetScrollY; +} + +/** + * 保存的位置比恢复上限还深时(滚过 MAX_RESTORE_ITEMS 条之后返回),退而 + * 求其次滚到能到达的最远处:停在已恢复内容的末尾,比停在顶部更接近原位, + * 继续往下滚也能无缝接上后续批次。 + */ +export function resolveReachableScrollY(input: { + targetScrollY: number; + documentHeight: number; + viewportHeight: number; +}): number { + const maxScrollY = Math.max(0, input.documentHeight - input.viewportHeight); + return Math.max(0, Math.min(input.targetScrollY, maxScrollY)); +} diff --git a/src/lib/listingSearchParams.ts b/src/lib/listingSearchParams.ts index 3dc29d01..c38a5438 100644 --- a/src/lib/listingSearchParams.ts +++ b/src/lib/listingSearchParams.ts @@ -72,6 +72,26 @@ export function withListingView( return next; } +export type HomeFeedKey = "recommend" | "latest"; + +/** 首页推荐/最新两个 tab 记在 URL 里,前进后退才能回到原来那个 tab。 */ +export function readHomeFeed(params: URLSearchParams): HomeFeedKey { + return params.get("feed") === "latest" ? "latest" : "recommend"; +} + +export function withHomeFeed( + params: URLSearchParams, + feed: HomeFeedKey +): URLSearchParams { + const next = new URLSearchParams(params); + if (feed === "latest") { + next.set("feed", "latest"); + } else { + next.delete("feed"); + } + return next; +} + export function withListingNavigation( params: URLSearchParams, patch: ListingNavigationPatch diff --git a/src/lib/useInfiniteListing.ts b/src/lib/useInfiniteListing.ts new file mode 100644 index 00000000..667f09de --- /dev/null +++ b/src/lib/useInfiniteListing.ts @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { + emptyInfiniteListingState, + infiniteListingHasMore, + infiniteListingReducer, + nextListingRequest, + type InfiniteListingState, +} from "@/lib/infiniteListing"; +import type { InfiniteFeedSource } from "@/lib/infiniteFeedSource"; +import type { VideoItem } from "@/types"; + +/** + * 无限滚动的数据层:按 feed source 描述的方式一批批往后取,负责累积、去重、 + * 中断过期请求和会话内缓存。渲染窗口交给 VirtualVideoGrid,滚动现场交给 + * useListingScrollRestore。 + */ + +const INFINITE_LISTING_CACHE_TTL_MS = 60_000; +const INFINITE_LISTING_CACHE_MAX_ENTRIES = 8; + +type CachedInfiniteListing = { + key: string; + items: VideoItem[]; + total: number; + requestedCount: number; + exhausted: boolean; + receivedAt: number; +}; + +const infiniteListingCache = new Map(); + +function readInfiniteListingCache(key: string): CachedInfiniteListing | null { + const cached = infiniteListingCache.get(key) ?? null; + if (!cached) return null; + infiniteListingCache.delete(key); + infiniteListingCache.set(key, cached); + return cached; +} + +function writeInfiniteListingCache(entry: CachedInfiniteListing) { + infiniteListingCache.delete(entry.key); + infiniteListingCache.set(entry.key, entry); + while (infiniteListingCache.size > INFINITE_LISTING_CACHE_MAX_ENTRIES) { + const oldestKey = infiniteListingCache.keys().next().value as + | string + | undefined; + if (!oldestKey) break; + infiniteListingCache.delete(oldestKey); + } +} + +export function clearInfiniteListingCache(key?: string) { + if (key === undefined) { + infiniteListingCache.clear(); + return; + } + infiniteListingCache.delete(key); +} + +function cacheIsFresh(entry: CachedInfiniteListing, now: number): boolean { + return now - entry.receivedAt < INFINITE_LISTING_CACHE_TTL_MS; +} + +/** + * 恢复现场的首个请求要对齐批边界,否则后续游标接不上(page/size 接口尤其 + * 如此)。不支持恢复的 feed 一律按普通首屏来。 + */ +function initialBatchSize(restoreCount: number, batchSize: number): number { + if (!Number.isInteger(restoreCount) || restoreCount <= batchSize) { + return batchSize; + } + return Math.ceil(restoreCount / batchSize) * batchSize; +} + +function errorValue(error: unknown): Error { + return error instanceof Error ? error : new Error("视频列表加载失败"); +} + +function initialState( + source: InfiniteFeedSource, + enabled: boolean +): InfiniteListingState { + const base = emptyInfiniteListingState(source.key, source.batchSize); + if (!enabled) return base; + const cached = infiniteListingCache.get(source.key) ?? null; + if (cached && cacheIsFresh(cached, Date.now())) { + return { + ...base, + items: cached.items, + total: cached.total, + requestedCount: cached.requestedCount, + exhausted: cached.exhausted, + status: "ready", + receivedAt: cached.receivedAt, + }; + } + return { ...base, status: "initial-loading" }; +} + +export type UseInfiniteListingOptions = { + enabled?: boolean; + /** 后退回列表时要一次补回的条目数,0 表示普通首屏。 */ + restoreCount?: number; +}; + +export function useInfiniteListing( + source: InfiniteFeedSource, + options: UseInfiniteListingOptions = {} +) { + const enabled = options.enabled ?? true; + const key = source.key; + const batchSize = source.batchSize; + const [state, dispatch] = useReducer(infiniteListingReducer, undefined, () => + initialState(source, enabled) + ); + const [reloadVersion, setReloadVersion] = useState(0); + + const nextRequestIDRef = useRef(0); + const controllerRef = useRef(null); + const stateRef = useRef(state); + const sourceRef = useRef(source); + const enabledRef = useRef(enabled); + const restoreCountRef = useRef(options.restoreCount ?? 0); + stateRef.current = state; + sourceRef.current = source; + enabledRef.current = enabled; + restoreCountRef.current = options.restoreCount ?? 0; + + const sendRequest = useCallback( + ( + requestID: number, + feed: InfiniteFeedSource, + request: { offset: number; size: number } + ) => { + const controller = new AbortController(); + controllerRef.current = controller; + dispatch({ type: "load-start", requestID }); + feed + .fetchBatch(request, { signal: controller.signal }) + .then((result) => { + if (controller.signal.aborted) return; + dispatch({ + type: "load-success", + requestID, + offset: request.offset, + batchSize: request.size, + items: result.items ?? [], + total: result.total ?? 0, + receivedAt: Date.now(), + stopOnDuplicateBatch: feed.stopOnDuplicateBatch, + }); + }) + .catch((error) => { + if (controller.signal.aborted) return; + dispatch({ type: "load-failure", requestID, error: errorValue(error) }); + }); + }, + [] + ); + + useEffect(() => { + const requestID = ++nextRequestIDRef.current; + controllerRef.current?.abort(); + controllerRef.current = null; + + if (!enabled) { + dispatch({ type: "disable", requestID }); + return; + } + + const cached = readInfiniteListingCache(key); + if (cached && cacheIsFresh(cached, Date.now())) { + dispatch({ + type: "hydrate", + requestID, + key, + pageSize: batchSize, + items: cached.items, + total: cached.total, + requestedCount: cached.requestedCount, + exhausted: cached.exhausted, + receivedAt: cached.receivedAt, + }); + return; + } + + dispatch({ type: "reset", requestID, key, pageSize: batchSize }); + const restoreCount = sourceRef.current.supportsRestore + ? restoreCountRef.current + : 0; + sendRequest(requestID, sourceRef.current, { + offset: 0, + size: initialBatchSize(restoreCount, batchSize), + }); + + return () => { + controllerRef.current?.abort(); + controllerRef.current = null; + }; + }, [batchSize, enabled, key, reloadVersion, sendRequest]); + + // 会话内缓存以真实响应时间为准:hydrate 回来的状态不会自我续命。 + useEffect(() => { + if (!enabled || state.key !== key) return; + if (state.status !== "ready" || state.items.length === 0) return; + writeInfiniteListingCache({ + key, + items: state.items, + total: state.total, + requestedCount: state.requestedCount, + exhausted: state.exhausted, + receivedAt: state.receivedAt, + }); + }, [enabled, key, state]); + + const requestBatch = useCallback( + (batchOptions: { force?: boolean } = {}) => { + if (!enabledRef.current) return; + const current = stateRef.current; + if (current.status === "initial-loading" || current.status === "loading-more") { + return; + } + if (!batchOptions.force && current.status === "error") return; + const request = nextListingRequest(current); + if (!request) return; + sendRequest(++nextRequestIDRef.current, sourceRef.current, request); + }, + [sendRequest] + ); + + const loadMore = useCallback(() => requestBatch(), [requestBatch]); + + const reload = useCallback(() => { + clearInfiniteListingCache(sourceRef.current.key); + setReloadVersion((version) => version + 1); + }, []); + + // 首屏失败要整段重来,尾部失败只重试失败的那一批,已加载内容保持不动。 + const retry = useCallback(() => { + if (stateRef.current.items.length === 0) { + reload(); + return; + } + requestBatch({ force: true }); + }, [reload, requestBatch]); + + const matchesQuery = state.key === key; + const items = matchesQuery ? state.items : []; + const initialLoading = + enabled && + (!matchesQuery || (state.status === "initial-loading" && items.length === 0)); + + return { + items, + total: matchesQuery ? state.total : 0, + status: state.status, + error: state.error, + initialLoading, + loadingMore: matchesQuery && state.status === "loading-more", + failed: matchesQuery && state.status === "error", + exhausted: matchesQuery && state.exhausted, + hasMore: matchesQuery && infiniteListingHasMore(state), + requestedCount: matchesQuery ? state.requestedCount : 0, + loadMore, + reload, + retry, + }; +} diff --git a/src/lib/useListingScrollRestore.ts b/src/lib/useListingScrollRestore.ts new file mode 100644 index 00000000..0492520e --- /dev/null +++ b/src/lib/useListingScrollRestore.ts @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + canRestoreScrollY, + readListingScrollEntry, + resolveReachableScrollY, + resolveRestoreCount, + resolveRestoreScrollY, + writeListingScrollEntry, + type ListingScrollStorage, +} from "@/lib/listingScrollRestore"; + +/** + * 无限滚动列表的前进/后退现场恢复。历史条目的 key 是存储键,因此"后退" + * 拿回的是那一条历史自己的进度,重新进入列表则是干净的新会话。 + * + * 拆成两个 hook 是因为存在先后依赖:要补回多少条必须在数据层发起第一个 + * 请求之前就确定,而落盘进度又依赖数据层已经请求到的条数。 + */ + +// 内容还没渲染够时滚不到目标位置,按帧重试;超过上限就放弃,避免死循环。 +const RESTORE_MAX_FRAMES = 90; + +function sessionStorageOrNull(): ListingScrollStorage | null { + try { + return window.sessionStorage; + } catch { + return null; + } +} + +export type ListingRestoreTarget = { + historyKey: string; + count: number; + scrollY: number; +}; + +/** + * 解析当前历史条目要恢复的进度。在渲染期读取(而不是 effect):数据层的 + * 首个请求就发生在这次渲染之后,晚一拍就会先打一个只有首屏的请求。 + */ +export function useListingRestoreTarget(input: { + historyKey: string; + queryKey: string; + pageSize: number; +}): ListingRestoreTarget { + const targetRef = useRef(null); + if (!targetRef.current || targetRef.current.historyKey !== input.historyKey) { + const entry = readListingScrollEntry( + sessionStorageOrNull(), + input.historyKey + ); + targetRef.current = { + historyKey: input.historyKey, + count: resolveRestoreCount({ + entry, + queryKey: input.queryKey, + pageSize: input.pageSize, + }), + scrollY: resolveRestoreScrollY(entry, input.queryKey), + }; + } + return targetRef.current; +} + +export type UseListingScrollRestoreInput = { + target: ListingRestoreTarget; + queryKey: string; + /** 当前已经请求过的条目数,作为下次恢复的进度。 */ + requestedCount: number; + itemCount: number; +}; + +export function useListingScrollRestore({ + target, + queryKey, + requestedCount, + itemCount, +}: UseListingScrollRestoreInput) { + const historyKey = target.historyKey; + const pendingScrollYRef = useRef(target.scrollY); + const lastScrollYRef = useRef(target.scrollY); + const restoredHistoryKeyRef = useRef(historyKey); + const [restoring, setRestoring] = useState(target.scrollY > 0); + + if (restoredHistoryKeyRef.current !== historyKey) { + restoredHistoryKeyRef.current = historyKey; + pendingScrollYRef.current = target.scrollY; + lastScrollYRef.current = target.scrollY; + } + + useEffect(() => { + const targetScrollY = pendingScrollYRef.current; + if (targetScrollY <= 0) { + setRestoring(false); + return; + } + setRestoring(true); + if (itemCount === 0) return; + + let frame = 0; + let handle = 0; + const finish = (restoredScrollY: number) => { + pendingScrollYRef.current = 0; + lastScrollYRef.current = restoredScrollY; + setRestoring(false); + }; + const attempt = () => { + if ( + canRestoreScrollY({ + targetScrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }) + ) { + window.scrollTo(0, targetScrollY); + finish(targetScrollY); + return; + } + frame += 1; + if (frame >= RESTORE_MAX_FRAMES) { + // 保存的位置比恢复上限更深时,停在能到达的最远处而不是回到顶部。 + const reachable = resolveReachableScrollY({ + targetScrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }); + window.scrollTo(0, reachable); + finish(reachable); + return; + } + handle = window.requestAnimationFrame(attempt); + }; + handle = window.requestAnimationFrame(attempt); + + return () => window.cancelAnimationFrame(handle); + }, [historyKey, itemCount]); + + const save = useCallback( + (scrollY: number) => { + // 还没恢复完就落盘,会把保存的位置覆盖成恢复前的 0。 + if (pendingScrollYRef.current > 0) return; + if (requestedCount <= 0) return; + writeListingScrollEntry(sessionStorageOrNull(), historyKey, { + queryKey, + requestedCount, + scrollY: Math.max(0, Math.round(scrollY)), + }); + }, + [historyKey, queryKey, requestedCount] + ); + + useEffect(() => { + let ticking = false; + let live = true; + const handleScroll = () => { + // 位置要在滚动事件里同步记下:卸载时列表 DOM 已经被详情页顶掉, + // 那时再读 window.scrollY 拿到的是被浏览器压缩过的值。 + lastScrollYRef.current = Math.max(0, Math.round(window.scrollY)); + if (ticking) return; + ticking = true; + window.requestAnimationFrame(() => { + ticking = false; + if (!live) return; + save(lastScrollYRef.current); + }); + }; + const handlePageHide = () => save(window.scrollY); + + window.addEventListener("scroll", handleScroll, { passive: true }); + window.addEventListener("pagehide", handlePageHide); + return () => { + live = false; + window.removeEventListener("scroll", handleScroll); + window.removeEventListener("pagehide", handlePageHide); + // 离开列表页(例如进详情页)时把最后的位置落盘,后退才能回到原处。 + save(lastScrollYRef.current); + }; + }, [save]); + + return { restoring }; +} diff --git a/src/lib/virtualGrid.ts b/src/lib/virtualGrid.ts new file mode 100644 index 00000000..6243e70a --- /dev/null +++ b/src/lib/virtualGrid.ts @@ -0,0 +1,69 @@ +/** + * 虚拟网格的纯计算层:把"一维的视频列表"折成"二维的行",以及决定何时 + * 续下一批。窗口本身由 @tanstack/react-virtual 负责,这里只留可以在没有 + * 浏览器的单元测试里覆盖到每个分支的算术。 + */ + +function toCount(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0; + return Math.floor(value); +} + +/** + * 从计算样式里读出列数。只有 grid 容器才会把 grid-template-columns 解析成 + * 逐条轨道宽度;compact 视图是 flex 列表,此时 grid-template-columns 仍是 + * 未解析的 "repeat(4, minmax(0, 1fr))",按空格数它会被误读成 3 列,所以 + * 必须先看 display。 + */ +export function virtualGridColumns(style: { + display: string; + gridTemplateColumns: string; +}): number { + if (!style.display?.includes("grid")) return 1; + const trimmed = style.gridTemplateColumns?.trim() ?? ""; + if (!trimmed || trimmed === "none" || trimmed.includes("(")) return 1; + return Math.max(1, trimmed.split(/\s+/).filter(Boolean).length); +} + +/** 虚拟单元是"整行",行数决定 virtualizer 的 count。 */ +export function virtualRowCount(itemCount: number, columns: number): number { + const items = toCount(itemCount); + if (items === 0) return 0; + return Math.ceil(items / Math.max(1, toCount(columns) || 1)); +} + +/** 某一行覆盖的下标区间 [start, end),末行不足一整行时按实际条数收口。 */ +export function virtualRowRange( + rowIndex: number, + columns: number, + itemCount: number +): { start: number; end: number } { + const items = toCount(itemCount); + const perRow = Math.max(1, toCount(columns) || 1); + const row = Number.isFinite(rowIndex) ? Math.floor(rowIndex) : -1; + if (row < 0 || items === 0) return { start: 0, end: 0 }; + const start = Math.min(row * perRow, items); + return { start, end: Math.min(start + perRow, items) }; +} + +export type LoadMoreInput = { + /** 当前渲染窗口的结束下标(不含)。 */ + endIndex: number; + itemCount: number; + columns: number; + hasMore: boolean; + loading: boolean; + /** 距列表尾部还有几行时开始预取。 */ + prefetchRows?: number; +}; + +/** + * 加载更多的触发条件直接来自渲染窗口,而不是另加一个哨兵节点: + * 虚拟列表里哨兵本身可能被回收,两套机制也会各自漂移。 + */ +export function shouldLoadMore(input: LoadMoreInput): boolean { + if (!input.hasMore || input.loading) return false; + const columns = Math.max(1, toCount(input.columns) || 1); + const prefetchRows = Math.max(1, toCount(input.prefetchRows ?? 1) || 1); + return input.endIndex >= toCount(input.itemCount) - prefetchRows * columns; +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 8821da0f..5c97f969 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,40 +1,58 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { RefreshCw } from "lucide-react"; -import { useSearchParams } from "react-router"; +import { useLocation, useSearchParams } from "react-router"; import { AdminEmptyVisual } from "@/admin/AdminEmptyVisual"; import { AppShell } from "@/components/AppShell"; +import { HomeFeedTabs } from "@/components/HomeFeedTabs"; import { ListingLoadError } from "@/components/ListingLoadError"; import { Pagination } from "@/components/Pagination"; import { PromoStrip } from "@/components/PromoStrip"; import { SearchPanel } from "@/components/SearchPanel"; -import { SectionHeader } from "@/components/SectionHeader"; import { SortToolbar } from "@/components/SortToolbar"; import { TagCloud } from "@/components/TagCloud"; import { VideoGrid } from "@/components/VideoGrid"; -import { fetchHomeVideos, fetchLatestHomeVideos } from "@/data/videos"; import { + VirtualVideoGrid, + type VirtualGridRange, +} from "@/components/VirtualVideoGrid"; +import { + homeLatestFeedSource, + homeRecommendationFeedSource, +} from "@/lib/infiniteFeedSource"; +import { + readHomeFeed, readListingPage, readListingSort, readListingView, + withHomeFeed, withListingNavigation, withListingPage, withListingView, } from "@/lib/listingSearchParams"; import { MOBILE_VIDEO_PAGE_SIZE, useIsMobile } from "@/lib/responsive"; +import { useInfiniteListing } from "@/lib/useInfiniteListing"; import { useListingQuery } from "@/lib/useListingQuery"; -import type { VideoItem } from "@/types"; +import { + useListingRestoreTarget, + useListingScrollRestore, +} from "@/lib/useListingScrollRestore"; +import { shouldLoadMore } from "@/lib/virtualGrid"; -const DESKTOP_COUNT = 12; -const MOBILE_COUNT = 8; const HOME_SEARCH_DESKTOP_PAGE_SIZE = 20; +const HOME_FEED_DESKTOP_BATCH_SIZE = 20; -// 首页推荐接口每次请求都会推进会话轮换游标。模块级快照因此在 SPA 会话内 -// 保持稳定,只有用户主动刷新、浏览器整页刷新或响应式布局需要补足卡片时才更新。 -let cachedRanking: VideoItem[] | null = null; -let cachedLatest: VideoItem[] | null = null; +// 距列表尾部还有两行时就续下一批,滚动到底之前数据已经在路上。 +const PREFETCH_ROWS = 2; + +const EMPTY_RANGE: VirtualGridRange = { + startIndex: 0, + endIndex: 0, + columns: 1, +}; export default function HomePage() { const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); const activeSearchQuery = searchParams.get("q")?.trim() ?? ""; const activeTag = searchParams.get("tag")?.trim() ?? ""; const hasActiveSearch = activeSearchQuery.length > 0; @@ -43,8 +61,11 @@ export default function HomePage() { const searchPage = readListingPage(searchParams); const searchSort = readListingSort(searchParams); const searchView = readListingView(searchParams); + const feed = readHomeFeed(searchParams); const isMobile = useIsMobile(); - const displayCount = isMobile ? MOBILE_COUNT : DESKTOP_COUNT; + const eagerCount = isMobile ? 2 : 4; + + // 搜索和标签结果仍然分页:这类结果常常需要定位到具体某一页。 const searchPageSize = isMobile ? MOBILE_VIDEO_PAGE_SIZE : HOME_SEARCH_DESKTOP_PAGE_SIZE; @@ -68,82 +89,41 @@ export default function HomePage() { searchResult.phase === "error" && searchHasContent; const searchShowEmptyError = searchResult.phase === "error" && !searchHasContent; - const eagerCount = isMobile ? 2 : 4; - - const [rankingVideos, setRankingVideos] = useState(cachedRanking ?? []); - const [latestVideos, setLatestVideos] = useState(cachedLatest ?? []); - const [rankingLoading, setRankingLoading] = useState(cachedRanking === null); - const [rankingError, setRankingError] = useState(false); - const [rankingRevalidating, setRankingRevalidating] = useState(false); - const [latestLoading, setLatestLoading] = useState(cachedLatest === null); - const [latestError, setLatestError] = useState(false); - const [latestRevalidating, setLatestRevalidating] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const homeRequestVersion = useRef(0); - const rankingRequestVersion = useRef(0); - const latestRequestVersion = useRef(0); - const displayCountRef = useRef(displayCount); - const previousDisplayCountRef = useRef(displayCount); const previousSearchPageSizeRef = useRef(searchPageSize); const searchScrollOnCommitRef = useRef(false); - displayCountRef.current = displayCount; - const loadRanking = useCallback(async (background: boolean) => { - const requestVersion = ++rankingRequestVersion.current; - const hasCachedContent = cachedRanking !== null; - setRankingLoading(!hasCachedContent); - setRankingRevalidating(background && hasCachedContent); - setRankingError(false); - try { - const rankingItems = await fetchHomeVideos(DESKTOP_COUNT); - if (requestVersion !== rankingRequestVersion.current) return; - cachedRanking = rankingItems; - setRankingVideos(rankingItems); - setRankingError(false); - } catch { - if (requestVersion !== rankingRequestVersion.current) return; - setRankingError(true); - } finally { - if (requestVersion === rankingRequestVersion.current) { - setRankingLoading(false); - setRankingRevalidating(false); - } - } - }, []); - - const loadLatest = useCallback(async (background: boolean) => { - const requestVersion = ++latestRequestVersion.current; - const hasCachedContent = cachedLatest !== null; - setLatestLoading(!hasCachedContent); - setLatestRevalidating(background && hasCachedContent); - setLatestError(false); - try { - const latestItems = await fetchLatestHomeVideos(displayCountRef.current); - if (requestVersion !== latestRequestVersion.current) return; - cachedLatest = latestItems; - setLatestVideos(latestItems); - setLatestError(false); - } catch { - if (requestVersion !== latestRequestVersion.current) return; - setLatestError(true); - } finally { - if (requestVersion === latestRequestVersion.current) { - setLatestLoading(false); - setLatestRevalidating(false); - } - } - }, []); - - const refreshRanking = useCallback(() => loadRanking(true), [loadRanking]); - const refreshLatest = useCallback(() => loadLatest(true), [loadLatest]); + // 两个推荐 tab 都是无限滚动。随机推荐走服务端轮换 feed,最新视频走真正 + // 分页的列表接口。 + const feedBatchSize = isMobile + ? MOBILE_VIDEO_PAGE_SIZE + : HOME_FEED_DESKTOP_BATCH_SIZE; + const feedSource = useMemo( + () => + feed === "latest" + ? homeLatestFeedSource(feedBatchSize) + : homeRecommendationFeedSource(), + [feed, feedBatchSize] + ); + const restoreTarget = useListingRestoreTarget({ + historyKey: location.key, + queryKey: feedSource.key, + pageSize: feedSource.batchSize, + }); + const homeFeed = useInfiniteListing(feedSource, { + enabled: !hasActiveFilter, + restoreCount: restoreTarget.count, + }); + useListingScrollRestore({ + target: restoreTarget, + queryKey: feedSource.key, + requestedCount: hasActiveFilter ? 0 : homeFeed.requestedCount, + itemCount: homeFeed.items.length, + }); - const refreshHome = useCallback(async () => { - const requestVersion = ++homeRequestVersion.current; - setRefreshing(true); - await Promise.allSettled([loadRanking(false), loadLatest(false)]); - if (requestVersion !== homeRequestVersion.current) return; - setRefreshing(false); - }, [loadLatest, loadRanking]); + const feedItems = homeFeed.items; + const feedHasContent = feedItems.length > 0; + const [range, setRange] = useState(EMPTY_RANGE); + const previousFeedKeyRef = useRef(feedSource.key); useEffect(() => { document.title = activeSearchQuery @@ -153,52 +133,6 @@ export default function HomePage() { : "首页"; }, [activeSearchQuery, activeTag]); - useEffect(() => { - if (cachedRanking === null) { - void loadRanking(false); - } - - if (cachedLatest === null) { - void loadLatest(false); - } else if (cachedLatest.length < displayCountRef.current) { - void loadLatest(true); - } else { - setLatestVideos(cachedLatest); - setLatestLoading(false); - } - - return () => { - homeRequestVersion.current += 1; - rankingRequestVersion.current += 1; - latestRequestVersion.current += 1; - }; - }, [loadLatest, loadRanking]); - - useEffect(() => { - if (hasActiveFilter) return; - const previousCount = previousDisplayCountRef.current; - if (displayCount <= previousCount) { - previousDisplayCountRef.current = displayCount; - return; - } - if (latestVideos.length >= displayCount) { - previousDisplayCountRef.current = displayCount; - return; - } - if (refreshing || latestLoading || latestRevalidating) return; - - previousDisplayCountRef.current = displayCount; - void refreshLatest(); - }, [ - displayCount, - hasActiveFilter, - latestLoading, - latestRevalidating, - latestVideos.length, - refreshLatest, - refreshing, - ]); - useEffect(() => { if (previousSearchPageSizeRef.current === searchPageSize) return; previousSearchPageSizeRef.current = searchPageSize; @@ -217,17 +151,61 @@ export default function HomePage() { window.scrollTo({ top: 0, behavior: "smooth" }); }, [searchResult.key, searchSnapshot?.key]); - const ranking = rankingVideos.slice(0, displayCount); - const latest = latestVideos.slice(0, displayCount); - const homeLoading = rankingLoading || latestLoading; - const hasAnyVideos = ranking.length > 0 || latest.length > 0; - const hasHomeError = rankingError || latestError; - const showEmptyHome = !homeLoading && !hasHomeError && !hasAnyVideos; + // 换 tab 是一次全新的列表,回到顶部再开始累积。平滑滚动会被虚拟列表的 + // 行高补偿打断而停在半路,所以直接落到顶部。 + useEffect(() => { + if (previousFeedKeyRef.current === feedSource.key) return; + previousFeedKeyRef.current = feedSource.key; + window.scrollTo({ top: 0, behavior: "auto" }); + }, [feedSource.key]); + + const handleRangeChange = useCallback((next: VirtualGridRange) => { + setRange((current) => + current.startIndex === next.startIndex && + current.endIndex === next.endIndex && + current.columns === next.columns + ? current + : next + ); + }, []); + + const { loadMore, loadingMore, hasMore } = homeFeed; + useEffect(() => { + if (hasActiveFilter) return; + if ( + shouldLoadMore({ + endIndex: range.endIndex, + itemCount: feedItems.length, + columns: range.columns, + hasMore, + loading: loadingMore, + prefetchRows: PREFETCH_ROWS, + }) + ) { + loadMore(); + } + }, [ + feedItems.length, + hasActiveFilter, + hasMore, + loadMore, + loadingMore, + range, + ]); + + const reloadFeed = homeFeed.reload; + const refreshHome = useCallback(() => { + window.scrollTo({ top: 0, behavior: "auto" }); + reloadFeed(); + }, [reloadFeed]); + const displayedSearchSort = searchResult.phase === "error" && searchSnapshot ? searchSnapshot.query.sort : searchSort; const displayedSearchPage = searchSnapshot?.query.page ?? searchPage; + const showRefresh = !hasActiveFilter && feed === "recommend"; + const refreshing = showRefresh && homeFeed.initialLoading; return ( @@ -239,7 +217,7 @@ export default function HomePage() { placeholder="" className="search-panel--public search-panel--transparent" /> - {hasAnyVideos || hasActiveFilter ? ( + {feedHasContent || hasActiveFilter ? ( ) : ( - ) : showEmptyHome ? ( + ) : (
- { + setSearchParams(withHomeFeed(searchParams, nextFeed), { + replace: true, + }); + }} /> -
- ) : ( - <> -
- - {rankingError && ranking.length > 0 && ( - void refreshRanking()} - /> - )} - -
-
- - {latestError && latest.length > 0 && ( - void refreshLatest()} - /> - )} - + ) : homeFeed.failed && !feedHasContent ? ( + + ) : !feedHasContent ? ( + -
- + ) : ( + <> + + + {homeFeed.failed ? ( + + ) : homeFeed.loadingMore ? ( +
+
+ ) : homeFeed.exhausted ? ( +
+ 没有更多了 +
+ ) : null} + + )} +
)} - {!hasActiveFilter && ( + {showRefresh && (