/* 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') }; })();