This commit is contained in:
truewhile
2026-09-15 00:00:54 +08:00
4 changed files with 58 additions and 52 deletions
+2 -1
View File
@@ -430,7 +430,8 @@ video::cue(*) {
.animate-overlay-in { animation: overlay-in 0.15s ease-out both; } .animate-overlay-in { animation: overlay-in 0.15s ease-out both; }
.animate-drawer-in { animation: drawer-in 0.22s ease-out both; } .animate-drawer-in { animation: drawer-in 0.22s ease-out both; }
.animate-page-in { animation: page-in 0.25s ease-out both; } /* 播放完成后不能保留 transform,否则长页面会成为 fixed 弹框的包含块。 */
.animate-page-in { animation: page-in 0.25s ease-out backwards; }
.animate-accordion-in { animation: accordion-in 0.18s ease-out both; } .animate-accordion-in { animation: accordion-in 0.18s ease-out both; }
.animate-fade-slide-in { animation: fade-slide-in 0.2s ease-out both; } .animate-fade-slide-in { animation: fade-slide-in 0.2s ease-out both; }
.animate-menu-in { animation: menu-in 0.15s ease-out both; } .animate-menu-in { animation: menu-in 0.15s ease-out both; }
+24 -28
View File
@@ -151,33 +151,30 @@ export function HomePage() {
) )
}, []) }, [])
// 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后再按批加载。 // 3. 首屏只预取轮播和前三行所需的预览;入口卡片进入视口后自行按批加载。
useEffect(() => { useEffect(() => {
if (sortedLibraries.length === 0) return if (sortedLibraries.length === 0) return
const carouselLibIds = sortedLibraries const carouselLibIds = sortedLibraries
.filter((l) => l.carousel_enabled === true) .filter((l) => l.carousel_enabled === true)
.map((l) => l.id) .map((l) => l.id)
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id) const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
// 入口网格仍然是一次展示 20 个库;没有自定义封面的库必须在首屏
// 立即拉预览,否则会先出现占位图标再补图,体感反而更慢。
const topGridLibIds = sortedLibraries
.slice(0, 20)
.filter((l) => !l.cover_url)
.map((l) => l.id)
const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds])) const shelfTargets = Array.from(new Set([...carouselLibIds, ...topRowLibIds]))
const shelfTargetSet = new Set(shelfTargets)
const gridTargets = topGridLibIds.filter((id) => !shelfTargetSet.has(id))
// 入口网格只需要 2 张封面,横向货架才需要 10 张。分开请求可以避免
// 为暂时不会出现的货架预取完整卡片窗口。
void fetchPreviews(shelfTargets, 10) void fetchPreviews(shelfTargets, 10)
void fetchPreviews(gridTargets, 2)
}, [sortedLibraries, fetchPreviews]) }, [sortedLibraries, fetchPreviews])
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库 // 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
const INITIAL_ROWS = 3 const INITIAL_ROWS = 3
const STEP_ROWS = 2 const STEP_ROWS = 2
const [visibleTargetCount, setVisibleTargetCount] = useState(INITIAL_ROWS) const [visibleTargetCount, setVisibleTargetCount] = useState(INITIAL_ROWS)
const sentinelRef = useRef<HTMLDivElement | null>(null) const sentinelRef = useRef<HTMLButtonElement | null>(null)
const userScrolledRef = useRef(false)
const revealMoreLibraries = useCallback(() => {
setVisibleTargetCount((prev) => {
if (prev >= sortedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
})
}, [sortedLibraries.length])
// 随 visibleTargetCount 增加,按需触发后续库的预览加载 // 随 visibleTargetCount 增加,按需触发后续库的预览加载
useEffect(() => { useEffect(() => {
@@ -201,12 +198,11 @@ export function HomePage() {
if (!scrollParent) return if (!scrollParent) return
const handleCheckBottom = () => { const handleCheckBottom = () => {
if (scrollParent.scrollTop <= 0) return
userScrolledRef.current = true
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
if (remaining < 600) { if (remaining < 600) {
setVisibleTargetCount((prev) => { revealMoreLibraries()
if (prev >= sortedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
})
} }
} }
@@ -218,11 +214,8 @@ export function HomePage() {
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
const [entry] = entries const [entry] = entries
if (entry?.isIntersecting) { if (entry?.isIntersecting && userScrolledRef.current) {
setVisibleTargetCount((prev) => { revealMoreLibraries()
if (prev >= sortedLibraries.length) return prev
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
})
} }
}, },
{ {
@@ -234,15 +227,13 @@ export function HomePage() {
observer.observe(sentinel) observer.observe(sentinel)
} }
handleCheckBottom()
return () => { return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom) scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) { if (observer) {
observer.disconnect() observer.disconnect()
} }
} }
}, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length]) }, [hasMoreLibraries, revealMoreLibraries, visibleLibraries.length])
// Quick lookup map for libraries // Quick lookup map for libraries
const libraryMap = useMemo(() => { const libraryMap = useMemo(() => {
@@ -333,7 +324,7 @@ export function HomePage() {
)} )}
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */} {/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
{visibleLibraries.length > 0 && ( {(visibleLibraries.length > 0 || hasMoreLibraries) && (
<div className="space-y-10"> <div className="space-y-10">
{visibleLibraries.map((lib) => { {visibleLibraries.map((lib) => {
const cards = libraryData[lib.id]?.cards || [] const cards = libraryData[lib.id]?.cards || []
@@ -346,12 +337,17 @@ export function HomePage() {
) )
})} })}
{hasMoreLibraries && ( {hasMoreLibraries && (
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60"> <button
ref={sentinelRef}
type="button"
onClick={revealMoreLibraries}
className="flex h-10 w-full items-center justify-center py-2 opacity-60 transition-opacity hover:opacity-100"
>
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]"> <div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" /> <div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
<span>加载更多媒体库…</span> <span>加载更多媒体库…</span>
</div> </div>
</div> </button>
)} )}
</div> </div>
)} )}
+23 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react' import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
@@ -95,7 +95,15 @@ export function LibrariesContent({
const INITIAL_SHELVES = 3 const INITIAL_SHELVES = 3
const STEP_SHELVES = 2 const STEP_SHELVES = 2
const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES) const [visibleCount, setVisibleCount] = useState(INITIAL_SHELVES)
const sentinelRef = useRef<HTMLDivElement | null>(null) const sentinelRef = useRef<HTMLButtonElement | null>(null)
const userScrolledRef = useRef(false)
const revealMoreShelves = useCallback(() => {
setVisibleCount((prev) => {
if (prev >= previews.length) return prev
return Math.min(prev + STEP_SHELVES, previews.length)
})
}, [previews.length])
useEffect(() => { useEffect(() => {
const currentTargets = previews.slice(0, visibleCount).map((preview) => preview.library.id) const currentTargets = previews.slice(0, visibleCount).map((preview) => preview.library.id)
@@ -107,12 +115,11 @@ export function LibrariesContent({
if (!scrollParent) return if (!scrollParent) return
const handleCheckBottom = () => { const handleCheckBottom = () => {
if (scrollParent.scrollTop <= 0) return
userScrolledRef.current = true
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
if (remaining < 600) { if (remaining < 600) {
setVisibleCount((prev) => { revealMoreShelves()
if (prev >= previews.length) return prev
return Math.min(prev + STEP_SHELVES, previews.length)
})
} }
} }
@@ -124,11 +131,8 @@ export function LibrariesContent({
observer = new IntersectionObserver( observer = new IntersectionObserver(
(entries) => { (entries) => {
const [entry] = entries const [entry] = entries
if (entry?.isIntersecting) { if (entry?.isIntersecting && userScrolledRef.current) {
setVisibleCount((prev) => { revealMoreShelves()
if (prev >= previews.length) return prev
return Math.min(prev + STEP_SHELVES, previews.length)
})
} }
}, },
{ {
@@ -140,13 +144,11 @@ export function LibrariesContent({
observer.observe(sentinel) observer.observe(sentinel)
} }
handleCheckBottom()
return () => { return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom) scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) observer.disconnect() if (observer) observer.disconnect()
} }
}, [visibleCount, previews.length]) }, [revealMoreShelves, visibleCount, previews.length])
const visiblePreviews = useMemo( const visiblePreviews = useMemo(
() => previews.slice(0, visibleCount), () => previews.slice(0, visibleCount),
@@ -196,12 +198,17 @@ export function LibrariesContent({
))} ))}
{visibleCount < previews.length && ( {visibleCount < previews.length && (
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60"> <button
ref={sentinelRef}
type="button"
onClick={revealMoreShelves}
className="flex h-10 w-full items-center justify-center py-2 opacity-60 transition-opacity hover:opacity-100"
>
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]"> <div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" /> <div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
<span>加载更多媒体库货架…</span> <span>加载更多媒体库货架…</span>
</div> </div>
</div> </button>
)} )}
</section> </section>
)} )}
+8 -6
View File
@@ -86,7 +86,7 @@ export function useLibraryData(
const seriesMode = isSeriesLibraryType(lib.type) const seriesMode = isSeriesLibraryType(lib.type)
modeRef.current = seriesMode ? 'series' : 'media' modeRef.current = seriesMode ? 'series' : 'media'
const pageSize = pageSizeFor(lib, seriesMode) const pageSize = pageSizeFor(lib)
try { try {
if (seriesMode) { if (seriesMode) {
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder }) const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
@@ -155,7 +155,7 @@ export function useLibraryData(
try { try {
const page = nextPageRef.current const page = nextPageRef.current
if (modeRef.current === 'series') { if (modeRef.current === 'series') {
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib, true), { const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib), {
sort: sortField, sort: sortField,
order: sortOrder, order: sortOrder,
}) })
@@ -167,7 +167,7 @@ export function useLibraryData(
nextPageRef.current = page + 1 nextPageRef.current = page + 1
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
} else { } else {
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib, false), { const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), {
sort: sortField, sort: sortField,
order: sortOrder, order: sortOrder,
}) })
@@ -238,9 +238,11 @@ function isSeriesLibraryType(type?: string) {
return type === 'tv' || type === 'anime' || type === 'variety' return type === 'tv' || type === 'anime' || type === 'variety'
} }
function pageSizeFor(lib: Library, seriesMode: boolean): number { function pageSizeFor(lib: Library): number {
if (lib.is_remote_emby) return 100 // 首屏保持一屏多一点,后续统一由底部哨兵按页追加。此前沿用旧的
return seriesMode ? 500 : 2000 // 500/2000 批量值,会让中小型媒体库在第一次请求时就等于全量加载。
if (lib.is_remote_emby) return 50
return 60
} }
function yieldToBrowser(): Promise<void> { function yieldToBrowser(): Promise<void> {