Add transparent OBS lyrics output through presenter pairing
This commit is contained in:
@@ -4892,6 +4892,7 @@ const Remote = (() => {
|
|||||||
<div class="rh-sub">Lyrics-only display that follows this screen. On the projector's computer or TV browser open
|
<div class="rh-sub">Lyrics-only display that follows this screen. On the projector's computer or TV browser open
|
||||||
<code>${escapeHtml(location.origin)}/?present=${host.code}</code> (same code), or scan the QR.</div>
|
<code>${escapeHtml(location.origin)}/?present=${host.code}</code> (same code), or scan the QR.</div>
|
||||||
<button class="np-btn" type="button" data-act="present">🖥 Open presenter window here</button>
|
<button class="np-btn" type="button" data-act="present">🖥 Open presenter window here</button>
|
||||||
|
<button class="np-btn" type="button" data-act="obs">Copy OBS browser-source URL</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rh-label">Connected phones & screens</div>
|
<div class="rh-label">Connected phones & screens</div>
|
||||||
@@ -4927,6 +4928,7 @@ const Remote = (() => {
|
|||||||
else if (act.dataset.act === 'stop') stopHosting();
|
else if (act.dataset.act === 'stop') stopHosting();
|
||||||
else if (act.dataset.act === 'code') hostSend({ type: 'new-code' });
|
else if (act.dataset.act === 'code') hostSend({ type: 'new-code' });
|
||||||
else if (act.dataset.act === 'unpair') unpairAll();
|
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') {
|
else if (act.dataset.act === 'present') {
|
||||||
// A second window on this computer (drag it to the projector display).
|
// A second window on this computer (drag it to the projector display).
|
||||||
window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720');
|
window.open(`/?present=${host.code}`, 'ytp-presenter', 'popup,width=1280,height=720');
|
||||||
@@ -5944,7 +5946,8 @@ const Presenter = (() => {
|
|||||||
const noop = { boot() {}, active: () => false };
|
const noop = { boot() {}, active: () => false };
|
||||||
const view = $('presenterView');
|
const view = $('presenterView');
|
||||||
if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
|
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 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 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)}`;
|
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; }
|
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}`); }
|
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 === '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) => {
|
ws.onclose = (e) => {
|
||||||
if (st.ws !== ws) return;
|
if (st.ws !== ws) return;
|
||||||
@@ -6044,6 +6047,7 @@ const Presenter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(v) {
|
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 sung = st.all.map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
|
||||||
const title = $('pvTitle');
|
const title = $('pvTitle');
|
||||||
if (!sung.length) {
|
if (!sung.length) {
|
||||||
@@ -6075,6 +6079,7 @@ const Presenter = (() => {
|
|||||||
|
|
||||||
function tick(force = false) {
|
function tick(force = false) {
|
||||||
if (view.classList.contains('hidden') || !st.all.length || !st.state) return;
|
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));
|
const idx = LyricsCore.activeIndex(st.all, liveTime(), st.docOff + (Number(st.state.off) || 0));
|
||||||
if (idx === st.idx && !force) return;
|
if (idx === st.idx && !force) return;
|
||||||
st.idx = idx;
|
st.idx = idx;
|
||||||
@@ -6108,7 +6113,7 @@ const Presenter = (() => {
|
|||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
let param = null;
|
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;
|
if (param === null) return;
|
||||||
try {
|
try {
|
||||||
const u = new URL(location.href);
|
const u = new URL(location.href);
|
||||||
@@ -6117,7 +6122,8 @@ const Presenter = (() => {
|
|||||||
} catch { /* keep */ }
|
} catch { /* keep */ }
|
||||||
show();
|
show();
|
||||||
const code = String(param).replace(/\D/g, '');
|
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 if (load()) connect();
|
||||||
else showPairForm();
|
else showPairForm();
|
||||||
setInterval(() => tick(), 200);
|
setInterval(() => tick(), 200);
|
||||||
@@ -12436,6 +12442,7 @@ async function boot() {
|
|||||||
syncOnLaunch();
|
syncOnLaunch();
|
||||||
startPlaylistInboxWatch();
|
startPlaylistInboxWatch();
|
||||||
Presenter.boot();
|
Presenter.boot();
|
||||||
|
if (LowerThird.active()) return;
|
||||||
SectionRail.boot();
|
SectionRail.boot();
|
||||||
Share.bootFromUrl();
|
Share.bootFromUrl();
|
||||||
bootViewFromUrl();
|
bootViewFromUrl();
|
||||||
@@ -12444,6 +12451,7 @@ async function boot() {
|
|||||||
Party.boot();
|
Party.boot();
|
||||||
PartyDJ.register(() => $('navPartyBtn').click());
|
PartyDJ.register(() => $('navPartyBtn').click());
|
||||||
Remote.boot();
|
Remote.boot();
|
||||||
|
LowerThird.register(() => $('remoteChip').click());
|
||||||
Transition.renderButton();
|
Transition.renderButton();
|
||||||
PiP.render();
|
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 });
|
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 });
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<link rel="stylesheet" href="theme-classic.css" />
|
<link rel="stylesheet" href="theme-classic.css" />
|
||||||
<link rel="stylesheet" href="recommendations.css" />
|
<link rel="stylesheet" href="recommendations.css" />
|
||||||
<link rel="stylesheet" href="settings-sections.css" />
|
<link rel="stylesheet" href="settings-sections.css" />
|
||||||
|
<link rel="stylesheet" href="lower-third.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
@@ -658,6 +659,7 @@
|
|||||||
<script src="party-dj.js"></script>
|
<script src="party-dj.js"></script>
|
||||||
<script src="lyrics-window.js"></script>
|
<script src="lyrics-window.js"></script>
|
||||||
<script src="midi.js"></script>
|
<script src="midi.js"></script>
|
||||||
|
<script src="lower-third.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
10
frontend/lower-third.css
Normal file
10
frontend/lower-third.css
Normal file
@@ -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; } }
|
||||||
16
frontend/lower-third.js
Normal file
16
frontend/lower-third.js
Normal file
@@ -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);
|
||||||
3
frontend/lower-third.test.js
Normal file
3
frontend/lower-third.test.js
Normal file
@@ -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'); });
|
||||||
@@ -87,6 +87,8 @@ const SHELL = [
|
|||||||
'/party-dj.js',
|
'/party-dj.js',
|
||||||
'/lyrics-window.js',
|
'/lyrics-window.js',
|
||||||
'/midi.js',
|
'/midi.js',
|
||||||
|
'/lower-third.js',
|
||||||
|
'/lower-third.css',
|
||||||
'/settings-sections.css',
|
'/settings-sections.css',
|
||||||
'/app.js',
|
'/app.js',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
|
|||||||
@@ -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 expect(page.locator('#stageLyrics')).toHaveCount(0);
|
||||||
await popup.close(); await expect(page.locator('#stageLyrics')).toHaveCount(1);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user