Load the presenter client only on presenter entry

This commit is contained in:
Jonathan Sykes
2026-10-08 04:39:52 +08:00
parent 3c9a24ae45
commit 5fb9564468
7 changed files with 238 additions and 211 deletions

View File

@@ -5971,215 +5971,14 @@ async function renderStats() {
// like a phone, but only listens: the host's state messages carry the song id,
// position, speed and lyric offset, and this view extrapolates the position
// between updates. Lyrics come from the shared /api/notes store.
const Presenter = (() => {
const noop = { boot() {}, active: () => false };
const view = $('presenterView');
if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
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)}`;
const list = $('pvList');
const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 };
const Presenter = {
async boot() {
if (!new URLSearchParams(location.search).has('present') && !LowerThird.active()) return;
return Lazy.proxy('feature:presenter', ['boot'], 'Presenter').boot();
},
active: () => !document.getElementById('presenterView')?.classList.contains('hidden'),
};
function setStatus(t) { $('pvStatus').textContent = t; }
function show() {
view.classList.remove('hidden');
document.body.classList.add('presenter-open');
// The app underneath may have focused its search box; keys belong here now.
try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ }
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
keepAwake();
bumpIdle();
}
async function keepAwake() {
try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ }
}
function showPairForm(msg = '') {
$('pvPair').classList.remove('hidden');
$('pvMsg').textContent = msg;
setStatus('Not paired');
}
async function pair(code) {
code = String(code || '').replace(/\D/g, '');
if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; }
$('pvMsg').textContent = 'Connecting…';
try {
const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: '🖥 Presenter' }) });
const j = await res.json().catch(() => null);
if (!j || !j.ok) { showPairForm('⚠ ' + ((j && j.error) || `HTTP ${res.status}`)); return; }
save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName });
$('pvPair').classList.add('hidden');
connect();
} catch {
showPairForm('⚠ Network error — try again');
}
}
function connect() {
const p = load();
if (!p) { showPairForm(); return; }
clearTimeout(st.retry);
try { if (st.ws) st.ws.close(); } catch { /* gone */ }
setStatus('Connecting…');
const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: '🖥 Presenter' }));
st.ws = ws;
ws.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch { return; }
if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); 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') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
};
ws.onclose = (e) => {
if (st.ws !== ws) return;
st.ws = null;
if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; }
setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
st.retry = setTimeout(connect, 3000);
};
}
function onState(s) {
st.state = s;
st.stateAt = Date.now();
const v = s && s.v;
setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`);
const id = v && v.id;
if (id !== st.vid) loadSong(id, v);
}
async function loadSong(id, v) {
const seq = ++st.seq;
st.vid = id;
st.all = [];
st.docOff = 0;
render(v);
if (!id || !isMediaId(id)) return;
let notes = st.notes.get(id);
if (!notes) {
try {
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json();
notes = j && j.ok ? j : null;
if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); }
} catch { notes = null; }
}
if (seq !== st.seq) return;
const doc = notes && notes.lyrics && notes.lyrics.data;
st.all = (doc && doc.lines) || [];
st.docOff = (doc && Number(doc.offset)) || 0;
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 title = $('pvTitle');
if (!sung.length) {
list.innerHTML = '';
title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `<small>${escapeHtml(v.channel)}</small>` : ''}` : '';
title.classList.toggle('hidden', !v);
return;
}
title.classList.add('hidden');
const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px';
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
refit();
st.idx = -2;
tick(true);
setTimeout(refit, 900); // once more after fonts/layout settle
}
function refit() {
fitLyricLines(list, { maxShare: 0.2, maxPx: 240 });
}
function liveTime() {
const s = st.state;
if (!s) return 0;
return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1);
}
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;
const rows = [...list.querySelectorAll('.sl-line')];
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
rows.forEach((el, k) => {
const d = pos < 0 ? 99 : Math.abs(k - pos);
el.classList.toggle('active', d === 0);
el.classList.toggle('near', d === 1);
el.classList.toggle('past', pos >= 0 && k < pos);
});
const target = pos >= 0 ? rows[pos] : rows[0];
if (target) {
const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
go();
setTimeout(go, 380);
}
}
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
else view.requestFullscreen?.().catch(() => {});
}
// The status bar and cursor hide after 3 s without mouse movement.
function bumpIdle() {
view.classList.remove('idle');
clearTimeout(st.idleTimer);
st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000);
}
function boot() {
let param = null;
try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ }
if (param === null) return;
try {
const u = new URL(location.href);
u.searchParams.delete('present');
history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
} catch { /* keep */ }
show();
const code = String(param).replace(/\D/g, '');
if (overlay && load()) connect();
else if (code.length === 6) pair(code);
else if (load()) connect();
else showPairForm();
setInterval(() => tick(), 200);
window.addEventListener('resize', () => { refit(); tick(true); });
if (document.fonts) {
document.fonts.ready.then(refit);
document.fonts.addEventListener('loadingdone', refit);
}
view.addEventListener('mousemove', bumpIdle);
view.addEventListener('dblclick', toggleFullscreen);
$('pvFull').addEventListener('click', toggleFullscreen);
$('pvPairBtn').addEventListener('click', () => pair($('pvCode').value));
$('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); });
document.addEventListener('keydown', (e) => {
if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return;
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); }
else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); }
}, true);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); }
});
}
return { boot, active: () => !view.classList.contains('hidden') };
})();
// Open a video and jump to `t` seconds once it can play (deep links, notes).
async function playVideoAt(v, t = 0) {
@@ -10632,7 +10431,7 @@ async function boot() {
syncOnLaunch();
startPlaylistInboxWatch();
if(LowerThird.active()) await Lazy.load('feature:lower-third');
Presenter.boot();
await Presenter.boot();
if (LowerThird.active()) return;
SectionRail.boot();
Share.bootFromUrl();

View File

@@ -316,6 +316,14 @@
"eager": false,
"background": true,
"files": []
},
"feature:presenter": {
"contract": 1,
"eager": false,
"background": true,
"files": [
"/presenter.js"
]
}
}
}

View File

@@ -19,7 +19,7 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preload" href="fonts/BricolageGrotesque-latin.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="fonts/HankenGrotesk-latin.woff2" as="font" type="font/woff2" crossorigin />
<script type="application/json" id="ytp-assets">{"buildTag":"static","groups":{"core":{"contract":1,"eager":true,"files":["/index.html","/styles.css","/service-ui.css","/refresh.css","/layout-base.css","/recommendations.css","/flag-ui.js","/async-guard.js","/visible-timer.js","/asset-sync-core.js","/sw-update.js","/fingerprint.js","/opfs.js","/opfs-worker.js","/lyrics-core.js","/lyrics-display.js","/sha256.js","/resume-core.js","/loudness-wasm.js","/loudness.js","/sha256-wasm.js","/settings-sections.js","/related-videos.js","/related-videos.css","/audio-continuity.js","/fullscreen-orientation.js","/fullscreen-orientation.css","/download-actions.js","/download-actions.css","/download-errors.js","/downloads.js","/downloads-page.js","/offline-pages.css","/saved-page.js","/server-backfill.js","/saved-page.css","/prewarm-next.js","/settings-sections.css","/app.js","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/fonts/fonts.css","/fonts/BricolageGrotesque-latin-ext.woff2","/fonts/BricolageGrotesque-latin.woff2","/fonts/HankenGrotesk-latin-ext.woff2","/fonts/HankenGrotesk-latin.woff2","/fonts/JetBrainsMono-latin-ext.woff2","/fonts/JetBrainsMono-latin.woff2","/lazy.js","/feature-bootstrap.js","/eq-parameters.js","/assets.json","/device-db.js","/shell-core.js","/views-core.js","/section-rail.js"],"background":false},"layout:glass-stage":{"contract":1,"eager":false,"files":["/theme-glass.css","/theme-glass-panel.css","/glass-panel-layout.js"],"background":true,"dependencies":["feature:layout-controls"]},"layout:classic":{"contract":1,"eager":false,"files":["/theme-classic.css"],"background":true},"layout:bento-hub":{"contract":1,"eager":false,"files":["/theme-bento-hub.css","/bento-hub.js","/fonts/PlusJakartaSans-latin.woff2","/fonts/PlusJakartaSans-latin-ext.woff2"],"background":true,"dependencies":["feature:layout-controls"]},"layout:f7-swipe":{"contract":1,"eager":false,"files":["/theme-f7.css","/vendor/framework7-swipe.min.js","/f7-layout.js"],"background":true},"feature:piano":{"contract":1,"eager":false,"files":["/piano-core.js","/piano.js","/piano-engine.mjs"],"background":true},"feature:midi":{"contract":1,"eager":false,"files":["/midi.js"],"background":true},"feature:party-dj":{"contract":1,"eager":false,"files":["/party-dj.js"],"background":true},"feature:lyrics-window":{"contract":1,"eager":false,"files":["/lyrics-window.js"],"background":true},"feature:lower-third":{"contract":1,"eager":false,"files":["/lower-third.js","/lower-third.css"],"background":true},"feature:vocal-reducer":{"contract":1,"eager":false,"files":["/vocal-reducer.js"],"background":true},"feature:eq-core":{"contract":1,"eager":false,"files":["/eq-core.js"],"background":true},"feature:setlist-import":{"contract":1,"eager":false,"files":["/setlist-import.js"],"background":true},"feature:car-mode":{"contract":1,"eager":false,"files":["/car-mode.js","/car-mode.css"],"background":true},"feature:p2p":{"contract":1,"eager":false,"files":["/p2p-core.js","/p2p-client.js","/p2p-transfer.js","/p2p-recv-worker.js","/hash-worker.js"],"background":true},"feature:direct":{"contract":1,"eager":false,"files":["/direct-protocol.js","/direct-stream.js","/direct-media.js","/direct-media.css","/direct-recv-worker.js"],"background":true,"dependencies":["feature:p2p"]},"feature:video-edit":{"contract":1,"eager":false,"files":["/video-edit.js"],"background":true},"feature:stats":{"contract":1,"eager":false,"files":["/stats-core.js"],"background":true},"feature:export":{"contract":1,"eager":false,"files":["/export.js"],"background":true},"feature:playlist":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js"],"background":false},"feature:settings":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/settings-sections.js","/settings-sections.css","/views-core.js"],"background":false},"feature:service-mode":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/service-ui.css"],"background":false},"feature:lyrics-editor":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/lyrics-core.js","/lyrics-display.js"],"background":false},"feature:layout-controls":{"contract":1,"eager":false,"background":true,"files":["/theme-glass-controls.css","/glass-controls.js"]},"layout:sanctuary-stage":{"contract":1,"eager":false,"background":true,"files":[]},"layout:liturgical":{"contract":1,"eager":false,"background":true,"files":[]},"layout:living-hymnal":{"contract":1,"eager":false,"background":true,"files":[]},"layout:broadcaster":{"contract":1,"eager":false,"background":true,"files":[]},"layout:contemplative":{"contract":1,"eager":false,"background":true,"files":[]}},"files":{}}</script>
<script type="application/json" id="ytp-assets">{"buildTag":"static","groups":{"core":{"contract":1,"eager":true,"files":["/index.html","/styles.css","/service-ui.css","/refresh.css","/layout-base.css","/recommendations.css","/flag-ui.js","/async-guard.js","/visible-timer.js","/asset-sync-core.js","/sw-update.js","/fingerprint.js","/opfs.js","/opfs-worker.js","/lyrics-core.js","/lyrics-display.js","/sha256.js","/resume-core.js","/loudness-wasm.js","/loudness.js","/sha256-wasm.js","/settings-sections.js","/related-videos.js","/related-videos.css","/audio-continuity.js","/fullscreen-orientation.js","/fullscreen-orientation.css","/download-actions.js","/download-actions.css","/download-errors.js","/downloads.js","/downloads-page.js","/offline-pages.css","/saved-page.js","/server-backfill.js","/saved-page.css","/prewarm-next.js","/settings-sections.css","/app.js","/manifest.webmanifest","/icons/icon-192.png","/icons/icon-512.png","/fonts/fonts.css","/fonts/BricolageGrotesque-latin-ext.woff2","/fonts/BricolageGrotesque-latin.woff2","/fonts/HankenGrotesk-latin-ext.woff2","/fonts/HankenGrotesk-latin.woff2","/fonts/JetBrainsMono-latin-ext.woff2","/fonts/JetBrainsMono-latin.woff2","/lazy.js","/feature-bootstrap.js","/eq-parameters.js","/assets.json","/device-db.js","/shell-core.js","/views-core.js","/section-rail.js"],"background":false},"layout:glass-stage":{"contract":1,"eager":false,"files":["/theme-glass.css","/theme-glass-panel.css","/glass-panel-layout.js"],"background":true,"dependencies":["feature:layout-controls"]},"layout:classic":{"contract":1,"eager":false,"files":["/theme-classic.css"],"background":true},"layout:bento-hub":{"contract":1,"eager":false,"files":["/theme-bento-hub.css","/bento-hub.js","/fonts/PlusJakartaSans-latin.woff2","/fonts/PlusJakartaSans-latin-ext.woff2"],"background":true,"dependencies":["feature:layout-controls"]},"layout:f7-swipe":{"contract":1,"eager":false,"files":["/theme-f7.css","/vendor/framework7-swipe.min.js","/f7-layout.js"],"background":true},"feature:piano":{"contract":1,"eager":false,"files":["/piano-core.js","/piano.js","/piano-engine.mjs"],"background":true},"feature:midi":{"contract":1,"eager":false,"files":["/midi.js"],"background":true},"feature:party-dj":{"contract":1,"eager":false,"files":["/party-dj.js"],"background":true},"feature:lyrics-window":{"contract":1,"eager":false,"files":["/lyrics-window.js"],"background":true},"feature:lower-third":{"contract":1,"eager":false,"files":["/lower-third.js","/lower-third.css"],"background":true},"feature:vocal-reducer":{"contract":1,"eager":false,"files":["/vocal-reducer.js"],"background":true},"feature:eq-core":{"contract":1,"eager":false,"files":["/eq-core.js"],"background":true},"feature:setlist-import":{"contract":1,"eager":false,"files":["/setlist-import.js"],"background":true},"feature:car-mode":{"contract":1,"eager":false,"files":["/car-mode.js","/car-mode.css"],"background":true},"feature:p2p":{"contract":1,"eager":false,"files":["/p2p-core.js","/p2p-client.js","/p2p-transfer.js","/p2p-recv-worker.js","/hash-worker.js"],"background":true},"feature:direct":{"contract":1,"eager":false,"files":["/direct-protocol.js","/direct-stream.js","/direct-media.js","/direct-media.css","/direct-recv-worker.js"],"background":true,"dependencies":["feature:p2p"]},"feature:video-edit":{"contract":1,"eager":false,"files":["/video-edit.js"],"background":true},"feature:stats":{"contract":1,"eager":false,"files":["/stats-core.js"],"background":true},"feature:export":{"contract":1,"eager":false,"files":["/export.js"],"background":true},"feature:playlist":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js"],"background":false},"feature:settings":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/settings-sections.js","/settings-sections.css","/views-core.js"],"background":false},"feature:service-mode":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/service-ui.css"],"background":false},"feature:lyrics-editor":{"contract":1,"eager":true,"files":[],"embeddedIn":["/app.js","/lyrics-core.js","/lyrics-display.js"],"background":false},"feature:layout-controls":{"contract":1,"eager":false,"background":true,"files":["/theme-glass-controls.css","/glass-controls.js"]},"layout:sanctuary-stage":{"contract":1,"eager":false,"background":true,"files":[]},"layout:liturgical":{"contract":1,"eager":false,"background":true,"files":[]},"layout:living-hymnal":{"contract":1,"eager":false,"background":true,"files":[]},"layout:broadcaster":{"contract":1,"eager":false,"background":true,"files":[]},"layout:contemplative":{"contract":1,"eager":false,"background":true,"files":[]},"feature:presenter":{"contract":1,"eager":false,"background":true,"files":["/presenter.js"]}},"files":{}}</script>
<script src="lazy.js"></script>
<link rel="stylesheet" href="fonts/fonts.css" />
<link rel="preconnect" href="https://i.ytimg.com" crossorigin />

View File

@@ -111,7 +111,8 @@
proxy(name, methods, globalName) {
return Object.fromEntries(methods.map(method => [method, async (...args) => {
await load(name);
const target = root[globalName || name.split(':')[1]];
const key = globalName || name.split(':')[1];
const target = root.YT?.[key] || root[key];
if (typeof target?.[method] !== 'function') throw new Error('Missing lazy method: ' + method);
return target[method](...args);
}]));

View File

@@ -76,3 +76,11 @@ test('new group files with a changed dependency contract cannot run against the
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);f.root.Lazy.manifest.groups['feature:test'].dependencies=['feature:dep'];f.root.Lazy.manifest.groups['feature:dep']={contract:1,files:[]};
await f.root.Lazy.load('feature:test');assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);assert.equal(f.root.Lazy.reloadRequired,true);
});
test('classic chunk registry proxies use window.YT and keep the executed singleton pinned',async()=>{
const {root,inserted}=fixture();root.YT={TestApi:{open:n=>n+2}};
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
assert.equal(await proxy.open(3),5);
const singleton=root.YT.TestApi;await root.Lazy.load('feature:test');
assert.equal(root.YT.TestApi,singleton);assert.equal(inserted.length,2);
});

210
frontend/presenter.js Normal file
View File

@@ -0,0 +1,210 @@
/* Lazy classic chunk; its executing singleton stays pinned until reload. */
(window.YT ||= {}).Presenter = (() => {
const noop = { boot() {}, active: () => false };
const view = $('presenterView');
if (!WEB || !view || typeof WebSocket === 'undefined') return noop;
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)}`;
const list = $('pvList');
const st = { ws: null, state: null, stateAt: 0, hostName: '', vid: null, all: [], docOff: 0, idx: -2, retry: null, notes: new Map(), wake: null, idleTimer: null, seq: 0 };
function setStatus(t) { $('pvStatus').textContent = t; }
function show() {
view.classList.remove('hidden');
document.body.classList.add('presenter-open');
// The app underneath may have focused its search box; keys belong here now.
try { if (document.activeElement && !view.contains(document.activeElement)) document.activeElement.blur(); } catch { /* fine */ }
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
keepAwake();
bumpIdle();
}
async function keepAwake() {
try { if ('wakeLock' in navigator && !st.wake) { st.wake = await navigator.wakeLock.request('screen'); st.wake.addEventListener('release', () => { st.wake = null; }); } } catch { /* not allowed */ }
}
function showPairForm(msg = '') {
$('pvPair').classList.remove('hidden');
$('pvMsg').textContent = msg;
setStatus('Not paired');
}
async function pair(code) {
code = String(code || '').replace(/\D/g, '');
if (code.length !== 6) { showPairForm('Enter the 6-digit code from the playing screen.'); return; }
$('pvMsg').textContent = 'Connecting…';
try {
const res = await fetch('/api/remote/pair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, name: '🖥 Presenter' }) });
const j = await res.json().catch(() => null);
if (!j || !j.ok) { showPairForm('⚠ ' + ((j && j.error) || `HTTP ${res.status}`)); return; }
save({ roomId: j.roomId, remoteId: j.remoteId, token: j.token, hostName: j.hostName });
$('pvPair').classList.add('hidden');
connect();
} catch {
showPairForm('⚠ Network error — try again');
}
}
function connect() {
const p = load();
if (!p) { showPairForm(); return; }
clearTimeout(st.retry);
try { if (st.ws) st.ws.close(); } catch { /* gone */ }
setStatus('Connecting…');
const ws = new WebSocket(wsUrl({ role: 'remote', room: p.roomId, rid: p.remoteId, token: p.token, name: '🖥 Presenter' }));
st.ws = ws;
ws.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch { return; }
if (!overlay) { DirectMedia.attach('presenter', { send: m => ws.send(JSON.stringify(m)), peers: () => [{ id: 'host', name: st.hostName || 'Screen' }] }); }
if (m.type === 'direct') { if (!overlay) DirectMedia.handle('presenter', m); 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') { if (overlay) { st.all = []; LowerThird.paint([], -1); } setStatus(`${st.hostName || 'The screen'} is offline — waiting…`); }
};
ws.onclose = (e) => {
if (st.ws !== ws) return;
st.ws = null;
if (e.code === 4001) { save(null); showPairForm('This presenter was unpaired — enter a new code.'); return; }
setStatus(e.code === 4004 ? 'Screen offline — retrying…' : 'Reconnecting…');
st.retry = setTimeout(connect, 3000);
};
}
function onState(s) {
st.state = s;
st.stateAt = Date.now();
const v = s && s.v;
setStatus(v ? `● Following ${st.hostName} · ${v.title}` : `● Following ${st.hostName} · nothing playing`);
const id = v && v.id;
if (id !== st.vid) loadSong(id, v);
}
async function loadSong(id, v) {
const seq = ++st.seq;
st.vid = id;
st.all = [];
st.docOff = 0;
render(v);
if (!id || !isMediaId(id)) return;
let notes = st.notes.get(id);
if (!notes) {
try {
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}`)).json();
notes = j && j.ok ? j : null;
if (notes) { st.notes.set(id, notes); if (st.notes.size > 50) st.notes.delete(st.notes.keys().next().value); }
} catch { notes = null; }
}
if (seq !== st.seq) return;
const doc = notes && notes.lyrics && notes.lyrics.data;
st.all = (doc && doc.lines) || [];
st.docOff = (doc && Number(doc.offset)) || 0;
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 title = $('pvTitle');
if (!sung.length) {
list.innerHTML = '';
title.innerHTML = v ? `${escapeHtml(v.title)}${v.channel ? `<small>${escapeHtml(v.channel)}</small>` : ''}` : '';
title.classList.toggle('hidden', !v);
return;
}
title.classList.add('hidden');
const pad = Math.round(list.clientHeight / 2);
list.style.paddingTop = pad + 'px';
list.style.paddingBottom = pad + 'px';
list.innerHTML = sung.map((l) => `<div class="sl-line" data-i="${l.i}">${LyricsDisplay.html(l)}</div>`).join('');
refit();
st.idx = -2;
tick(true);
setTimeout(refit, 900); // once more after fonts/layout settle
}
function refit() {
fitLyricLines(list, { maxShare: 0.2, maxPx: 240 });
}
function liveTime() {
const s = st.state;
if (!s) return 0;
return s.paused ? s.cur : s.cur + ((Date.now() - st.stateAt) / 1000) * (s.rate || 1);
}
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;
const rows = [...list.querySelectorAll('.sl-line')];
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
rows.forEach((el, k) => {
const d = pos < 0 ? 99 : Math.abs(k - pos);
el.classList.toggle('active', d === 0);
el.classList.toggle('near', d === 1);
el.classList.toggle('past', pos >= 0 && k < pos);
});
const target = pos >= 0 ? rows[pos] : rows[0];
if (target) {
const go = () => list.scrollTo({ top: Math.max(0, target.offsetTop - list.clientHeight / 2 + target.offsetHeight / 2), behavior: 'smooth' });
go();
setTimeout(go, 380);
}
}
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
else view.requestFullscreen?.().catch(() => {});
}
// The status bar and cursor hide after 3 s without mouse movement.
function bumpIdle() {
view.classList.remove('idle');
clearTimeout(st.idleTimer);
st.idleTimer = setTimeout(() => view.classList.add('idle'), 3000);
}
function boot() {
let param = null;
try { param = new URLSearchParams(location.search).get(overlay ? 'code' : 'present'); } catch { /* no URL */ }
if (param === null) return;
try {
const u = new URL(location.href);
u.searchParams.delete('present');
history.replaceState(null, '', u.pathname + (u.search || '') + u.hash);
} catch { /* keep */ }
show();
const code = String(param).replace(/\D/g, '');
if (overlay && load()) connect();
else if (code.length === 6) pair(code);
else if (load()) connect();
else showPairForm();
setInterval(() => tick(), 200);
window.addEventListener('resize', () => { refit(); tick(true); });
if (document.fonts) {
document.fonts.ready.then(refit);
document.fonts.addEventListener('loadingdone', refit);
}
view.addEventListener('mousemove', bumpIdle);
view.addEventListener('dblclick', toggleFullscreen);
$('pvFull').addEventListener('click', toggleFullscreen);
$('pvPairBtn').addEventListener('click', () => pair($('pvCode').value));
$('pvCode').addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') pair($('pvCode').value); });
document.addEventListener('keydown', (e) => {
if (view.classList.contains('hidden') || (e.target.closest && e.target.closest('#pvPair'))) return;
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); e.stopImmediatePropagation(); toggleFullscreen(); }
else if (e.key === 'b' || e.key === 'B') { e.preventDefault(); e.stopImmediatePropagation(); $('pvBlank').classList.toggle('hidden'); }
}, true);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { keepAwake(); if (!st.ws && load()) connect(); }
});
}
return { boot, active: () => !view.classList.contains('hidden') };
})();

View File

@@ -140,6 +140,7 @@ const SHELL = [
'/shell-core.js',
'/views-core.js',
'/section-rail.js',
'/presenter.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',