mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 12:06:38 +08:00
优化
This commit is contained in:
+50
-13
@@ -2,25 +2,34 @@
|
|||||||
import { lazy, type ReactElement } from 'react'
|
import { lazy, type ReactElement } from 'react'
|
||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
|
|
||||||
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
|
const HomePageLoader = () => import('./pages/HomePage').then((m) => ({ default: m.HomePage }))
|
||||||
const LibraryPage = lazy(() => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })))
|
const LibraryPageLoader = () => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage }))
|
||||||
const LibrariesPage = lazy(() => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })))
|
const LibrariesPageLoader = () => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage }))
|
||||||
const FavouritesPage = lazy(() => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })))
|
const FavouritesPageLoader = () => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage }))
|
||||||
const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })))
|
const PlaylistsPageLoader = () => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage }))
|
||||||
const PlaylistDetailPage = lazy(() =>
|
const PlaylistDetailPageLoader = () =>
|
||||||
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })),
|
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage }))
|
||||||
)
|
const MediaDetailPageLoader = () =>
|
||||||
const MediaDetailPage = lazy(() => import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })))
|
import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage }))
|
||||||
const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })))
|
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 AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
|
||||||
const ProfilePage = lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })))
|
const ProfilePage = lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })))
|
||||||
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
|
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
|
||||||
const FileManagerPage = lazy(() =>
|
const FileManagerPage = lazy(() =>
|
||||||
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||||
)
|
)
|
||||||
const WatchHistoryPage = lazy(() =>
|
const WatchHistoryPage = lazy(WatchHistoryPageLoader)
|
||||||
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
|
|
||||||
)
|
|
||||||
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
|
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
|
||||||
const ProfileManagementPage = lazy(() =>
|
const ProfileManagementPage = lazy(() =>
|
||||||
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
|
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 ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
|
||||||
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
|
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 = {
|
export type AppRoute = {
|
||||||
path?: string
|
path?: string
|
||||||
index?: boolean
|
index?: boolean
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { prefetchCommonRouteChunks } from '../appRoutes'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { usePlayProfileStore } from '../stores/playProfile'
|
import { usePlayProfileStore } from '../stores/playProfile'
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +39,13 @@ function isMediaView(pathname: string, search: string): boolean {
|
|||||||
export function Layout() {
|
export function Layout() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
|
// 登录后的外壳挂载即开始空闲预取常用页面的路由 chunk,
|
||||||
|
// 让首次点击进入各页面时不出现"加载中…"等 chunk 下载。
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchCommonRouteChunks()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { historyAPI } from '../api/history'
|
|||||||
import type { HistoryItem } from '../api/playback'
|
import type { HistoryItem } from '../api/playback'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
} from './HomePageSections'
|
} from './HomePageSections'
|
||||||
|
|
||||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||||
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
|
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
@@ -30,17 +30,24 @@ export function HomePage() {
|
|||||||
const [historyLoading, setHistoryLoading] = useState(true)
|
const [historyLoading, setHistoryLoading] = useState(true)
|
||||||
const { pinnedIds } = usePinnedLibraries()
|
const { pinnedIds } = usePinnedLibraries()
|
||||||
|
|
||||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)
|
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||||
|
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
libraryAPI
|
const cached = peekLibraries()
|
||||||
.list()
|
if (cached && cached.length > 0) {
|
||||||
.then((rows) => asArray<Library>(rows))
|
setLibraries(cached)
|
||||||
|
setLibrariesLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLibraries()
|
||||||
|
.then((rows) => (Array.isArray(rows) ? rows : []))
|
||||||
.catch(() => [] as Library[])
|
.catch(() => [] as Library[])
|
||||||
.then((libs) => {
|
.then((libs) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLibraries(libs)
|
// 失败兜底返回空数组时,不要覆盖已先行渲染的缓存数据
|
||||||
|
setLibraries((prev) => (libs.length === 0 && prev.length > 0 ? prev : libs))
|
||||||
setLibrariesLoading(false)
|
setLibrariesLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
@@ -53,10 +54,18 @@ export function LibrariesPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadLibraries = useCallback(async () => {
|
const loadLibraries = useCallback(async (options?: { force?: boolean }) => {
|
||||||
setLoading(true)
|
if (options?.force) invalidateLibraries()
|
||||||
|
// 会话缓存命中时先行渲染,避免每次进入都白等一轮请求
|
||||||
|
const cached = peekLibraries()
|
||||||
|
if (cached) {
|
||||||
|
setLibraries(cached)
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const libs = await libraryAPI.list()
|
const libs = await fetchLibraries()
|
||||||
setLibraries(libs)
|
setLibraries(libs)
|
||||||
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
|
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
|
||||||
const topIds = libs.slice(0, 20).map((l) => l.id)
|
const topIds = libs.slice(0, 20).map((l) => l.id)
|
||||||
@@ -82,7 +91,7 @@ export function LibrariesPage() {
|
|||||||
|
|
||||||
const handleManageLibraries = async () => {
|
const handleManageLibraries = async () => {
|
||||||
await openManageLibrariesDialog()
|
await openManageLibrariesDialog()
|
||||||
await loadLibraries()
|
await loadLibraries({ force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
|
|||||||
|
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import type { Library, LibraryRoot } from '../types'
|
import type { Library, LibraryRoot } from '../types'
|
||||||
|
import { invalidateLibraries } from '../utils/libraryCache'
|
||||||
import { confirmAction } from '../components/confirmAction'
|
import { confirmAction } from '../components/confirmAction'
|
||||||
import { apiErrorMessage, createRootPayload, displayLibraryRootName, displayLibraryRootPath, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel'
|
import { apiErrorMessage, createRootPayload, displayLibraryRootName, displayLibraryRootPath, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel'
|
||||||
|
|
||||||
@@ -18,10 +19,14 @@ export function useAdminLibraryPanel() {
|
|||||||
|
|
||||||
function useAdminLibraryList() {
|
function useAdminLibraryList() {
|
||||||
const [libs, setLibs] = useState<Library[]>([])
|
const [libs, setLibs] = useState<Library[]>([])
|
||||||
const refresh = () =>
|
const refresh = () => {
|
||||||
libraryAPI
|
// 后台任何库变更都会走到这里;顺带清掉前台会话缓存,
|
||||||
|
// 避免返回首页/媒体库页后 30 秒 TTL 内还显示旧列表。
|
||||||
|
invalidateLibraries()
|
||||||
|
return libraryAPI
|
||||||
.list({ includeHidden: true })
|
.list({ includeHidden: true })
|
||||||
.then((libs) => setLibs(libs.filter((l) => !l.is_remote_emby))) // 远程挂载库只读,不在后台管理列表内
|
.then((libs) => setLibs(libs.filter((l) => !l.is_remote_emby))) // 远程挂载库只读,不在后台管理列表内
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh().catch(() => undefined)
|
refresh().catch(() => undefined)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
|
|||||||
|
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
|
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
||||||
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
||||||
|
|
||||||
export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) {
|
export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) {
|
||||||
@@ -25,74 +26,79 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
|||||||
return groupSeries(items)
|
return groupSeries(items)
|
||||||
}, [isSeries, isSeriesLibrary, items, serverSeriesCards])
|
}, [isSeries, isSeriesLibrary, items, serverSeriesCards])
|
||||||
|
|
||||||
|
// reloadCurrentLibrary 通过自增 tick 重跑加载(原实现靠克隆 library 对象
|
||||||
|
// 触发第二个 effect,这里合并成单个 bootstrap effect 后改用显式信号)。
|
||||||
|
const [reloadTick, setReloadTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryID) return
|
if (!libraryID) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setLoadingAll(false)
|
||||||
setLibrary(null)
|
setLibrary(null)
|
||||||
setItems([])
|
setItems([])
|
||||||
setServerSeriesCards([])
|
setServerSeriesCards([])
|
||||||
setSeriesEpisodeItems([])
|
setSeriesEpisodeItems([])
|
||||||
libraryAPI.get(libraryID)
|
|
||||||
.then((lib) => {
|
const bootstrap = async () => {
|
||||||
if (!cancelled) setLibrary(lib)
|
// 库信息先查会话缓存(首页/全部媒体库页已拉过全量列表),命中则
|
||||||
})
|
// 同步就绪,内容请求在挂载当帧即发出;未命中才退回单独请求,
|
||||||
.catch(() => {
|
// 消除原先"先等库信息、再等内容"的两段串行首屏等待。
|
||||||
|
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) {
|
if (!cancelled) {
|
||||||
setLibrary(null)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
toast.error('媒体库不存在或无权限')
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const collected = await loadAllMedia(libraryID, library.is_remote_emby, (next) => {
|
const seriesLibrary = isSeriesLibraryType(lib.type)
|
||||||
if (cancelled) return
|
setLoadingAll(true)
|
||||||
setTotal(next.total)
|
try {
|
||||||
if (next.firstPage) {
|
if (seriesLibrary) {
|
||||||
setItems(next.items)
|
const collected = await loadAllSeriesCards(libraryID, lib.is_remote_emby, (next) => {
|
||||||
setLoading(false)
|
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()
|
const collected = await loadAllMedia(libraryID, lib.is_remote_emby, (next) => {
|
||||||
.catch(() => {
|
if (cancelled) return
|
||||||
|
setTotal(next.total)
|
||||||
|
if (next.firstPage) {
|
||||||
|
setItems(next.items)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!cancelled) setItems(collected.items)
|
||||||
|
} catch {
|
||||||
if (!cancelled) toast.error('媒体库加载失败')
|
if (!cancelled) toast.error('媒体库加载失败')
|
||||||
})
|
} finally {
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setLoadingAll(false)
|
setLoadingAll(false)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [libraryID, library, isSeriesLibrary])
|
}, [libraryID, reloadTick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
||||||
@@ -117,7 +123,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
|||||||
}, [libraryID, isSeriesLibrary, selectedSeries])
|
}, [libraryID, isSeriesLibrary, selectedSeries])
|
||||||
|
|
||||||
const reloadCurrentLibrary = useCallback(() => {
|
const reloadCurrentLibrary = useCallback(() => {
|
||||||
setLibrary((current) => (current ? { ...current } : current))
|
setReloadTick((tick) => tick + 1)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length)
|
const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length)
|
||||||
|
|||||||
@@ -143,21 +143,27 @@ function useMediaDetailRefresh({
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLoadingEpisodes(true)
|
setLoadingEpisodes(true)
|
||||||
try {
|
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)
|
setMedia(nextMedia)
|
||||||
const favourites = await playbackAPI.listFavourites().catch(() => [])
|
setLoading(false)
|
||||||
|
|
||||||
|
const favourites = await favouritesPromise
|
||||||
setFavourite(favourites.some((item) => item.id === nextMedia.id))
|
setFavourite(favourites.some((item) => item.id === nextMedia.id))
|
||||||
// 异步加载分集列表
|
|
||||||
try {
|
const episodes = await episodesPromise
|
||||||
const epRes = await mediaAPI.getEpisodes(id)
|
setEpisodes(episodes)
|
||||||
setEpisodes(epRes.items ?? [])
|
|
||||||
} catch {
|
|
||||||
setEpisodes([])
|
|
||||||
} finally {
|
|
||||||
setLoadingEpisodes(false)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setLoadingEpisodes(false)
|
||||||
}
|
}
|
||||||
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Library[]> | null = null
|
||||||
|
|
||||||
|
const byId = new Map<string, { at: number; data: Library }>()
|
||||||
|
const byIdInflight = new Map<string, Promise<Library>>()
|
||||||
|
|
||||||
|
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<Library[]> {
|
||||||
|
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<Library> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user