\n`;
} else {
ttml += '
\n';
}
}
// For TTML, we can represent syllables as spans if word-synced
const begin = AmLyrics.formatTimestampTTML(line.timestamp);
const end = AmLyrics.formatTimestampTTML(line.endtime);
ttml += `
\n`;
for (const word of line.text) {
const wBegin = AmLyrics.formatTimestampTTML(word.timestamp);
const wEnd = AmLyrics.formatTimestampTTML(word.endtime);
// Escape special characters in text
const text = word.text
.replace(/&/g, '&')
.replace(//g, '>');
ttml += ` ${text}\n`;
}
ttml += '
\n';
}
if (this.lyrics.length > 0) {
ttml += '
\n';
}
ttml += ' \n';
ttml += '';
return ttml;
}
private static formatTimestampLRC(ms: number): string {
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.floor(totalSeconds % 60);
const hundredths = Math.floor((ms % 1000) / 10);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(minutes)}:${pad(seconds)}.${pad(hundredths)}`;
}
private static formatTimestampTTML(ms: number): string {
// TTML standard format: HH:MM:SS.mmm
const totalSeconds = ms / 1000;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = Math.floor(totalSeconds % 60);
const milliseconds = Math.floor(ms % 1000);
const pad = (n: number, width = 2) => n.toString().padStart(width, '0');
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}`;
}
private downloadLyrics() {
if (!this.lyrics || this.lyrics.length === 0) return;
// Determine format: TTML if ANY line is word-synced, else LRC
const isWordSynced = this.lyrics.some(l => l.isWordSynced !== false);
let content = '';
let extension: 'auto' | 'lrc' | 'ttml' | 'plain' | 'txt' =
this.downloadFormat;
if (extension === 'auto') {
extension = isWordSynced ? 'ttml' : 'lrc';
}
let mimeType = '';
if (extension === 'ttml') {
const source = this.availableSources[this.currentSourceIndex];
content =
(source?.source === this.lyricsSource && source.originalTTML) ||
this.generateTTML();
mimeType = 'application/xml';
} else if (extension === 'plain') {
content = this.generatePlain();
extension = 'txt';
mimeType = 'text/plain;charset=utf-8';
} else {
content = this.generateLRC();
mimeType = 'text/plain';
}
if (!content) return;
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const filename = this.songTitle
? `${this.songTitle}${this.songArtist ? ` - ${this.songArtist}` : ''}.${extension}`
: `lyrics.${extension}`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
render() {
if (this.fontFamily) {
this.style.fontFamily = this.fontFamily;
}
// Set both old internal CSS variables (for backward compatibility)
// and new public CSS variables (which take precedence)
this.style.setProperty('--highlight-color', this.highlightColor);
const sourceLabel = this.lyricsSource ?? 'Unavailable';
const isUnsynced = this.cachedIsUnsynced;
const hasLeftAlignedLines = this.lyrics?.some(
line => line.alignment !== 'end',
);
const hasRightAlignedLines = this.lyrics?.some(
line => line.alignment === 'end',
);
const hasDuetLines = hasLeftAlignedLines && hasRightAlignedLines;
const renderContent = () => {
if (this.isLoading) {
// Render stylized skeleton lines
return html`
`;
}
if (!this.lyrics || this.lyrics.length === 0) {
return html`
No lyrics found.
`;
}
// Build a lookup map of ALL gaps so they are always in the DOM
const allGaps = this.findAllInstrumentalGaps();
const gapByIndex = new Map(
allGaps.map(g => [g.insertBeforeIndex, g] as const),
);
return this.lyrics.map((line, lineIndex) => {
const lineId = `lyrics-line-${lineIndex}`;
// Calculate line timing
const lineStartTime = line.text[0]?.timestamp || 0;
const lineEndTime = line.text[line.text.length - 1]?.endtime || 0;
// Always render background vocals in the DOM so the syllable cache
// includes them and the wipe effect applies correctly.
const hasBackground =
line.backgroundText && line.backgroundText.length > 0;
const bgPlacement = hasBackground
? AmLyrics.getBackgroundTextPlacement(line)
: 'after';
const backingWords: Array<{
text: string;
start: number;
end: number;
}> = [];
const backing = line.backgroundText || [];
for (let first = 0; first < backing.length; ) {
let last = first;
while (
last + 1 < backing.length &&
!/\s$/.test(backing[last].text) &&
!/^\s/.test(backing[last + 1].text)
)
last += 1;
const word = {
text: backing
.slice(first, last + 1)
.map(syllable => syllable.text)
.join(''),
start: backing[first].timestamp,
end: backing[last].endtime,
};
for (let index = first; index <= last; index += 1)
backingWords[index] = word;
first = last + 1;
}
const lineIsRTL = this.cachedLineData?.[lineIndex]?.lineIsRTL ?? false;
// Create background vocals container (with romanization support)
const backgroundVocalElement = hasBackground
? html`
${line.backgroundText!.map((syllable, syllableIndex) => {
const startTimeMs = syllable.timestamp;
const endTimeMs = syllable.endtime;
const durationMs = endTimeMs - startTimeMs;
const bgRomanizedText =
this.showRomanization &&
syllable.romanizedText &&
syllable.romanizedText.trim() !== syllable.text.trim()
? html`${syllable.romanizedText}`
: '';
const backingWord = backingWords[syllableIndex];
const bgChars =
!syllable.lineSynced &&
AmLyrics.characterMotionMode(
backingWord.text,
backingWord.end - backingWord.start,
true,
) !== 'none'
? Array.from(syllable.text.replace(/\s/g, ''))
: [];
let bgCharIndex = 0;
return html`${bgChars.length
? Array.from(syllable.text).map(char => {
if (/\s/.test(char)) return char;
const index = bgCharIndex;
bgCharIndex += 1;
return html`${char}`;
})
: syllable.text}${bgRomanizedText}`;
})}
`
: '';
// Background vocals share the same line.translation and line.romanizedText
// as the main vocal, so we intentionally do NOT render a separate
// translation/romanization block for background — it would just duplicate
// the main line's text.
const lineData = this.cachedLineData?.[lineIndex];
const wordGroups = lineData?.wordGroups ?? [];
const groupGrowable = lineData?.groupGrowable ?? [];
const groupGlowing = lineData?.groupGlowing ?? [];
const groupCharRise = lineData?.groupCharRise ?? [];
const groupCharDrag = lineData?.groupCharDrag ?? [];
const vwFullDuration = lineData?.vwFullDuration ?? [];
const vwCharOffset = lineData?.vwCharOffset ?? [];
const vwStartMs = lineData?.vwStartMs ?? [];
const vwEndMs = lineData?.vwEndMs ?? [];
const mainVocalElement = html`
${wordGroups.map((group, groupIdx) => {
const isGrowable = groupGrowable[groupIdx];
const isGlowing = groupGlowing[groupIdx];
const isCharRise = groupCharRise[groupIdx];
const isCharDrag = groupCharDrag[groupIdx];
const isAnimatedByChar = isGrowable || isCharRise || isCharDrag;
const groupLineSynced =
line.isWordSynced === false || group.some(s => s.lineSynced);
const wordDuration = isAnimatedByChar
? vwFullDuration[groupIdx]
: 0;
const groupCharOffset = isAnimatedByChar
? vwCharOffset[groupIdx]
: 0;
const virtualWordId = `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}`;
const virtualWordStart = vwStartMs[groupIdx];
const virtualWordEnd = vwEndMs[groupIdx];
let sylCharAccumulator = 0;
const groupText = group.map(s => s.text).join('');
const visibleWordLength = groupText.replace(/\s/g, '').length;
const shouldAllowBreak =
groupText.trim().length >= 16 ||
/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(
groupText,
);
return html`${group.map((syllable, sylIdx) => {
const startTimeMs = syllable.timestamp;
const endTimeMs = syllable.endtime;
const durationMs = endTimeMs - startTimeMs;
const text = syllable.text || '';
const romanizedText =
this.showRomanization &&
syllable.romanizedText &&
syllable.romanizedText.trim() !== syllable.text.trim()
? html`${syllable.romanizedText}`
: '';
const animateSyllable = isAnimatedByChar && !groupLineSynced;
let syllableContent: any = text;
if (animateSyllable) {
const numCharsInSyllable =
Array.from(text.replace(/\s/g, '')).length || 1;
let charIndexInsideSyllable = 0;
syllableContent = html`${Array.from(text).map(char => {
if (char === ' ') return ' ';
const charIndexInsideWord =
groupCharOffset + sylCharAccumulator;
const localCharIndex = charIndexInsideSyllable;
sylCharAccumulator += 1;
charIndexInsideSyllable += 1;
return html`${char}`;
})}`;
}
return html`${syllableContent}${romanizedText}`;
})}`;
})}
`;
// Translation container (if enabled)
// Hide translation if it matches the original line text
const fullLineText = line.text
.map(s => s.text)
.join('')
.trim();
const translationElement =
this.showTranslation &&
line.translation &&
line.translation.trim() !== fullLineText
? html`
${line.translation}
`
: '';
// Line-synced romanization (fallback if no word-level romanization)
// Hide if the romanized text matches the original line text
const lineRomanizationElement =
this.showRomanization &&
line.romanizedText &&
!line.text.some(s => s.romanizedText) &&
line.romanizedText.trim() !== fullLineText
? html`
${line.romanizedText}
`
: '';
// Check for instrumental gap before this line
let maybeInstrumentalBlock: unknown = null;
const gapForLine = gapByIndex.get(lineIndex);
if (gapForLine) {
const gapDuration = gapForLine.gapEnd - gapForLine.gapStart;
// Calculate dot timing for fill-up animation (3 dots)
const dotDuration = gapDuration / 3;
// Gap starts without 'active' — _onTimeChanged toggles it imperatively
maybeInstrumentalBlock = html`
`;
}
return html`
${maybeInstrumentalBlock}
this.handleLineClick(line)}
tabindex="${isUnsynced ? -1 : 0}"
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleLineClick(line);
}
}}
>
${bgPlacement === 'before' ? backgroundVocalElement : ''}
${mainVocalElement}
${bgPlacement === 'after' ? backgroundVocalElement : ''}
${lineRomanizationElement} ${translationElement}
`;
});
};
return html`
${!this.isLoading && this.lyrics && this.lyrics.length > 0
? html`
`
: ''}
${renderContent()}
${!this.isLoading
? html`
`
: ''}
`;
}
}