diff --git a/docs/lyric-languages.md b/docs/lyric-languages.md
new file mode 100644
index 0000000..e601d97
--- /dev/null
+++ b/docs/lyric-languages.md
@@ -0,0 +1,12 @@
+# Translations and pronunciation
+
+A cue can contain a primary lyric and translation separated by ` / `:
+
+```
+[1:23.45] 主你的恩典 / Your grace is enough
+!pinyin: zhǔ nǐ de ēndiǎn
+```
+
+The translation and phonetic text share the primary cue timestamp. They are smaller and muted beneath it in stage lyrics, Service mode, Presenter and OBS. The notes API preserves optional `secondary` and `phonetic` fields. Existing documents with only text, timing and kind remain unchanged.
+
+Use `| ` continuation lines (or Shift+Enter in the editor) for tight breaks within a cue. A continuation can also carry `Primary / Translation`. Multiple timestamps copy translations and pronunciation to every occurrence. A slash without surrounding spaces, such as `and/or`, remains literal. Ordinary `! Band cue` lines retain their existing meaning.
diff --git a/frontend/app.js b/frontend/app.js
index acb1212..d85f6bd 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -3298,7 +3298,7 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
const fits = [];
for (const el of rows) {
// Size a grouped cue by its widest visual line, preserving tight breaks.
- const parts = el.textContent.split('\n');
+ const parts = (el.querySelector('.lyric-primary') || el).textContent.split('\n');
widthAt.text = parts.reduce((widest, part) => {
widthAt.text = part; const width = widthAt(100);
return width > widest.width ? { text: part, width } : widest;
@@ -3483,7 +3483,7 @@ const Notes = (() => {
if (l.kind === 'cue') {
return `
🥁 ${escapeHtml(l.text)}
`;
}
- return `${time}${escapeHtml(l.text)}
`;
+ return `${time}${LyricsDisplay.html(l)}
`;
}).join('');
if (st.sync) renderSyncPointer();
else tick(now(), true);
@@ -4160,7 +4160,7 @@ const Notes = (() => {
return;
}
list.innerHTML = items.map((l) =>
- `${escapeHtml(l.text)}
`).join('');
+ `${LyricsDisplay.html(l)}
`).join('');
paintFlags();
loadFlags();
applyFits();
@@ -4271,7 +4271,7 @@ const Notes = (() => {
return;
}
list.innerHTML = items.map((l) =>
- `${escapeHtml(l.text)}
`).join('');
+ `${LyricsDisplay.html(l)}
`).join('');
fitLyricLines(list, { maxShare: 0.15, maxPx: 96 });
tickStage(now(), true);
clearTimeout(stg.refit);
@@ -6060,7 +6060,7 @@ const Presenter = (() => {
const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px';
- list.innerHTML = sung.map((l) => `${escapeHtml(l.text)}
`).join('');
+ list.innerHTML = sung.map((l) => `${LyricsDisplay.html(l)}
`).join('');
refit();
st.idx = -2;
tick(true);
@@ -12449,6 +12449,7 @@ async function boot() {
PartyDJ.register(() => $('navPartyBtn').click());
SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } });
Remote.boot();
+ LyricsDisplay.register();
LowerThird.register(() => $('remoteChip').click());
Transition.renderButton();
PiP.render();
diff --git a/frontend/index.html b/frontend/index.html
index 110cb0f..cb68ce5 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -642,6 +642,7 @@
+
diff --git a/frontend/lower-third.js b/frontend/lower-third.js
index 0134c4e..8dfe1db 100644
--- a/frontend/lower-third.js
+++ b/frontend/lower-third.js
@@ -9,7 +9,7 @@
if (!box) { box = root.document.createElement('div'); box.id = 'obsLowerThird'; box.setAttribute('aria-live', 'polite'); root.document.body.append(box); }
const pair = cuePair(lines, index), key = JSON.stringify(pair); if (key === signature) return; signature = key;
box.replaceChildren();
- for (const [role, cue] of Object.entries(pair)) { const p = root.document.createElement('div'); p.className = `obs-line obs-${role}`; p.textContent = cue?.text || ''; if (cue?.secondary || cue?.phonetic) { const secondary = root.document.createElement('small'); secondary.textContent = [cue.secondary, cue.phonetic].filter(Boolean).join(' · '); p.append(secondary); } box.append(p); }
+ for (const [role, cue] of Object.entries(pair)) { const p = root.document.createElement('div'); p.className = `obs-line obs-${role}`; const parts = root.LyricsCore.display(cue); p.textContent = parts.text; if (parts.secondary || parts.phonetic) { const secondary = root.document.createElement('small'); secondary.textContent = [parts.secondary, parts.phonetic].filter(Boolean).join(' · '); p.append(secondary); } box.append(p); }
}
function register(open) { root.SettingsSections.register({ id: 'broadcast', title: 'Presenter & broadcast', cluster: 'Live & worship', summary: () => 'Projector · OBS lower third', render(container) { const doc = container.ownerDocument, p = doc.createElement('p'); p.textContent = 'Start a remote-controlled screen, then copy its OBS browser-source URL. Set the browser source to 1920 × 1080. The output follows the host and remains transparent. Pairing codes expire; an already paired source reconnects until you unpair it.'; const b = doc.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open presenter & remote controls'; b.onclick = open; container.append(p, b); } }); }
const api = { active, paint, cuePair, url, register }; if (typeof module !== 'undefined') module.exports = api; else { root.LowerThird = api; if (active()) root.document.documentElement.classList.add('obs-overlay'); }
diff --git a/frontend/lyrics-core.js b/frontend/lyrics-core.js
index b0acac5..dcd667f 100644
--- a/frontend/lyrics-core.js
+++ b/frontend/lyrics-core.js
@@ -56,6 +56,11 @@
return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean);
}
+ function display(line) {
+ const text = String(line?.text || ''), divider = text.indexOf(' / ');
+ return { text: divider < 0 ? text : text.slice(0, divider).trim(), secondary: line?.secondary || (divider < 0 ? '' : text.slice(divider + 3).trim()), phonetic: line?.phonetic || '' };
+ }
+
// Editor text → { lines, tags, offset }.
function fromText(text) {
const lines = [];
@@ -65,8 +70,14 @@
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = rawLine.trim();
if (!rest) continue;
+ if (/^!pinyin:\s*/i.test(rest) && group.some(l => l.kind === 'line')) {
+ const value = rest.replace(/^!pinyin:\s*/i, '').trim();
+ for (const line of group) if (value) line.phonetic = line.phonetic ? line.phonetic + '\n' + value : value;
+ continue;
+ }
if (rest.startsWith('| ') && group.length) {
- for (const line of group) line.text += '\n' + rest.slice(2).trim();
+ const extra = display({ text: rest.slice(2).trim() });
+ for (const line of group) { line.text += '\n' + extra.text; if (extra.secondary) line.secondary = line.secondary ? line.secondary + '\n' + extra.secondary : extra.secondary; }
continue;
}
group = [];
@@ -92,6 +103,7 @@
if (!rest) continue;
group = stamps.length ? stamps.map(t => ({ t: Math.round(t * 100) / 100, text: rest, kind }))
: [{ t: null, text: rest, kind }];
+ if (kind === 'line') for (const line of group) { const parts = display(line); line.text = parts.text; if (parts.secondary) line.secondary = parts.secondary; }
lines.push(...group);
}
// A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in
@@ -108,8 +120,11 @@
for (const l of (doc && doc.lines) || []) {
const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `;
const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '';
- const parts = String(l.text || '').split('\n');
- out.push(stamp + mark + parts[0], ...parts.slice(1).map(part => '| ' + part));
+ const parts = (l.kind === 'line' ? display(l) : { text: l.text || '', secondary: '' });
+ const primary = parts.text.split('\n'), secondary = parts.secondary.split('\n');
+ const combined = primary.map((part, i) => part + (secondary[i] ? ' / ' + secondary[i] : ''));
+ out.push(stamp + mark + combined[0], ...combined.slice(1).map(part => '| ' + part));
+ if (l.phonetic) out.push(...String(l.phonetic).split('\n').map(part => '!pinyin: ' + part));
}
return out.join('\n');
}
@@ -162,7 +177,7 @@
}
const LyricsCore = {
- parseStamp, fmtStamp, fmtClock, fromText, toText,
+ parseStamp, fmtStamp, fmtClock, fromText, toText, display,
activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps,
};
diff --git a/frontend/lyrics-core.test.js b/frontend/lyrics-core.test.js
index d93180e..661d1a1 100644
--- a/frontend/lyrics-core.test.js
+++ b/frontend/lyrics-core.test.js
@@ -91,3 +91,13 @@ test('multiline cues round-trip without adding timestamps or active rows', () =>
assert.strictEqual(repeated.lines.length, 2);
assert.strictEqual(repeated.lines[1].text, doc.lines[0].text);
});
+test('bilingual lyrics and pinyin share one timed cue and round-trip', () => {
+ const doc = LC.fromText('[1:23.45] 主你的恩典 / Your grace is enough\n!pinyin: zhǔ nǐ de ēndiǎn');
+ assert.equal(doc.lines.length, 1); assert.equal(doc.lines[0].t,83.45); assert.equal(doc.lines[0].secondary,'Your grace is enough'); assert.equal(doc.lines[0].phonetic,'zhǔ nǐ de ēndiǎn');
+ assert.deepEqual(LC.fromText(LC.toText(doc)).lines, doc.lines);
+});
+test('translations preserve grouped breaks, multi-stamps and legacy slash text', () => {
+ const doc = LC.fromText('[0:01][0:10] 主 / Lord\n| 恩典 / Grace\n!pinyin: zhǔ\n!pinyin: ēndiǎn');
+ assert.equal(doc.lines.length,2); assert.equal(doc.lines[1].secondary,'Lord\nGrace'); assert.equal(doc.lines[1].phonetic,'zhǔ\nēndiǎn'); assert.deepEqual(LC.fromText(LC.toText(doc)).lines, doc.lines);
+ assert.equal(LC.fromText('and/or').lines[0].text,'and/or'); assert.equal(LC.fromText('! Band cue').lines[0].kind,'cue');
+});
diff --git a/frontend/lyrics-display.js b/frontend/lyrics-display.js
new file mode 100644
index 0000000..ddfd01c
--- /dev/null
+++ b/frontend/lyrics-display.js
@@ -0,0 +1,8 @@
+(function(root) {
+ 'use strict';
+ const core = typeof module !== 'undefined' ? require('./lyrics-core') : root.LyricsCore;
+ const escape = value => String(value || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[c]);
+ function html(line) { const parts = core.display(line); return `${escape(parts.text)}${parts.secondary ? `${escape(parts.secondary)}` : ''}${parts.phonetic ? `${escape(parts.phonetic)}` : ''}`; }
+ function register() { root.SettingsSections.register({ id: 'lyrics-language', title: 'Lyric languages', cluster: 'Live & worship', summary: () => 'Translations · phonetic lines', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = 'In the lyrics editor, write “Primary lyric / Translation” in one timed cue. Add “!pinyin: pronunciation” immediately below it. Both secondary lines follow the primary timestamp and appear smaller in Service, stage lyrics, Presenter and OBS output. Shift+Enter still creates a tight line break within one cue.'; container.append(p); } }); }
+ const api = { html, register }; if (typeof module !== 'undefined') module.exports = api; else root.LyricsDisplay = api;
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/frontend/lyrics-display.test.js b/frontend/lyrics-display.test.js
new file mode 100644
index 0000000..2b86fdf
--- /dev/null
+++ b/frontend/lyrics-display.test.js
@@ -0,0 +1,2 @@
+const { test } = require('node:test'), assert = require('node:assert/strict'); const { html } = require('./lyrics-display');
+test('primary and muted secondary lyric markup escape all content', () => { const result = html({ text:'<主>', secondary:'Grace & mercy', phonetic:'"zhǔ"' }); assert.match(result,/lyric-primary"><主>/); assert.match(result,/lyric-secondary">Grace & mercy/); assert.match(result,/lyric-phonetic">"zhǔ"/); assert.doesNotMatch(result,/<主>/); });
diff --git a/frontend/styles.css b/frontend/styles.css
index b350358..fe1407b 100755
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -4353,3 +4353,6 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
.cache-fold > .cache-list { margin: 0; padding: 4px 10px 10px; }
@media (min-width: 900px) { .modal:has(.eq-panel) { width: min(92vw, 780px); max-width: 780px; } .eq-bands { grid-template-columns: repeat(10, minmax(44px, 1fr)); } }
+
+.lyric-primary { white-space: pre-line; }
+.lyric-secondary { display: block; white-space: pre-line; font-size: .62em; font-weight: 450; line-height: 1.2; opacity: .68; margin-top: .15em; }
diff --git a/frontend/sw.js b/frontend/sw.js
index 098991a..8b7249b 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -70,6 +70,7 @@ const SHELL = [
'/opfs-worker.js',
'/video-edit.js',
'/lyrics-core.js',
+ '/lyrics-display.js',
'/stats-core.js',
'/sha256.js',
'/resume-core.js',
diff --git a/server/notes.js b/server/notes.js
index 63eb0ea..7d3bf69 100644
--- a/server/notes.js
+++ b/server/notes.js
@@ -87,7 +87,9 @@ export function sanitizeLyrics(input) {
const text = cleanLyricText(l.text);
if (!text) continue;
const kind = l.kind === 'section' || l.kind === 'cue' ? l.kind : 'line';
- lines.push({ t: cleanTime(l.t), text, kind });
+ const line = { t: cleanTime(l.t), text, kind };
+ if (kind === 'line') { const secondary = cleanLyricText(l.secondary), phonetic = cleanLyricText(l.phonetic); if (secondary) line.secondary = secondary; if (phonetic) line.phonetic = phonetic; }
+ lines.push(line);
}
const tags = (Array.isArray(input.tags) ? input.tags : [])
.map((t) => cleanText(t, 40)).filter(Boolean).slice(0, MAX_TAGS);
diff --git a/server/notes.test.js b/server/notes.test.js
index 4548dac..ea198a3 100644
--- a/server/notes.test.js
+++ b/server/notes.test.js
@@ -267,3 +267,7 @@ test('saving a grouped cue preserves one timestamp in current and revision docum
const flags = await (await app.request(`/api/notes/${id}/flags`)).json();
expect(flags.flags[0].text).toBe(lines[0].text);
});
+test('secondary and phonetic lyric fields survive validation without changing old documents', () => {
+ const result = N.sanitizeLyrics({ lines:[{t:1,text:'主',kind:'line',secondary:' Your grace\n is enough ',phonetic:' zhǔ\u0007 '},{t:2,text:'Legacy',kind:'line'}] });
+ expect(result.lines[0].secondary).toBe('Your grace\nis enough'); expect(result.lines[0].phonetic).toBe('zhǔ'); expect(result.lines[1]).toEqual({t:2,text:'Legacy',kind:'line'});
+});
diff --git a/tests/settings-sections.spec.js b/tests/settings-sections.spec.js
index bb74c1b..ccff3e4 100644
--- a/tests/settings-sections.spec.js
+++ b/tests/settings-sections.spec.js
@@ -65,3 +65,11 @@ for (const layout of ['classic', 'glass-stage']) for (const width of [390, 1440]
expect(await page.evaluate(() => data.settings.eq.g[7])).toBe(4);
expect(await page.locator('.eq-panel').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
});
+for (const layout of ['classic', 'glass-stage']) test(`${layout} secondary lyrics in stage and Service mode`, async ({ page }) => {
+ await openClassic(page); await page.route('**/api/notes/0gfX0dFLaBc', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok:true, lyrics:{rev:1,data:{offset:0,lines:[{t:0,text:'主你的恩典',secondary:'Your grace is enough',phonetic:'zhǔ nǐ de ēndiǎn',kind:'line'}]}}, chapters:null }) }));
+ await page.evaluate(async layout => { data.settings.layout = layout; applyAppearance(); current = {meta:{id:'0gfX0dFLaBc',title:'Fixture'}}; await Notes.load(current.meta); Notes.stageLyrics.set(true); }, layout);
+ await expect(page.locator('#stageLyricsList .lyric-secondary').first()).toHaveText('Your grace is enough');
+ await page.evaluate(() => { $('serviceMode').classList.remove('hidden'); data.settings.serviceLyrics = true; Notes.serviceRefresh(); });
+ await expect(page.locator('#serviceLyricsList .lyric-phonetic')).toHaveText('zhǔ nǐ de ēndiǎn');
+ expect(await page.locator('#serviceLyricsList .lyric-secondary').first().evaluate(el => parseFloat(getComputedStyle(el).fontSize) < parseFloat(getComputedStyle(el.parentElement).fontSize))).toBe(true);
+});