Compare commits

...

3 Commits

Author SHA1 Message Date
truewhile 0c4668c156 优化 2026-09-19 21:30:32 +08:00
truewhile 474513f18a bug处理 2026-09-18 23:35:56 +08:00
truewhile 0810bfb234 bug处理 2026-09-18 22:52:09 +08:00
9 changed files with 251 additions and 45 deletions
+1
View File
@@ -89,6 +89,7 @@ export function Layout() {
)}
<LayoutWorkspace
routeKey={location.pathname}
scrollKey={`${location.pathname}${location.search}`}
userKey={user?.id}
showMobileBottomNav={showMobileBottomNav}
/>
+4 -1
View File
@@ -31,6 +31,8 @@ type LayoutSidebarsProps = Omit<
type LayoutWorkspaceProps = {
routeKey: string
/** 滚动记忆的 key:含 query,让同一路径下的不同视图(如剧集面板)各记一份位置。 */
scrollKey?: string
userKey?: string
showMobileBottomNav?: boolean
}
@@ -113,10 +115,11 @@ export function LayoutSidebars({
export function LayoutWorkspace({
routeKey,
scrollKey,
userKey = 'anonymous',
showMobileBottomNav = false,
}: LayoutWorkspaceProps) {
useScrollMemory(routeKey, userKey)
useScrollMemory(scrollKey ?? routeKey, userKey)
const bottomPad = showMobileBottomNav
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
+4 -3
View File
@@ -10,13 +10,14 @@ const ACTION_OVERLAY_CLASS =
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
export const MediaCard = memo(function MediaCard({
media, progress, count, rating, linkTo, onClick, actions, renderActions, compact,
media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact,
}: {
media: Media
progress?: number
count?: number
rating?: number
linkTo?: string
linkState?: { from?: string }
onClick?: () => void
actions?: ReactNode
renderActions?: (media: Media) => ReactNode
@@ -169,7 +170,7 @@ export const MediaCard = memo(function MediaCard({
if (actionContent) {
return (
<div className="group relative block">
<Link to={href} className="block">
<Link to={href} state={linkState} className="block">
{card}
</Link>
<div className={ACTION_OVERLAY_CLASS}>
@@ -180,7 +181,7 @@ export const MediaCard = memo(function MediaCard({
}
return (
<Link to={href} className="group block">
<Link to={href} state={linkState} className="group block">
{card}
</Link>
)
+52 -1
View File
@@ -1,4 +1,10 @@
import { shouldPersistScrollSample, shouldRememberScroll } from './useScrollMemory.ts'
import {
isJumpIntentKey,
isScrollIntentKey,
shouldPersistClampedSample,
shouldPersistScrollSample,
shouldRememberScroll,
} from './useScrollMemory.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`useScrollMemory: ${name}`)
@@ -7,6 +13,7 @@ function check(name: string, condition: boolean) {
check('home remembers scroll', shouldRememberScroll('/'))
check('library list remembers scroll', shouldRememberScroll('/libraries'))
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
check('library series panel remembers scroll', shouldRememberScroll('/library/lib-1'))
check('media detail remembers scroll', shouldRememberScroll('/media/media-1'))
check('settings does not remember scroll', !shouldRememberScroll('/settings'))
check('player does not remember scroll', !shouldRememberScroll('/play/media-1'))
@@ -48,4 +55,48 @@ check(
}),
)
// 恢复途中内容还没长出来(滚不动)或被钳在矮内容底部时,scrollTop 是浏览器钳制
// 出来的值,不能写回存储,否则一次误触就把记忆位置清成 0。
check(
'clamped sample while list is still loading is ignored',
!shouldPersistClampedSample({ current: 0, maxScroll: 0, saved: 1800 }),
)
check(
'clamped sample at the bottom of a shrunken list is ignored',
!shouldPersistClampedSample({ current: 1200, maxScroll: 1200, saved: 1800 }),
)
check(
'sample at the bottom of a completely scrolled list stays persistable',
shouldPersistClampedSample({ current: 1800, maxScroll: 1800, saved: 1800 }),
)
check(
'reachable scroll position is persisted',
shouldPersistClampedSample({ current: 900, maxScroll: 1200, saved: 1800 }),
)
check(
'reaching the remembered target is persisted',
shouldPersistClampedSample({ current: 1800, maxScroll: 2600, saved: 1800 }),
)
check(
'deeper than the remembered target is persisted',
shouldPersistClampedSample({ current: 3000, maxScroll: 4000, saved: 1800 }),
)
check(
'intentional scroll to top of a tall list is persisted',
shouldPersistClampedSample({ current: 0, maxScroll: 4000, saved: 1800 }),
)
check(
'unscrollable page without memory cannot persist',
!shouldPersistClampedSample({ current: 0, maxScroll: 0, saved: 0 }),
)
check('page down counts as scroll intent', isScrollIntentKey('PageDown'))
check('space counts as scroll intent', isScrollIntentKey(' '))
check('typing does not count as scroll intent', !isScrollIntentKey('a'))
check('shortcut does not count as scroll intent', !isScrollIntentKey('Meta'))
check('page down allows a long jump', isJumpIntentKey('PageDown'))
check('home allows a long jump', isJumpIntentKey('Home'))
check('arrow key keeps smooth-only intent', !isJumpIntentKey('ArrowDown'))
check('space keeps smooth-only intent', !isJumpIntentKey(' '))
console.log('useScrollMemory.test.ts ok')
+144 -31
View File
@@ -3,6 +3,36 @@ import { useLayoutEffect } from 'react'
const SCROLL_STORAGE_PREFIX = 'mebox.scroll.'
const scrollPositions = new Map<string, number>()
/**
* 用户滚动意图的有效期:滚轮 / 触摸 / 翻页键之后这段时间内的 scroll 事件才算
* “用户自己滚出来的位置”。连续滚动(含惯性)期间每个 scroll 事件都会续期,
* 所以正常滑动不会中途过期,而恢复回填 / 虚拟列表量高这类程序化位移会被排除。
*/
const USER_SCROLL_INTENT_TTL_MS = 800
/** 平滑滚动(滚轮/触摸/方向键)单次采样允许的最大“向上跳”距离。 */
function maxSmoothJumpUp(viewportHeight: number): number {
return Math.max(viewportHeight * 2, 1200)
}
/** 会滚动容器的按键。 */
export function isScrollIntentKey(key: string): boolean {
return (
key === 'PageDown' ||
key === 'PageUp' ||
key === 'Home' ||
key === 'End' ||
key === 'ArrowDown' ||
key === 'ArrowUp' ||
key === ' '
)
}
/** 一次按键可能直接跳很远(首页/末页/翻页),这些不算异常向上跳。 */
export function isJumpIntentKey(key: string): boolean {
return key === 'PageDown' || key === 'PageUp' || key === 'Home' || key === 'End'
}
function readScrollPosition(key: string): number {
const cached = scrollPositions.get(key)
if (cached !== undefined) return cached
@@ -56,21 +86,54 @@ export function shouldPersistScrollSample(input: {
return true
}
/**
* 判断一次滚动采样是不是“用户真正选中的位置”。
*
* 内容还在加载(容器根本滚不动)或被钳在“变矮内容”的底部时,scrollTop 是浏览器
* 钳制出来的值:恢复途中任何一次滚轮 / 触摸 / 按键都会读到这个值,写回存储就会把
* 记忆位置抹成 0(表现为返回媒体库时永远停在顶部,且之后再也不会记录)。
*/
export function shouldPersistClampedSample(input: {
current: number
maxScroll: number
saved: number
}): boolean {
const { current, maxScroll, saved } = input
if (maxScroll <= 0) {
return false
}
if (current >= maxScroll && current < saved) {
return false
}
return true
}
/**
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
*
* 恢复还没完成时,容器里的 scrollTop 往往是浏览器钳制出来的值(内容还在加载
* 占位,或被钳在变矮内容的底部)。这种采样既不写回存储,也不当成用户“接管”,
* 否则一次误触的滚轮 / 触摸就会把记忆位置清成 0。
*
* 只有带“用户滚动意图”(滚轮 / 触摸 / 翻页键 / 拖滚动条)的 scroll 事件才会写
* 存储:恢复回填、虚拟列表挂载量高、浏览器钳制造成的位移都不算用户选择的位置。
*
* routeKey 允许包含 query:媒体库详情页的剧集面板(`/library/:id?series=...`)
* 与网格视图是两块不同内容,各自记一份位置,互不覆盖。
*/
export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
useLayoutEffect(() => {
const el = document.getElementById('app-main-scroll')
if (!el) return
const pathname = routeKey.split('?')[0]
if (!shouldRememberScroll(pathname)) {
el.scrollTop = 0
return
}
const key = `${userKey}:${pathname}`
const key = `${userKey}:${routeKey}`
const saved = readScrollPosition(key)
let restoring = saved > 0
let observer: ResizeObserver | null = null
@@ -79,6 +142,36 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
let restorePumpUntil = 0
let lastSaved = saved
let lastHeight = el.scrollHeight
// 最近一次“用户主动滚动”的时间戳与类型(滚轮 / 触摸 / 翻页键 / 拖滚动条)。
// 只有用户自己滚出来的位置才写回存储:恢复逻辑、虚拟列表挂载量高、浏览器
// 钳制造成的 scrollTop 变化都不代表用户想要的位置,写进去就会把记忆抹掉。
let userScrollUntil = 0
let userScrollAllowsJump = false
const markUserScroll = (allowsJump = false) => {
const now = performance.now()
// 上一段意图已过期:新的一段从“只允许小幅向上跳”开始。
if (userScrollUntil <= now) {
userScrollAllowsJump = false
}
userScrollUntil = now + USER_SCROLL_INTENT_TTL_MS
if (allowsJump) {
userScrollAllowsJump = true
}
}
const userScrolling = () => userScrollUntil > performance.now()
const currentMaxScroll = () => Math.max(0, el.scrollHeight - el.clientHeight)
// 只有“用户真正能滚到”的位置才算有效采样:内容还在加载占位(滚不动)或
// 被钳在变矮内容的底部时,scrollTop 都是浏览器钳制出来的值。
const sampleIsTrustworthy = (current: number) =>
shouldPersistClampedSample({ current, maxScroll: currentMaxScroll(), saved })
const adoptScrollPosition = (current: number) => {
lastSaved = current
lastHeight = el.scrollHeight
writeScrollPosition(key, current)
}
const stopRestore = () => {
if (restoreFrame) {
@@ -97,14 +190,18 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
if (!restoring) return
restoring = false
stopRestore()
lastSaved = Math.round(el.scrollTop)
lastHeight = el.scrollHeight
writeScrollPosition(key, lastSaved)
const current = Math.round(el.scrollTop)
if (!sampleIsTrustworthy(current)) {
// 目标一直不可达(内容仍比记忆位置矮 / 还在加载):保留原记忆值,
// 不要把钳制出来的位置写回去。
return
}
adoptScrollPosition(current)
}
const tryRestore = () => {
if (!restoring) return
const max = Math.max(0, el.scrollHeight - el.clientHeight)
const max = currentMaxScroll()
const target = Math.min(saved, max)
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
el.scrollTop = target
@@ -125,8 +222,30 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
}
const saveNow = () => {
if (restoring) return
const current = Math.round(el.scrollTop)
const userDriven = userScrolling()
// 惯性/连续滚动期间持续续期,避免长距离滑动中途被判定为“非用户滚动”。
if (userDriven) markUserScroll(userScrollAllowsJump)
// 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储,
// 也不能当成“用户接管”。
if (!sampleIsTrustworthy(current)) return
// 一瞬间大幅向上跳(恢复回填、虚拟列表挂载量高、浏览器钳制)只有按键翻页 /
// 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
const upJump = lastSaved > current ? lastSaved - current : 0
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
return
}
if (restoring) {
if (!userDriven || Math.abs(current - saved) <= 1) return
// 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复
// 逻辑继续和用户抢滚动条。
restoring = false
stopRestore()
adoptScrollPosition(current)
return
}
// 非用户滚动(恢复逻辑回填、虚拟列表挂载/量高、浏览器钳制)不写存储。
if (!userDriven) return
const height = el.scrollHeight
if (
!shouldPersistScrollSample({
@@ -136,7 +255,6 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
lastHeight,
})
) {
lastHeight = height
return
}
lastHeight = height
@@ -145,13 +263,13 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
writeScrollPosition(key, current)
}
const cancelRestore = () => {
if (!restoring) return
restoring = false
stopRestore()
lastSaved = Math.round(el.scrollTop)
lastHeight = el.scrollHeight
writeScrollPosition(key, lastSaved)
// 用户滚动意图:滚轮 / 触摸 = 平滑滚动;翻页键 / 拖滚动条 = 允许直接跳很远。
const onScrollIntent = () => {
markUserScroll(false)
}
const onJumpIntent = () => {
markUserScroll(true)
}
if (saved <= 0) {
@@ -171,42 +289,37 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
}
const onPointerDown = (event: PointerEvent) => {
// 点在容器本身 = 拖滚动条或在空白处按下:允许大幅跳转。
if (event.target === el) {
cancelRestore()
onJumpIntent()
}
saveNow()
}
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key === 'PageDown' ||
event.key === 'PageUp' ||
event.key === 'Home' ||
event.key === 'End' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowUp' ||
event.key === ' '
) {
cancelRestore()
if (isJumpIntentKey(event.key)) {
onJumpIntent()
} else if (isScrollIntentKey(event.key)) {
onScrollIntent()
}
saveNow()
}
el.addEventListener('scroll', saveNow, { passive: true })
el.addEventListener('pointerdown', onPointerDown, true)
window.addEventListener('wheel', cancelRestore, { passive: true, capture: true })
window.addEventListener('touchstart', cancelRestore, { passive: true, capture: true })
window.addEventListener('wheel', onScrollIntent, { passive: true, capture: true })
window.addEventListener('touchstart', onScrollIntent, { passive: true, capture: true })
window.addEventListener('keydown', onKeyDown, true)
return () => {
el.removeEventListener('scroll', saveNow)
el.removeEventListener('pointerdown', onPointerDown, true)
window.removeEventListener('wheel', cancelRestore, true)
window.removeEventListener('touchstart', cancelRestore, true)
window.removeEventListener('wheel', onScrollIntent, true)
window.removeEventListener('touchstart', onScrollIntent, true)
window.removeEventListener('keydown', onKeyDown, true)
stopRestore()
// 清理时写入最后一次有效位置,避免依赖已被钳制的 el.scrollTop。
writeScrollPosition(key, lastSaved)
}
}, [pathname, userKey])
}, [routeKey, userKey])
}
+1 -1
View File
@@ -74,7 +74,7 @@ export function FavouritesPage() {
{items.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
<MediaCard key={m.id} media={m} linkState={{ from: '/favourites' }} />
))}
</div>
)}
+1 -1
View File
@@ -85,7 +85,7 @@ export function MediaDetailBackButton({ onBack }: { onBack: () => void }) {
className="btn-ghost gap-2 bg-white/80 shadow-sm backdrop-blur hover:bg-white"
>
<ArrowLeft size={16} />
<span>返回媒体库</span>
<span>返回</span>
</button>
</div>
)
+2
View File
@@ -127,6 +127,7 @@ export function WatchHistoryPage() {
<div className="min-w-0 flex-1 space-y-1">
<Link
to={`/media/${m.id}`}
state={{ from: '/history' }}
className="block truncate font-medium text-ink-600 transition hover:text-brand-500"
>
{m.title}
@@ -150,6 +151,7 @@ export function WatchHistoryPage() {
<div className="flex w-full items-center gap-2 sm:w-auto sm:shrink-0">
<Link
to={`/play/${m.id}`}
state={{ from: '/history' }}
className="neon-button !px-3 !py-1 !text-xs"
>
<Play size={12} /> 继续
+42 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
import type { NavigateFunction } from 'react-router-dom'
import { useLocation, type NavigateFunction } from 'react-router-dom'
import toast from 'react-hot-toast'
import { api } from '../api/client'
@@ -11,6 +11,8 @@ import type { Media } from '../types'
import { seasonSortOrder } from '../utils/groupSeries'
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
type MediaDetailLocationState = { from?: string } | null
interface MediaDetailPageStateParams {
id: string
navigate: NavigateFunction
@@ -29,6 +31,7 @@ interface MediaDetailActionsParams {
media: Media | null
scrapeEpisodeArtwork: boolean
navigate: NavigateFunction
backFrom: string | undefined
refresh: () => Promise<void>
setFavourite: Dispatch<SetStateAction<boolean>>
}
@@ -54,10 +57,13 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
setEpisodes,
setLoadingEpisodes,
})
const location = useLocation()
const backFrom = (location.state as MediaDetailLocationState)?.from
const actions = useMediaDetailActions({
media,
scrapeEpisodeArtwork,
navigate,
backFrom,
refresh,
setFavourite,
})
@@ -173,10 +179,14 @@ function useMediaDetailActions({
media,
scrapeEpisodeArtwork,
navigate,
backFrom,
refresh,
setFavourite,
}: MediaDetailActionsParams) {
const goBack = useCallback(() => goBackFromMediaDetail(media, navigate), [media, navigate])
const goBack = useCallback(
() => goBackFromMediaDetail(media, navigate, backFrom),
[backFrom, media, navigate],
)
const toggleFavourite = useCallback(
() => toggleMediaFavourite(media, setFavourite),
[media, setFavourite],
@@ -188,13 +198,34 @@ function useMediaDetailActions({
const reprobe = useCallback(() => reprobeMedia(media, refresh), [media, refresh])
const exportNFO = useCallback(() => exportMediaNFO(media), [media])
const deleteMedia = useCallback(
() => deleteMediaFromLibrary(media, navigate),
[media, navigate],
() => deleteMediaFromLibrary(media, navigate, backFrom),
[backFrom, media, navigate],
)
return { goBack, toggleFavourite, rescrape, reprobe, exportNFO, deleteMedia }
}
function goBackFromMediaDetail(media: Media | null, navigate: NavigateFunction, replace = false): void {
function canNavigateHistoryBack(): boolean {
if (typeof window === 'undefined') return false
const idx = (window.history.state as { idx?: number } | null)?.idx
return typeof idx === 'number' ? idx > 0 : window.history.length > 1
}
function goBackFromMediaDetail(
media: Media | null,
navigate: NavigateFunction,
from: string | undefined,
replace = false,
): void {
// Prefer the explicit return path (e.g. Favourites → Detail), then browser
// history, and only fall back to the owning library for direct/deep links.
if (typeof from === 'string' && from.length > 0) {
navigate(from, replace ? { replace: true } : undefined)
return
}
if (canNavigateHistoryBack()) {
navigate(-1)
return
}
if (!media) {
navigate('/libraries')
return
@@ -259,7 +290,11 @@ async function exportMediaNFO(media: Media | null): Promise<void> {
}
}
async function deleteMediaFromLibrary(media: Media | null, navigate: NavigateFunction): Promise<void> {
async function deleteMediaFromLibrary(
media: Media | null,
navigate: NavigateFunction,
from: string | undefined,
): Promise<void> {
if (!media) return
const result = await confirmActionResult({
title: '删除媒体',
@@ -275,7 +310,7 @@ async function deleteMediaFromLibrary(media: Media | null, navigate: NavigateFun
return
}
toast.success(result.checked ? '已删除媒体及本地文件' : '已从媒体库删除')
goBackFromMediaDetail(media, navigate, true)
goBackFromMediaDetail(media, navigate, from, true)
}
function apiErrorMessage(err: unknown, fallback: string): string {