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([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(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 ( 下载音乐 {currentTrack ? `${currentTrack.title} · ${currentTrack.artist.join(' / ')}` : '暂无歌曲'} {loading ? (
) : error ? (

{error}

) : options.length === 0 ? (
暂无可下载音质
) : (
{[...options].reverse().map((option) => { const label = getAudioQualityLabel(option.quality) const details = optionDetails(option) return ( ) })}
)}
) }