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([]) @@ -376,36 +378,67 @@ export default function MusicPage() {

) : (
- {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 (
-
-
-
{p.name}
-
{p.songCount} songs
-
- {expandedPlaylist === p.id ? ( - - ) : ( - + + {/* Download entire playlist */} + {expandedPlaylist === p.id && playlistSongs.length > 0 && ( + )} - +
{expandedPlaylist === p.id && (
@@ -427,7 +460,7 @@ export default function MusicPage() {
)} - ))} + )})} )} 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 ( + + ) + })} + {/* Bottom padding so last line can center */} +
+
+
+ ) +}