Feed and profile cards now show one slim line (small artwork, title, artist, Open button) with the progress bar and reactions. The arrow opens the rest: album, your other services, Share, and lyrics. Lyrics come from LRCLIB (open source; LRCLIB_URL can point at a self-hosted copy, or be empty to turn lyrics off). The server looks each song up once and caches it, including misses (retried after a week). Timed lyrics follow along with the song; otherwise the first lines show with "All lyrics". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
48 lines
1.5 KiB
TypeScript
48 lines
1.5 KiB
TypeScript
/** One line of time-synced lyrics; empty text is an instrumental gap. */
|
|
export interface LyricLine {
|
|
timeMs: number;
|
|
text: string;
|
|
}
|
|
|
|
export interface LyricsDTO {
|
|
/** Lines with timestamps, when the song has synced lyrics. */
|
|
synced: LyricLine[] | null;
|
|
/** Plain text, when there are no synced lyrics (or as a fallback). */
|
|
plain: string | null;
|
|
instrumental: boolean;
|
|
/** Where they came from, for attribution. */
|
|
source: { name: string; url: string };
|
|
}
|
|
|
|
const TIME_TAG = /\[(\d{1,3}):(\d{1,2}(?:\.\d{1,3})?)\]/g;
|
|
|
|
/**
|
|
* Parse LRC ("[01:23.45] words"). A line may carry several time tags; tags
|
|
* that aren't times (like [ar:Artist]) are skipped. Sorted by time.
|
|
*/
|
|
export function parseLrc(lrc: string): LyricLine[] {
|
|
const lines: LyricLine[] = [];
|
|
for (const raw of lrc.split(/\r?\n/)) {
|
|
const times = [...raw.matchAll(TIME_TAG)];
|
|
if (times.length === 0) continue;
|
|
const text = raw.replace(TIME_TAG, '').trim();
|
|
for (const [, min, sec] of times) lines.push({ timeMs: Math.round((Number(min) * 60 + Number(sec)) * 1000), text });
|
|
}
|
|
return lines.sort((a, b) => a.timeMs - b.timeMs);
|
|
}
|
|
|
|
/** Index of the line being sung at `positionMs`, or -1 before the first one. */
|
|
export function currentLyricIndex(lines: LyricLine[], positionMs: number): number {
|
|
let lo = 0;
|
|
let hi = lines.length - 1;
|
|
let found = -1;
|
|
while (lo <= hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (lines[mid]!.timeMs <= positionMs) {
|
|
found = mid;
|
|
lo = mid + 1;
|
|
} else hi = mid - 1;
|
|
}
|
|
return found;
|
|
}
|