diff --git a/internal/handler/media.go b/internal/handler/media.go index 1aebcdb..3c0d10a 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -13,6 +13,7 @@ import ( "github.com/truewhile/MeBox/internal/middleware" "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/service" ) @@ -109,11 +110,25 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { for _, p := range previews { out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards}) } - } else { - for _, l := range libs { - out = append(out, webLibraryPayload{Library: l}) + } else { + visibility := mediaVisibilityForRequest(c, svc) + libIDs := make([]string, len(libs)) + for i, l := range libs { + libIDs[i] = l.ID + } + counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + }) + for _, l := range libs { + var total int64 + if counts != nil { + total = counts[l.ID] + } + out = append(out, webLibraryPayload{Library: l, Total: total}) + } } - } // 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。 if svc.EmbyRemote != nil { if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil { diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 656fd93..00f09ab 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -115,14 +115,15 @@ export function HomePage() { } }, [fetchedLibIds]) - // 3. 首屏视口媒体库:优先加载轮播图指定的库和前 3 个库 + // 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 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, ...topRowLibIds])) + const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...topRowLibIds])) void fetchPreviews(initialTargets) }, [sortedLibraries, fetchPreviews]) @@ -139,34 +140,63 @@ export function HomePage() { void fetchPreviews(currentTargets) }, [sortedLibraries, visibleTargetCount, fetchPreviews]) - // 底部哨兵监听(触底解锁后续媒体库行) + // 当前已拉取并确认有内容的媒体库行 + const visibleLibraries = useMemo(() => { + return sortedLibraries + .slice(0, visibleTargetCount) + .filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0) + }, [sortedLibraries, visibleTargetCount, libraryData]) + + const hasMoreLibraries = visibleTargetCount < sortedLibraries.length + + // 底部哨兵监听与滚动双保险(触底解锁后续媒体库行) useEffect(() => { - const sentinel = sentinelRef.current - if (!sentinel) return - const scrollParent = document.getElementById('app-main-scroll') - const observer = new IntersectionObserver( - (entries) => { - const [entry] = entries - if (entry?.isIntersecting) { - setVisibleTargetCount((prev) => { - if (prev >= sortedLibraries.length) return prev - return Math.min(prev + STEP_ROWS, sortedLibraries.length) - }) - } - }, - { - root: scrollParent, - rootMargin: '400px 0px', - threshold: 0, - }, - ) + if (!scrollParent) return - observer.observe(sentinel) - return () => { - observer.disconnect() + const handleCheckBottom = () => { + const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight + if (remaining < 600) { + setVisibleTargetCount((prev) => { + if (prev >= sortedLibraries.length) return prev + return Math.min(prev + STEP_ROWS, sortedLibraries.length) + }) + } } - }, [sortedLibraries.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) { + setVisibleTargetCount((prev) => { + if (prev >= sortedLibraries.length) return prev + return Math.min(prev + STEP_ROWS, sortedLibraries.length) + }) + } + }, + { + root: scrollParent, + rootMargin: '600px 0px', + threshold: 0, + }, + ) + observer.observe(sentinel) + } + + handleCheckBottom() + + return () => { + scrollParent.removeEventListener('scroll', handleCheckBottom) + if (observer) { + observer.disconnect() + } + } + }, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length]) // Quick lookup map for libraries const libraryMap = useMemo(() => { @@ -221,15 +251,6 @@ export function HomePage() { return candidateMedia.slice(0, 10) }, [libraries, libraryData]) - // 当前已拉取并确认有内容的媒体库行 - const visibleLibraries = useMemo(() => { - return sortedLibraries - .slice(0, visibleTargetCount) - .filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0) - }, [sortedLibraries, visibleTargetCount, libraryData]) - - const hasMoreLibraries = visibleTargetCount < sortedLibraries.length - // 库列表还没回来先展示整页 loading;库为空时再等一下播放记录, // 以免在"空站点"和"有观看记录"两个终态之间闪空白。 if (librariesLoading || (libraries.length === 0 && historyLoading)) { @@ -261,6 +282,7 @@ export function HomePage() { libraries={sortedLibraries} libraryData={libraryData} libraryCounts={libraryCounts} + onNeedPreviews={fetchPreviews} /> )} diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index 0a88ff0..acb150b 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -366,10 +366,12 @@ export function HomeLibrariesSection({ libraries, libraryData, libraryCounts, + onNeedPreviews, }: { libraries: Library[] libraryData?: Record libraryCounts: Record + onNeedPreviews?: (ids: string[]) => void }) { const PAGE_SIZE = 20 const [currentPage, setCurrentPage] = useState(1) @@ -381,6 +383,11 @@ export function HomeLibrariesSection({ return libraries.slice(start, start + PAGE_SIZE) }, [libraries, effectivePage]) + useEffect(() => { + const ids = pagedLibraries.map((l) => l.id) + onNeedPreviews?.(ids) + }, [pagedLibraries, onNeedPreviews]) + return (
diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index b059b3a..dd46e17 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -62,8 +62,8 @@ export function LibrariesPage() { try { const libs = await libraryAPI.list() setLibraries(libs) - // 优先拉取前 3 个库的卡片预览 - const topIds = libs.slice(0, 3).map((l) => l.id) + // 优先拉取入口卡片网格当前页(前 20 个库)的预览 + const topIds = libs.slice(0, 20).map((l) => l.id) void fetchPreviews(topIds) } finally { setLoading(false) diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index e07b7fb..6ff3e36 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -128,33 +128,54 @@ export function LibrariesContent({ onNeedPreviews?.(currentTargets) }, [previews, visibleCount, onNeedPreviews]) + // 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架) useEffect(() => { - const sentinel = sentinelRef.current - if (!sentinel) return - const scrollParent = document.getElementById('app-main-scroll') - const 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: '400px 0px', - threshold: 0, - }, - ) + if (!scrollParent) return - observer.observe(sentinel) - return () => { - observer.disconnect() + 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) + }) + } } - }, [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) @@ -169,6 +190,11 @@ export function LibrariesContent({ return previews.slice(start, start + ENTRY_PAGE_SIZE) }, [previews, effectiveEntryPage]) + useEffect(() => { + const ids = pagedPreviews.map((p) => p.library.id) + onNeedPreviews?.(ids) + }, [pagedPreviews, onNeedPreviews]) + return ( <>