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:
parent
c24962f829
commit
47d7565d04
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue