This commit is contained in:
truewhile
2026-09-03 16:53:12 +08:00
parent 4150d03852
commit c702a68cfb
24 changed files with 691 additions and 168 deletions
+11
View File
@@ -19,6 +19,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.23.1",
"react-virtuoso": "^4.18.12",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -3874,6 +3875,16 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-virtuoso": {
"version": "4.18.12",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.12.tgz",
"integrity": "sha512-6c1SnRicSBfG+WnbhcyJUxzDHvvxD3vsux/EpcfVbgB7clyP1Od2r81TAGTgwbL7U2qP889RR4u+CH6WjgpW0g==",
"license": "MIT",
"peerDependencies": {
"react": ">=16 || >=17 || >= 18 || >= 19",
"react-dom": ">=16 || >=17 || >= 18 || >=19"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+1
View File
@@ -21,6 +21,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.23.1",
"react-virtuoso": "^4.18.12",
"zustand": "^4.5.2"
},
"devDependencies": {
+1 -1
View File
@@ -137,7 +137,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
}
return (
<main className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
+67
View File
@@ -0,0 +1,67 @@
import { forwardRef, useEffect, useState, type CSSProperties, type ReactNode } from 'react'
import { VirtuosoGrid } from 'react-virtuoso'
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
export const MEDIA_GRID_CLASS =
'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'
// 首帧滚动容器尚未就绪时的占位渲染数量(约一屏)。
const FIRST_PAINT_COUNT = 28
const GridList = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
function GridList({ children, style, ...rest }, ref) {
return (
<div ref={ref} style={style} {...rest} className={MEDIA_GRID_CLASS}>
{children}
</div>
)
},
)
const GridItem = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
function GridItem({ children, style, ...rest }, ref) {
return (
<div ref={ref} style={style} {...rest}>
{children}
</div>
)
},
)
// VirtualMediaGrid 大库性能优化:无论库里有 1 千还是 5 万张卡片,
// 实际挂载的只有视口内及邻近一屏的节点。滚动容器是 Layout 的
// <main id="app-main-scroll">(非 window 滚动),通过 customScrollParent 接入;
// 元素尚未挂载时先渲染一屏卡片,挂载后切换为虚拟列表。
export function VirtualMediaGrid({
totalCount,
renderItem,
}: {
totalCount: number
renderItem: (index: number) => ReactNode
}) {
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(null)
useEffect(() => {
setScrollParent(document.getElementById('app-main-scroll'))
}, [])
if (!scrollParent) {
return (
<div className={MEDIA_GRID_CLASS}>
{Array.from({ length: Math.min(totalCount, FIRST_PAINT_COUNT) }, (_, index) => (
<div key={index}>{renderItem(index)}</div>
))}
</div>
)
}
return (
<VirtuosoGrid
customScrollParent={scrollParent}
totalCount={totalCount}
overscan={800}
components={{ List: GridList, Item: GridItem }}
itemContent={renderItem}
/>
)
}
+49 -38
View File
@@ -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 && (
+35
View File
@@ -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)
========================================================================= */
+20 -9
View File
@@ -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">