Preserve and display bilingual and phonetic lyric cues
This commit is contained in:
12
docs/lyric-languages.md
Normal file
12
docs/lyric-languages.md
Normal file
@@ -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.
|
||||||
@@ -3298,7 +3298,7 @@ function fitLyricLines(list, { maxShare = 0.17, maxPx = 160, minPx = 18, uniform
|
|||||||
const fits = [];
|
const fits = [];
|
||||||
for (const el of rows) {
|
for (const el of rows) {
|
||||||
// Size a grouped cue by its widest visual line, preserving tight breaks.
|
// 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 = parts.reduce((widest, part) => {
|
||||||
widthAt.text = part; const width = widthAt(100);
|
widthAt.text = part; const width = widthAt(100);
|
||||||
return width > widest.width ? { text: part, width } : widest;
|
return width > widest.width ? { text: part, width } : widest;
|
||||||
@@ -3483,7 +3483,7 @@ const Notes = (() => {
|
|||||||
if (l.kind === 'cue') {
|
if (l.kind === 'cue') {
|
||||||
return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`;
|
return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`;
|
||||||
}
|
}
|
||||||
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${escapeHtml(l.text)}</span></div>`;
|
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${LyricsDisplay.html(l)}</span></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
if (st.sync) renderSyncPointer();
|
if (st.sync) renderSyncPointer();
|
||||||
else tick(now(), true);
|
else tick(now(), true);
|
||||||
@@ -4160,7 +4160,7 @@ const Notes = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = items.map((l) =>
|
list.innerHTML = items.map((l) =>
|
||||||
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
|
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
|
||||||
paintFlags();
|
paintFlags();
|
||||||
loadFlags();
|
loadFlags();
|
||||||
applyFits();
|
applyFits();
|
||||||
@@ -4271,7 +4271,7 @@ const Notes = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = items.map((l) =>
|
list.innerHTML = items.map((l) =>
|
||||||
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
|
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
|
||||||
fitLyricLines(list, { maxShare: 0.15, maxPx: 96 });
|
fitLyricLines(list, { maxShare: 0.15, maxPx: 96 });
|
||||||
tickStage(now(), true);
|
tickStage(now(), true);
|
||||||
clearTimeout(stg.refit);
|
clearTimeout(stg.refit);
|
||||||
@@ -6060,7 +6060,7 @@ const Presenter = (() => {
|
|||||||
const pad = Math.round(list.clientHeight / 2);
|
const pad = Math.round(list.clientHeight / 2);
|
||||||
list.style.paddingTop = pad + 'px';
|
list.style.paddingTop = pad + 'px';
|
||||||
list.style.paddingBottom = pad + 'px';
|
list.style.paddingBottom = pad + 'px';
|
||||||
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
|
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
|
||||||
refit();
|
refit();
|
||||||
st.idx = -2;
|
st.idx = -2;
|
||||||
tick(true);
|
tick(true);
|
||||||
@@ -12449,6 +12449,7 @@ async function boot() {
|
|||||||
PartyDJ.register(() => $('navPartyBtn').click());
|
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`); } });
|
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();
|
Remote.boot();
|
||||||
|
LyricsDisplay.register();
|
||||||
LowerThird.register(() => $('remoteChip').click());
|
LowerThird.register(() => $('remoteChip').click());
|
||||||
Transition.renderButton();
|
Transition.renderButton();
|
||||||
PiP.render();
|
PiP.render();
|
||||||
|
|||||||
@@ -642,6 +642,7 @@
|
|||||||
<script src="opfs.js"></script>
|
<script src="opfs.js"></script>
|
||||||
<script src="video-edit.js"></script>
|
<script src="video-edit.js"></script>
|
||||||
<script src="lyrics-core.js"></script>
|
<script src="lyrics-core.js"></script>
|
||||||
|
<script src="lyrics-display.js"></script>
|
||||||
<script src="flag-ui.js"></script>
|
<script src="flag-ui.js"></script>
|
||||||
<script src="export.js"></script>
|
<script src="export.js"></script>
|
||||||
<script src="stats-core.js"></script>
|
<script src="stats-core.js"></script>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
if (!box) { box = root.document.createElement('div'); box.id = 'obsLowerThird'; box.setAttribute('aria-live', 'polite'); root.document.body.append(box); }
|
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;
|
const pair = cuePair(lines, index), key = JSON.stringify(pair); if (key === signature) return; signature = key;
|
||||||
box.replaceChildren();
|
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); } }); }
|
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'); }
|
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'); }
|
||||||
|
|||||||
@@ -56,6 +56,11 @@
|
|||||||
return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean);
|
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 }.
|
// Editor text → { lines, tags, offset }.
|
||||||
function fromText(text) {
|
function fromText(text) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
@@ -65,8 +70,14 @@
|
|||||||
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
|
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
|
||||||
let rest = rawLine.trim();
|
let rest = rawLine.trim();
|
||||||
if (!rest) continue;
|
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) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
group = [];
|
group = [];
|
||||||
@@ -92,6 +103,7 @@
|
|||||||
if (!rest) continue;
|
if (!rest) continue;
|
||||||
group = stamps.length ? stamps.map(t => ({ t: Math.round(t * 100) / 100, text: rest, kind }))
|
group = stamps.length ? stamps.map(t => ({ t: Math.round(t * 100) / 100, text: rest, kind }))
|
||||||
: [{ t: null, 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);
|
lines.push(...group);
|
||||||
}
|
}
|
||||||
// A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in
|
// A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in
|
||||||
@@ -108,8 +120,11 @@
|
|||||||
for (const l of (doc && doc.lines) || []) {
|
for (const l of (doc && doc.lines) || []) {
|
||||||
const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `;
|
const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `;
|
||||||
const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '';
|
const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '';
|
||||||
const parts = String(l.text || '').split('\n');
|
const parts = (l.kind === 'line' ? display(l) : { text: l.text || '', secondary: '' });
|
||||||
out.push(stamp + mark + parts[0], ...parts.slice(1).map(part => '| ' + part));
|
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');
|
return out.join('\n');
|
||||||
}
|
}
|
||||||
@@ -162,7 +177,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LyricsCore = {
|
const LyricsCore = {
|
||||||
parseStamp, fmtStamp, fmtClock, fromText, toText,
|
parseStamp, fmtStamp, fmtClock, fromText, toText, display,
|
||||||
activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps,
|
activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.length, 2);
|
||||||
assert.strictEqual(repeated.lines[1].text, doc.lines[0].text);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
8
frontend/lyrics-display.js
Normal file
8
frontend/lyrics-display.js
Normal file
@@ -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 `<span class="lyric-primary">${escape(parts.text)}</span>${parts.secondary ? `<small class="lyric-secondary">${escape(parts.secondary)}</small>` : ''}${parts.phonetic ? `<small class="lyric-secondary lyric-phonetic">${escape(parts.phonetic)}</small>` : ''}`; }
|
||||||
|
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);
|
||||||
2
frontend/lyrics-display.test.js
Normal file
2
frontend/lyrics-display.test.js
Normal file
@@ -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,/<主>/); });
|
||||||
@@ -4353,3 +4353,6 @@ html.ios-nozoom, html.ios-nozoom body { touch-action: manipulation; }
|
|||||||
.cache-fold > .cache-list { margin: 0; padding: 4px 10px 10px; }
|
.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)); } }
|
@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; }
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const SHELL = [
|
|||||||
'/opfs-worker.js',
|
'/opfs-worker.js',
|
||||||
'/video-edit.js',
|
'/video-edit.js',
|
||||||
'/lyrics-core.js',
|
'/lyrics-core.js',
|
||||||
|
'/lyrics-display.js',
|
||||||
'/stats-core.js',
|
'/stats-core.js',
|
||||||
'/sha256.js',
|
'/sha256.js',
|
||||||
'/resume-core.js',
|
'/resume-core.js',
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export function sanitizeLyrics(input) {
|
|||||||
const text = cleanLyricText(l.text);
|
const text = cleanLyricText(l.text);
|
||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
const kind = l.kind === 'section' || l.kind === 'cue' ? l.kind : 'line';
|
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 : [])
|
const tags = (Array.isArray(input.tags) ? input.tags : [])
|
||||||
.map((t) => cleanText(t, 40)).filter(Boolean).slice(0, MAX_TAGS);
|
.map((t) => cleanText(t, 40)).filter(Boolean).slice(0, MAX_TAGS);
|
||||||
|
|||||||
@@ -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();
|
const flags = await (await app.request(`/api/notes/${id}/flags`)).json();
|
||||||
expect(flags.flags[0].text).toBe(lines[0].text);
|
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'});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.evaluate(() => data.settings.eq.g[7])).toBe(4);
|
||||||
expect(await page.locator('.eq-panel').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user