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