Load the presenter client only on presenter entry
This commit is contained in:
217
frontend/app.js
217
frontend/app.js
@@ -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();
|
||||
|
||||
@@ -316,6 +316,14 @@
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": []
|
||||
},
|
||||
"feature:presenter": {
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"files": [
|
||||
"/presenter.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}]));
|
||||
|
||||
@@ -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
210
frontend/presenter.js
Normal 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') };
|
||||
})();
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user