diff --git a/app/api/music/lyrics/route.ts b/app/api/music/lyrics/route.ts
index 69ff892..c4f131e 100644
--- a/app/api/music/lyrics/route.ts
+++ b/app/api/music/lyrics/route.ts
@@ -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('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 })
}
}
diff --git a/app/music/page.tsx b/app/music/page.tsx
index f4550ba..0dbe702 100644
--- a/app/music/page.tsx
+++ b/app/music/page.tsx
@@ -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
) : (
- {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 (
-
- ))}
+ )})}
)}
diff --git a/components/music/full-screen-player.tsx b/components/music/full-screen-player.tsx
index 4bf6f27..6bcf9c6 100644
--- a/components/music/full-screen-player.tsx
+++ b/components/music/full-screen-player.tsx
@@ -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(null)
+ const [syncedLyrics, setSyncedLyrics] = useState(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 ? (
Loading lyrics...
+ ) : syncedLyrics ? (
+
) : lyrics ? (
Lyrics
diff --git a/components/music/synced-lyrics.tsx b/components/music/synced-lyrics.tsx
new file mode 100644
index 0000000..0af447d
--- /dev/null
+++ b/components/music/synced-lyrics.tsx
@@ -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
(null)
+ const activeRef = useRef(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 (
+
+
Lyrics
+
+ {/* Top padding so first line can center */}
+
+ {lines.map((line, i) => {
+ const isActive = i === activeIndex
+ const isPast = i < activeIndex
+ return (
+
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}
+
+ )
+ })}
+ {/* Bottom padding so last line can center */}
+
+
+
+ )
+}