import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' import { getProxiedCoverUrl } from '@/lib/cover' import { usePlayerStore } from '@/stores/playerStore' import { useRoomStore } from '@/stores/roomStore' import { useSocketContext } from '@/providers/socket-context' import type { BilibiliMetadataSource, Track } from '@music-together/shared' import { EVENTS } from '@music-together/shared' import { useHasHover } from '@/hooks/useHasHover' import { useIsMobile } from '@/hooks/useIsMobile' import { useCallback, useContext, useEffect, useRef, useState, type MouseEvent } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { AbilityContext } from '@/providers/ability-context' import { ArrowUpToLine, ChevronDown, ChevronUp, ListX, Music, Play, RefreshCw, Trash2, User, X } from 'lucide-react' import { toast } from 'sonner' import { MarqueeText } from '@/components/ui/marquee-text' import { BilibiliMetadataDialog } from './BilibiliMetadataDialog' import type { MusicSource } from '@music-together/shared' const EMPTY_QUEUE: Track[] = [] const SOURCE_STYLE: Record = { netease: { label: '网易', className: 'text-white bg-red-500 ring-red-600/50' }, tencent: { label: 'QQ', className: 'text-white bg-green-500 ring-green-600/50' }, kugou: { label: '酷狗', className: 'text-white bg-blue-500 ring-blue-600/50' }, kugou_concept: { label: '概念版', className: 'text-white bg-sky-500 ring-sky-600/50' }, bilibili: { label: 'B站', className: 'text-white bg-pink-500 ring-pink-600/50' }, } interface QueueDrawerProps { open: boolean onOpenChange: (open: boolean) => void onRemoveFromQueue: (trackId: string) => void onReorderQueue: (trackIds: string[]) => void onUpdateBilibiliMetadata: ( trackId: string, metadata: | { metadataSource: BilibiliMetadataSource; lyricId?: string; picId?: string; cover: string } | { clearMetadata: true }, ) => void onClearQueue: () => void } export function QueueDrawer({ open, onOpenChange, onRemoveFromQueue, onReorderQueue, onUpdateBilibiliMetadata, onClearQueue, }: QueueDrawerProps) { const queue = useRoomStore((s) => s.room?.queue ?? EMPTY_QUEUE) const roomId = useRoomStore((s) => s.room?.id) const room = useRoomStore((s) => s.room) const currentUser = useRoomStore((s) => s.currentUser) const currentTrack = usePlayerStore((s) => s.currentTrack) const { socket } = useSocketContext() const isMobile = useIsMobile() // layout: Drawer direction, height const hasHover = useHasHover() // interaction: hover vs touch const isTouch = !hasHover const ability = useContext(AbilityContext) const canRemove = ability.can('remove', 'Queue') const isTemporaryAdmin = room?.temporaryAdminUserId === currentUser?.id && !currentUser?.isServerAdmin const allowTemporaryAdminTrackRemoval = room?.allowTemporaryAdminTrackRemoval ?? false const allowTemporaryAdminQueueClear = room?.allowTemporaryAdminQueueClear ?? false const canRemoveTrack = canRemove && (!isTemporaryAdmin || allowTemporaryAdminTrackRemoval) const canClearQueue = canRemove && (!isTemporaryAdmin || allowTemporaryAdminQueueClear) const canAdd = ability.can('add', 'Queue') const canReorder = ability.can('reorder', 'Queue') const canPlay = ability.can('play', 'Player') const canVote = ability.can('vote', 'Player') const [confirmClear, setConfirmClear] = useState(false) const confirmTimerRef = useRef | null>(null) // Mobile: track which item has its action toolbar visible const [activeTrackId, setActiveTrackId] = useState(null) // Desktop: after clicking an action, temporarily suppress the hover toolbar until the cursor leaves the item const [dismissedHoverTrackId, setDismissedHoverTrackId] = useState(null) const [metadataTrack, setMetadataTrack] = useState(null) const [scrollElement, setScrollElement] = useState(null) // TanStack Virtual manages mutable measurements internally and cannot be compiler-memoized. // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: queue.length, getScrollElement: () => scrollElement, estimateSize: () => 56, overscan: 5, }) // Clear the confirm-dismiss timer on unmount useEffect( () => () => { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current) }, [], ) const handleClear = useCallback(() => { if (!confirmClear) { setConfirmClear(true) // Auto-dismiss after 3s if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current) confirmTimerRef.current = setTimeout(() => { confirmTimerRef.current = null setConfirmClear(false) }, 3000) return } if (confirmTimerRef.current) { clearTimeout(confirmTimerRef.current) confirmTimerRef.current = null } onClearQueue() setConfirmClear(false) toast.success('播放列表已清空') }, [confirmClear, onClearQueue]) const handleMoveUp = (index: number) => { if (index <= 0) return const ids = queue.map((t) => t.id) ;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]] onReorderQueue(ids) } const handleMoveDown = (index: number) => { if (index >= queue.length - 1) return const ids = queue.map((t) => t.id) ;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]] onReorderQueue(ids) } const handlePlayTrack = (track: Track) => { if (canPlay) { socket.emit(EVENTS.PLAYER_PLAY, { track }) } else if (canVote) { socket.emit(EVENTS.VOTE_START, { action: 'play-track' as const, payload: { trackId: track.id, trackTitle: track.title }, }) toast.info(`已发起投票:播放「${track.title}」`) } } const handleRemoveTrack = (track: Track) => { if (canRemoveTrack) { onRemoveFromQueue(track.id) toast.success(`已移除「${track.title}」`) } else if (canVote) { socket.emit(EVENTS.VOTE_START, { action: 'remove-track' as const, payload: { trackId: track.id, trackTitle: track.title }, }) toast.info(`已发起投票:移除「${track.title}」`) } } const handleInsertAfterCurrent = (track: Track, e?: MouseEvent) => { // If this was triggered from inside the floating actions, hide it immediately. // - Touch: activeTrackId controls visibility // - Desktop: group-hover/group-focus-within can keep it visible after DOM reorders (transform keeps hover) if (e) { e.stopPropagation() ;(e.currentTarget as HTMLButtonElement | null)?.blur() } if (isTouch && activeTrackId === track.id) setActiveTrackId(null) if (!isTouch) setDismissedHoverTrackId(track.id) const current = currentTrack const currentIndex = current?.id ? queue.findIndex((t) => t.id === current.id) : -1 if (current && track.id === current.id) return const ids = queue.map((t) => t.id) const from = ids.indexOf(track.id) if (from < 0) return // 先移除再插入,避免重复 ids.splice(from, 1) if (currentIndex >= 0) { // 目标位置:当前播放歌曲的下方(已播放的在上方,不动) // 如果被移动的歌曲在 current 之前,移除后 currentIndex 会左移一位 const adjustedCurrentIndex = from < currentIndex ? currentIndex - 1 : currentIndex const to = adjustedCurrentIndex + 1 ids.splice(to, 0, track.id) } else { // 无 currentTrack(或 currentTrack 不在队列中)时,退化为置顶到队首 ids.unshift(track.id) } onReorderQueue(ids) toast.success(`已置顶「${track.title}」`) } return (
播放列表 ({queue.length})
{canClearQueue && queue.length > 0 && ( {confirmClear ? '再次点击确认清空' : '清空播放列表'} )} {!isMobile && ( )}
{queue.length === 0 ? (
播放列表为空
) : (
{virtualizer.getVirtualItems().map((virtualRow) => { const i = virtualRow.index const track = queue[i] if (!track) return null return (
{ if (isTouch) { setActiveTrackId((prev) => (prev === track.id ? null : track.id)) } }} onMouseLeave={() => { if (!isTouch && dismissedHoverTrackId === track.id) setDismissedHoverTrackId(null) }} > {/* Index */} {i + 1} {/* Cover + source badge */}
{track.cover ? ( {track.title} { e.currentTarget.style.display = 'none' e.currentTarget.nextElementSibling?.classList.remove('hidden') }} /> ) : null}
{track.source && SOURCE_STYLE[track.source] && ( {SOURCE_STYLE[track.source].label} )}
{/* Track info */}
{track.title}
{track.artist.join(' / ')} {track.requestedBy && ( {track.requestedBy} )}
{/* Actions — visible on hover (desktop) or tap (mobile) */}
e.stopPropagation()} > {/* Play button — hidden for currently playing track */} {currentTrack?.id !== track.id && (canPlay || canVote) && ( {canPlay ? '播放' : '投票播放'} )} {track.source === 'bilibili' && canAdd && ( 重选歌词和封面 )} {canReorder && ( <> 上移 下移 置顶到当前播放下方 )} {(canRemoveTrack || canVote) && ( {canRemoveTrack ? '移除' : '投票移除'} )}
) })}
)}
!isOpen && setMetadataTrack(null)} onSelect={(metadata, metadataSource) => { if (!metadataTrack) return onUpdateBilibiliMetadata(metadataTrack.id, { metadataSource, lyricId: metadata.lyricId, picId: metadata.picId, cover: metadata.cover || metadataTrack.cover, }) setMetadataTrack(null) }} onSkip={() => { if (!metadataTrack) return onUpdateBilibiliMetadata(metadataTrack.id, { clearMetadata: true }) setMetadataTrack(null) }} />
) }