import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react' import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { motion } from 'framer-motion' import toast from 'react-hot-toast' import type { Media } from '../types' import { useAuthStore } from '../stores/auth' import { libraryAPI, type LibraryFacets } from '../api/library' import { EMPTY_LIBRARY_FILTERS, parseLibraryFilters, withFilterParams, type LibraryFilterParams, } from '../utils/libraryFilters' import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries' import { sortMediaList, sortSeriesList, type SortField, type SortOrder, } from '../utils/mediaSort' import { LibraryPageDialogs } from './LibraryPageDialogs' import { PageBackButton } from '../components/PageBackButton' import { MediaFavouriteButton } from '../components/MediaFavouriteButton' import { LibraryPageHeader } from './LibraryPageHeader' import { LibraryFilterBar } from './LibraryFilterBar' import { LibraryMediaSections } from './LibraryMediaSections' import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection' import { useLibraryData } from './useLibraryData' import { useLibraryScanStatus } from './useLibraryScanStatus' import { useLibrarySeriesSelection } from './useLibrarySeriesSelection' import { useLibraryAdminActions } from './useLibraryAdminActions' import { usePermission } from '../hooks/usePermission' import { useFavourites } from '../hooks/useFavourites' export function LibraryPage() { const { id = '' } = useParams() const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() const navigate = useNavigate() const role = useAuthStore((s) => s.user?.role) const canFavorite = usePermission('can_favorite') const { isFavourite, toggleFavourite } = useFavourites() const [favouriteBusyID, setFavouriteBusyID] = useState('') const [scrapeDialogOpen, setScrapeDialogOpen] = useState(false) const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false) const [seriesMetadataEditOpen, setSeriesMetadataEditOpen] = useState(false) const [manualMovie, setManualMovie] = useState(null) // 排序状态(支持按库记忆) const [sortField, setSortField] = useState(() => { const saved = localStorage.getItem(`mebox_lib_sort_field_${id}`) || localStorage.getItem('mebox_lib_sort_field') return (saved as SortField) || 'title' }) const [sortOrder, setSortOrder] = useState(() => { const saved = localStorage.getItem(`mebox_lib_sort_order_${id}`) || localStorage.getItem('mebox_lib_sort_order') return (saved as SortOrder) || 'asc' }) const [randomSeed, setRandomSeed] = useState(() => Date.now()) const [lastNonRandomSort, setLastNonRandomSort] = useState(sortField === 'random' ? 'release_date' : sortField) const [lastNonRandomOrder, setLastNonRandomOrder] = useState(sortOrder) const serverSortField = sortField === 'random' ? lastNonRandomSort : sortField const serverSortOrder = sortField === 'random' ? lastNonRandomOrder : sortOrder // 剧集模式:选中某个剧集后展开详情 const [selectedSeries, setSelectedSeries] = useState(null) const [selectedSeason, setSelectedSeason] = useState(null) // 筛选状态放在 URL:可分享、刷新保持、浏览器返回可撤销。 const libraryFilters = useMemo( () => parseLibraryFilters(location.search), [location.search], ) const [facets, setFacets] = useState(null) const [loadingFacets, setLoadingFacets] = useState(true) const [randomBusy, setRandomBusy] = useState(false) useEffect(() => { let cancelled = false setLoadingFacets(true) libraryAPI .facets(id) .then((data) => { if (!cancelled) setFacets(data) }) .catch(() => { if (!cancelled) setFacets(null) }) .finally(() => { if (!cancelled) setLoadingFacets(false) }) return () => { cancelled = true } }, [id]) const applyFilters = (next: LibraryFilterParams) => { navigate({ search: withFilterParams(location.search, next) }, { replace: true }) } const resetFilters = () => { navigate({ search: withFilterParams(location.search, EMPTY_LIBRARY_FILTERS) }, { replace: true }) } // 「随便看看」直接进播放页:多一步详情页会削弱「随手看点什么」的意图。 // 接受来自 LibraryFilterBar 的有效草稿筛选(草稿与已应用不同时,面板已事先 // 调用 applyFilters 把草稿写入 URL,再用相同的条件发起随机请求以保证一致)。 const handleRandom = async (filters: LibraryFilterParams) => { setRandomBusy(true) try { const media = await libraryAPI.random(id, filters) navigate(`/play/${media.id}`) } catch (err) { const status = (err as { response?: { status?: number } })?.response?.status toast.error( status === 404 ? '没有符合当前筛选条件的媒体' : '随机播放失败,请稍后重试', ) } finally { setRandomBusy(false) } } const { library, items, seriesEpisodeItems, total, loading, loadingMore, hasMore, loadMore, loadAll, loadingSeriesEpisodes, isSeriesLibrary, isSeries, seriesCards, loadingAllText, reloadCurrentLibrary, } = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder, libraryFilters) // 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。 const displayedItems = useMemo(() => { return sortField === 'random' ? sortMediaList(items, 'random', sortOrder, randomSeed) : items }, [items, sortField, sortOrder, randomSeed]) 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, handleScan, } = useLibraryScanStatus({ libraryID: id, isAdmin: role === 'admin', onLibraryChanged: reloadCurrentLibrary, }) const { resolvingSeries, selectedEpisodes, visibleEpisodes, selectedSeriesEpisodes, selectedSeriesAllEpisodes, selectedSeriesMediaIDs, handleSeriesClick, clearSelectedSeries, } = useLibrarySeriesSelection({ libraryID: id, items, seriesEpisodeItems, isSeriesLibrary, isSeries, loading, seriesCards: displayedSeriesCards, searchParams, setSearchParams, selectedSeries, setSelectedSeries, selectedSeason, setSelectedSeason, onClearSeriesState: () => setSeriesMetadataEditOpen(false), }) const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media)) ?? selectedSeries?.rep ?? null const { scraping, scrapeEpisodeArtwork, repairing, seriesToolBusy, setScrapeEpisodeArtwork, handleRepairRescrape, handleSeriesSmartScrape, handleSeriesProbe, handleSeriesNFO, handleSeriesOrganize, handleSeriesDelete, movieActions, } = useLibraryAdminActions({ libraryID: id, role, library, selectedSeries, selectedSeriesEpisodes: selectedSeriesAllEpisodes, reloadCurrentLibrary, clearSelectedSeries, setManualMovie, }) const handleToggleFavourite = useCallback(async (mediaID: string) => { if (!canFavorite || favouriteBusyID) return setFavouriteBusyID(mediaID) try { await toggleFavourite(mediaID) } finally { setFavouriteBusyID('') } }, [canFavorite, favouriteBusyID, toggleFavourite]) // useCallback 稳定引用:配合 MediaCard 的 memo,仅在收藏状态/操作集变化时 // 才让卡片重渲染。 const cardActions = useCallback((media: Media): ReactNode => { const actions: ReactNode[] = [] if (canFavorite) { actions.push( { void handleToggleFavourite(media.id) }} />, ) } const adminActions = movieActions(media) if (adminActions) { actions.push({adminActions}) } if (actions.length === 0) return undefined return <>{actions} }, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions]) // resolvingSeries:深链目标剧集不在已加载的一页卡片里,正在按 key // 单独解析。此时先显示加载态,避免先渲染整个媒体库列表再跳走。 if (loading || resolvingSeries) { return (
加载中…
) } return (
{!selectedSeries && (
)} {!selectedSeries && ( setScrapeDialogOpen(true)} onRepairRescrape={handleRepairRescrape} /> )} void handleRandom(f)} randomBusy={randomBusy} /> { if (!selectedSeries) return void handleToggleFavourite(selectedSeries.rep.id) }} seriesToolBusy={seriesToolBusy} onBack={clearSelectedSeries} onSmartScrape={handleSeriesSmartScrape} onManualScrape={() => setManualSeriesScrapeOpen(true)} onMetadataEdit={() => setSeriesMetadataEditOpen(true)} onProbe={handleSeriesProbe} onNFO={handleSeriesNFO} onOrganize={handleSeriesOrganize} onDelete={handleSeriesDelete} onSeasonChange={setSelectedSeason} onManualScrapeMedia={setManualMovie} /> setScrapeDialogOpen(false)} onCloseManualSeriesScrape={() => setManualSeriesScrapeOpen(false)} onCloseSeriesMetadataEdit={() => setSeriesMetadataEditOpen(false)} onCloseManualMovie={() => setManualMovie(null)} onApplied={reloadCurrentLibrary} />
) }