From a9b3110b2c4e070addccb94564ec4b680ed266ba Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:05:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/hooks/useListPositionMemory.test.ts | 62 ++++++++ web/src/hooks/useListPositionMemory.ts | 87 +++++++++++ web/src/pages/HomePageSections.tsx | 11 +- web/src/pages/LibrariesPageSections.tsx | 16 +- web/src/pages/useLibraryData.ts | 153 ++++++++++++-------- 5 files changed, 264 insertions(+), 65 deletions(-) create mode 100644 web/src/hooks/useListPositionMemory.test.ts create mode 100644 web/src/hooks/useListPositionMemory.ts diff --git a/web/src/hooks/useListPositionMemory.test.ts b/web/src/hooks/useListPositionMemory.test.ts new file mode 100644 index 0000000..ee8404a --- /dev/null +++ b/web/src/hooks/useListPositionMemory.test.ts @@ -0,0 +1,62 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { + forgetListPosition, + normalizeListPosition, + readListPosition, + useRememberedListPosition, + writeListPosition, +} from './useListPositionMemory.ts' + +function check(name: string, condition: boolean) { + if (!condition) throw new Error(`useListPositionMemory: ${name}`) +} + +check('unknown key falls back', readListPosition('unknown-key', 3) === 3) +check('missing value uses default fallback', readListPosition('unknown-key') === 1) + +writeListPosition('demo', 4) +check('round trip keeps value', readListPosition('demo') === 4) + +writeListPosition('demo', 9) +check('rewrite keeps latest value', readListPosition('demo') === 9) + +forgetListPosition('demo') +check('forget resets to fallback', readListPosition('demo', 2) === 2) + +check('normalize parses numeric string', normalizeListPosition('7', 1) === 7) +check('normalize floors decimals', normalizeListPosition(3.8, 1) === 3) +check('normalize rejects zero', normalizeListPosition(0, 2) === 2) +check('normalize rejects negatives', normalizeListPosition(-5, 2) === 2) +check('normalize rejects NaN input', normalizeListPosition('abc', 2) === 2) +check('normalize rejects infinity', normalizeListPosition(Number.POSITIVE_INFINITY, 2) === 2) +check('normalize clamps to max', normalizeListPosition(99, 1, 8) === 8) +check('normalize keeps value under max', normalizeListPosition(6, 1, 8) === 6) + +// 详情页返回时列表组件会重新挂载:挂载即可读到上次页码,而不是先回第一页。 +function PageProbe({ positionKey, max }: { positionKey: string; max?: number }) { + const [position] = useRememberedListPosition(positionKey, 1, max) + return createElement('span', null, String(position)) +} + +writeListPosition('grid:/libraries', 3) +check( + 'remount restores remembered page', + renderToStaticMarkup(createElement(PageProbe, { positionKey: 'grid:/libraries' })) === '3', +) +check( + 'remount restores independent key', + renderToStaticMarkup(createElement(PageProbe, { positionKey: 'grid:/' })) === '1', +) +writeListPosition('shelves', 40) +check( + 'remount clamps to restore cap', + renderToStaticMarkup(createElement(PageProbe, { positionKey: 'shelves', max: 15 })) === '15', +) +check( + 'live state can grow past restore cap', + readListPosition('shelves') === 40, +) + +console.log('useListPositionMemory.test.ts ok') diff --git a/web/src/hooks/useListPositionMemory.ts b/web/src/hooks/useListPositionMemory.ts new file mode 100644 index 0000000..714bd83 --- /dev/null +++ b/web/src/hooks/useListPositionMemory.ts @@ -0,0 +1,87 @@ +import { useCallback, useEffect, useState } from 'react' + +// 列表分页位置(媒体库入口当前页、媒体库已加载页数、货架解锁数量)需要在 +// 进入详情页再返回后依然生效,所以用 sessionStorage 记录,并在内存里留一份 +// 兜底缓存;同一标签页内刷新也仍然记得。 +const LIST_POSITION_PREFIX = 'mebox.listpos.' +const positions = new Map() + +/** + * 详情页返回时最多自动补拉的分页数量:滚动到很深的用户重新进入列表时 + * 依次补拉这些页,剩下的继续由底部哨兵按需加载,避免一次打太多请求。 + */ +export const MAX_RESTORE_PAGES = 8 + +/** 把存储里的任意值收敛成 >= 1 的整数分页位置。 */ +export function normalizeListPosition( + value: unknown, + fallback = 1, + max = Number.MAX_SAFE_INTEGER, +): number { + const numeric = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(numeric)) return fallback + const rounded = Math.floor(numeric) + if (rounded < 1) return fallback + return Math.min(rounded, max) +} + +export function readListPosition(key: string, fallback = 1, max = Number.MAX_SAFE_INTEGER): number { + const cached = positions.get(key) + if (cached !== undefined) return Math.min(cached, max) + if (typeof window === 'undefined') return fallback + try { + const raw = window.sessionStorage.getItem(LIST_POSITION_PREFIX + key) + if (raw === null) return fallback + const value = normalizeListPosition(raw, fallback, max) + positions.set(key, value) + return value + } catch { + return fallback + } +} + +export function writeListPosition(key: string, value: number): void { + const next = normalizeListPosition(value) + positions.set(key, next) + if (typeof window === 'undefined') return + try { + window.sessionStorage.setItem(LIST_POSITION_PREFIX + key, String(next)) + } catch { + // 私密模式等场景忽略存储失败,不影响分页。 + } +} + +export function forgetListPosition(key: string): void { + positions.delete(key) + if (typeof window === 'undefined') return + try { + window.sessionStorage.removeItem(LIST_POSITION_PREFIX + key) + } catch { + // 同上,存储不可用时无需处理。 + } +} + +/** + * 记住列表当前分页位置。key 需要在一份挂载生命周期内保持稳定:路由切换会 + * 让列表重新挂载,因此每次挂载都能直接读到上次记录的位置(不会先闪回第一页)。 + */ +export function useRememberedListPosition( + key: string, + fallback = 1, + max = Number.MAX_SAFE_INTEGER, +): [number, (next: number | ((prev: number) => number)) => void] { + const [position, setPosition] = useState(() => readListPosition(key, fallback, max)) + + useEffect(() => { + writeListPosition(key, position) + }, [key, position]) + + const remember = useCallback((next: number | ((prev: number) => number)) => { + setPosition((prev) => { + const value = typeof next === 'function' ? next(prev) : next + return normalizeListPosition(value, prev) + }) + }, []) + + return [position, remember] +} diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index f1bc396..84fab44 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' import { ArrowRight, ChevronLeft, @@ -20,6 +20,7 @@ import { import { imageURL } from '../api/client' import { useInViewOnce } from '../hooks/useInViewOnce' import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch' +import { useRememberedListPosition } from '../hooks/useListPositionMemory' import { MediaCard } from '../components/MediaCard' import type { HistoryItem } from '../api/playback' import type { Library, Media } from '../types' @@ -371,7 +372,13 @@ export function HomeLibrariesSection({ title?: string }) { const PAGE_SIZE = 20 - const [currentPage, setCurrentPage] = useState(1) + // 分页位置按路由分别记忆:首页「媒体库」和 /libraries 的「媒体库入口」 + // 各自保留自己的页码,进入媒体库详情再返回时不会掉回第一页。 + const { pathname } = useLocation() + const [currentPage, setCurrentPage] = useRememberedListPosition( + `libraries-grid:${pathname}`, + 1, + ) const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE)) const effectivePage = Math.min(currentPage, totalPages) const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2)) diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 024421d..096de0a 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -1,11 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { Link } from 'react-router-dom' import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' +import { useRememberedListPosition } from '../hooks/useListPositionMemory' import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections' import type { LibraryPreview } from './librariesPageModel' +// 返回媒体库列表时最多一次性解锁的货架数量:滚动到很深的位置后回来 +// 仍能落到原处,同时避免一次渲染过多货架。上限之外的继续按需解锁。 +const MAX_RESTORED_SHELVES = 15 + export function LibrariesHeader({ previewCount, total, @@ -100,7 +105,12 @@ export function LibrariesContent({ // 下方媒体库货架继续按需解锁:首屏先展示前 3 个,滚动接近底部再加载 2 个。 const INITIAL_SHELVES = 3 const STEP_SHELVES = 2 - const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES) + // 货架解锁数量同样跨路由记忆:进入媒体库详情再返回不会从头解锁。 + const [visibleCount, setVisibleCount] = useRememberedListPosition( + 'libraries-shelves', + INITIAL_SHELVES, + MAX_RESTORED_SHELVES, + ) const sentinelRef = useRef(null) const userScrolledRef = useRef(false) @@ -109,7 +119,7 @@ export function LibrariesContent({ if (prev >= previews.length) return prev return Math.min(prev + STEP_SHELVES, previews.length) }) - }, [previews.length]) + }, [previews.length, setVisibleCount]) useEffect(() => { const currentTargets = previews.slice(0, visibleCount).map((preview) => preview.library.id) diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts index a697e2b..f55d2da 100644 --- a/web/src/pages/useLibraryData.ts +++ b/web/src/pages/useLibraryData.ts @@ -6,6 +6,7 @@ import type { Library, Media } from '../types' import { peekLibrary, resolveLibrary } from '../utils/libraryCache' import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries' import type { SortField, SortOrder } from '../utils/mediaSort' +import { MAX_RESTORE_PAGES, readListPosition, writeListPosition } from '../hooks/useListPositionMemory' export function useLibraryData( libraryID: string, @@ -43,6 +44,84 @@ export function useLibraryData( const modeRef = useRef<'media' | 'series'>('media') const moreInFlightRef = useRef(false) + // 分页位置按「媒体库 + 排序」记忆:详情页返回、甚至换完排序再切回来, + // 都能回到上次加载到的页数。 + const positionKey = `library:${libraryID}:${sortField}:${sortOrder}` + + // 拉取并追加下一页。滚动哨兵、按钮和首屏分页恢复共用这一条路径, + // 避免两套分页逻辑各自算页码。 + const appendNextPage = useCallback(async (options?: { remember?: boolean }) => { + const lib = libraryRef.current + if (!lib || !hasMoreRef.current) return false + const seq = requestSeqRef.current + const page = nextPageRef.current + try { + if (modeRef.current === 'series') { + const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib), { + sort: sortField, + order: sortOrder, + }) + if (seq !== requestSeqRef.current) return false + const pageItems = data.items ?? [] + setServerSeriesCards((prev) => [...prev, ...pageItems]) + loadedCountRef.current += pageItems.length + totalRef.current = data.total ?? totalRef.current + nextPageRef.current = page + 1 + hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current + } else { + const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), { + sort: sortField, + order: sortOrder, + }) + if (seq !== requestSeqRef.current) return false + const pageItems = data.items ?? [] + setItems((prev) => [...prev, ...pageItems]) + loadedCountRef.current += pageItems.length + totalRef.current = data.total ?? totalRef.current + nextPageRef.current = page + 1 + hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current + } + setTotal(totalRef.current) + setHasMore(hasMoreRef.current) + if (options?.remember !== false) writeListPosition(positionKey, page) + return true + } catch { + if (seq === requestSeqRef.current) { + toast.error('媒体库加载失败') + hasMoreRef.current = false + setHasMore(false) + } + return false + } + }, [libraryID, positionKey, sortField, sortOrder]) + + const loadMore = useCallback(async (options?: { remember?: boolean }) => { + if (moreInFlightRef.current || !hasMoreRef.current) return + moreInFlightRef.current = true + setLoadingMore(true) + try { + await appendNextPage(options) + } finally { + moreInFlightRef.current = false + setLoadingMore(false) + } + }, [appendNextPage]) + + const loadAll = useCallback(async () => { + const seq = requestSeqRef.current + setLoadingMore(true) + try { + while (seq === requestSeqRef.current && hasMoreRef.current) { + // 一次拉全量(random 排序前的准备)不代表用户的分页位置,不写入记忆。 + await loadMore({ remember: false }) + if (seq !== requestSeqRef.current || !hasMoreRef.current) break + await yieldToBrowser() + } + } finally { + if (seq === requestSeqRef.current) setLoadingMore(false) + } + }, [loadMore]) + useEffect(() => { if (!libraryID) return const seq = ++requestSeqRef.current @@ -106,6 +185,19 @@ export function useLibraryData( setTotal(totalRef.current) hasMoreRef.current = loadedCountRef.current < totalRef.current setHasMore(hasMoreRef.current) + + // 按记忆的分页位置把后续页补回来:从详情页返回时直接回到上次翻到的 + // 位置,而不是只剩第一页。超出上限的部分继续交给底部哨兵按需加载。 + const restoreTarget = Math.min(readListPosition(positionKey, 1), MAX_RESTORE_PAGES) + while ( + !cancelled && + seq === requestSeqRef.current && + hasMoreRef.current && + nextPageRef.current <= restoreTarget + ) { + const advanced = await appendNextPage() + if (!advanced) break + } } catch { if (!cancelled && seq === requestSeqRef.current) { toast.error('媒体库加载失败') @@ -122,7 +214,7 @@ export function useLibraryData( cancelled = true requestSeqRef.current += 1 } - }, [libraryID, reloadTick, sortField, sortOrder]) + }, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder]) useEffect(() => { if (!libraryID || !isSeriesLibrary || !selectedSeries) { @@ -146,65 +238,6 @@ export function useLibraryData( return () => { cancelled = true } }, [libraryID, isSeriesLibrary, selectedSeries]) - const loadMore = useCallback(async () => { - const lib = libraryRef.current - if (!lib || moreInFlightRef.current || !hasMoreRef.current) return - const seq = requestSeqRef.current - moreInFlightRef.current = true - setLoadingMore(true) - try { - const page = nextPageRef.current - if (modeRef.current === 'series') { - const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib), { - sort: sortField, - order: sortOrder, - }) - if (seq !== requestSeqRef.current) return - const pageItems = data.items ?? [] - setServerSeriesCards((prev) => [...prev, ...pageItems]) - loadedCountRef.current += pageItems.length - totalRef.current = data.total ?? totalRef.current - nextPageRef.current = page + 1 - hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current - } else { - const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), { - sort: sortField, - order: sortOrder, - }) - if (seq !== requestSeqRef.current) return - const pageItems = data.items ?? [] - setItems((prev) => [...prev, ...pageItems]) - loadedCountRef.current += pageItems.length - totalRef.current = data.total ?? totalRef.current - nextPageRef.current = page + 1 - hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current - } - setTotal(totalRef.current) - setHasMore(hasMoreRef.current) - } catch { - toast.error('媒体库加载失败') - hasMoreRef.current = false - setHasMore(false) - } finally { - moreInFlightRef.current = false - setLoadingMore(false) - } - }, [libraryID, sortField, sortOrder]) - - const loadAll = useCallback(async () => { - const seq = requestSeqRef.current - setLoadingMore(true) - try { - while (seq === requestSeqRef.current && hasMoreRef.current) { - await loadMore() - if (seq !== requestSeqRef.current || !hasMoreRef.current) break - await yieldToBrowser() - } - } finally { - if (seq === requestSeqRef.current) setLoadingMore(false) - } - }, [loadMore]) - const reloadCurrentLibrary = useCallback(() => { setReloadTick((tick) => tick + 1) }, [])