重构核心模块并同步更新项目配置与相关实现

This commit is contained in:
truewhile
2026-09-13 20:36:14 +08:00
parent 386fc358d1
commit 9a68ef701f
38 changed files with 1587 additions and 678 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
import { Component, Suspense, type ErrorInfo, type ReactNode } from 'react'
import { Component, Suspense, lazy, type ErrorInfo, type ReactNode } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { appRoutes, type AppRoute } from './appRoutes'
import { Layout } from './components/Layout'
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
import { LoginPage } from './pages/LoginPage'
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
+17 -3
View File
@@ -210,22 +210,36 @@ export type ImageURLOptions =
| {
refreshCache?: boolean
retryFailed?: boolean
maxWidth?: number
maxHeight?: number
quality?: number
}
export function imageURL(remote?: string, version?: string, options: ImageURLOptions = false): string {
if (!remote) return ''
const versionQuery = version ? `v=${encodeURIComponent(version)}` : ''
const retryFailed = typeof options === 'boolean' ? options : Boolean(options.retryFailed)
const refreshCache = typeof options === 'boolean' ? false : Boolean(options.refreshCache)
const opts: Exclude<ImageURLOptions, boolean> = typeof options === 'boolean' ? {} : options
const retryFailed = typeof options === 'boolean' ? options : Boolean(opts.retryFailed)
const refreshCache = typeof options === 'boolean' ? false : Boolean(opts.refreshCache)
const retryQuery = retryFailed ? 'retry=1' : ''
const refreshQuery = refreshCache ? 'refresh=1' : ''
const imageQuery = [versionQuery, retryQuery, refreshQuery].filter(Boolean).join('&')
const resizeQuery = [
positiveDimensionQuery('maxWidth', opts.maxWidth),
positiveDimensionQuery('maxHeight', opts.maxHeight),
positiveDimensionQuery('quality', opts.quality, 100),
].filter(Boolean).join('&')
const imageQuery = [versionQuery, retryQuery, refreshQuery, resizeQuery].filter(Boolean).join('&')
if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), imageQuery)
if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), imageQuery)
if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), imageQuery)
return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, imageQuery)
}
function positiveDimensionQuery(name: string, value?: number, max = 10_000): string {
if (!Number.isFinite(value) || !value || value <= 0) return ''
return `${name}=${Math.min(Math.round(value), max)}`
}
function withQuery(url: string, query: string): string {
if (!query) return url
return `${url}${url.includes('?') ? '&' : '?'}${query}`
+16 -3
View File
@@ -105,6 +105,7 @@ export const libraryAPI = {
.get<LibraryWithPreview[]>('/libraries', {
params: {
with_preview: 1,
include_total: 0,
preview_limit: previewLimit,
ids: ids.join(','),
},
@@ -160,22 +161,34 @@ export const libraryAPI = {
scrape: (id: string, options?: ScrapeOptions) =>
api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
listMedia: (id: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
listMedia: (
id: string,
page = 1,
pageSize = 50,
options?: { groupVersions?: boolean; sort?: string; order?: 'asc' | 'desc' },
) =>
api
.get<MediaPage>(`/libraries/${id}/media`, {
params: {
page,
page_size: pageSize,
group_versions: options?.groupVersions === false ? 0 : undefined,
sort: options?.sort,
order: options?.order,
},
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
listSeries: (id: string, page = 1, pageSize = 500) =>
listSeries: (
id: string,
page = 1,
pageSize = 500,
options?: { sort?: string; order?: 'asc' | 'desc' },
) =>
api
.get<SeriesPage>(`/libraries/${id}/series`, {
params: { page, page_size: pageSize },
params: { page, page_size: pageSize, sort: options?.sort, order: options?.order },
timeout: LONG_REQUEST_TIMEOUT,
})
.then((r) => r.data),
+16 -4
View File
@@ -47,17 +47,29 @@ const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m)
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径
// (库列表/库详情/媒体详情/播放/收藏/历史等)的 chunk 拉下来,
// (库列表/库详情/媒体详情/收藏/历史等)的 chunk 拉下来,
// 首次点击进入时不再出现"加载中…"等 chunk 下载。失败静默(导航时会重试)。
let prefetchStarted = false
export function prefetchCommonRouteChunks() {
if (prefetchStarted || typeof window === 'undefined') return
// 弱网/省流模式不做空闲预取:首屏预览和海报加载优先占用带宽。
const connection = (navigator as Navigator & {
connection?: { saveData?: boolean; effectiveType?: string }
}).connection
if (
connection?.saveData ||
connection?.effectiveType === 'slow-2g' ||
connection?.effectiveType === '2g'
) {
return
}
prefetchStarted = true
const loaders = [
LibrariesPageLoader,
LibraryPageLoader,
MediaDetailPageLoader,
PlayerPageLoader,
FavouritesPageLoader,
WatchHistoryPageLoader,
PlaylistsPageLoader,
@@ -68,9 +80,9 @@ export function prefetchCommonRouteChunks() {
}
}
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(run, { timeout: 5000 })
requestIdleCallback(run, { timeout: 8000 })
} else {
window.setTimeout(run, 2000)
window.setTimeout(run, 2500)
}
}
+8 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
@@ -132,12 +132,12 @@ function LayoutHeaderSearch() {
const locationKey = `${location.pathname}${location.search}`
const prevLocationKeyRef = useRef(locationKey)
const setSearchOpen = (open: boolean) => {
const setSearchOpen = useCallback((open: boolean) => {
isOpenRef.current = open
setIsOpen(open)
}
}, [])
const dismissSearch = (clearQuery: boolean) => {
const dismissSearch = useCallback((clearQuery: boolean) => {
suppressInputRef.current = true
setSearchOpen(false)
if (clearQuery) {
@@ -156,9 +156,11 @@ function LayoutHeaderSearch() {
window.setTimeout(() => {
suppressInputRef.current = false
}, 0)
}
}, [setSearchOpen])
const dismissSearchRef = useRef(dismissSearch)
dismissSearchRef.current = dismissSearch
useEffect(() => {
dismissSearchRef.current = dismissSearch
}, [dismissSearch])
useEffect(() => {
const trimmed = query.trim()
+16 -36
View File
@@ -1,5 +1,4 @@
import { Outlet } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import clsx from 'clsx'
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
@@ -50,29 +49,18 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP
}
export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileSidebarProps) {
if (!isOpen) return null
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex lg:hidden">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/15 backdrop-blur-sm"
/>
<motion.div
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl"
>
{children}
</motion.div>
</div>
)}
</AnimatePresence>
<div className="fixed inset-0 z-50 flex lg:hidden">
<div
onClick={onClose}
className="fixed inset-0 bg-black/15 backdrop-blur-sm animate-overlay-in"
/>
<div className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl animate-drawer-in">
{children}
</div>
</div>
)
}
@@ -139,19 +127,11 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
return (
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto [overflow-anchor:none] px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
key={routeKey}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
>
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</motion.div>
</AnimatePresence>
<div key={routeKey} className="animate-page-in">
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</div>
</div>
</main>
)
+2 -7
View File
@@ -1,5 +1,4 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowLeft, Menu, X } from 'lucide-react'
import clsx from 'clsx'
@@ -77,13 +76,9 @@ function LayoutSidebarHeader({
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
/>
{sidebarExpanded && (
<motion.span
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)]"
>
<span className="font-display text-lg font-extrabold tracking-tight text-[var(--app-text)] animate-fade-slide-in">
MeBox
</motion.span>
</span>
)}
</Link>
<SidebarIconButton className="hidden lg:block" onClick={onToggleSidebar}>
+9 -22
View File
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronDown } from 'lucide-react'
import clsx from 'clsx'
@@ -52,21 +51,13 @@ export function SidebarGroup({ id, icon, label, children, collapsed, open, activ
</div>
)}
</button>
<AnimatePresence initial={false}>
{!collapsed && open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="space-y-1 pb-1 pl-3">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
{!collapsed && open && (
<div className="overflow-hidden animate-accordion-in">
<div className="space-y-1 pb-1 pl-3">
{children}
</div>
</div>
)}
</div>
)
}
@@ -103,13 +94,9 @@ export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarL
{icon}
</span>
{!collapsed && (
<motion.span
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
className="truncate whitespace-nowrap"
>
<span className="truncate whitespace-nowrap">
{label}
</motion.span>
</span>
)}
{collapsed && (
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
+7 -16
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Link, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, Tv, UserCog } from 'lucide-react'
import clsx from 'clsx'
@@ -103,26 +102,18 @@ export function LayoutUserMenu({
const menuPortal = isOpen && menuPosition && typeof document !== 'undefined'
? createPortal(
<AnimatePresence>
<motion.div
<>
<div
key="layout-user-menu-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12 }}
className="fixed inset-0 z-[120]"
className="fixed inset-0 z-[120] animate-overlay-in"
aria-hidden="true"
onPointerDown={onClose}
/>
<motion.div
<div
key="layout-user-menu-panel"
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.15 }}
role="menu"
style={{ top: menuPosition.top, right: menuPosition.right }}
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl animate-menu-in"
onPointerDown={(event) => event.stopPropagation()}
>
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
@@ -191,8 +182,8 @@ export function LayoutUserMenu({
<LogOut size={16} />
<span>安全登出系统</span>
</button>
</motion.div>
</AnimatePresence>,
</div>
</>,
document.body,
)
: null
+8 -3
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Film, Play, Layers, Star } from 'lucide-react'
import { imageURL } from '../api/client'
@@ -22,7 +22,12 @@ export const MediaCard = memo(function MediaCard({
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
const posterSrc = imageURL(media.poster_url, media.updated_at)
const posterSrc = imageURL(media.poster_url, media.updated_at, {
maxWidth: compact ? 320 : 480,
maxHeight: compact ? 480 : 600,
quality: 82,
})
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, { maxWidth: 160, quality: 60 })
const displayRating = rating ?? media.rating
const versionCount = media.versions?.length ?? 0
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
@@ -43,7 +48,7 @@ export const MediaCard = memo(function MediaCard({
<>
{posterFit === 'contain' && (
<img
src={posterSrc}
src={blurredPosterSrc}
alt=""
aria-hidden="true"
loading="lazy"
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef, type RefObject } from 'react'
const DEFAULT_ROOT_MARGIN = '480px 0px'
/**
* 元素首次进入滚动容器附近时触发一次。用于媒体库预览的按需加载。
*/
export function useInViewOnce<T extends Element>(
onVisible: () => void,
rootMargin = DEFAULT_ROOT_MARGIN,
): RefObject<T> {
const ref = useRef<T>(null)
const callbackRef = useRef(onVisible)
useEffect(() => {
callbackRef.current = onVisible
}, [onVisible])
useEffect(() => {
const element = ref.current
if (!element) return
if (typeof IntersectionObserver === 'undefined') {
callbackRef.current()
return
}
const root = document.getElementById('app-main-scroll')
let fired = false
const observer = new IntersectionObserver(
(entries) => {
if (fired || !entries.some((entry) => entry.isIntersecting)) return
fired = true
observer.disconnect()
callbackRef.current()
},
{ root, rootMargin, threshold: 0.01 },
)
observer.observe(element)
return () => observer.disconnect()
}, [rootMargin])
return ref
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef } from 'react'
/**
* 合并短时间内进入视口的预览请求,避免每个卡片各发一次 HTTP 请求。
* 回调放在 ref 中,调用方每次渲染传新的闭包也不会重建定时器。
*/
export function useLazyPreviewBatch(
onNeedPreviews?: (ids: string[]) => void,
delayMs = 48,
) {
const pendingRef = useRef<Set<string>>(new Set())
const timerRef = useRef<number | null>(null)
const callbackRef = useRef(onNeedPreviews)
useEffect(() => {
callbackRef.current = onNeedPreviews
}, [onNeedPreviews])
useEffect(() => {
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
}
}
}, [])
return useCallback((id: string) => {
if (!id || !callbackRef.current) return
pendingRef.current.add(id)
if (timerRef.current !== null) return
timerRef.current = window.setTimeout(() => {
timerRef.current = null
const ids = Array.from(pendingRef.current)
pendingRef.current.clear()
if (ids.length > 0) {
callbackRef.current?.(ids)
}
}, delayMs)
}, [delayMs])
}
+39
View File
@@ -418,3 +418,42 @@ video::cue(*) {
0 0 8px rgba(0, 0, 0, 0.8),
0 0 16px rgba(0, 0, 0, 0.6) !important;
}
/* ── Entrance animations (replaces framer-motion on the critical shell) ── */
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }
@keyframes page-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
@keyframes accordion-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fade-slide-in { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); } }
@keyframes menu-in { from { opacity: 0; transform: translateY(10px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
.animate-overlay-in { animation: overlay-in 0.15s ease-out both; }
.animate-drawer-in { animation: drawer-in 0.22s ease-out both; }
.animate-page-in { animation: page-in 0.25s ease-out both; }
.animate-accordion-in { animation: accordion-in 0.18s ease-out both; }
.animate-fade-slide-in { animation: fade-slide-in 0.2s ease-out both; }
.animate-menu-in { animation: menu-in 0.15s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.animate-overlay-in,
.animate-drawer-in,
.animate-page-in,
.animate-accordion-in,
.animate-fade-slide-in,
.animate-menu-in { animation: none !important; }
}
@keyframes pulse-soft { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
@keyframes hero-in { from { opacity: 0; transform: scale(1.08); } to { opacity: 0.38; transform: scale(1.02); } }
@keyframes title-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.animate-pulse-soft { animation: pulse-soft 1.5s ease-in-out infinite; }
.animate-hero-in { animation: hero-in 0.8s ease-out both; }
.animate-title-in { animation: title-in 0.3s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.animate-pulse-soft,
.animate-hero-in,
.animate-title-in { animation: none !important; }
}
+57 -32
View File
@@ -18,6 +18,7 @@ import {
HomeLoadingState,
} from './HomePageSections'
const PREVIEW_BATCH_SIZE = 4
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
export function HomePage() {
@@ -86,48 +87,72 @@ export function HomePage() {
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
// 按需拉取卡片预览管理
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
const fetchingRef = useRef<Set<string>>(new Set())
// 按需拉取卡片预览管理。同一个库可能先以 4 张封面用于入口网格,
// 稍后需要 10 张用于内容横排,因此缓存的是已加载数量而不是简单布尔值。
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
const fetchingPreviewLimitsRef = useRef<Map<string, number>>(new Map())
const fetchPreviews = useCallback(async (ids: string[]) => {
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
const fetchPreviews = useCallback(async (ids: string[], limit = 10) => {
const uniqueIDs = Array.from(new Set(ids.filter(Boolean)))
const targets = uniqueIDs.filter(
(id) =>
(fetchedPreviewLimitsRef.current.get(id) ?? 0) < limit &&
(fetchingPreviewLimitsRef.current.get(id) ?? 0) < limit,
)
if (targets.length === 0) return
targets.forEach((id) => fetchingRef.current.add(id))
targets.forEach((id) => fetchingPreviewLimitsRef.current.set(id, limit))
try {
const rows = await libraryAPI.listPreviews(targets, 10)
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
next[row.id] = {
cards: row.cards ?? [],
items: [],
total: row.total ?? 0,
}
}
return next
})
} catch {
// 容错
} finally {
targets.forEach((id) => {
fetchedLibIdsRef.current.add(id)
fetchingRef.current.delete(id)
})
const batches: string[][] = []
for (let i = 0; i < targets.length; i += PREVIEW_BATCH_SIZE) {
batches.push(targets.slice(i, i + PREVIEW_BATCH_SIZE))
}
// 分批并发:一个慢库不再阻塞整页预览,先返回的批次立即渲染。
await Promise.allSettled(
batches.map(async (batch) => {
let loaded = false
try {
const rows = await libraryAPI.listPreviews(batch, limit)
loaded = true
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
next[row.id] = {
cards: row.cards ?? [],
items: [],
total: row.total ?? 0,
}
}
return next
})
} catch {
// 单个批次失败不影响其他批次;导航回来时会重试。
} finally {
batch.forEach((id) => {
if (loaded) {
fetchedPreviewLimitsRef.current.set(
id,
Math.max(fetchedPreviewLimitsRef.current.get(id) ?? 0, limit),
)
}
if (fetchingPreviewLimitsRef.current.get(id) === limit) {
fetchingPreviewLimitsRef.current.delete(id)
}
})
}
}),
)
}, [])
// 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后再按批加载。
useEffect(() => {
if (sortedLibraries.length === 0) return
const carouselLibIds = sortedLibraries
.filter((l) => l.carousel_enabled === true)
.map((l) => l.id)
const topGridLibIds = sortedLibraries.slice(0, 20).map((l) => l.id)
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...topRowLibIds]))
void fetchPreviews(initialTargets)
const initialTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
void fetchPreviews(initialTargets, 10)
}, [sortedLibraries, fetchPreviews])
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
@@ -140,7 +165,7 @@ export function HomePage() {
useEffect(() => {
if (sortedLibraries.length === 0) return
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
void fetchPreviews(currentTargets)
void fetchPreviews(currentTargets, 10)
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
// 当前已拉取并确认有内容的媒体库行
@@ -214,7 +239,7 @@ export function HomePage() {
const libraryCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const lib of libraries) {
counts[lib.id] = libraryData[lib.id]?.total ?? 0
counts[lib.id] = lib.total ?? libraryData[lib.id]?.total ?? 0
}
return counts
}, [libraries, libraryData])
+108 -105
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import {
ArrowRight,
ChevronLeft,
@@ -18,6 +17,8 @@ import {
} from 'lucide-react'
import { imageURL } from '../api/client'
import { useInViewOnce } from '../hooks/useInViewOnce'
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
import { MediaCard } from '../components/MediaCard'
import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
@@ -37,7 +38,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
music: <Music size={18} />,
adult: <Film size={18} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
movies: '电影',
@@ -53,11 +53,7 @@ const TYPE_LABELS: Record<string, string> = {
export function HomeLoadingState() {
return (
<div className="flex items-center justify-center py-48">
<motion.div
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Infinity, duration: 1.5 }}
className="flex flex-col items-center gap-4"
>
<div className="flex flex-col items-center gap-4 animate-pulse-soft">
<div className="relative flex items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[var(--app-border)] border-t-[var(--app-active-bg)]" />
<Film className="absolute h-4 w-4 text-brand-500" />
@@ -65,7 +61,7 @@ export function HomeLoadingState() {
<span className="text-sm font-semibold uppercase tracking-widest text-[var(--app-muted)]">
首页内容准备中…
</span>
</motion.div>
</div>
</div>
)
}
@@ -170,24 +166,20 @@ export function HomeCarouselSection({
{/* Background Backdrop Image with Crossfade */}
<div className="absolute inset-0 z-0">
<div className="theme-hero-bg h-full w-full" />
<AnimatePresence mode="wait">
{visual && (
<motion.img
key={visual + currentItem.id}
initial={{ opacity: 0, scale: 1.08 }}
animate={{ opacity: 0.38, scale: 1.02 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
src={imageURL(visual, currentItem.updated_at)}
alt=""
className="absolute inset-0 h-full w-full object-cover object-center blur-[1px]"
referrerPolicy="no-referrer"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</AnimatePresence>
{visual && (
<img
key={visual + currentItem.id}
src={imageURL(visual, currentItem.updated_at, { maxWidth: 1920, maxHeight: 1080, quality: 78 })}
alt=""
fetchPriority="high"
decoding="async"
className="absolute inset-0 h-full w-full object-cover object-center blur-[1px] animate-hero-in"
referrerPolicy="no-referrer"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
<div className="theme-hero-overlay absolute inset-0" />
<div className="theme-hero-fade absolute inset-x-0 bottom-0 h-36" />
</div>
@@ -212,18 +204,12 @@ export function HomeCarouselSection({
{/* Title */}
<div className="space-y-2">
<AnimatePresence mode="wait">
<motion.h1
key={currentItem.title + currentItem.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.3 }}
className="font-display text-2xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-3xl md:text-4xl lg:text-5xl"
>
{currentItem.title}
</motion.h1>
</AnimatePresence>
<h1
key={currentItem.title + currentItem.id}
className="font-display text-2xl font-extrabold leading-tight tracking-tight text-[var(--app-text)] sm:text-3xl md:text-4xl lg:text-5xl animate-title-in"
>
{currentItem.title}
</h1>
{currentItem.original_name && currentItem.original_name !== currentItem.title && (
<p className="text-xs font-semibold text-[var(--app-muted)] tracking-wide">
{currentItem.original_name}
@@ -295,8 +281,10 @@ export function HomeCarouselSection({
</div>
{poster && (
<img
src={imageURL(poster, currentItem.updated_at)}
src={imageURL(poster, currentItem.updated_at, { maxWidth: 560, quality: 84 })}
alt={currentItem.title}
fetchPriority="high"
decoding="async"
className="absolute inset-2 h-[calc(100%-1rem)] w-[calc(100%-1rem)] rounded-[1.25rem] object-cover"
referrerPolicy="no-referrer"
onError={(e) => {
@@ -377,17 +365,13 @@ export function HomeLibrariesSection({
const [currentPage, setCurrentPage] = useState(1)
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
const effectivePage = Math.min(currentPage, totalPages)
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
const pagedLibraries = useMemo<Library[]>(() => {
const start = (effectivePage - 1) * PAGE_SIZE
return libraries.slice(start, start + PAGE_SIZE)
}, [libraries, effectivePage])
useEffect(() => {
const ids = pagedLibraries.map((l) => l.id)
onNeedPreviews?.(ids)
}, [pagedLibraries, onNeedPreviews])
return (
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--app-border)] pb-3">
@@ -449,72 +433,91 @@ export function HomeLibrariesSection({
</div>
</div>
{/* Libraries Grid */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
{pagedLibraries.map((lib) => {
const count = libraryCounts[lib.id] ?? 0
const cards = libraryData?.[lib.id]?.cards || []
const artwork = getLibraryArtworks(lib, cards)
return (
<Link
key={lib.id}
to={`/library/${lib.id}`}
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
title={lib.name}
>
{/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
<div
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
}`}
>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(event) => {
event.currentTarget.style.visibility = 'hidden'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-brand-500">
{TYPE_ICONS[lib.type] || <FolderOpen size={28} />}
</div>
)}
{/* 浮动类型标签 */}
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
{TYPE_LABELS[lib.type] || '自定义'}
</div>
</div>
{/* 媒体库信息 */}
<div className="mt-3 flex flex-col justify-between">
<h3
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
title={lib.name}
>
{lib.name}
</h3>
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
{count > 0 ? `${count} 部媒体` : '暂无条目'}
</p>
</div>
</Link>
)
})}
{pagedLibraries.map((lib) => (
<HomeLibraryCard
key={lib.id}
library={lib}
count={libraryCounts[lib.id] ?? 0}
cards={libraryData?.[lib.id]?.cards ?? []}
onVisible={() => {
if (!lib.cover_url) {
queuePreview(lib.id)
}
}}
/>
))}
</div>
</section>
)
}
function HomeLibraryCard({
library,
count,
cards,
onVisible,
}: {
library: Library
count: number
cards: SeriesCard[]
onVisible: () => void
}) {
const ref = useInViewOnce<HTMLAnchorElement>(onVisible)
const artwork = getLibraryArtworks(library, cards)
return (
<Link
ref={ref}
to={`/library/${library.id}`}
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
title={library.name}
>
<div
className={`relative h-28 w-full overflow-hidden rounded-xl bg-[linear-gradient(135deg,var(--app-panel-soft),var(--app-panel))] shadow-inner ${
artwork.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''
}`}
>
{artwork.length > 0 ? (
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version, { maxWidth: 480, maxHeight: 320, quality: 78 })}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={(event) => {
event.currentTarget.style.visibility = 'hidden'
}}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-brand-500">
{TYPE_ICONS[library.type] || <FolderOpen size={28} />}
</div>
)}
<div className="absolute top-2 right-2 rounded-lg border border-white/20 bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white backdrop-blur-md shadow-sm">
{TYPE_LABELS[library.type] || '自定义'}
</div>
</div>
<div className="mt-3 flex flex-col justify-between">
<h3
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
title={library.name}
>
{library.name}
</h3>
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
{count > 0 ? `${count} 部媒体` : '暂无条目'}
</p>
</div>
</Link>
)
}
/* =========================================================================
3. 单个媒体库内容行 (Horizontal Scroll Row)
========================================================================= */
@@ -694,7 +697,7 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
{media.poster_url ? (
<img
src={imageURL(media.poster_url, media.updated_at)}
src={imageURL(media.poster_url, media.updated_at, { maxWidth: 180, maxHeight: 240, quality: 78 })}
alt=""
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
+7 -6
View File
@@ -33,8 +33,10 @@ export function LibrariesPage() {
if (targets.length === 0) return
targets.forEach((id) => fetchingRef.current.add(id))
let loaded = false
try {
const rows = await libraryAPI.listPreviews(targets, 10)
loaded = true
setLibraryData((prev) => {
const next = { ...prev }
for (const row of rows) {
@@ -49,7 +51,9 @@ export function LibrariesPage() {
// 容错
} finally {
targets.forEach((id) => {
fetchedLibIdsRef.current.add(id)
if (loaded) {
fetchedLibIdsRef.current.add(id)
}
fetchingRef.current.delete(id)
})
}
@@ -68,13 +72,10 @@ export function LibrariesPage() {
try {
const libs = await fetchLibraries()
setLibraries(libs)
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
const topIds = libs.slice(0, 20).map((l) => l.id)
void fetchPreviews(topIds)
} finally {
setLoading(false)
}
}, [fetchPreviews])
}, [])
async function handleRepairRescrape() {
if (repairing) return
@@ -105,7 +106,7 @@ export function LibrariesPage() {
return {
library,
items: [],
total: data?.total ?? 0,
total: library.total ?? data?.total ?? 0,
cards: data?.cards ?? [],
}
})
+22 -19
View File
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
import { imageURL } from '../api/client'
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import { MediaCard } from '../components/MediaCard'
import { useInViewOnce } from '../hooks/useInViewOnce'
import { useLazyPreviewBatch } from '../hooks/useLazyPreviewBatch'
import { seriesCardLink } from '../utils/groupSeries'
import { libraryDisplayPath } from './libraryDisplayModel'
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
@@ -19,7 +20,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
music: <Music size={18} />,
adult: <Film size={18} />,
}
const TYPE_LABELS: Record<string, string> = {
movie: '电影',
tv: '剧集',
@@ -115,6 +115,7 @@ export function LibrariesContent({
onNeedPreviews?: (ids: string[]) => void
}) {
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
const queuePreview = useLazyPreviewBatch(onNeedPreviews)
// 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架,
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。
@@ -185,15 +186,12 @@ export function LibrariesContent({
const [entryPage, setEntryPage] = useState(1)
const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE))
const effectiveEntryPage = Math.min(entryPage, totalEntryPages)
const pagedPreviews = useMemo<LibraryPreview[]>(() => {
const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE
return previews.slice(start, start + ENTRY_PAGE_SIZE)
}, [previews, effectiveEntryPage])
useEffect(() => {
const ids = pagedPreviews.map((p) => p.library.id)
onNeedPreviews?.(ids)
}, [pagedPreviews, onNeedPreviews])
return (
<>
@@ -241,37 +239,38 @@ export function LibrariesContent({
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
{pagedPreviews.map((preview, index) => (
<motion.div
{pagedPreviews.map((preview) => (
<div
key={preview.library.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className="animate-page-in"
>
<LibraryEntryCard
preview={preview}
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
onTogglePin={() => onTogglePin(preview.library.id)}
onVisible={() => {
if (!preview.library.cover_url) {
queuePreview(preview.library.id)
}
}}
/>
</motion.div>
</div>
))}
</div>
</section>
{visiblePreviews.length > 0 && (
<section className="space-y-6">
{visiblePreviews.map((preview, index) => (
<motion.div
{visiblePreviews.map((preview) => (
<div
key={preview.library.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index, 3) * 0.03 }}
className="animate-page-in"
>
<LibraryShelf
preview={preview}
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
/>
</motion.div>
</div>
))}
{visibleCount < previews.length && (
@@ -292,12 +291,15 @@ function LibraryEntryCard({
preview,
pinned,
onTogglePin,
onVisible,
}: {
preview: LibraryPreview
pinned: boolean
onTogglePin: () => void
onVisible?: () => void
}) {
const library = preview.library
const ref = useInViewOnce<HTMLDivElement>(onVisible ?? (() => {}))
const artwork = library.cover_url
? [{ src: library.cover_url, version: library.updated_at }]
: libraryArtworkItems(preview.cards)
@@ -305,6 +307,7 @@ function LibraryEntryCard({
return (
<div
ref={ref}
className={
'group relative flex overflow-hidden rounded-2xl sm:rounded-3xl border bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:shadow-card-hover ' +
(pinned ? 'border-brand-300 ring-1 ring-brand-100' : 'border-sand-200 hover:border-brand-200')
@@ -339,7 +342,7 @@ function LibraryEntryCard({
artwork.map(({ src, version }, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src, version)}
src={imageURL(src, version, { maxWidth: 400, maxHeight: 300, quality: 78 })}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
+56 -2
View File
@@ -1,4 +1,4 @@
import { memo, type ReactNode } from 'react'
import { memo, useEffect, useRef, type ReactNode } from 'react'
import { Film } from 'lucide-react'
import { MediaCard } from '../components/MediaCard'
@@ -15,6 +15,9 @@ type LibraryMediaSectionsProps = {
seriesCards: SeriesCard[]
selectedSeries: SeriesCard | null
loading: boolean
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
cardActions: (media: Media) => ReactNode
onSeriesClick: (series: SeriesCard) => void
}
@@ -25,6 +28,9 @@ export function LibraryMediaSections({
seriesCards,
selectedSeries,
loading,
hasMore,
loadingMore,
onLoadMore,
cardActions,
onSeriesClick,
}: LibraryMediaSectionsProps) {
@@ -60,10 +66,58 @@ export function LibraryMediaSections({
{isSeries && seriesCards.length === 0 && !loading && (
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
)}
<LoadMoreSentinel
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
/>
</>
)
}
function LoadMoreSentinel({
hasMore,
loadingMore,
onLoadMore,
}: {
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const element = ref.current
if (!element || !hasMore) return
const root = document.getElementById('app-main-scroll')
const observer = new IntersectionObserver(
(entries) => {
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
onLoadMore()
}
},
{ root, rootMargin: '600px 0px', threshold: 0 },
)
observer.observe(element)
return () => observer.disconnect()
}, [hasMore, loadingMore, onLoadMore])
if (!hasMore) return null
return (
<div ref={ref} className="flex justify-center py-8">
<button
type="button"
disabled={loadingMore}
onClick={onLoadMore}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
>
{loadingMore ? '加载中…' : '加载更多'}
</button>
</div>
)
}
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
// 父级重渲染不会穿透到每张剧集卡片。
const SeriesCardItem = memo(function SeriesCardItem({
@@ -99,4 +153,4 @@ function LibraryEmptyState({ message }: { message: string }) {
<p className="text-ink-50">{message}</p>
</div>
)
}
}
+53 -55
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useCallback, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { historyAPI } from '../api/history'
import type { Media } from '../types'
import { useAuthStore } from '../stores/auth'
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
@@ -49,48 +48,10 @@ export function LibraryPage() {
return (saved as SortOrder) || 'asc'
})
const [randomSeed, setRandomSeed] = useState(() => Date.now())
const [historyMap, setHistoryMap] = useState<Map<string, string>>(new Map())
useEffect(() => {
if (sortField !== 'last_played') return
let cancelled = false
historyAPI
.list(1000)
.then((historyItems) => {
if (cancelled) return
const map = new Map<string, string>()
for (const item of historyItems ?? []) {
if (item.media_id && item.watched_at) {
if (!map.has(item.media_id) || new Date(item.watched_at) > new Date(map.get(item.media_id)!)) {
map.set(item.media_id, item.watched_at)
}
}
}
setHistoryMap(map)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [sortField])
const handleSortChange = (field: SortField, order: SortOrder) => {
setSortField(field)
setSortOrder(order)
if (id) {
localStorage.setItem(`mebox_lib_sort_field_${id}`, field)
localStorage.setItem(`mebox_lib_sort_order_${id}`, order)
}
localStorage.setItem('mebox_lib_sort_field', field)
localStorage.setItem('mebox_lib_sort_order', order)
if (field === 'random') {
setRandomSeed(Date.now())
}
}
const handleReshuffle = () => {
setRandomSeed(Date.now())
}
const [lastNonRandomSort, setLastNonRandomSort] = useState<SortField>(sortField === 'random' ? 'release_date' : sortField)
const [lastNonRandomOrder, setLastNonRandomOrder] = useState<SortOrder>(sortOrder)
const serverSortField = sortField === 'random' ? lastNonRandomSort : sortField
const serverSortOrder = sortField === 'random' ? lastNonRandomOrder : sortOrder
// 剧集模式:选中某个剧集后展开详情
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
@@ -102,22 +63,56 @@ export function LibraryPage() {
seriesEpisodeItems,
total,
loading,
loadingMore,
hasMore,
loadMore,
loadAll,
loadingSeriesEpisodes,
isSeriesLibrary,
isSeries,
seriesCards,
loadingAllText,
reloadCurrentLibrary,
} = useLibraryData(id, selectedSeries)
} = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder)
const sortedItems = useMemo(() => {
return sortMediaList(items, sortField, sortOrder, randomSeed, historyMap)
}, [items, sortField, sortOrder, randomSeed, historyMap])
// 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。
const displayedItems = useMemo(() => {
return sortField === 'random' ? sortMediaList(items, 'random', sortOrder, randomSeed) : items
}, [items, sortField, sortOrder, randomSeed])
const sortedSeriesCards = useMemo(() => {
return sortSeriesList(seriesCards, sortField, sortOrder, randomSeed, historyMap)
}, [seriesCards, sortField, sortOrder, randomSeed, historyMap])
const displayedSeriesCards = useMemo(() => {
return sortField === 'random' ? sortSeriesList(seriesCards, 'random', sortOrder, randomSeed) : seriesCards
}, [seriesCards, sortField, sortOrder, randomSeed])
const handleSortChange = useCallback(async (field: SortField, order: SortOrder) => {
if (field === 'random') {
if (sortField !== 'random') {
setLastNonRandomSort(sortField)
setLastNonRandomOrder(sortOrder)
await loadAll()
}
setRandomSeed(Date.now())
} else {
setLastNonRandomSort(field)
setLastNonRandomOrder(order)
}
setSortField(field)
setSortOrder(order)
if (id) {
localStorage.setItem(`mebox_lib_sort_field_${id}`, field)
localStorage.setItem(`mebox_lib_sort_order_${id}`, order)
}
localStorage.setItem('mebox_lib_sort_field', field)
localStorage.setItem('mebox_lib_sort_order', order)
}, [id, loadAll, sortField, sortOrder])
const handleLoadMore = useCallback(() => {
void loadMore()
}, [loadMore])
const handleReshuffle = () => {
setRandomSeed(Date.now())
}
const {
scanning,
scanProgress,
@@ -142,7 +137,7 @@ export function LibraryPage() {
isSeriesLibrary,
isSeries,
loading,
seriesCards: sortedSeriesCards,
seriesCards: displayedSeriesCards,
searchParams,
setSearchParams,
selectedSeries,
@@ -236,7 +231,7 @@ export function LibraryPage() {
{!selectedSeries && (
<LibraryPageHeader
library={library}
itemCount={isSeries ? sortedSeriesCards.length : total}
itemCount={isSeries ? displayedSeriesCards.length : total}
loadingAllText={loadingAllText}
scanProgress={scanProgress}
isAdmin={role === 'admin'}
@@ -257,10 +252,13 @@ export function LibraryPage() {
<LibraryMediaSections
isSeries={isSeries}
items={sortedItems}
seriesCards={sortedSeriesCards}
items={displayedItems}
seriesCards={displayedSeriesCards}
selectedSeries={selectedSeries}
loading={loading}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={handleLoadMore}
cardActions={cardActions}
onSeriesClick={handleSeriesClick}
/>
+139 -97
View File
@@ -1,19 +1,26 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
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'
export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) {
export function useLibraryData(
libraryID: string,
selectedSeries: SeriesCard | null,
sortField: SortField,
sortOrder: SortOrder,
) {
const [library, setLibrary] = useState<Library | null>(null)
const [items, setItems] = useState<Media[]>([])
const [serverSeriesCards, setServerSeriesCards] = useState<SeriesCard[]>([])
const [seriesEpisodeItems, setSeriesEpisodeItems] = useState<Media[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [loadingAll, setLoadingAll] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [loadingSeriesEpisodes, setLoadingSeriesEpisodes] = useState(false)
const isSeriesLibrary = isSeriesLibraryType(library?.type)
@@ -26,79 +33,96 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
return groupSeries(items)
}, [isSeries, isSeriesLibrary, items, serverSeriesCards])
// reloadCurrentLibrary 通过自增 tick 重跑加载(原实现靠克隆 library 对象
// 触发第二个 effect,这里合并成单个 bootstrap effect 后改用显式信号)。
const [reloadTick, setReloadTick] = useState(0)
const requestSeqRef = useRef(0)
const nextPageRef = useRef(2)
const loadedCountRef = useRef(0)
const totalRef = useRef(0)
const hasMoreRef = useRef(false)
const libraryRef = useRef<Library | null>(null)
const modeRef = useRef<'media' | 'series'>('media')
const moreInFlightRef = useRef(false)
useEffect(() => {
if (!libraryID) return
const seq = ++requestSeqRef.current
let cancelled = false
nextPageRef.current = 2
loadedCountRef.current = 0
totalRef.current = 0
hasMoreRef.current = false
moreInFlightRef.current = false
setLoading(true)
setLoadingAll(false)
setLoadingMore(false)
setHasMore(false)
setLibrary(null)
setItems([])
setServerSeriesCards([])
setSeriesEpisodeItems([])
setTotal(0)
const bootstrap = async () => {
// 库信息先查会话缓存(首页/全部媒体库页已拉过全量列表),命中则
// 同步就绪,内容请求在挂载当帧即发出;未命中才退回单独请求,
// 消除原先"先等库信息、再等内容"的两段串行首屏等待。
let lib = peekLibrary(libraryID)
if (lib) setLibrary(lib)
let lib = peekLibrary(libraryID) ?? null
if (lib) {
libraryRef.current = lib
setLibrary(lib)
}
try {
const resolved = await resolveLibrary(libraryID)
if (cancelled) return
if (cancelled || seq !== requestSeqRef.current) return
if (!lib) {
lib = resolved
libraryRef.current = lib
setLibrary(lib)
}
} catch {
if (!cancelled) {
if (!cancelled && seq === requestSeqRef.current) {
setLoading(false)
toast.error('媒体库不存在或无权限')
}
return
}
if (!lib || cancelled || seq !== requestSeqRef.current) return
const seriesLibrary = isSeriesLibraryType(lib.type)
setLoadingAll(true)
const seriesMode = isSeriesLibraryType(lib.type)
modeRef.current = seriesMode ? 'series' : 'media'
const pageSize = pageSizeFor(lib, seriesMode)
try {
if (seriesLibrary) {
const collected = await loadAllSeriesCards(libraryID, lib.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
setServerSeriesCards(next.items)
setLoading(false)
}
}, () => cancelled)
if (!cancelled) setServerSeriesCards(collected.items)
return
if (seriesMode) {
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
if (cancelled || seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setServerSeriesCards(pageItems)
loadedCountRef.current = pageItems.length
totalRef.current = data.total ?? pageItems.length
} else {
const data = await libraryAPI.listMedia(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
if (cancelled || seq !== requestSeqRef.current) return
const pageItems = data.items ?? []
setItems(pageItems)
loadedCountRef.current = pageItems.length
totalRef.current = data.total ?? pageItems.length
}
const collected = await loadAllMedia(libraryID, lib.is_remote_emby, (next) => {
if (cancelled) return
setTotal(next.total)
if (next.firstPage) {
setItems(next.items)
setLoading(false)
}
}, () => cancelled)
if (!cancelled) setItems(collected.items)
setTotal(totalRef.current)
hasMoreRef.current = loadedCountRef.current < totalRef.current
setHasMore(hasMoreRef.current)
} catch {
if (!cancelled) toast.error('媒体库加载失败')
if (!cancelled && seq === requestSeqRef.current) {
toast.error('媒体库加载失败')
}
} finally {
if (!cancelled) {
if (!cancelled && seq === requestSeqRef.current) {
setLoading(false)
setLoadingAll(false)
}
}
}
void bootstrap()
return () => { cancelled = true }
}, [libraryID, reloadTick])
return () => {
cancelled = true
requestSeqRef.current += 1
}
}, [libraryID, reloadTick, sortField, sortOrder])
useEffect(() => {
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
@@ -122,15 +146,74 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
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, true), {
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, false), {
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)
}, [])
const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length)
? (isSeriesLibrary
? `正在继续加载剧集卡片:${serverSeriesCards.length} / ${total}`
: `正在继续加载全部条目:${items.length} / ${total}`)
: ''
const loadedCount = isSeriesLibrary ? serverSeriesCards.length : items.length
const loadingAllText =
!loading && hasMore
? `已加载 ${loadedCount} / ${total},向下滚动继续加载`
: ''
return {
library,
@@ -138,6 +221,10 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
seriesEpisodeItems,
total,
loading,
loadingMore,
hasMore,
loadMore,
loadAll,
loadingSeriesEpisodes,
isSeriesLibrary,
isSeries,
@@ -151,6 +238,11 @@ function isSeriesLibraryType(type?: string) {
return type === 'tv' || type === 'anime' || type === 'variety'
}
function pageSizeFor(lib: Library, seriesMode: boolean): number {
if (lib.is_remote_emby) return 100
return seriesMode ? 500 : 2000
}
function yieldToBrowser(): Promise<void> {
return new Promise((resolve) => {
if (typeof requestIdleCallback !== 'undefined') {
@@ -159,54 +251,4 @@ function yieldToBrowser(): Promise<void> {
setTimeout(resolve, 0)
}
})
}
async function loadAllSeriesCards(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: SeriesCard[]; total: number; firstPage: boolean }) => void,
isCancelled: () => boolean,
) {
const pageSize = isRemoteEmby ? 100 : 500
let page = 1
let collected: SeriesCard[] = []
for (;;) {
// 切库/卸载后取消标志置位,立即停止继续拉取剩余页
if (isCancelled()) return { items: collected }
const data = await libraryAPI.listSeries(libraryID, page, pageSize)
if (isCancelled()) return { items: collected }
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
const pageItems = data.items ?? []
collected = collected.concat(pageItems)
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
page += 1
await yieldToBrowser()
}
return { items: collected }
}
async function loadAllMedia(
libraryID: string,
isRemoteEmby: boolean | undefined,
onPage: (state: { items: Media[]; total: number; firstPage: boolean }) => void,
isCancelled: () => boolean,
) {
const pageSize = isRemoteEmby ? 100 : 2000
let page = 1
let collected: Media[] = []
for (;;) {
// 切库/卸载后取消标志置位,立即停止继续拉取剩余页
if (isCancelled()) return { items: collected }
const data = await libraryAPI.listMedia(libraryID, page, pageSize)
if (isCancelled()) return { items: collected }
// 后端对空库可能返回 items: null(Go nil slice);不兜底会 concat 出 [null] 并崩溃。
const pageItems = data.items ?? []
collected = collected.concat(pageItems)
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
page += 1
await yieldToBrowser()
}
return { items: collected }
}
}
+2
View File
@@ -24,6 +24,8 @@ export interface Library {
/** 远程 Emby 挂载库(只读,不支持扫描/刮削/编辑) */
is_remote_emby?: boolean
remote_source?: string
/** 媒体条目总数;`/api/libraries` 已在元数据请求中一并返回。 */
total?: number
}
export interface ScanResult {