Lyrics Sync Deep Dive
This page covers how LyricsPlugin works internally, covering the cue parser registry, CueTracker lifecycle, parser selection, and extending the system with custom formats.
How LyricsPlugin works
LyricsPlugin.use()runs once at registration and installs acurrentlistener; that listener fires on everycurrentevent- The plugin calls
resolveLyricsUrl(item), which returnsitem.lyricsUrlor the customgetLyricsUrl(item)result - The URL is passed to
resolveParser(url), which callsplayer.resolveCueParser(url) - The core walks the registered
ICueParser[]in registration order. The first parser whosecanParse(url)returnstruewins. - The plugin fetches the URL via
this.fetch(url)(auth-aware, retried) - The raw text is passed to
parser.parse(raw)→ returns aCueList - A new
CueTrackeris attached to the player, which listens to thetimeevent and firesenter/exiton the tracker
ICueParser interface
interface ICueParser {
readonly id: string;
canParse(url: string, contentType?: string): boolean;
parse(raw: string, opts?: { baseUrl?: string }): CueList<any>;
}
canParse(url) inspects the URL (typically the file extension) and returns true if this parser can handle it.
parse(raw) receives the raw text content and returns a CueList.
Each Cue<T> in the list has start and end fields in seconds (matching the player’s time event units).
Built-in parsers
| Parser id | Extension | Format |
|---|---|---|
lrc | .lrc | LRC timestamp format: [mm:ss.cc] text |
vtt | .vtt | WebVTT: HH:MM:SS.mmm --> HH:MM:SS.mmm |
Both are pre-registered in the player core.
Registering a custom parser
import type { ICueParser, CueList, Cue } from '@nomercy-entertainment/nomercy-player-core';
import { createCueList } from '@nomercy-entertainment/nomercy-player-core';
interface LrcLine {
text: string;
}
// Simple parser for a JSON format: [{ "t": 12.0, "text": "First line" }]
// t is in seconds (matching the player's time event).
const jsonLrcParser: ICueParser = {
id: 'json-lrc',
canParse(url: string): boolean {
return url.endsWith('.jlrc');
},
parse(raw: string): CueList<LrcLine> {
const data: Array<{ t: number; text: string }> = JSON.parse(raw);
const cues: Cue<LrcLine>[] = data.map((entry, index) => ({
start: entry.t,
end: data[index + 1]?.t ?? Infinity,
payload: { text: entry.text },
}));
return createCueList(cues);
},
};
// Register before setup:
player.registerCueParser(jsonLrcParser);
// Register at the front of the chain (checked first):
player.registerCueParser(jsonLrcParser, /* prepend */ true);
LRC format reference
Standard LRC:
[00:12.00] First line of lyrics
[00:17.20] Second line
[00:22.40] Third line
Enhanced LRC (word-level timing, not all players support this):
[00:12.00] <00:12.00> First <00:12.50> line <00:13.00> of lyrics
The built-in LRC parser supports standard LRC.
Enhanced LRC (word-level <> tags) requires a custom parser if you want word-level highlighting.
Word-level highlighting
For word-level karaoke effects, parse enhanced LRC into sub-cues:
const enhancedLrcParser: ICueParser = {
id: 'enhanced-lrc',
canParse: (url) => url.endsWith('.elrc'),
parse(raw): CueList<{ text: string; words: Array<{ t: number; text: string }> }> {
// Parse enhanced LRC format and return word-level cues
// Implementation left as an exercise, enhanced LRC is non-standard
return createCueList([]);
},
};
CueTracker lifecycle
LyricsPlugin.attach(cueList):
- Calls
this.clear(), which disposes any existing tracker - Creates a new
CueTracker<LyricPayload>(list) - Binds
tracker.on('enter', cue => ...)andtracker.on('exit', cue => ...) - Calls
tracker.attach(this.player), so the tracker subscribes to the player’stimeevent
When the player emits a time event, the tracker scans the cue list and fires enter / exit events for any cues whose [start, end) window crosses the current time.
LyricsPlugin.clear() calls tracker.dispose() which unsubscribes the time listener, so there is no leak.
Handling missing lyrics
LyricsPlugin reports (not throws) when no parser is found or the fetch fails:
// Listen to player errors with a lyrics-specific code:
player.on('warning', (event) => {
if (event.error.code === 'plugin:lyrics/fetch-failed' || event.error.code === 'plugin:lyrics/no-parser') {
console.warn('Lyrics failed:', event.error.message);
showNoLyricsMessage();
}
});
When autoFetch: false, you control when lyrics load and can handle errors from fetchLyrics():
const cueList = await lyricsPlugin.fetchLyrics(url);
if (!cueList) {
showNoLyricsMessage();
}
See also
- LyricsPlugin: options, methods, events
- ILyricSource: resolver adapter
- Recipes: Lyrics and Equalizer: practical implementation guide