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); +});