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 ( ) })}
) })