From 5fb95644689266e009d3595a1e0bf3029988661e Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 8 Oct 2026 04:39:52 +0800 Subject: [PATCH] Load the presenter client only on presenter entry --- frontend/app.js | 217 ++---------------------------------------- frontend/assets.json | 8 ++ frontend/index.html | 2 +- frontend/lazy.js | 3 +- frontend/lazy.test.js | 8 ++ frontend/presenter.js | 210 ++++++++++++++++++++++++++++++++++++++++ frontend/sw.js | 1 + 7 files changed, 238 insertions(+), 211 deletions(-) create mode 100644 frontend/presenter.js diff --git a/frontend/app.js b/frontend/app.js index b29e6d3..b3b8088 100755 --- a/frontend/app.js +++ b/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 ? `${escapeHtml(v.channel)}` : ''}` : ''; - 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) => `
${LyricsDisplay.html(l)}
`).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(); diff --git a/frontend/assets.json b/frontend/assets.json index dd849b4..140186a 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -316,6 +316,14 @@ "eager": false, "background": true, "files": [] + }, + "feature:presenter": { + "contract": 1, + "eager": false, + "background": true, + "files": [ + "/presenter.js" + ] } } } diff --git a/frontend/index.html b/frontend/index.html index 7798602..8eae8af 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,7 +19,7 @@ - + diff --git a/frontend/lazy.js b/frontend/lazy.js index a10e2bd..7bb4258 100644 --- a/frontend/lazy.js +++ b/frontend/lazy.js @@ -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); }])); diff --git a/frontend/lazy.test.js b/frontend/lazy.test.js index 8265580..b084413 100644 --- a/frontend/lazy.test.js +++ b/frontend/lazy.test.js @@ -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); +}); diff --git a/frontend/presenter.js b/frontend/presenter.js new file mode 100644 index 0000000..0f7961a --- /dev/null +++ b/frontend/presenter.js @@ -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 ? `${escapeHtml(v.channel)}` : ''}` : ''; + 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) => `
${LyricsDisplay.html(l)}
`).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') }; +})(); diff --git a/frontend/sw.js b/frontend/sw.js index ed90ab1..4bfd5b0 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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',