211 lines
9.4 KiB
JavaScript
211 lines
9.4 KiB
JavaScript
/* 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') };
|
|
})();
|