ghp / packages /client /src /components /Overlays /QueueDrawer.tsx
QSLY's picture
deploy: build Hugging Face Space from source
00a912e
Raw
History Blame Contribute Delete
22.1 kB
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<MusicSource, { label: string; className: string }> = {
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<ReturnType<typeof setTimeout> | null>(null)
// Mobile: track which item has its action toolbar visible
const [activeTrackId, setActiveTrackId] = useState<string | null>(null)
// Desktop: after clicking an action, temporarily suppress the hover toolbar until the cursor leaves the item
const [dismissedHoverTrackId, setDismissedHoverTrackId] = useState<string | null>(null)
const [metadataTrack, setMetadataTrack] = useState<Track | null>(null)
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(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 (
<Drawer open={open} onOpenChange={onOpenChange} direction={isMobile ? 'bottom' : 'right'}>
<DrawerContent className={cn('flex flex-col p-0', isMobile && 'h-[70vh]')}>
<DrawerHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center justify-between">
<DrawerTitle className="flex items-center gap-2 text-base">
<Music className="h-4 w-4" />
播放列表 ({queue.length})
</DrawerTitle>
<div className="flex items-center gap-1">
{canClearQueue && queue.length > 0 && (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn('h-7 w-7', confirmClear && 'text-destructive hover:text-destructive')}
onClick={handleClear}
aria-label="清空播放列表"
>
<ListX className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{confirmClear ? '再次点击确认清空' : '清空播放列表'}</TooltipContent>
</Tooltip>
)}
{!isMobile && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onOpenChange(false)}
aria-label="关闭播放列表"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</DrawerHeader>
<div ref={setScrollElement} className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-2">
{queue.length === 0 ? (
<div className="flex h-40 items-center justify-center text-muted-foreground">播放列表为空</div>
) : (
<div className="w-full" style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const i = virtualRow.index
const track = queue[i]
if (!track) return null
return (
<div
key={track.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
paddingTop: '8px', // To simulate the top padding/gap
}}
>
<div
className={cn(
'group relative flex h-full items-center gap-2 rounded-lg px-2 transition-colors hover:bg-accent/50',
currentTrack?.id === track.id && 'bg-primary/10',
)}
onClick={() => {
if (isTouch) {
setActiveTrackId((prev) => (prev === track.id ? null : track.id))
}
}}
onMouseLeave={() => {
if (!isTouch && dismissedHoverTrackId === track.id) setDismissedHoverTrackId(null)
}}
>
{/* Index */}
<span className="w-5 shrink-0 text-center text-xs tabular-nums text-muted-foreground">
{i + 1}
</span>
{/* Cover + source badge */}
<div className="relative shrink-0">
{track.cover ? (
<img
src={getProxiedCoverUrl(track.cover)}
alt={track.title}
referrerPolicy="no-referrer"
className="h-9 w-9 rounded object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
}}
/>
) : null}
<div
className={cn(
'flex h-9 w-9 items-center justify-center rounded bg-muted',
track.cover && 'hidden',
)}
>
<Music className="h-4 w-4 text-muted-foreground" />
</div>
{track.source && SOURCE_STYLE[track.source] && (
<span
className={cn(
'absolute -bottom-1 -right-1 rounded px-0.5 text-[8px] font-bold leading-tight ring-1',
SOURCE_STYLE[track.source].className,
)}
>
{SOURCE_STYLE[track.source].label}
</span>
)}
</div>
{/* Track info */}
<div className="min-w-0 flex-1">
<MarqueeText
className={cn('text-sm', currentTrack?.id === track.id && 'font-medium text-primary')}
>
{track.title}
</MarqueeText>
<div className="flex min-w-0 items-center gap-1">
<MarqueeText className="min-w-0 flex-1 text-xs text-muted-foreground">
{track.artist.join(' / ')}
</MarqueeText>
{track.requestedBy && (
<Badge
variant="outline"
className="h-4 max-w-28 shrink-0 gap-0.5 truncate border-primary/30 bg-primary/10 px-1.5 py-0 text-[10px] font-normal text-primary"
>
<User className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{track.requestedBy}</span>
</Badge>
)}
</div>
</div>
{/* Actions — visible on hover (desktop) or tap (mobile) */}
<div
className={cn(
'absolute right-1 top-1/2 z-20 flex -translate-y-1/2 items-center gap-0.5',
'rounded-md border border-border/50 bg-popover px-1 py-0.5 shadow-md backdrop-blur-md',
'opacity-0 pointer-events-none transition-opacity',
'group-hover:opacity-100 group-hover:pointer-events-auto',
'group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
isTouch && activeTrackId === track.id && 'opacity-100 pointer-events-auto',
!isTouch && dismissedHoverTrackId === track.id && 'opacity-0 pointer-events-none',
)}
onClick={(e) => e.stopPropagation()}
>
{/* Play button — hidden for currently playing track */}
{currentTrack?.id !== track.id && (canPlay || canVote) && (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
onClick={() => handlePlayTrack(track)}
aria-label={canPlay ? `播放 ${track.title}` : `投票播放 ${track.title}`}
>
<Play className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{canPlay ? '播放' : '投票播放'}</TooltipContent>
</Tooltip>
)}
{track.source === 'bilibili' && canAdd && (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
onClick={() => setMetadataTrack(track)}
aria-label={`重选 ${track.title} 的歌词和封面`}
>
<RefreshCw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">重选歌词和封面</TooltipContent>
</Tooltip>
)}
{canReorder && (
<>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
disabled={i === 0}
onClick={() => handleMoveUp(i)}
aria-label={`上移 ${track.title}`}
>
<ChevronUp className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">上移</TooltipContent>
</Tooltip>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
disabled={i === queue.length - 1}
onClick={() => handleMoveDown(i)}
aria-label={`下移 ${track.title}`}
>
<ChevronDown className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">下移</TooltipContent>
</Tooltip>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0"
onClick={(e) => handleInsertAfterCurrent(track, e)}
aria-label={`置顶 ${track.title}`}
>
<ArrowUpToLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">置顶到当前播放下方</TooltipContent>
</Tooltip>
</>
)}
{(canRemoveTrack || canVote) && (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 min-h-9 min-w-9 sm:min-h-0 sm:min-w-0 text-destructive hover:text-destructive"
onClick={() => handleRemoveTrack(track)}
aria-label={canRemoveTrack ? `移除 ${track.title}` : `投票移除 ${track.title}`}
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{canRemoveTrack ? '移除' : '投票移除'}</TooltipContent>
</Tooltip>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</DrawerContent>
<BilibiliMetadataDialog
track={metadataTrack}
roomId={roomId}
onOpenChange={(isOpen) => !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)
}}
/>
</Drawer>
)
}