Files
Aurora-SpotiRip/components/LyricsPanel.tsx
T
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

117 lines
3.4 KiB
TypeScript

import { useEffect, useMemo, useRef } from "react";
import { Music2, X } from "lucide-react";
import { Button } from "./Button";
import styles from "./LyricsPanel.module.css";
type LyricsPanelProps = {
title?: string;
artist?: string;
plain?: string;
synced?: string;
instrumental?: boolean;
positionMs: number;
loading?: boolean;
error?: string;
onClose: () => void;
};
type SyncedLine = {
timeMs: number;
text: string;
};
function parseSynced(value?: string) {
if (!value) return [] as SyncedLine[];
return value
.split(/\r?\n/)
.map((line) => {
const match = line.match(/^\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\](.*)$/);
if (!match) return undefined;
const minutes = Number(match[1]);
const seconds = Number(match[2]);
const fraction = match[3] || "0";
const millis = fraction.length === 3
? Number(fraction)
: fraction.length === 2
? Number(fraction) * 10
: Number(fraction) * 100;
return {
timeMs: (minutes * 60 + seconds) * 1000 + millis,
text: match[4].trim(),
};
})
.filter((line): line is SyncedLine => Boolean(line));
}
export function LyricsPanel({
title,
artist,
plain,
synced,
instrumental,
positionMs,
loading,
error,
onClose,
}: LyricsPanelProps) {
const lines = useMemo(() => parseSynced(synced), [synced]);
const activeIndex = useMemo(() => {
let active = -1;
for (let index = 0; index < lines.length; index += 1) {
if (lines[index].timeMs <= positionMs + 120) active = index;
else break;
}
return active;
}, [lines, positionMs]);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeIndex < 0 || !containerRef.current) return;
const active = containerRef.current.querySelector(
`[data-line="${activeIndex}"]`,
);
active?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [activeIndex]);
return (
<aside className={styles.panel} aria-label="Lyrics">
<header>
<div>
<span>LYRICS</span>
<strong>{title || "Now playing"}</strong>
<small>{artist}</small>
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close lyrics">
<X size={16} />
</Button>
</header>
<div className={styles.body} ref={containerRef}>
{loading ? <div className={styles.empty}>Loading lyrics…</div> : null}
{error ? <div className={styles.error}>{error}</div> : null}
{!loading && !error && instrumental ? (
<div className={styles.empty}><Music2 size={22} /> Instrumental track</div>
) : null}
{!loading && !error && lines.length ? (
<div className={styles.synced}>
{lines.map((line, index) => (
<p
key={`${line.timeMs}-${index}`}
data-line={index}
className={index === activeIndex ? styles.active : undefined}
>
{line.text || "♪"}
</p>
))}
</div>
) : null}
{!loading && !error && !lines.length && plain ? (
<div className={styles.plain}>{plain}</div>
) : null}
{!loading && !error && !instrumental && !lines.length && !plain ? (
<div className={styles.empty}>No lyrics found for this track.</div>
) : null}
</div>
</aside>
);
}