From 7d84fdb3dc392ff2059640d229d04679dbf3a0d3 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 14:58:20 +0800 Subject: [PATCH] Add transparent OBS lyrics output through presenter pairing --- frontend/app.js | 16 ++++++++++++---- frontend/index.html | 2 ++ frontend/lower-third.css | 10 ++++++++++ frontend/lower-third.js | 16 ++++++++++++++++ frontend/lower-third.test.js | 3 +++ frontend/sw.js | 2 ++ tests/settings-sections.spec.js | 8 ++++++++ 7 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 frontend/lower-third.css create mode 100644 frontend/lower-third.js create mode 100644 frontend/lower-third.test.js diff --git a/frontend/app.js b/frontend/app.js index e45e50a..2d16bac 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -4892,6 +4892,7 @@ const Remote = (() => {
Lyrics-only display that follows this screen. On the projector's computer or TV browser open ${escapeHtml(location.origin)}/?present=${host.code} (same code), or scan the QR.
+
Connected phones & screens
@@ -4927,6 +4928,7 @@ const Remote = (() => { else if (act.dataset.act === 'stop') stopHosting(); else if (act.dataset.act === 'code') hostSend({ type: 'new-code' }); else if (act.dataset.act === 'unpair') unpairAll(); + else if (act.dataset.act === 'obs') { navigator.clipboard.writeText(LowerThird.url(location.origin, host.code)).then(() => toast('OBS URL copied'), () => toast('Copy blocked')); } else if (act.dataset.act === 'present') { // A second window on this computer (drag it to the projector display). window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720'); @@ -5944,7 +5946,8 @@ const Presenter = (() => { const noop = { boot() {}, active: () => false }; const view = $('presenterView'); if (!WEB || !view || typeof WebSocket === 'undefined') return noop; - const KEY = 'ytpPresenterPair'; + const overlay = LowerThird.active(); + const KEY = overlay ? `ytpOverlayPair:${new URLSearchParams(location.search).get('code') || ''}` : 'ytpPresenterPair'; const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } }; const save = (v) => { try { if (v) localStorage.setItem(KEY, JSON.stringify(v)); else localStorage.removeItem(KEY); } catch { /* blocked */ } }; const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/remote?${new URLSearchParams(q)}`; @@ -6001,7 +6004,7 @@ const Presenter = (() => { let m; try { m = JSON.parse(e.data); } catch { return; } if (m.type === 'hello') { st.hostName = m.hostName || 'screen'; if (m.state) onState(m.state); else setStatus(`Following ${st.hostName}`); } else if (m.type === 'state') onState(m.state); - else if (m.type === 'host-offline') setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); + else if (m.type === 'host-offline') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); } }; ws.onclose = (e) => { if (st.ws !== ws) return; @@ -6044,6 +6047,7 @@ const Presenter = (() => { } function render(v) { + if (overlay) { LowerThird.paint([], -1); st.idx = -2; tick(true); return; } const sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line'); const title = $('pvTitle'); if (!sung.length) { @@ -6075,6 +6079,7 @@ const Presenter = (() => { function tick(force = false) { if (view.classList.contains('hidden') || !st.all.length || !st.state) return; + if (overlay) { LowerThird.paint(st.all, LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0))); return; } const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0)); if (idx === st.idx && !force) return; st.idx = idx; @@ -6108,7 +6113,7 @@ const Presenter = (() => { function boot() { let param = null; - try { param = new URLSearchParams(location.search).get('present'); } catch { /* no URL */ } + try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ } if (param === null) return; try { const u = new URL(location.href); @@ -6117,7 +6122,8 @@ const Presenter = (() => { } catch { /* keep */ } show(); const code = String(param).replace(/\D/g, ''); - if (code.length === 6) pair(code); + if (overlay && load()) connect(); + else if (code.length === 6) pair(code); else if (load()) connect(); else showPairForm(); setInterval(() => tick(), 200); @@ -12436,6 +12442,7 @@ async function boot() { syncOnLaunch(); startPlaylistInboxWatch(); Presenter.boot(); + if (LowerThird.active()) return; SectionRail.boot(); Share.bootFromUrl(); bootViewFromUrl(); @@ -12444,6 +12451,7 @@ async function boot() { Party.boot(); PartyDJ.register(() => $('navPartyBtn').click()); Remote.boot(); + LowerThird.register(() => $('remoteChip').click()); Transition.renderButton(); PiP.render(); LyricsWindow.configure({ prepare: () => { if (!isServiceModeOpen()) Notes.stageLyrics.set(true); }, view: () => isServiceModeOpen() && data.settings.serviceLyrics ? $('serviceLyrics') : $('stageLyrics'), line: () => $('serviceLyricsList')?.querySelector('.active')?.textContent || $('stageLyricsList')?.querySelector('.active')?.textContent || current?.meta?.title || 'Nothing playing', toggle: () => { Player.toggle(); updatePlayBtn(); }, next: playNext, refresh: () => { Notes.stageLyrics.render(true); Notes.serviceRefresh(); }, toast }); diff --git a/frontend/index.html b/frontend/index.html index 819d93c..8a19a29 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -28,6 +28,7 @@ +
@@ -658,6 +659,7 @@ + diff --git a/frontend/lower-third.css b/frontend/lower-third.css new file mode 100644 index 0000000..78f847e --- /dev/null +++ b/frontend/lower-third.css @@ -0,0 +1,10 @@ +html.obs-overlay, .obs-overlay body, .obs-overlay #presenterView { background: transparent !important; } +.obs-overlay body > *:not(#presenterView):not(#obsLowerThird) { display: none !important; } +.obs-overlay #presenterView { pointer-events: none; } +.obs-overlay #presenterView > * { visibility: hidden; } +#obsLowerThird { position: fixed; inset: auto 6vw 7vh; z-index: 10000; text-align: center; color: white; font-family: system-ui, sans-serif; font-weight: 650; font-size: clamp(24px, 3.4vw, 70px); line-height: 1.15; text-shadow: 0 2px 4px #000, 0 0 14px #000, 0 0 3px #000; pointer-events: none; } +.obs-line { white-space: pre-line; animation: obs-cue-in 220ms ease-out; } +.obs-next { font-size: .7em; opacity: .8; margin-top: .3em; } +.obs-line small { display: block; font-size: .65em; opacity: .8; margin-top: .15em; } +@keyframes obs-cue-in { from { opacity: 0; transform: translateY(12px); } to { transform: translateY(0); } } +@media (prefers-reduced-motion: reduce) { .obs-line { animation: none; } } diff --git a/frontend/lower-third.js b/frontend/lower-third.js new file mode 100644 index 0000000..0134c4e --- /dev/null +++ b/frontend/lower-third.js @@ -0,0 +1,16 @@ +/* Broadcast lyrics rendered through the existing presenter relay. */ +(function(root) { + 'use strict'; + function cuePair(lines, index) { const current = lines[index]?.kind === 'line' ? lines[index] : null; const next = lines.slice(Math.max(0, index + 1)).find(l => l.kind === 'line') || null; return { current, next }; } + function url(origin, code) { const u = new URL('/', origin); u.searchParams.set('overlay', 'lowerthird'); u.searchParams.set('code', String(code)); return u.href; } + const active = () => new URLSearchParams(root.location?.search).get('overlay') === 'lowerthird'; + let box, signature = ''; + function paint(lines, index) { + 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); } + } + 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'); } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/lower-third.test.js b/frontend/lower-third.test.js new file mode 100644 index 0000000..847c60c --- /dev/null +++ b/frontend/lower-third.test.js @@ -0,0 +1,3 @@ +const { test } = require('node:test'), assert = require('node:assert/strict'); const { cuePair, url } = require('./lower-third'); +test('lower third skips section headings for next lyrics and does not invent current lyrics', () => { const lines = [{ kind: 'line', text: 'First' }, { kind: 'section', text: 'Chorus' }, { kind: 'line', text: 'Second' }]; assert.equal(cuePair(lines, 0).next.text, 'Second'); assert.equal(cuePair(lines, 1).current, null); assert.equal(cuePair(lines, -1).next.text, 'First'); assert.equal(cuePair(lines, 2).next, null); }); +test('OBS output URL encodes the code without adding host credentials', () => { const u = new URL(url('https://example.test/subpath', '123456')); assert.equal(u.pathname, '/'); assert.equal(u.searchParams.get('overlay'), 'lowerthird'); assert.equal(u.searchParams.get('code'), '123456'); }); diff --git a/frontend/sw.js b/frontend/sw.js index c5132bf..ddc33d8 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -87,6 +87,8 @@ const SHELL = [ '/party-dj.js', '/lyrics-window.js', '/midi.js', + '/lower-third.js', + '/lower-third.css', '/settings-sections.css', '/app.js', '/manifest.webmanifest', diff --git a/tests/settings-sections.spec.js b/tests/settings-sections.spec.js index 4242ee8..260e56b 100644 --- a/tests/settings-sections.spec.js +++ b/tests/settings-sections.spec.js @@ -33,3 +33,11 @@ test('floating lyrics move the real view and restore it on close', async ({ page await expect(page.locator('#stageLyrics')).toHaveCount(0); await popup.close(); await expect(page.locator('#stageLyrics')).toHaveCount(1); }); +test('OBS lower third has a transparent background and renders two cues', async ({ page }) => { + await page.route('**/api/**', route => route.fulfill({ contentType: 'application/json', body: '{"ok":false}' })); + await page.goto('/?overlay=lowerthird&code=123456'); + await page.evaluate(() => LowerThird.paint([{ kind: 'line', text: 'Current lyric' }, { kind: 'line', text: 'Next lyric' }], 0)); + await expect(page.locator('.obs-current')).toHaveText('Current lyric'); await expect(page.locator('.obs-next')).toHaveText('Next lyric'); + expect(await page.evaluate(() => getComputedStyle(document.body).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); + await expect(page.locator('#searchInput')).toBeHidden(); +});