ghp / packages /client /src /components /VirtualTrackList.tsx
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
5.26 kB
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
import type { Track } from '@music-together/shared'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Loader2, Music2 } from 'lucide-react'
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { TrackListItem } from './TrackListItem'
/** Start loading more items when the last visible row is within this many rows of the end */
const LOAD_MORE_THRESHOLD = 5
export interface VirtualTrackListProps {
tracks: Track[]
loading: boolean
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
isTrackAdded: (track: Track) => boolean
onAddTrack: (track: Track) => void
onInsertAfterCurrent?: (track: Track) => void
onArtistClick?: (artist: string) => void
emptyIcon?: React.ReactNode
emptyMessage?: string
rowHeight?: number
overscan?: number
className?: string
}
export interface VirtualTrackListRef {
scrollToTop: () => void
}
function TrackSkeleton() {
return (
<div className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="h-4 w-6" />
<Skeleton className="h-10 w-10 rounded" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
<Skeleton className="h-8 w-8 rounded-md" />
</div>
)
}
export const VirtualTrackList = forwardRef<VirtualTrackListRef, VirtualTrackListProps>(function VirtualTrackList(
{
tracks,
loading,
hasMore,
loadingMore,
onLoadMore,
isTrackAdded,
onAddTrack,
onInsertAfterCurrent,
onArtistClick,
emptyIcon,
emptyMessage = '暂无内容',
rowHeight = 52,
overscan = 5,
className,
},
ref,
) {
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
useImperativeHandle(ref, () => ({
scrollToTop: () => scrollElement?.scrollTo({ top: 0 }),
}))
const rowCount = tracks.length + (hasMore ? 1 : 0)
// TanStack Virtual manages mutable measurements internally and cannot be compiler-memoized.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollElement,
estimateSize: () => rowHeight,
overscan,
})
// Infinite scroll: trigger onLoadMore when approaching the bottom
const virtualItems = virtualizer.getVirtualItems()
const lastItemIndex = virtualItems.at(-1)?.index
useEffect(() => {
if (lastItemIndex === undefined) return
if (lastItemIndex >= tracks.length - LOAD_MORE_THRESHOLD && hasMore && !loadingMore) {
onLoadMore()
}
}, [lastItemIndex, tracks.length, hasMore, loadingMore, onLoadMore])
// Loading skeleton
if (loading) {
return (
<div className={cn('min-h-0 flex-1 overflow-y-auto rounded-md border', className)}>
<div className="divide-y">
{Array.from({ length: 5 }).map((_, i) => (
<TrackSkeleton key={i} />
))}
</div>
</div>
)
}
// Empty state
if (tracks.length === 0) {
return (
<div className={cn('min-h-0 flex-1 overflow-y-auto rounded-md border', className)}>
<div className="flex h-48 flex-col items-center justify-center gap-2 text-muted-foreground">
{emptyIcon ?? <Music2 className="h-8 w-8" />}
<span className="text-sm">{emptyMessage}</span>
</div>
</div>
)
}
return (
<div
ref={setScrollElement}
className={cn('min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md border', className)}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const isLoaderRow = virtualRow.index >= tracks.length
if (isLoaderRow) {
return (
<div
key="loader"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
className="flex items-center justify-center"
>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)
}
const track = tracks[virtualRow.index]
if (!track) return null
return (
<TrackListItem
key={track.id}
track={track}
index={virtualRow.index}
isAdded={isTrackAdded(track)}
onAdd={onAddTrack}
onInsertAfterCurrent={onInsertAfterCurrent}
onArtistClick={onArtistClick}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
/>
)
})}
</div>
</div>
)
})