diff --git a/web/src/appRoutes.tsx b/web/src/appRoutes.tsx index 1bdd869..1fea31d 100644 --- a/web/src/appRoutes.tsx +++ b/web/src/appRoutes.tsx @@ -2,25 +2,34 @@ import { lazy, type ReactElement } from 'react' import { Navigate } from 'react-router-dom' -const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage }))) -const LibraryPage = lazy(() => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage }))) -const LibrariesPage = lazy(() => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage }))) -const FavouritesPage = lazy(() => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage }))) -const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage }))) -const PlaylistDetailPage = lazy(() => - import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })), -) -const MediaDetailPage = lazy(() => import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage }))) -const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage }))) +const HomePageLoader = () => import('./pages/HomePage').then((m) => ({ default: m.HomePage })) +const LibraryPageLoader = () => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })) +const LibrariesPageLoader = () => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })) +const FavouritesPageLoader = () => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })) +const PlaylistsPageLoader = () => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })) +const PlaylistDetailPageLoader = () => + import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })) +const MediaDetailPageLoader = () => + import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })) +const PlayerPageLoader = () => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })) +const WatchHistoryPageLoader = () => + import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })) + +const HomePage = lazy(HomePageLoader) +const LibraryPage = lazy(LibraryPageLoader) +const LibrariesPage = lazy(LibrariesPageLoader) +const FavouritesPage = lazy(FavouritesPageLoader) +const PlaylistsPage = lazy(PlaylistsPageLoader) +const PlaylistDetailPage = lazy(PlaylistDetailPageLoader) +const MediaDetailPage = lazy(MediaDetailPageLoader) +const PlayerPage = lazy(PlayerPageLoader) const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage }))) const ProfilePage = lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage }))) const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage }))) const FileManagerPage = lazy(() => import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })), ) -const WatchHistoryPage = lazy(() => - import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })), -) +const WatchHistoryPage = lazy(WatchHistoryPageLoader) const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage }))) const ProfileManagementPage = lazy(() => import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })), @@ -37,6 +46,34 @@ const StrmUploadQueuePage = lazy(() => const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage }))) const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage }))) +// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径 +// (库列表/库详情/媒体详情/播放/收藏/历史等)的 chunk 拉下来, +// 首次点击进入时不再出现"加载中…"等 chunk 下载。失败静默(导航时会重试)。 +let prefetchStarted = false +export function prefetchCommonRouteChunks() { + if (prefetchStarted || typeof window === 'undefined') return + prefetchStarted = true + const loaders = [ + LibrariesPageLoader, + LibraryPageLoader, + MediaDetailPageLoader, + PlayerPageLoader, + FavouritesPageLoader, + WatchHistoryPageLoader, + PlaylistsPageLoader, + ] + const run = () => { + for (const load of loaders) { + load().catch(() => undefined) + } + } + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(run, { timeout: 5000 }) + } else { + window.setTimeout(run, 2000) + } +} + export type AppRoute = { path?: string index?: boolean diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 9016732..5c9aa56 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,5 +1,7 @@ +import { useEffect } from 'react' import { useLocation, useNavigate } from 'react-router-dom' +import { prefetchCommonRouteChunks } from '../appRoutes' import { useAuthStore } from '../stores/auth' import { usePlayProfileStore } from '../stores/playProfile' import { @@ -37,6 +39,13 @@ function isMediaView(pathname: string, search: string): boolean { export function Layout() { const navigate = useNavigate() const location = useLocation() + + // 登录后的外壳挂载即开始空闲预取常用页面的路由 chunk, + // 让首次点击进入各页面时不出现"加载中…"等 chunk 下载。 + useEffect(() => { + prefetchCommonRouteChunks() + }, []) + const user = useAuthStore((s) => s.user) const logout = useAuthStore((s) => s.logout) const activeProfileId = usePlayProfileStore((s) => s.activeProfileId) diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 7822908..44dec41 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -5,6 +5,7 @@ import { historyAPI } from '../api/history' import type { HistoryItem } from '../api/playback' import type { Library, Media } from '../types' import type { SeriesCard } from '../utils/groupSeries' +import { fetchLibraries, peekLibraries } from '../utils/libraryCache' import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { sortByPinnedIds } from '../utils/pinnedLibraries' import { @@ -18,7 +19,6 @@ import { } from './HomePageSections' const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url) -const asArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []) export function HomePage() { const [libraries, setLibraries] = useState([]) @@ -30,17 +30,24 @@ export function HomePage() { const [historyLoading, setHistoryLoading] = useState(true) const { pinnedIds } = usePinnedLibraries() - // 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏) + // 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。 + // 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。 useEffect(() => { let cancelled = false - libraryAPI - .list() - .then((rows) => asArray(rows)) + const cached = peekLibraries() + if (cached && cached.length > 0) { + setLibraries(cached) + setLibrariesLoading(false) + } + + fetchLibraries() + .then((rows) => (Array.isArray(rows) ? rows : [])) .catch(() => [] as Library[]) .then((libs) => { if (cancelled) return - setLibraries(libs) + // 失败兜底返回空数组时,不要覆盖已先行渲染的缓存数据 + setLibraries((prev) => (libs.length === 0 && prev.length > 0 ? prev : libs)) setLibrariesLoading(false) }) diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 72701f6..228205b 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -12,6 +12,7 @@ import { import type { LibraryPreview } from './librariesPageModel' import type { Library } from '../types' import type { SeriesCard } from '../utils/groupSeries' +import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache' import { sortLibraryPreviews } from '../utils/pinnedLibraries' export function LibrariesPage() { @@ -53,10 +54,18 @@ export function LibrariesPage() { } }, []) - const loadLibraries = useCallback(async () => { - setLoading(true) + const loadLibraries = useCallback(async (options?: { force?: boolean }) => { + if (options?.force) invalidateLibraries() + // 会话缓存命中时先行渲染,避免每次进入都白等一轮请求 + const cached = peekLibraries() + if (cached) { + setLibraries(cached) + setLoading(false) + } else { + setLoading(true) + } try { - const libs = await libraryAPI.list() + const libs = await fetchLibraries() setLibraries(libs) // 优先拉取入口卡片网格当前页(前 20 个库)的预览 const topIds = libs.slice(0, 20).map((l) => l.id) @@ -82,7 +91,7 @@ export function LibrariesPage() { const handleManageLibraries = async () => { await openManageLibrariesDialog() - await loadLibraries() + await loadLibraries({ force: true }) } useEffect(() => { diff --git a/web/src/pages/useAdminLibraryPanel.ts b/web/src/pages/useAdminLibraryPanel.ts index 57b7c21..4cd7aba 100644 --- a/web/src/pages/useAdminLibraryPanel.ts +++ b/web/src/pages/useAdminLibraryPanel.ts @@ -3,6 +3,7 @@ import toast from 'react-hot-toast' import { libraryAPI } from '../api/library' import type { Library, LibraryRoot } from '../types' +import { invalidateLibraries } from '../utils/libraryCache' import { confirmAction } from '../components/confirmAction' import { apiErrorMessage, createRootPayload, displayLibraryRootName, displayLibraryRootPath, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel' @@ -18,10 +19,14 @@ export function useAdminLibraryPanel() { function useAdminLibraryList() { const [libs, setLibs] = useState([]) - const refresh = () => - libraryAPI + const refresh = () => { + // 后台任何库变更都会走到这里;顺带清掉前台会话缓存, + // 避免返回首页/媒体库页后 30 秒 TTL 内还显示旧列表。 + invalidateLibraries() + return libraryAPI .list({ includeHidden: true }) .then((libs) => setLibs(libs.filter((l) => !l.is_remote_emby))) // 远程挂载库只读,不在后台管理列表内 + } useEffect(() => { refresh().catch(() => undefined) diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts index ed3706a..719b7ca 100644 --- a/web/src/pages/useLibraryData.ts +++ b/web/src/pages/useLibraryData.ts @@ -3,6 +3,7 @@ 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' export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) { @@ -25,74 +26,79 @@ 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) + useEffect(() => { if (!libraryID) return let cancelled = false setLoading(true) + setLoadingAll(false) setLibrary(null) setItems([]) setServerSeriesCards([]) setSeriesEpisodeItems([]) - libraryAPI.get(libraryID) - .then((lib) => { - if (!cancelled) setLibrary(lib) - }) - .catch(() => { + + const bootstrap = async () => { + // 库信息先查会话缓存(首页/全部媒体库页已拉过全量列表),命中则 + // 同步就绪,内容请求在挂载当帧即发出;未命中才退回单独请求, + // 消除原先"先等库信息、再等内容"的两段串行首屏等待。 + let lib = peekLibrary(libraryID) + if (lib) setLibrary(lib) + try { + const resolved = await resolveLibrary(libraryID) + if (cancelled) return + if (!lib) { + lib = resolved + setLibrary(lib) + } + } catch { if (!cancelled) { - setLibrary(null) setLoading(false) toast.error('媒体库不存在或无权限') } - }) - return () => { cancelled = true } - }, [libraryID]) - - useEffect(() => { - if (!libraryID || !library) return - let cancelled = false - setLoading(true) - setLoadingAll(true) - setItems([]) - setServerSeriesCards([]) - setSeriesEpisodeItems([]) - - const loadAll = async () => { - if (isSeriesLibrary) { - const collected = await loadAllSeriesCards(libraryID, library.is_remote_emby, (next) => { - if (cancelled) return - setTotal(next.total) - if (next.firstPage) { - setServerSeriesCards(next.items) - setLoading(false) - } - }) - if (!cancelled) setServerSeriesCards(collected.items) return } - const collected = await loadAllMedia(libraryID, library.is_remote_emby, (next) => { - if (cancelled) return - setTotal(next.total) - if (next.firstPage) { - setItems(next.items) - setLoading(false) + const seriesLibrary = isSeriesLibraryType(lib.type) + setLoadingAll(true) + 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) + } + }) + if (!cancelled) setServerSeriesCards(collected.items) + return } - }) - if (!cancelled) setItems(collected.items) - } - loadAll() - .catch(() => { + const collected = await loadAllMedia(libraryID, lib.is_remote_emby, (next) => { + if (cancelled) return + setTotal(next.total) + if (next.firstPage) { + setItems(next.items) + setLoading(false) + } + }) + if (!cancelled) setItems(collected.items) + } catch { if (!cancelled) toast.error('媒体库加载失败') - }) - .finally(() => { + } finally { if (!cancelled) { setLoading(false) setLoadingAll(false) } - }) + } + } + + void bootstrap() return () => { cancelled = true } - }, [libraryID, library, isSeriesLibrary]) + }, [libraryID, reloadTick]) useEffect(() => { if (!libraryID || !isSeriesLibrary || !selectedSeries) { @@ -117,7 +123,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n }, [libraryID, isSeriesLibrary, selectedSeries]) const reloadCurrentLibrary = useCallback(() => { - setLibrary((current) => (current ? { ...current } : current)) + setReloadTick((tick) => tick + 1) }, []) const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length) diff --git a/web/src/pages/useMediaDetailPageState.ts b/web/src/pages/useMediaDetailPageState.ts index b4d2975..b8fddc6 100644 --- a/web/src/pages/useMediaDetailPageState.ts +++ b/web/src/pages/useMediaDetailPageState.ts @@ -143,21 +143,27 @@ function useMediaDetailRefresh({ setLoading(true) setLoadingEpisodes(true) try { - const nextMedia = await mediaAPI.get(id) + // 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表 + // 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。 + const nextMediaPromise = mediaAPI.get(id) + const favouritesPromise = playbackAPI.listFavourites().catch(() => []) + const episodesPromise = mediaAPI + .getEpisodes(id) + .then((r) => r.items ?? []) + .catch(() => []) + + const nextMedia = await nextMediaPromise setMedia(nextMedia) - const favourites = await playbackAPI.listFavourites().catch(() => []) + setLoading(false) + + const favourites = await favouritesPromise setFavourite(favourites.some((item) => item.id === nextMedia.id)) - // 异步加载分集列表 - try { - const epRes = await mediaAPI.getEpisodes(id) - setEpisodes(epRes.items ?? []) - } catch { - setEpisodes([]) - } finally { - setLoadingEpisodes(false) - } + + const episodes = await episodesPromise + setEpisodes(episodes) } finally { setLoading(false) + setLoadingEpisodes(false) } }, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes]) } diff --git a/web/src/utils/libraryCache.ts b/web/src/utils/libraryCache.ts new file mode 100644 index 0000000..de3649c --- /dev/null +++ b/web/src/utils/libraryCache.ts @@ -0,0 +1,89 @@ +import { libraryAPI } from '../api/library' +import type { Library } from '../types' + +// 会话级媒体库元数据缓存(不落 localStorage)。 +// 首页 / 媒体库页 / 媒体库详情页都要先拿库列表或库信息才能渲染, +// 同一会话内反复进出这些页面时直接命中缓存,避免每次都等一整轮请求。 +// TTL 内直接返回缓存;过期后重新请求并刷新。并发调用共享同一个在途请求。 + +const LIST_TTL_MS = 30_000 +const LIBRARY_TTL_MS = 30_000 + +let listData: Library[] | null = null +let listAt = 0 +let listInflight: Promise | null = null + +const byId = new Map() +const byIdInflight = new Map>() + +function fresh(at: number, ttl: number): boolean { + return Date.now() - at < ttl +} + +function rememberList(data: Library[]) { + listData = data + listAt = Date.now() + for (const lib of data) { + byId.set(lib.id, { at: listAt, data: lib }) + } +} + +// fetchLibraries 拉取库列表:TTL 内命中缓存立即返回(含 peek 先行渲染), +// 过期或未缓存时发起请求,并发调用共享在途请求。 +export function fetchLibraries(): Promise { + if (listData && fresh(listAt, LIST_TTL_MS)) return Promise.resolve(listData) + if (!listInflight) { + listInflight = libraryAPI + .list() + .then((rows) => { + const data = Array.isArray(rows) ? rows : [] + rememberList(data) + return data + }) + .finally(() => { + listInflight = null + }) + } + return listInflight +} + +// peekLibraries 同步返回当前缓存的库列表(可能过期),没有则 null。 +// 页面用它先行渲染上一次的数据,再由 fetchLibraries 在后台补一次刷新。 +export function peekLibraries(): Library[] | null { + return listData +} + +// peekLibrary 同步返回单库缓存(库列表缓存也会回填单库索引)。 +export function peekLibrary(id: string): Library | null { + const entry = byId.get(id) + return entry?.data ?? null +} + +// resolveLibrary 拿单个库信息:命中缓存直接返回;未命中走 /libraries/:id +// 并回填缓存。远程 Emby 挂载库的详情接口同样返回 is_remote_emby,缓存可用。 +export function resolveLibrary(id: string): Promise { + const entry = byId.get(id) + if (entry && fresh(entry.at, LIBRARY_TTL_MS)) return Promise.resolve(entry.data) + const inflight = byIdInflight.get(id) + if (inflight) return inflight + const request = libraryAPI + .get(id) + .then((lib) => { + byId.set(id, { at: Date.now(), data: lib }) + return lib + }) + .finally(() => { + byIdInflight.delete(id) + }) + byIdInflight.set(id, request) + return request +} + +// invalidate 清空缓存。管理页对库做过增删改后调用,确保下次拉到新数据。 +export function invalidateLibraries() { + listData = null + listAt = 0 + listInflight = null + byId.clear() + byIdInflight.clear() +}