jefflix-website/components/music/synced-lyrics.tsx

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>
)
}