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)
}, [])