mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
优化
This commit is contained in:
+49
-38
@@ -9,6 +9,7 @@ import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||
import {
|
||||
ContinueWatchingSection,
|
||||
ContinueWatchingSkeleton,
|
||||
HomeCarouselSection,
|
||||
HomeEmptyState,
|
||||
HomeLibrariesSection,
|
||||
@@ -23,38 +24,22 @@ export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>>({})
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// 两个请求独立渐进加载:库列表控制整页首屏,播放记录区块自行渲染,
|
||||
// 避免整页被 Promise.all 里最慢的那个请求拖住。
|
||||
const [librariesLoading, setLibrariesLoading] = useState(true)
|
||||
const [historyLoading, setHistoryLoading] = useState(true)
|
||||
const { pinnedIds } = usePinnedLibraries()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [libs, hist] = await Promise.all([
|
||||
libraryAPI
|
||||
.list({ withPreview: true, previewLimit: 20 })
|
||||
.then((rows) => asArray<LibraryWithPreview>(rows))
|
||||
.catch(() => [] as LibraryWithPreview[]),
|
||||
historyAPI
|
||||
.continueWatching(12)
|
||||
.then((rows) =>
|
||||
rows.map(
|
||||
(row): HistoryItem => ({
|
||||
...row.history,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
media: row.media,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => [] as HistoryItem[]),
|
||||
])
|
||||
|
||||
libraryAPI
|
||||
.list({ withPreview: true, previewLimit: 20 })
|
||||
.then((rows) => asArray<LibraryWithPreview>(rows))
|
||||
.catch(() => [] as LibraryWithPreview[])
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
setHistory(hist.filter((h) => h && !h.completed))
|
||||
|
||||
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
|
||||
for (const lib of libs) {
|
||||
@@ -65,12 +50,36 @@ export function HomePage() {
|
||||
}
|
||||
}
|
||||
setLibraryData(mapData)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
setLibrariesLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
historyAPI
|
||||
.continueWatching(12)
|
||||
.then((rows) =>
|
||||
rows.map(
|
||||
(row): HistoryItem => ({
|
||||
...row.history,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
media: row.media,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => [] as HistoryItem[])
|
||||
.then((rows) => {
|
||||
if (cancelled) return
|
||||
setHistory(rows.filter((h) => h && !h.completed))
|
||||
setHistoryLoading(false)
|
||||
})
|
||||
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
@@ -131,15 +140,16 @@ export function HomePage() {
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
const empty =
|
||||
!loading &&
|
||||
libraries.length === 0 &&
|
||||
history.length === 0
|
||||
|
||||
if (loading) {
|
||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||
return <HomeLoadingState />
|
||||
}
|
||||
|
||||
const empty =
|
||||
libraries.length === 0 &&
|
||||
history.length === 0
|
||||
|
||||
if (empty) {
|
||||
return <HomeEmptyState />
|
||||
}
|
||||
@@ -151,8 +161,9 @@ export function HomePage() {
|
||||
<HomeCarouselSection items={carouselItems} libraryMap={libraryMap} />
|
||||
)}
|
||||
|
||||
{/* 2. 继续观看(若有历史) */}
|
||||
{history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
{/* 2. 继续观看(独立加载,未回来前展示同构骨架) */}
|
||||
{historyLoading && <ContinueWatchingSkeleton />}
|
||||
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
|
||||
{/* 3. 媒体库卡片区 */}
|
||||
{sortedLibraries.length > 0 && (
|
||||
|
||||
@@ -87,6 +87,41 @@ export function HomeEmptyState() {
|
||||
)
|
||||
}
|
||||
|
||||
// ContinueWatchingSkeleton 与 ContinueWatchingSection 同构的占位块:
|
||||
// 播放记录请求独立渐进加载时,避免区块突然弹入造成的布局跳动。
|
||||
export function ContinueWatchingSkeleton() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-1.5 text-[var(--app-text)]">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-5 w-24 animate-pulse rounded bg-[var(--app-panel-soft)]" />
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-[var(--app-panel-soft)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 overflow-hidden pb-3 pt-1">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="w-64 sm:w-72 shrink-0">
|
||||
<div className="flex items-center gap-3.5 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 shadow-sm">
|
||||
<div className="h-20 w-14 shrink-0 animate-pulse rounded-xl bg-[var(--app-panel-soft)]" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-3 w-3/4 animate-pulse rounded bg-[var(--app-panel-soft)]" />
|
||||
<div className="h-2.5 w-1/2 animate-pulse rounded bg-[var(--app-panel-soft)]" />
|
||||
<div className="h-1.5 w-full animate-pulse rounded-full bg-[var(--app-panel-soft)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
1. 海报轮播区 (Hero Carousel Section)
|
||||
========================================================================= */
|
||||
|
||||
@@ -2,9 +2,13 @@ import type { ReactNode } from 'react'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { MEDIA_GRID_CLASS, VirtualMediaGrid } from '../components/VirtualMediaGrid'
|
||||
import type { Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
// 超过该数量后切换为虚拟滚动:DOM 只挂载视口内的卡片,大库不再线性劣化。
|
||||
const VIRTUALIZE_THRESHOLD = 200
|
||||
|
||||
type LibraryMediaSectionsProps = {
|
||||
isSeries: boolean
|
||||
items: Media[]
|
||||
@@ -27,11 +31,10 @@ export function LibraryMediaSections({
|
||||
return (
|
||||
<>
|
||||
{!isSeries && items.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{items.map((media) => (
|
||||
<MediaCard key={media.id} media={media} actions={cardActions(media)} />
|
||||
))}
|
||||
</div>
|
||||
<MediaGrid count={items.length} renderItem={(index) => {
|
||||
const media = items[index]
|
||||
return <MediaCard key={media.id} media={media} actions={cardActions(media)} />
|
||||
}} />
|
||||
)}
|
||||
|
||||
{!isSeries && items.length === 0 && (
|
||||
@@ -39,8 +42,9 @@ export function LibraryMediaSections({
|
||||
)}
|
||||
|
||||
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||
{seriesCards.map((series) => (
|
||||
<MediaGrid count={seriesCards.length} renderItem={(index) => {
|
||||
const series = seriesCards[index]
|
||||
return (
|
||||
<MediaCard
|
||||
key={series.key}
|
||||
media={series.rep}
|
||||
@@ -48,8 +52,8 @@ export function LibraryMediaSections({
|
||||
actions={cardActions(series.rep)}
|
||||
onClick={() => onSeriesClick(series)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}} />
|
||||
)}
|
||||
|
||||
{isSeries && seriesCards.length === 0 && !loading && (
|
||||
@@ -59,6 +63,13 @@ export function LibraryMediaSections({
|
||||
)
|
||||
}
|
||||
|
||||
function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) {
|
||||
if (count <= VIRTUALIZE_THRESHOLD) {
|
||||
return <div className={MEDIA_GRID_CLASS}>{Array.from({ length: count }, (_, index) => renderItem(index))}</div>
|
||||
}
|
||||
return <VirtualMediaGrid totalCount={count} renderItem={renderItem} />
|
||||
}
|
||||
|
||||
function LibraryEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
|
||||
Reference in New Issue
Block a user