import { Button } from '@/components/ui/button' import { ResponsiveDialog, ResponsiveDialogBody, ResponsiveDialogContent, ResponsiveDialogHeader, ResponsiveDialogTitle, } from '@/components/ui/responsive-dialog' import { Input } from '@/components/ui/input' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { VirtualTrackList, type VirtualTrackListRef } from '@/components/VirtualTrackList' import { PLATFORM_ACTIVE, PLATFORM_TEXT } from '@/lib/platform' import { cn, trackKey } from '@/lib/utils' import { useRoomStore } from '@/stores/roomStore' import { useSearch } from '@/hooks/useSearch' import { useRecommendations } from '@/hooks/useRecommendations' import { usePlaylist } from '@/hooks/usePlaylist' import { useIsMobile } from '@/hooks/useIsMobile' import { useSocketContext } from '@/providers/socket-context' import { EVENTS, LIMITS } from '@music-together/shared' import type { MusicSource, Track, Playlist } from '@music-together/shared' import type { BilibiliMetadataSource } from '@music-together/shared' import { Loader2, Music2, Search, ListMusic, Radio, RefreshCw, Sparkles } from 'lucide-react' import { motion } from 'motion/react' import { useCallback, useLayoutEffect, useMemo, useRef, useState, useEffect } from 'react' import { toast } from 'sonner' import { PlaylistDetail } from './Settings/PlaylistDetail' import { BilibiliCollectionDialog } from './BilibiliCollectionDialog' import { BilibiliMetadataDialog } from './BilibiliMetadataDialog' const EMPTY_QUEUE: Track[] = [] type BilibiliQueueAction = 'add' | 'insert' type SearchMode = 'song' | 'album' | 'playlist' | 'recommend' type TencentRecommendationView = 'radar' | 'playlists' const SOURCES: { id: MusicSource; label: string }[] = [ { id: 'netease', label: '网易云' }, { id: 'tencent', label: 'QQ' }, { id: 'kugou', label: '酷狗' }, { id: 'kugou_concept', label: '概念版' }, { id: 'bilibili', label: 'B站' }, ] interface SearchDialogProps { open: boolean onOpenChange: (open: boolean) => void onAddToQueue: (track: Track) => void onInsertAfterCurrent: (track: Track) => void } interface PlaylistListProps { playlists: Playlist[] onSelect: (playlist: Playlist) => void hasMore?: boolean loadingMore?: boolean onLoadMore?: () => void } function PlaylistList({ playlists, onSelect, hasMore, loadingMore, onLoadMore }: PlaylistListProps) { return (
{playlists.map((playlist, index) => ( ))} {hasMore && onLoadMore && ( )}
) } export function SearchDialog({ open, onOpenChange, onAddToQueue, onInsertAfterCurrent }: SearchDialogProps) { const [source, setSource] = useState('netease') const [searchType, setSearchType] = useState('song') const [tencentRecommendationView, setTencentRecommendationView] = useState('radar') const [bilibiliMatch, setBilibiliMatch] = useState<{ track: Track; action: BilibiliQueueAction } | null>(null) const [bilibiliCollectionMatch, setBilibiliCollectionMatch] = useState<{ track: Track action: BilibiliQueueAction } | null>(null) const [keyword, setKeyword] = useState('') const [addedIds, setAddedIds] = useState>(new Set()) const listRef = useRef(null) const dialogContentRef = useRef(null) const sourceContainerRef = useRef(null) const [pillStyle, setPillStyle] = useState({ left: 0, width: 0 }) const queue = useRoomStore((s) => s.room?.queue ?? EMPTY_QUEUE) const roomId = useRoomStore((s) => s.room?.id) const queueKeys = useMemo(() => new Set(queue.map(trackKey)), [queue]) const { socket } = useSocketContext() const isMobile = useIsMobile() // Mobile browsers can pan the visual viewport when the keyboard focuses the // search input, which moves even fixed drawers above the visible screen. // Keep this search drawer pinned to the actual visible viewport instead. useLayoutEffect(() => { if (!open || !isMobile) return const content = dialogContentRef.current const viewport = window.visualViewport if (!content || !viewport) return let frame = 0 const syncToVisualViewport = () => { cancelAnimationFrame(frame) frame = requestAnimationFrame(() => { content.style.setProperty('top', `${Math.max(0, viewport.offsetTop)}px`, 'important') content.style.setProperty('bottom', 'auto', 'important') content.style.setProperty('height', `${viewport.height}px`, 'important') content.style.setProperty('max-height', `${viewport.height}px`, 'important') }) } syncToVisualViewport() viewport.addEventListener('resize', syncToVisualViewport) viewport.addEventListener('scroll', syncToVisualViewport) return () => { cancelAnimationFrame(frame) viewport.removeEventListener('resize', syncToVisualViewport) viewport.removeEventListener('scroll', syncToVisualViewport) content.style.removeProperty('top') content.style.removeProperty('bottom') content.style.removeProperty('height') content.style.removeProperty('max-height') } }, [open, isMobile]) // Album Detail view state const [selectedAlbum, setSelectedAlbum] = useState(null) const { playlistTracks, playlistTotal, tracksLoading, loadingMore: albumLoadingMore, hasMoreTracks, fetchPlaylistTracks, loadMoreTracks, fetchAllPlaylistTracks, } = usePlaylist() const { results, loading, loadingMore, hasMore, hasSearched, search, loadMore, resetState } = useSearch( source, searchType === 'recommend' ? 'song' : searchType, roomId, ) const { recommendations, loading: recommendationsLoading, loadingMore: recommendationsLoadingMore, loaded: recommendationsLoaded, load: loadRecommendations, loadMore: loadMoreRecommendations, reset: resetRecommendations, } = useRecommendations(roomId) const activeRecommendation = useMemo( () => recommendations.find((recommendation) => recommendation.platform === source), [recommendations, source], ) const visibleSources = useMemo(() => { if (searchType !== 'recommend') return SOURCES if (!recommendationsLoaded) return SOURCES const available = new Set(recommendations.map((recommendation) => recommendation.platform)) return SOURCES.filter((item) => available.has(item.id)) }, [recommendations, recommendationsLoaded, searchType]) // Auto re-search when source or type changes const prevSourceRef = useRef(source) const prevTypeRef = useRef(searchType) useEffect(() => { const sourceChanged = prevSourceRef.current !== source const typeChanged = prevTypeRef.current !== searchType prevSourceRef.current = source prevTypeRef.current = searchType if ((sourceChanged || typeChanged) && searchType !== 'recommend' && keyword.trim()) { search(keyword.trim()) if (searchType === 'song') listRef.current?.scrollToTop() } }, [source, searchType, keyword, search]) useEffect(() => { if (open && searchType === 'recommend' && !recommendationsLoaded && !recommendationsLoading) { loadRecommendations() } }, [loadRecommendations, open, recommendationsLoaded, recommendationsLoading, searchType]) useEffect(() => { if (searchType !== 'recommend' || !recommendationsLoaded || recommendations.length === 0) return if (!recommendations.some((recommendation) => recommendation.platform === source)) { const frame = requestAnimationFrame(() => setSource(recommendations[0]!.platform)) return () => cancelAnimationFrame(frame) } }, [recommendations, recommendationsLoaded, searchType, source]) // Measure active source button position for sliding pill const measurePill = useCallback(() => { const container = sourceContainerRef.current if (!container) return const activeBtn = container.querySelector(`[data-source="${source}"]`) if (!activeBtn) return setPillStyle({ left: activeBtn.offsetLeft, width: activeBtn.offsetWidth }) }, [source]) useLayoutEffect(() => { measurePill() }, [measurePill, visibleSources]) // Re-measure after dialog opens (DOM may not be ready on first render) useEffect(() => { if (open) requestAnimationFrame(measurePill) }, [open, measurePill]) useEffect(() => { if (open) return const frame = requestAnimationFrame(() => { setSelectedAlbum(null) resetRecommendations() }) return () => cancelAnimationFrame(frame) }, [open, resetRecommendations]) const handleSearch = (overrideKeyword?: string) => { const searchKeyword = (overrideKeyword ?? keyword).trim() if (!searchKeyword) return if (overrideKeyword !== undefined) setKeyword(overrideKeyword) setAddedIds(new Set()) search(searchKeyword) if (searchType === 'song') { listRef.current?.scrollToTop() } } const beginBilibiliMetadataMatch = useCallback((track: Track, action: BilibiliQueueAction) => { setBilibiliMatch({ track, action }) }, []) const beginBilibiliCollectionMatch = useCallback((track: Track, action: BilibiliQueueAction) => { setBilibiliCollectionMatch({ track, action }) }, []) const handleBilibiliNotCollection = useCallback(() => { if (!bilibiliCollectionMatch) return const { track, action } = bilibiliCollectionMatch setBilibiliCollectionMatch(null) beginBilibiliMetadataMatch(track, action) }, [bilibiliCollectionMatch, beginBilibiliMetadataMatch]) const handleBilibiliCollectionTrack = useCallback( (track: Track) => { if (!bilibiliCollectionMatch) return // Close the picker before opening the metadata dialog. Both dialogs use // the same portal z-index, so leaving it open would cover the matcher. const { action } = bilibiliCollectionMatch setBilibiliCollectionMatch(null) beginBilibiliMetadataMatch(track, action) }, [bilibiliCollectionMatch, beginBilibiliMetadataMatch], ) const applyBilibiliMetadataMatch = useCallback( (metadataTrack: Track, metadataSource: BilibiliMetadataSource) => { if (!bilibiliMatch) return const track = { ...bilibiliMatch.track, metadataSource, lyricId: metadataTrack.lyricId, picId: metadataTrack.picId, cover: metadataTrack.cover || bilibiliMatch.track.cover, } if (bilibiliMatch.action === 'insert') { onInsertAfterCurrent(track) } else { onAddToQueue(track) } setAddedIds((prev) => new Set(prev).add(trackKey(track))) setBilibiliMatch(null) }, [bilibiliMatch, onAddToQueue, onInsertAfterCurrent], ) const skipBilibiliMetadataMatch = useCallback(() => { if (!bilibiliMatch) return if (bilibiliMatch.action === 'insert') { onInsertAfterCurrent(bilibiliMatch.track) } else { onAddToQueue(bilibiliMatch.track) } setAddedIds((prev) => new Set(prev).add(trackKey(bilibiliMatch.track))) setBilibiliMatch(null) }, [bilibiliMatch, onAddToQueue, onInsertAfterCurrent]) const handleAdd = useCallback( (track: Track) => { const key = trackKey(track) if (queueKeys.has(key) || addedIds.has(key)) { toast.info(`「${track.title}」已在队列中`) return } if (track.source === 'bilibili') { beginBilibiliCollectionMatch(track, 'add') return } onAddToQueue(track) setAddedIds((prev) => new Set(prev).add(key)) // Removed duplicate toast.success since onAddToQueue (from useQueue) usually already handles it // or the UI handles feedback. }, [onAddToQueue, queueKeys, addedIds, beginBilibiliCollectionMatch], ) const handleInsertAfterCurrent = useCallback( (track: Track) => { const key = trackKey(track) if (queueKeys.has(key) || addedIds.has(key)) { toast.info(`「${track.title}」已在队列中`) return } if (track.source === 'bilibili') { beginBilibiliCollectionMatch(track, 'insert') return } onInsertAfterCurrent(track) setAddedIds((prev) => new Set(prev).add(key)) // Removed duplicate toast.success }, [onInsertAfterCurrent, queueKeys, addedIds, beginBilibiliCollectionMatch], ) const handleAddBatch = useCallback( (tracks: Track[], playlistName?: string) => { if (tracks.length === 0) return for (let offset = 0; offset < tracks.length; offset += LIMITS.QUEUE_BATCH_MAX_SIZE) { socket.emit(EVENTS.QUEUE_ADD_BATCH, { tracks: tracks.slice(offset, offset + LIMITS.QUEUE_BATCH_MAX_SIZE), playlistName, }) } setAddedIds((prev) => { const next = new Set(prev) for (const t of tracks) next.add(trackKey(t)) return next }) toast.success(`已添加 ${tracks.length} 首歌曲`) }, [socket], ) const isTrackAdded = useCallback( (track: Track) => { const key = trackKey(track) return addedIds.has(key) || queueKeys.has(key) }, [addedIds, queueKeys], ) const handleSelectAlbum = (playlist: Playlist, type?: 'album' | 'playlist') => { setSelectedAlbum(playlist) fetchPlaylistTracks( playlist.source, playlist.id, playlist.trackCount, type ?? (searchType === 'album' ? 'album' : 'playlist'), ) } return (
{selectedAlbum ? selectedAlbum.name : searchType === 'recommend' ? '推荐点歌' : '搜索点歌'} {!selectedAlbum && visibleSources.length > 0 && (
{visibleSources.map((s) => ( ))}
)}
{selectedAlbum ? ( setSelectedAlbum(null)} onAddTrack={handleAdd} onInsertAfterCurrent={handleInsertAfterCurrent} onAddAll={handleAddBatch} onLoadAll={fetchAllPlaylistTracks} onLoadMore={loadMoreTracks} /> ) : ( <> { setSearchType(value as SearchMode) resetState() setAddedIds(new Set()) }} > 单曲 推荐 {source !== 'bilibili' && source !== 'kugou_concept' && ( <> 专辑 歌单 )} {searchType === 'recommend' ? (
展示当前账号在平台上的原生推荐内容
) : (
setKeyword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} className="flex-1" autoFocus aria-label="搜索关键词" />
)} {/* Results area — virtual scrolling with auto-load */} {searchType === 'recommend' ? ( !recommendationsLoaded || recommendationsLoading ? (
) : recommendations.length === 0 ? (
请先在设置中登录音乐平台,再查看平台推荐
) : source === 'bilibili' ? ( undefined} isTrackAdded={isTrackAdded} onAddTrack={handleAdd} onInsertAfterCurrent={handleInsertAfterCurrent} onArtistClick={(artist) => { setSearchType('song') handleSearch(artist) }} emptyIcon={} emptyMessage={ activeRecommendation?.unavailableReason === 'upstream_unavailable' ? '平台推荐暂时不可用,请刷新重试' : '平台暂时没有返回推荐内容' } /> ) : source === 'tencent' ? ( setTencentRecommendationView(value as TencentRecommendationView)} className="min-h-0 flex-1 gap-2" > 雷达歌曲 推荐歌单 { setSearchType('song') handleSearch(artist) }} emptyIcon={} emptyMessage={ activeRecommendation?.unavailableReason === 'upstream_unavailable' ? 'QQ 雷达暂时不可用,请刷新重试' : 'QQ 雷达暂时没有返回歌曲' } /> {(activeRecommendation?.playlists?.length ?? 0) > 0 ? ( handleSelectAlbum(playlist, 'playlist')} hasMore={activeRecommendation?.pagination?.playlists?.hasMore ?? false} loadingMore={recommendationsLoadingMore} onLoadMore={loadMoreRecommendations} /> ) : (
{activeRecommendation?.unavailableReason === 'upstream_unavailable' ? 'QQ 推荐歌单暂时不可用,请刷新重试' : 'QQ 暂时没有返回推荐歌单'}
)}
) : (activeRecommendation?.playlists?.length ?? 0) > 0 ? ( handleSelectAlbum(playlist, 'playlist')} /> ) : (
{activeRecommendation?.unavailableReason === 'upstream_unavailable' ? '平台推荐暂时不可用,请刷新重试' : '平台暂时没有返回推荐歌单'}
) ) : hasSearched ? ( searchType === 'song' ? ( { setSearchType('song') handleSearch(artist) }} emptyIcon={} emptyMessage={ source === 'bilibili' ? '暂无结果,请检查链接、BV号或更换关键词' : '暂无结果,换个关键词试试' } /> ) : loading && results.length === 0 ? (
) : results.length === 0 ? (
暂无结果,换个关键词试试
) : ( ) ) : (
输入关键词开始搜索
)} )}
!isOpen && setBilibiliMatch(null)} onSelect={applyBilibiliMetadataMatch} onSkip={skipBilibiliMetadataMatch} /> !isOpen && setBilibiliCollectionMatch(null)} onSelectTrack={handleBilibiliCollectionTrack} onNotCollection={handleBilibiliNotCollection} isTrackAdded={isTrackAdded} />
) }