171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef } from 'react'
|
|
import { Download, Loader2, Check, X, Globe } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
interface MBResult {
|
|
mbid: string
|
|
title: string
|
|
artist: string
|
|
album: string
|
|
year: string
|
|
duration: number
|
|
score: number
|
|
}
|
|
|
|
type DownloadState = 'idle' | 'downloading' | 'success' | 'error'
|
|
|
|
function formatDuration(secs: number) {
|
|
if (!secs) return ''
|
|
const m = Math.floor(secs / 60)
|
|
const s = Math.floor(secs % 60)
|
|
return `${m}:${s.toString().padStart(2, '0')}`
|
|
}
|
|
|
|
export function MusicBrainzResultsSection({ query }: { query: string }) {
|
|
const [results, setResults] = useState<MBResult[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [downloadStates, setDownloadStates] = useState<Record<string, DownloadState>>({})
|
|
const debounceRef = useRef<NodeJS.Timeout>(null)
|
|
|
|
useEffect(() => {
|
|
if (query.length < 2) {
|
|
setResults([])
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
// 500ms debounce (800ms total after Navidrome's 300ms input debounce)
|
|
debounceRef.current = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`/api/music/musicbrainz?q=${encodeURIComponent(query)}`)
|
|
const d = await res.json()
|
|
setResults(d.results || [])
|
|
} catch {
|
|
setResults([])
|
|
}
|
|
setLoading(false)
|
|
}, 500)
|
|
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
}
|
|
}, [query])
|
|
|
|
const handleDownload = async (result: MBResult) => {
|
|
setDownloadStates((s) => ({ ...s, [result.mbid]: 'downloading' }))
|
|
|
|
try {
|
|
const res = await fetch('/api/music/slskd/auto-download', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ artist: result.artist, title: result.title }),
|
|
})
|
|
const d = await res.json()
|
|
|
|
if (d.success) {
|
|
setDownloadStates((s) => ({ ...s, [result.mbid]: 'success' }))
|
|
} else {
|
|
setDownloadStates((s) => ({ ...s, [result.mbid]: 'error' }))
|
|
setTimeout(() => {
|
|
setDownloadStates((s) => ({ ...s, [result.mbid]: 'idle' }))
|
|
}, 3000)
|
|
}
|
|
} catch {
|
|
setDownloadStates((s) => ({ ...s, [result.mbid]: 'error' }))
|
|
setTimeout(() => {
|
|
setDownloadStates((s) => ({ ...s, [result.mbid]: 'idle' }))
|
|
}, 3000)
|
|
}
|
|
}
|
|
|
|
if (loading && results.length === 0) {
|
|
return (
|
|
<div className="mt-6">
|
|
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2 mb-3">
|
|
<Globe className="h-4 w-4" />
|
|
Discover New Music
|
|
</h3>
|
|
<div className="flex justify-center py-6">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (results.length === 0) return null
|
|
|
|
return (
|
|
<div className="mt-6">
|
|
<h3 className="text-sm font-semibold text-muted-foreground flex items-center gap-2 mb-3">
|
|
<Globe className="h-4 w-4" />
|
|
Discover New Music
|
|
</h3>
|
|
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
|
{results.map((r) => {
|
|
const state = downloadStates[r.mbid] || 'idle'
|
|
return (
|
|
<div key={r.mbid} className="flex items-center gap-3 px-4 py-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">{r.title}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{r.artist}
|
|
{r.album && <> · {r.album}</>}
|
|
{r.year && <> · {r.year}</>}
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground tabular-nums hidden sm:block">
|
|
{formatDuration(r.duration)}
|
|
</span>
|
|
{r.score > 80 && (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 hidden sm:inline-flex">
|
|
{r.score}%
|
|
</Badge>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleDownload(r)}
|
|
disabled={state === 'downloading' || state === 'success'}
|
|
className={
|
|
state === 'success'
|
|
? 'bg-green-600 hover:bg-green-600 text-white'
|
|
: state === 'error'
|
|
? 'bg-red-600 hover:bg-red-600 text-white'
|
|
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
|
|
}
|
|
>
|
|
{state === 'downloading' && (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
|
<span className="hidden sm:inline">Searching...</span>
|
|
</>
|
|
)}
|
|
{state === 'success' && (
|
|
<>
|
|
<Check className="h-3.5 w-3.5 mr-1" />
|
|
<span className="hidden sm:inline">Queued</span>
|
|
</>
|
|
)}
|
|
{state === 'error' && (
|
|
<>
|
|
<X className="h-3.5 w-3.5 mr-1" />
|
|
<span className="hidden sm:inline">Not found</span>
|
|
</>
|
|
)}
|
|
{state === 'idle' && (
|
|
<>
|
|
<Download className="h-3.5 w-3.5 sm:mr-1" />
|
|
<span className="hidden sm:inline">Soulseek</span>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|