jefflix-website/components/music/musicbrainz-results.tsx

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 && <> &middot; {r.album}</>}
{r.year && <> &middot; {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>
)
}