import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' 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' import { isLibraryPinned } from '../utils/pinnedLibraries' const TYPE_ICONS: Record = { movie: , tv: , anime: , variety: , music: , adult: , } const TYPE_LABELS: Record = { movie: '电影', tv: '剧集', anime: '动漫', variety: '综艺', music: '音乐', adult: '成人', } export function LibrariesHeader({ previewCount, total, repairMsg, repairEpisodeArtwork, repairing, onRepairEpisodeArtworkChange, onRepairRescrape, onManageLibraries, }: { previewCount: number total: number repairMsg: string repairEpisodeArtwork: boolean repairing: boolean onRepairEpisodeArtworkChange: (value: boolean) => void onRepairRescrape: () => void onManageLibraries: () => void }) { return (

媒体库

共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。

{repairMsg && {repairMsg}} 刮削队列
) } export function LibrariesEmptyState() { return (

暂无媒体库,请到管理后台添加目录。

) } export function LibrariesContent({ previews, pinnedIds, onTogglePin, onNeedPreviews, }: { previews: LibraryPreview[] pinnedIds: string[] onTogglePin: (libraryId: string) => void onNeedPreviews?: (ids: string[], limit?: number) => void }) { const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length const queuePreview = useLazyPreviewBatch((ids) => onNeedPreviews?.(ids, 2)) // 下方媒体库货架支持向下滑动渐进流式加载:默认先展示前 3 个库货架, // 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库货架。 const INITIAL_SHELVES = 3 const STEP_SHELVES = 2 const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES) const sentinelRef = useRef(null) useEffect(() => { const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id) onNeedPreviews?.(currentTargets, 10) }, [previews, visibleCount, onNeedPreviews]) // 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架) useEffect(() => { const scrollParent = document.getElementById('app-main-scroll') if (!scrollParent) return const handleCheckBottom = () => { const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight if (remaining < 600) { setVisibleCount((prev) => { if (prev >= previews.length) return prev return Math.min(prev + STEP_SHELVES, previews.length) }) } } scrollParent.addEventListener('scroll', handleCheckBottom, { passive: true }) const sentinel = sentinelRef.current let observer: IntersectionObserver | null = null if (sentinel) { observer = new IntersectionObserver( (entries) => { const [entry] = entries if (entry?.isIntersecting) { setVisibleCount((prev) => { if (prev >= previews.length) return prev return Math.min(prev + STEP_SHELVES, previews.length) }) } }, { root: scrollParent, rootMargin: '600px 0px', threshold: 0, }, ) observer.observe(sentinel) } handleCheckBottom() return () => { scrollParent.removeEventListener('scroll', handleCheckBottom) if (observer) { observer.disconnect() } } }, [visibleCount, previews.length]) const visiblePreviews = useMemo(() => { return previews.slice(0, visibleCount) }, [previews, visibleCount]) const ENTRY_PAGE_SIZE = 20 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(() => { const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE return previews.slice(start, start + ENTRY_PAGE_SIZE) }, [previews, effectiveEntryPage]) // 入口网格首屏展示前 20 个库;没有自定义封面的库需要立即拉预览,否则卡片会先渲染占位图标再补图。 useEffect(() => { const ids = pagedPreviews.filter((preview) => !preview.library.cover_url).map((preview) => preview.library.id) if (ids.length > 0) { onNeedPreviews?.(ids, 2) } }, [pagedPreviews, onNeedPreviews]) return ( <>

媒体库入口

{previews.length > ENTRY_PAGE_SIZE && ( 共 {previews.length} 个 · 每页 {ENTRY_PAGE_SIZE} 个 )}

按目录进入完整媒体库;下方每个目录也会直接展示最新内容。 {pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}

{totalEntryPages > 1 && (
{effectiveEntryPage} / {totalEntryPages}
)}
{pagedPreviews.map((preview) => (
onTogglePin(preview.library.id)} onVisible={() => { if (!preview.library.cover_url) { queuePreview(preview.library.id) } }} />
))}
{visiblePreviews.length > 0 && (
{visiblePreviews.map((preview) => (
))} {visibleCount < previews.length && (
加载更多媒体库货架…
)}
)} ) } function LibraryEntryCard({ preview, pinned, onTogglePin, onVisible, }: { preview: LibraryPreview pinned: boolean onTogglePin: () => void onVisible?: () => void }) { const library = preview.library const ref = useInViewOnce(onVisible ?? (() => {})) const artwork = library.cover_url ? [{ src: library.cover_url, version: library.updated_at }] : libraryArtworkItems(preview.cards) const displayPath = libraryDisplayPath(library.path) return (
1 ? 'grid-cols-2' : 'grid-cols-1'}`}> {artwork.length > 0 ? ( artwork.map(({ src, version }, index) => ( { event.currentTarget.style.visibility = 'hidden' }} /> )) ) : (
{TYPE_ICONS[library.type] ?? }
)}
{TYPE_LABELS[library.type] ?? library.type} {preview.total.toLocaleString()} 个条目

{library.name} {pinned ? 置顶 : null}

{displayPath}

{preview.total.toLocaleString()} 个条目 浏览全部
) } function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: boolean }) { const library = preview.library const cards = preview.cards.slice(0, 10) const displayPath = libraryDisplayPath(library.path) return (
{TYPE_ICONS[library.type] ?? } {TYPE_LABELS[library.type] ?? library.type}

{library.name} {pinned ? 置顶 : null}

{displayPath} · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部

浏览全部
{cards.length > 0 ? (
{cards.map((card) => (
))}
) : (
该目录暂无可展示内容,扫描媒体库后会出现在这里。
)}
) }