This commit is contained in:
truewhile
2026-09-14 22:54:09 +08:00
parent 60f05129c9
commit e4c0e85eaa
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-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-fade-slide-in { animation: fade-slide-in 0.2s 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(() => {
if (sortedLibraries.length === 0) return
const carouselLibIds = sortedLibraries
.filter((l) => l.carousel_enabled === true)
.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 shelfTargetSet = new Set(shelfTargets)
const gridTargets = topGridLibIds.filter((id) => !shelfTargetSet.has(id))
// 入口网格只需要 2 张封面,横向货架才需要 10 张。分开请求可以避免
// 为暂时不会出现的货架预取完整卡片窗口。
void fetchPreviews(shelfTargets, 10)
void fetchPreviews(gridTargets, 2)
}, [sortedLibraries, fetchPreviews])
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
const INITIAL_ROWS = 3
const STEP_ROWS = 2
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 增加,按需触发后续库的预览加载
useEffect(() => {
@@ -201,12 +198,11 @@ export function HomePage() {
if (!scrollParent) return
const handleCheckBottom = () => {
if (scrollParent.scrollTop <= 0) return
userScrolledRef.current = true
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)
})
revealMoreLibraries()
}
}
@@ -218,11 +214,8 @@ export function HomePage() {
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)
})
if (entry?.isIntersecting && userScrolledRef.current) {
revealMoreLibraries()
}
},
{
@@ -234,15 +227,13 @@ export function HomePage() {
observer.observe(sentinel)
}
handleCheckBottom()
return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) {
observer.disconnect()
}
}
}, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length])
}, [hasMoreLibraries, revealMoreLibraries, visibleLibraries.length])
// Quick lookup map for libraries
const libraryMap = useMemo(() => {
@@ -333,7 +324,7 @@ export function HomePage() {
)}
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
{visibleLibraries.length > 0 && (
{(visibleLibraries.length > 0 || hasMoreLibraries) && (
<div className="space-y-10">
{visibleLibraries.map((lib) => {
const cards = libraryData[lib.id]?.cards || []
@@ -346,12 +337,17 @@ export function HomePage() {
)
})}
{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="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
<span>加载更多媒体库…</span>
</div>
</div>
</button>
)}
</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 { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
@@ -95,7 +95,15 @@ export function LibrariesContent({
const INITIAL_SHELVES = 3
const STEP_SHELVES = 2
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(() => {
const currentTargets = previews.slice(0, visibleCount).map((preview) => preview.library.id)
@@ -107,12 +115,11 @@ export function LibrariesContent({
if (!scrollParent) return
const handleCheckBottom = () => {
if (scrollParent.scrollTop <= 0) return
userScrolledRef.current = true
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)
})
revealMoreShelves()
}
}
@@ -124,11 +131,8 @@ export function LibrariesContent({
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)
})
if (entry?.isIntersecting && userScrolledRef.current) {
revealMoreShelves()
}
},
{
@@ -140,13 +144,11 @@ export function LibrariesContent({
observer.observe(sentinel)
}
handleCheckBottom()
return () => {
scrollParent.removeEventListener('scroll', handleCheckBottom)
if (observer) observer.disconnect()
}
}, [visibleCount, previews.length])
}, [revealMoreShelves, visibleCount, previews.length])
const visiblePreviews = useMemo(
() => previews.slice(0, visibleCount),
@@ -196,12 +198,17 @@ export function LibrariesContent({
))}
{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="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
<span>加载更多媒体库货架…</span>
</div>
</div>
</button>
)}
</section>
)}
+9 -7
View File
@@ -86,7 +86,7 @@ export function useLibraryData(
const seriesMode = isSeriesLibraryType(lib.type)
modeRef.current = seriesMode ? 'series' : 'media'
const pageSize = pageSizeFor(lib, seriesMode)
const pageSize = pageSizeFor(lib)
try {
if (seriesMode) {
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
@@ -155,7 +155,7 @@ export function useLibraryData(
try {
const page = nextPageRef.current
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,
order: sortOrder,
})
@@ -167,7 +167,7 @@ export function useLibraryData(
nextPageRef.current = page + 1
hasMoreRef.current = pageItems.length > 0 && loadedCountRef.current < totalRef.current
} else {
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib, false), {
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), {
sort: sortField,
order: sortOrder,
})
@@ -238,9 +238,11 @@ function isSeriesLibraryType(type?: string) {
return type === 'tv' || type === 'anime' || type === 'variety'
}
function pageSizeFor(lib: Library, seriesMode: boolean): number {
if (lib.is_remote_emby) return 100
return seriesMode ? 500 : 2000
function pageSizeFor(lib: Library): number {
// 首屏保持一屏多一点,后续统一由底部哨兵按页追加。此前沿用旧的
// 500/2000 批量值,会让中小型媒体库在第一次请求时就等于全量加载。
if (lib.is_remote_emby) return 50
return 60
}
function yieldToBrowser(): Promise<void> {
@@ -251,4 +253,4 @@ function yieldToBrowser(): Promise<void> {
setTimeout(resolve, 0)
}
})
}
}