File size: 6,239 Bytes
00a912e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | import type { AudioQuality, DownloadOptionsResponse, DownloadQualityOption } from '@music-together/shared'
import { Download, Loader2, RefreshCw } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
ResponsiveDialog,
ResponsiveDialogBody,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from '@/components/ui/responsive-dialog'
import { SERVER_URL } from '@/lib/config'
import { getAudioQualityLabel } from '@/lib/audioQuality'
import { usePlayerStore } from '@/stores/playerStore'
import { useRoomStore } from '@/stores/roomStore'
interface MusicDownloadDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
function formatFileSize(bytes?: number): string | null {
if (!bytes || bytes <= 0) return null
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function optionDetails(option: DownloadQualityOption): string {
return [option.format, option.actualBitrate ? `${option.actualBitrate} kbps` : null, formatFileSize(option.fileSize)]
.filter(Boolean)
.join(' · ')
}
export function MusicDownloadDialog({ open, onOpenChange }: MusicDownloadDialogProps) {
const roomId = useRoomStore((state) => state.room?.id)
const currentTrack = usePlayerStore((state) => state.currentTrack)
const [options, setOptions] = useState<DownloadQualityOption[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
if (!open || !roomId || !currentTrack) return
const controller = new AbortController()
const trackId = currentTrack.id
queueMicrotask(() => {
if (controller.signal.aborted) return
setLoading(true)
setError(null)
setOptions([])
const params = new URLSearchParams({ roomId, trackId })
void fetch(`${SERVER_URL}/api/music/download-options?${params}`, {
credentials: 'include',
signal: controller.signal,
})
.then(async (response) => {
const body = (await response.json().catch(() => null)) as
| (DownloadOptionsResponse & { error?: string })
| null
if (!response.ok) throw new Error(body?.error ?? '获取下载音质失败')
if (!body || body.trackId !== trackId) throw new Error('当前歌曲已切换')
setOptions(body.options)
})
.catch((fetchError: unknown) => {
if (controller.signal.aborted) return
setError(fetchError instanceof Error ? fetchError.message : '获取下载音质失败')
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false)
})
})
return () => controller.abort()
}, [open, roomId, currentTrack, reloadKey])
const startDownload = useCallback(
(quality: AudioQuality) => {
if (!roomId || !currentTrack) return
const params = new URLSearchParams({ roomId, trackId: currentTrack.id, quality: String(quality) })
const anchor = document.createElement('a')
anchor.href = `${SERVER_URL}/api/music/download?${params}`
anchor.download = ''
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
onOpenChange(false)
toast.success('已开始下载')
},
[currentTrack, onOpenChange, roomId],
)
return (
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent className="sm:max-w-md">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>下载音乐</ResponsiveDialogTitle>
<ResponsiveDialogDescription className="line-clamp-2">
{currentTrack ? `${currentTrack.title} · ${currentTrack.artist.join(' / ')}` : '暂无歌曲'}
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<ResponsiveDialogBody>
{loading ? (
<div className="flex min-h-36 items-center justify-center" role="status" aria-label="正在获取可用音质">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex min-h-36 flex-col items-center justify-center gap-3 text-center">
<p className="text-sm text-muted-foreground">{error}</p>
<Button variant="outline" size="sm" onClick={() => setReloadKey((key) => key + 1)}>
<RefreshCw />
重试
</Button>
</div>
) : options.length === 0 ? (
<div className="flex min-h-36 items-center justify-center text-sm text-muted-foreground">
暂无可下载音质
</div>
) : (
<div className="divide-y divide-border/60 border-y border-border/60">
{[...options].reverse().map((option) => {
const label = getAudioQualityLabel(option.quality)
const details = optionDetails(option)
return (
<button
type="button"
key={String(option.quality)}
className="flex min-h-16 w-full items-center gap-3 px-2 py-2 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => startDownload(option.quality)}
aria-label={`下载 ${label}`}
>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">{label}</div>
{details && <div className="mt-0.5 text-xs text-muted-foreground">{details}</div>}
</div>
<Download className="h-4 w-4 shrink-0 text-primary" />
</button>
)
})}
</div>
)}
</ResponsiveDialogBody>
</ResponsiveDialogContent>
</ResponsiveDialog>
)
}
|