diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e110286..b24bf882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -223,6 +223,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Pausing a large queue behind a reverse proxy (e.g. Nginx) could snap the player back to an earlier track — the save was one long URL that hit the HTTP 414 limit, failed silently, and idle auto-pull restored the stale server queue. * Servers advertising the OpenSubsonic `formPost` extension (Navidrome) now save via POST; others retry once as POST on 414. A failed save no longer lets auto-pull overwrite playback — it resumes only after a successful save. +### Lyrics — timing codes showing up in the text + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1266](https://github.com/Psychotoxical/psysonic/pull/1266)** + +* Tracks whose embedded lyrics use Enhanced LRC displayed the raw word timing codes (`<00:12.34>`) inside each line. The codes are now read as word timing instead of printed, so those lyrics also highlight word by word. + ## [1.49.0] - 2026-06-29 diff --git a/src/features/lyrics/api/lrclib.ts b/src/features/lyrics/api/lrclib.ts index bf3f4fec..216cab99 100644 --- a/src/features/lyrics/api/lrclib.ts +++ b/src/features/lyrics/api/lrclib.ts @@ -3,11 +3,6 @@ export interface LrclibLyrics { plainLyrics: string | null; } -export interface LrcLine { - time: number; // seconds - text: string; -} - export async function fetchLyrics( artist: string, title: string, @@ -32,18 +27,3 @@ export async function fetchLyrics( return null; } } - -export function parseLrc(lrc: string): LrcLine[] { - const lines: LrcLine[] = []; - for (const line of lrc.split('\n')) { - // \d+(?:\.\d*)? — decimal part is optional so [mm:ss] (no fraction) also matches. - // parseFloat handles all forms: "15", "15.", "15.3", "15.32" correctly. - const match = line.match(/^\[(\d+):(\d+(?:\.\d*)?)\](.*)/); - if (!match) continue; - const mins = parseInt(match[1], 10); - const secs = parseFloat(match[2]); - const text = match[3].trim(); - lines.push({ time: mins * 60 + secs, text }); - } - return lines.sort((a, b) => a.time - b.time); -} diff --git a/src/features/lyrics/components/LyricsPane.tsx b/src/features/lyrics/components/LyricsPane.tsx index e2752db7..c105c61e 100644 --- a/src/features/lyrics/components/LyricsPane.tsx +++ b/src/features/lyrics/components/LyricsPane.tsx @@ -2,7 +2,7 @@ import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '@/featur import { useEffect, useRef, useCallback } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { usePlayerStore } from '@/features/playback/store/playerStore'; -import type { LrcLine } from '@/features/lyrics/api/lrclib'; +import type { LrcLine } from '@/features/lyrics/types'; import { useLyrics } from '@/features/lyrics/hooks/useLyrics'; import type { WordLyricsLine } from '@/features/lyrics/types'; import { useAuthStore } from '@/store/authStore'; diff --git a/src/features/lyrics/hooks/useLyrics.ts b/src/features/lyrics/hooks/useLyrics.ts index cd87bf95..38d58767 100644 --- a/src/features/lyrics/hooks/useLyrics.ts +++ b/src/features/lyrics/hooks/useLyrics.ts @@ -3,7 +3,8 @@ import type { Track } from '@/lib/media/trackTypes'; import { useEffect, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { commands } from '@/generated/bindings'; -import { fetchLyrics, parseLrc, LrcLine } from '@/features/lyrics/api/lrclib'; +import { fetchLyrics } from '@/features/lyrics/api/lrclib'; +import { parseEnhancedLrc, parseLrc } from '@/features/lyrics/utils/lrc'; import { fetchNeteaselyrics } from '@/features/lyrics/api/netease'; import { fetchLyricsPlus, hasWordSync } from '@/features/lyrics/api/lyricsplus'; import { useAuthStore } from '@/store/authStore'; @@ -13,7 +14,7 @@ import { getCachedLyrics, putCachedLyrics, lyricsCacheKey } from '@/features/lyr import { parseStructuredLyrics, parseStructuredWordLines } from '@/features/lyrics/utils/structuredLyrics'; import { FEATURE_ENHANCED_LYRICS } from '@/lib/serverCapabilities/catalog'; import { isFeatureActiveForServer } from '@/lib/serverCapabilities/storeView'; -import type { CachedLyrics, LyricsSource, WordLyricsLine } from '@/features/lyrics/types'; +import type { CachedLyrics, LrcLine, LyricsSource, WordLyricsLine } from '@/features/lyrics/types'; // L1 cache: RAM, survives tab switches and component remount within a session. // L2 (IndexedDB) lives in `utils/lyricsPersistentCache.ts` — only touched on @@ -120,12 +121,14 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { const lrcString = await commands.getEmbeddedLyrics(filePath); if (!lrcString) return false; - const lines = parseLrc(lrcString); + // Embedded tags may hold Enhanced LRC, whose inline `` markers + // must not reach the text — and can drive word highlighting when present. + const { lines, wordLines } = parseEnhancedLrc(lrcString); const synced = lines.length > 0 ? lines : null; const plain = synced ? null : (lrcString.trim() || null); if (!synced && !plain) return false; - store({ syncedLines: synced, wordLines: null, plainLyrics: plain, source: 'embedded', notFound: false }); + store({ syncedLines: synced, wordLines: synced ? wordLines : null, plainLyrics: plain, source: 'embedded', notFound: false }); return true; } catch { return false; @@ -156,9 +159,10 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult { currentTrack.duration ?? 0, ); if (!result || (!result.syncedLyrics && !result.plainLyrics)) return false; - const lines = result.syncedLyrics ? parseLrc(result.syncedLyrics) : null; - const synced = lines && lines.length > 0 ? lines : null; - store({ syncedLines: synced, wordLines: null, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false }); + const parsed = result.syncedLyrics ? parseEnhancedLrc(result.syncedLyrics) : null; + const synced = parsed?.lines.length ? parsed.lines : null; + const wordLines = synced ? parsed?.wordLines ?? null : null; + store({ syncedLines: synced, wordLines, plainLyrics: result.plainLyrics, source: 'lrclib', notFound: false }); return true; } catch { return false; diff --git a/src/features/lyrics/index.ts b/src/features/lyrics/index.ts index b5b5c718..cbeedb85 100644 --- a/src/features/lyrics/index.ts +++ b/src/features/lyrics/index.ts @@ -12,5 +12,4 @@ export { default as LyricsPane } from './components/LyricsPane'; export { useLyrics } from './hooks/useLyrics'; export { useWordLyricsSync } from './hooks/useWordLyricsSync'; -export type { LrcLine } from './api/lrclib'; -export type { WordLyricsLine, WordLyricsWord, LyricsSource } from './types'; +export type { LrcLine, WordLyricsLine, WordLyricsWord, LyricsSource } from './types'; diff --git a/src/features/lyrics/types.ts b/src/features/lyrics/types.ts index a4d32018..db04d506 100644 --- a/src/features/lyrics/types.ts +++ b/src/features/lyrics/types.ts @@ -4,10 +4,15 @@ * the hook (dependency-cruiser counts type-only edges, and a back-edge would * close a cycle). */ -import type { LrcLine } from '@/features/lyrics/api/lrclib'; export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded' | 'lyricsplus'; +/** One timed line of lyrics. `time` is seconds. */ +export interface LrcLine { + time: number; + text: string; +} + /** * Karaoke-style word/syllable timing inside a single line. * All times are seconds (aligned with `LrcLine.time`), converted from the diff --git a/src/features/lyrics/utils/lrc.test.ts b/src/features/lyrics/utils/lrc.test.ts new file mode 100644 index 00000000..efd25020 --- /dev/null +++ b/src/features/lyrics/utils/lrc.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { parseEnhancedLrc, parseLrc } from '@/features/lyrics/utils/lrc'; + +describe('parseLrc', () => { + it('parses plain LRC with and without fractional seconds', () => { + expect(parseLrc('[00:15]first\n[01:02.50]second')).toEqual([ + { time: 15, text: 'first' }, + { time: 62.5, text: 'second' }, + ]); + }); + + it('sorts by time and skips unstamped lines', () => { + expect(parseLrc('[ti:Title]\n[00:20]later\n\n[00:10]earlier')).toEqual([ + { time: 10, text: 'earlier' }, + { time: 20, text: 'later' }, + ]); + }); + + it('strips inline Enhanced LRC word markers from the text', () => { + // Regression: the markers used to survive into the rendered line. + expect(parseLrc('[00:12.00]<00:12.00>Hello <00:12.90>world')).toEqual([ + { time: 12, text: 'Hello world' }, + ]); + }); +}); + +describe('parseEnhancedLrc', () => { + it('returns null word lines for plain LRC', () => { + const { lines, wordLines } = parseEnhancedLrc('[00:10]just a line'); + expect(lines).toEqual([{ time: 10, text: 'just a line' }]); + expect(wordLines).toBeNull(); + }); + + it('derives word timing from inline markers, ending at the next line', () => { + const { lines, wordLines } = parseEnhancedLrc( + '[00:12.00]<00:12.00>Hello <00:12.90>world\n[00:14.00]<00:14.00>bye', + ); + expect(lines).toEqual([ + { time: 12, text: 'Hello world' }, + { time: 14, text: 'bye' }, + ]); + expect(wordLines).toHaveLength(2); + + // Durations are seconds derived by subtraction, so compare with tolerance. + const [first, second] = wordLines!; + expect(first.text).toBe('Hello world'); + expect(first.time).toBe(12); + expect(first.duration).toBeCloseTo(2, 5); + expect(first.words.map(w => w.text)).toEqual(['Hello ', 'world']); + expect(first.words[0].time).toBe(12); + expect(first.words[0].duration).toBeCloseTo(0.9, 5); + expect(first.words[1].time).toBeCloseTo(12.9, 5); + expect(first.words[1].duration).toBeCloseTo(1.1, 5); + + expect(second).toEqual({ + time: 14, + duration: 0, + text: 'bye', + words: [{ text: 'bye', time: 14, duration: 0 }], + }); + }); + + it('folds text before the first marker into the first word', () => { + const { wordLines } = parseEnhancedLrc('[00:01.00]Oh <00:01.50>yeah\n[00:03.00]end'); + expect(wordLines?.[0].text).toBe('Oh yeah'); + expect(wordLines?.[0].words).toEqual([{ text: 'Oh yeah', time: 1.5, duration: 1.5 }]); + }); + + it('keeps a marker-free line as one full-line word so no line is dropped', () => { + const { wordLines } = parseEnhancedLrc('[00:00.00]<00:00.00>sung\n[00:02.00]instrumental\n[00:05.00]<00:05.00>again'); + expect(wordLines).toHaveLength(3); + expect(wordLines?.[1]).toEqual({ + time: 2, + duration: 3, + text: 'instrumental', + words: [{ text: 'instrumental', time: 2, duration: 3 }], + }); + }); +}); diff --git a/src/features/lyrics/utils/lrc.ts b/src/features/lyrics/utils/lrc.ts new file mode 100644 index 00000000..e4e2abcb --- /dev/null +++ b/src/features/lyrics/utils/lrc.ts @@ -0,0 +1,122 @@ +/** + * LRC parsing, shared by every provider that hands us an LRC string (embedded + * file tags, LRCLIB, Netease). + * + * Enhanced LRC ("ELRC") carries per-word timing as inline `` markers + * after the line timestamp: + * + * [00:12.00]<00:12.00>Hello <00:12.90>world + * + * The markers must never reach the rendered text, and where they exist they can + * drive the same word-by-word highlighting as the server's `songLyrics` v2 cues. + */ +import type { LrcLine, WordLyricsLine, WordLyricsWord } from '@/features/lyrics/types'; + +/** `[mm:ss]`, `[mm:ss.x]`, `[mm:ss.xx]`, `[mm:ss.xxx]` — the leading line stamp. */ +const LINE_STAMP = /^\[(\d+):(\d+(?:\.\d*)?)\](.*)/; +/** `` — an inline word stamp (Enhanced LRC). */ +const WORD_STAMP = /<(\d+):(\d+(?:\.\d*)?)>/g; + +function toSeconds(minutes: string, seconds: string): number { + return parseInt(minutes, 10) * 60 + parseFloat(seconds); +} + +interface ScannedLine { + time: number; + text: string; + /** Word stamps in source order; empty for a plain (non-enhanced) line. */ + cues: { time: number; text: string }[]; +} + +/** + * Split one LRC body line into its timestamp, its marker-free text, and any + * inline word cues. Text before the first word stamp has no timing of its own, + * so it is folded into the first cue. + */ +function scanLine(line: string): ScannedLine | null { + const stamp = line.match(LINE_STAMP); + if (!stamp) return null; + const time = toSeconds(stamp[1], stamp[2]); + const rest = stamp[3]; + + const cues: { time: number; text: string }[] = []; + let plain = ''; + let cursor = 0; + WORD_STAMP.lastIndex = 0; + for (let m = WORD_STAMP.exec(rest); m !== null; m = WORD_STAMP.exec(rest)) { + const between = rest.slice(cursor, m.index); + plain += between; + if (cues.length === 0) { + // Leading text before the first marker carries no timing of its own. + cues.push({ time: toSeconds(m[1], m[2]), text: between }); + } else { + cues[cues.length - 1].text += between; + cues.push({ time: toSeconds(m[1], m[2]), text: '' }); + } + cursor = m.index + m[0].length; + } + const tail = rest.slice(cursor); + plain += tail; + if (cues.length > 0) cues[cues.length - 1].text += tail; + + return { time, text: plain.trim(), cues }; +} + +function scan(lrc: string): ScannedLine[] { + const scanned: ScannedLine[] = []; + for (const line of lrc.split('\n')) { + const parsed = scanLine(line); + if (parsed) scanned.push(parsed); + } + return scanned.sort((a, b) => a.time - b.time); +} + +/** + * Timed lines with the text stripped of any inline word markers. + * Unstamped lines (metadata headers, blanks) are dropped. + */ +export function parseLrc(lrc: string): LrcLine[] { + return scan(lrc).map(({ time, text }) => ({ time, text })); +} + +export interface ParsedLrc { + lines: LrcLine[]; + /** Null unless at least one line carries inline word markers. */ + wordLines: WordLyricsLine[] | null; +} + +/** + * Parse an LRC string once into both line-level and (when present) word-level + * timing. A line without markers becomes a single full-line word, so enabling + * word mode never drops a line from the pane. + * + * The last word of a line and the line itself end at the next line's start — + * ELRC has no explicit end marker. + */ +export function parseEnhancedLrc(lrc: string): ParsedLrc { + const scanned = scan(lrc); + const lines: LrcLine[] = scanned.map(({ time, text }) => ({ time, text })); + if (!scanned.some(line => line.cues.length > 0)) return { lines, wordLines: null }; + + const wordLines = scanned.map((line, i) => { + const nextStart = scanned[i + 1]?.time; + const lineEnd = nextStart ?? line.time; + const cues = line.cues.length > 0 + ? line.cues + : [{ time: line.time, text: line.text }]; + + const words: WordLyricsWord[] = cues.map((cue, j) => { + const end = cues[j + 1]?.time ?? lineEnd; + return { text: cue.text, time: cue.time, duration: Math.max(0, end - cue.time) }; + }); + + return { + time: line.time, + duration: Math.max(0, lineEnd - line.time), + text: line.text, + words, + }; + }); + + return { lines, wordLines }; +} diff --git a/src/features/lyrics/utils/structuredLyrics.ts b/src/features/lyrics/utils/structuredLyrics.ts index d7c40248..ec0b2079 100644 --- a/src/features/lyrics/utils/structuredLyrics.ts +++ b/src/features/lyrics/utils/structuredLyrics.ts @@ -9,8 +9,7 @@ import type { SubsonicLyricCueLine, SubsonicStructuredLyrics, } from '@/lib/api/subsonicTypes'; -import type { LrcLine } from '@/features/lyrics/api/lrclib'; -import type { CachedLyrics, WordLyricsLine, WordLyricsWord } from '@/features/lyrics/types'; +import type { CachedLyrics, LrcLine, WordLyricsLine, WordLyricsWord } from '@/features/lyrics/types'; const MS_PER_SECOND = 1000;