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 (
)
}
export const VirtualTrackList = forwardRef(function VirtualTrackList(
{
tracks,
loading,
hasMore,
loadingMore,
onLoadMore,
isTrackAdded,
onAddTrack,
onInsertAfterCurrent,
onArtistClick,
emptyIcon,
emptyMessage = '暂无内容',
rowHeight = 52,
overscan = 5,
className,
},
ref,
) {
const [scrollElement, setScrollElement] = useState(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 (
{Array.from({ length: 5 }).map((_, i) => (
))}
)
}
// Empty state
if (tracks.length === 0) {
return (
{emptyIcon ?? }
{emptyMessage}
)
}
return (
{virtualItems.map((virtualRow) => {
const isLoaderRow = virtualRow.index >= tracks.length
if (isLoaderRow) {
return (
)
}
const track = tracks[virtualRow.index]
if (!track) return null
return (
)
})}
)
})