feat: synced scrolling lyrics + playlist offline download

- Fetch timed LRC lyrics from LRCLIB API (Navidrome fallback for plain text)
- Synced lyrics component with auto-scroll, active line highlighting, and tap-to-seek
- Past lines fade out, current line enlarges — Spotify-style karaoke experience
- Add "Download All" button on expanded playlists for bulk offline download
- Button shows green checkmark when all songs already downloaded

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 19:17:51 -07:00
parent c24962f829
commit 47d7565d04
4 changed files with 215 additions and 32 deletions

View File

@ -9,22 +9,62 @@ interface LyricsResult {
}
}
interface LrcLibResult {
syncedLyrics?: string | null
plainLyrics?: string | null
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const artist = searchParams.get('artist') || ''
const title = searchParams.get('title') || ''
if (!artist || !title) {
return NextResponse.json({ lyrics: null })
return NextResponse.json({ lyrics: null, synced: null })
}
// Try LRCLIB first for synced (timed) lyrics
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 4000)
const lrcRes = await fetch(
`https://lrclib.net/api/get?artist_name=${encodeURIComponent(artist)}&track_name=${encodeURIComponent(title)}`,
{
headers: {
'User-Agent': 'SoulSync/1.0 (jeffemmett@gmail.com)',
},
signal: controller.signal,
cache: 'no-store',
}
)
clearTimeout(timeout)
if (lrcRes.ok) {
const lrc: LrcLibResult = await lrcRes.json()
if (lrc.syncedLyrics) {
return NextResponse.json({
lyrics: lrc.plainLyrics || lrc.syncedLyrics.replace(/\[\d{2}:\d{2}\.\d{2,3}\]\s?/g, ''),
synced: lrc.syncedLyrics,
})
}
if (lrc.plainLyrics) {
return NextResponse.json({ lyrics: lrc.plainLyrics, synced: null })
}
}
} catch {
// LRCLIB unavailable, fall through to Navidrome
}
// Fallback to Navidrome plain lyrics
try {
const data = await navidromeGet<LyricsResult>('getLyrics.view', { artist, title })
return NextResponse.json({
lyrics: data.lyrics?.value || null,
synced: null,
})
} catch (error) {
console.error('Lyrics error:', error)
return NextResponse.json({ lyrics: null })
return NextResponse.json({ lyrics: null, synced: null })
}
}

View File

@ -7,7 +7,8 @@ import { JefflixLogo } from '@/components/jefflix-logo'
import { SongRow } from '@/components/music/search-results'
import { useMusicPlayer, type Track } from '@/components/music/music-provider'
import { MusicBrainzResultsSection } from '@/components/music/musicbrainz-results'
import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown, WifiOff, Users } from 'lucide-react'
import { useOffline } from '@/lib/stores/offline'
import { Search, Music, Download, Loader2, ArrowLeft, AlertCircle, ListMusic, ChevronRight, ChevronDown, WifiOff, Users, DownloadCloud, CheckCircle } from 'lucide-react'
import Link from 'next/link'
interface Playlist {
@ -33,6 +34,7 @@ interface SlskdFile {
export default function MusicPage() {
const { state } = useMusicPlayer()
const { offlineIds, download: downloadTrack } = useOffline()
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [songs, setSongs] = useState<Track[]>([])
@ -376,36 +378,67 @@ export default function MusicPage() {
</p>
) : (
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
{playlists.map((p) => (
{playlists.map((p) => {
// Check how many songs in this playlist are already offline
const allOffline = expandedPlaylist === p.id && playlistSongs.length > 0 &&
playlistSongs.every((s) => offlineIds.has(s.id))
return (
<div key={p.id}>
<button
onClick={() => togglePlaylist(p.id)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
>
<div className="flex-shrink-0 w-12 h-12 rounded overflow-hidden bg-muted">
{p.coverArt ? (
<img
src={`/api/music/cover/${p.coverArt}?size=96`}
alt={p.name}
className="w-full h-full object-cover"
loading="lazy"
/>
<div className="flex items-center hover:bg-muted/50 transition-colors">
<button
onClick={() => togglePlaylist(p.id)}
className="flex-1 flex items-center gap-3 px-4 py-3 text-left"
>
<div className="flex-shrink-0 w-12 h-12 rounded overflow-hidden bg-muted">
{p.coverArt ? (
<img
src={`/api/music/cover/${p.coverArt}?size=96`}
alt={p.name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ListMusic className="h-5 w-5 text-muted-foreground" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
</div>
{expandedPlaylist === p.id ? (
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : (
<div className="w-full h-full flex items-center justify-center">
<ListMusic className="h-5 w-5 text-muted-foreground" />
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{p.name}</div>
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
</div>
{expandedPlaylist === p.id ? (
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</button>
{/* Download entire playlist */}
{expandedPlaylist === p.id && playlistSongs.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
playlistSongs.forEach((s) => {
if (!offlineIds.has(s.id)) downloadTrack(s)
})
}}
className={`p-2 mr-2 rounded-full transition-colors flex-shrink-0 ${
allOffline
? 'text-green-500'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
title={allOffline ? 'All songs downloaded' : 'Download all for offline'}
disabled={allOffline}
>
{allOffline ? (
<CheckCircle className="h-5 w-5" />
) : (
<DownloadCloud className="h-5 w-5" />
)}
</button>
)}
</button>
</div>
{expandedPlaylist === p.id && (
<div className="bg-muted/30 border-t border-border">
@ -427,7 +460,7 @@ export default function MusicPage() {
</div>
)}
</div>
))}
)})}
</div>
)}
</div>

View File

@ -5,6 +5,7 @@ import { Drawer } from 'vaul'
import { useMusicPlayer } from './music-provider'
import { DownloadButton } from './download-button'
import { PlaylistPicker } from './playlist-picker'
import { SyncedLyrics } from './synced-lyrics'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import {
@ -30,6 +31,7 @@ function formatTime(secs: number) {
export function FullScreenPlayer() {
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
const [lyrics, setLyrics] = useState<string | null>(null)
const [syncedLyrics, setSyncedLyrics] = useState<string | null>(null)
const [loadingLyrics, setLoadingLyrics] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
@ -39,11 +41,15 @@ export function FullScreenPlayer() {
useEffect(() => {
if (!track) return
setLyrics(null)
setSyncedLyrics(null)
setLoadingLyrics(true)
fetch(`/api/music/lyrics?artist=${encodeURIComponent(track.artist)}&title=${encodeURIComponent(track.title)}`)
.then((r) => r.json())
.then((d) => setLyrics(d.lyrics))
.catch(() => setLyrics(null))
.then((d) => {
setLyrics(d.lyrics)
setSyncedLyrics(d.synced || null)
})
.catch(() => { setLyrics(null); setSyncedLyrics(null) })
.finally(() => setLoadingLyrics(false))
}, [track?.id]) // eslint-disable-line react-hooks/exhaustive-deps
@ -182,6 +188,12 @@ export function FullScreenPlayer() {
{/* Lyrics */}
{loadingLyrics ? (
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
) : syncedLyrics ? (
<SyncedLyrics
syncedLyrics={syncedLyrics}
currentTime={state.progress}
onSeek={seek}
/>
) : lyrics ? (
<div className="w-full max-w-sm">
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">Lyrics</h3>

View File

@ -0,0 +1,98 @@
'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>
)
}