99 lines
3.0 KiB
TypeScript
99 lines
3.0 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useRef, useEffect } from 'react'
|
|
|
|
interface LyricLine {
|
|
time: number // seconds
|
|
text: string
|
|
}
|
|
|
|
function parseLRC(lrc: string): LyricLine[] {
|
|
const lines: LyricLine[] = []
|
|
for (const raw of lrc.split('\n')) {
|
|
const match = raw.match(/^\[(\d{2}):(\d{2})\.(\d{2,3})\]\s?(.*)$/)
|
|
if (!match) continue
|
|
const mins = parseInt(match[1], 10)
|
|
const secs = parseInt(match[2], 10)
|
|
const ms = parseInt(match[3].padEnd(3, '0'), 10)
|
|
const text = match[4].trim()
|
|
if (!text) continue
|
|
lines.push({ time: mins * 60 + secs + ms / 1000, text })
|
|
}
|
|
return lines.sort((a, b) => a.time - b.time)
|
|
}
|
|
|
|
export function SyncedLyrics({
|
|
syncedLyrics,
|
|
currentTime,
|
|
onSeek,
|
|
}: {
|
|
syncedLyrics: string
|
|
currentTime: number
|
|
onSeek?: (time: number) => void
|
|
}) {
|
|
const lines = useMemo(() => parseLRC(syncedLyrics), [syncedLyrics])
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const activeRef = useRef<HTMLButtonElement>(null)
|
|
|
|
// Find active line index
|
|
let activeIndex = -1
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
if (currentTime >= lines[i].time) {
|
|
activeIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// Auto-scroll to active line
|
|
useEffect(() => {
|
|
if (activeRef.current && containerRef.current) {
|
|
const container = containerRef.current
|
|
const el = activeRef.current
|
|
const containerRect = container.getBoundingClientRect()
|
|
const elRect = el.getBoundingClientRect()
|
|
|
|
// Center the active line in the visible area
|
|
const targetScroll = el.offsetTop - container.offsetTop - containerRect.height / 2 + elRect.height / 2
|
|
container.scrollTo({ top: targetScroll, behavior: 'smooth' })
|
|
}
|
|
}, [activeIndex])
|
|
|
|
if (lines.length === 0) return null
|
|
|
|
return (
|
|
<div className="w-full max-w-sm">
|
|
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">Lyrics</h3>
|
|
<div
|
|
ref={containerRef}
|
|
className="max-h-[300px] overflow-y-auto scroll-smooth space-y-1 pr-2"
|
|
style={{ scrollbarWidth: 'thin' }}
|
|
>
|
|
{/* Top padding so first line can center */}
|
|
<div className="h-[120px]" />
|
|
{lines.map((line, i) => {
|
|
const isActive = i === activeIndex
|
|
const isPast = i < activeIndex
|
|
return (
|
|
<button
|
|
key={`${i}-${line.time}`}
|
|
ref={isActive ? activeRef : undefined}
|
|
onClick={() => onSeek?.(line.time)}
|
|
className={`block w-full text-left px-2 py-1.5 rounded-md transition-all duration-300 ${
|
|
isActive
|
|
? 'text-foreground text-lg font-semibold scale-[1.02]'
|
|
: isPast
|
|
? 'text-muted-foreground/40 text-sm'
|
|
: 'text-muted-foreground/60 text-sm'
|
|
} hover:bg-muted/30`}
|
|
>
|
|
{line.text}
|
|
</button>
|
|
)
|
|
})}
|
|
{/* Bottom padding so last line can center */}
|
|
<div className="h-[120px]" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|