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) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const artist = searchParams.get('artist') || ''
|
const artist = searchParams.get('artist') || ''
|
||||||
const title = searchParams.get('title') || ''
|
const title = searchParams.get('title') || ''
|
||||||
|
|
||||||
if (!artist || !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 {
|
try {
|
||||||
const data = await navidromeGet<LyricsResult>('getLyrics.view', { artist, title })
|
const data = await navidromeGet<LyricsResult>('getLyrics.view', { artist, title })
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
lyrics: data.lyrics?.value || null,
|
lyrics: data.lyrics?.value || null,
|
||||||
|
synced: null,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Lyrics error:', 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 { SongRow } from '@/components/music/search-results'
|
||||||
import { useMusicPlayer, type Track } from '@/components/music/music-provider'
|
import { useMusicPlayer, type Track } from '@/components/music/music-provider'
|
||||||
import { MusicBrainzResultsSection } from '@/components/music/musicbrainz-results'
|
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'
|
import Link from 'next/link'
|
||||||
|
|
||||||
interface Playlist {
|
interface Playlist {
|
||||||
|
|
@ -33,6 +34,7 @@ interface SlskdFile {
|
||||||
|
|
||||||
export default function MusicPage() {
|
export default function MusicPage() {
|
||||||
const { state } = useMusicPlayer()
|
const { state } = useMusicPlayer()
|
||||||
|
const { offlineIds, download: downloadTrack } = useOffline()
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||||
const [songs, setSongs] = useState<Track[]>([])
|
const [songs, setSongs] = useState<Track[]>([])
|
||||||
|
|
@ -376,36 +378,67 @@ export default function MusicPage() {
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="border border-border rounded-lg divide-y divide-border overflow-hidden">
|
<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}>
|
<div key={p.id}>
|
||||||
<button
|
<div className="flex items-center hover:bg-muted/50 transition-colors">
|
||||||
onClick={() => togglePlaylist(p.id)}
|
<button
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
|
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 ? (
|
<div className="flex-shrink-0 w-12 h-12 rounded overflow-hidden bg-muted">
|
||||||
<img
|
{p.coverArt ? (
|
||||||
src={`/api/music/cover/${p.coverArt}?size=96`}
|
<img
|
||||||
alt={p.name}
|
src={`/api/music/cover/${p.coverArt}?size=96`}
|
||||||
className="w-full h-full object-cover"
|
alt={p.name}
|
||||||
loading="lazy"
|
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">
|
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
<ListMusic className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
<div className="flex-1 min-w-0">
|
{/* Download entire playlist */}
|
||||||
<div className="font-medium text-sm truncate">{p.name}</div>
|
{expandedPlaylist === p.id && playlistSongs.length > 0 && (
|
||||||
<div className="text-xs text-muted-foreground">{p.songCount} songs</div>
|
<button
|
||||||
</div>
|
onClick={(e) => {
|
||||||
{expandedPlaylist === p.id ? (
|
e.stopPropagation()
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
playlistSongs.forEach((s) => {
|
||||||
) : (
|
if (!offlineIds.has(s.id)) downloadTrack(s)
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
})
|
||||||
|
}}
|
||||||
|
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 && (
|
{expandedPlaylist === p.id && (
|
||||||
<div className="bg-muted/30 border-t border-border">
|
<div className="bg-muted/30 border-t border-border">
|
||||||
|
|
@ -427,7 +460,7 @@ export default function MusicPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Drawer } from 'vaul'
|
||||||
import { useMusicPlayer } from './music-provider'
|
import { useMusicPlayer } from './music-provider'
|
||||||
import { DownloadButton } from './download-button'
|
import { DownloadButton } from './download-button'
|
||||||
import { PlaylistPicker } from './playlist-picker'
|
import { PlaylistPicker } from './playlist-picker'
|
||||||
|
import { SyncedLyrics } from './synced-lyrics'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,6 +31,7 @@ function formatTime(secs: number) {
|
||||||
export function FullScreenPlayer() {
|
export function FullScreenPlayer() {
|
||||||
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
|
const { state, togglePlay, seek, setVolume, nextTrack, prevTrack, setFullScreen, outputDevices, currentOutputId, setOutputDevice } = useMusicPlayer()
|
||||||
const [lyrics, setLyrics] = useState<string | null>(null)
|
const [lyrics, setLyrics] = useState<string | null>(null)
|
||||||
|
const [syncedLyrics, setSyncedLyrics] = useState<string | null>(null)
|
||||||
const [loadingLyrics, setLoadingLyrics] = useState(false)
|
const [loadingLyrics, setLoadingLyrics] = useState(false)
|
||||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||||
|
|
||||||
|
|
@ -39,11 +41,15 @@ export function FullScreenPlayer() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!track) return
|
if (!track) return
|
||||||
setLyrics(null)
|
setLyrics(null)
|
||||||
|
setSyncedLyrics(null)
|
||||||
setLoadingLyrics(true)
|
setLoadingLyrics(true)
|
||||||
fetch(`/api/music/lyrics?artist=${encodeURIComponent(track.artist)}&title=${encodeURIComponent(track.title)}`)
|
fetch(`/api/music/lyrics?artist=${encodeURIComponent(track.artist)}&title=${encodeURIComponent(track.title)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setLyrics(d.lyrics))
|
.then((d) => {
|
||||||
.catch(() => setLyrics(null))
|
setLyrics(d.lyrics)
|
||||||
|
setSyncedLyrics(d.synced || null)
|
||||||
|
})
|
||||||
|
.catch(() => { setLyrics(null); setSyncedLyrics(null) })
|
||||||
.finally(() => setLoadingLyrics(false))
|
.finally(() => setLoadingLyrics(false))
|
||||||
}, [track?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [track?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|
@ -182,6 +188,12 @@ export function FullScreenPlayer() {
|
||||||
{/* Lyrics */}
|
{/* Lyrics */}
|
||||||
{loadingLyrics ? (
|
{loadingLyrics ? (
|
||||||
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
|
<p className="text-sm text-muted-foreground animate-pulse">Loading lyrics...</p>
|
||||||
|
) : syncedLyrics ? (
|
||||||
|
<SyncedLyrics
|
||||||
|
syncedLyrics={syncedLyrics}
|
||||||
|
currentTime={state.progress}
|
||||||
|
onSeek={seek}
|
||||||
|
/>
|
||||||
) : lyrics ? (
|
) : lyrics ? (
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">Lyrics</h3>
|
<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