File size: 5,264 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | 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>
)
})
|