Watch party presentation mode: share a website, Google Slides/Docs or PDF; everyone sees it, Slides and PDFs follow the presenter's page

This commit is contained in:
Jonathan Sykes
2026-10-15 19:15:04 +08:00
parent 32d1f6891b
commit ca042ccbe3
5 changed files with 138 additions and 4 deletions

View File

@@ -13,7 +13,7 @@
<meta name="apple-mobile-web-app-title" content="YT Player" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'sha256-__APP_SCRIPT_HASH__' 'wasm-unsafe-eval' https://esm.sh; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'sha256-__APP_SCRIPT_HASH__' 'wasm-unsafe-eval' https://esm.sh; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self'; frame-src https:;"
/>
<title>YT Player</title>
<link rel="manifest" href="/manifest.webmanifest" />

View File

@@ -4064,6 +4064,25 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
}
.party-panel.min { bottom: auto; }
.party-panel.min .pt-body { display: none; }
.pt-present { display: flex; flex-wrap: wrap; gap: 6px; }
.pt-present:empty { display: none; }
/* Shared presentation: fills the screen beside the party panel (below it on phones). */
.party-present {
position: fixed; inset: 12px 372px 12px 12px; z-index: 155;
display: flex; flex-direction: column; overflow: hidden;
background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
}
.party-present.hidden { display: none; }
.pp-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
.pp-what { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: var(--text); }
.pp-pages { display: inline-flex; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; color: var(--text-2); }
.pp-pages[hidden], .pp-pages button[hidden] { display: none; }
.pp-bar .np-btn { min-height: 40px; text-decoration: none; display: inline-flex; align-items: center; }
.pp-frame { flex: 1; width: 100%; border: 0; background: #fff; }
.pp-hint { margin: 0; padding: 6px 10px; font-size: 12px; color: var(--text-dim); background: var(--bg-2); }
@media (max-width: 899px) {
.party-present { inset: calc(8px + env(safe-area-inset-top, 0px)) 8px calc(8px + env(safe-area-inset-bottom, 0px)) 8px; z-index: 165; }
}
.pt-head { display: flex; align-items: center; gap: 8px; font-weight: 700; }
.pt-head .rv-icon { width: 30px; height: 30px; font-size: 12px; margin-left: auto; }
.pt-status { font-family: var(--mono); font-size: 10px; color: var(--text-dim); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

View File

@@ -20,7 +20,7 @@
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
dj: { enabled: false, entries: [] }, djPending: false,
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0,
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false,
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
minimized: false,
};
@@ -69,6 +69,7 @@
st.chat = m.chat || [];
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
st.allowControl = !!m.allowControl;
st.present = m.present || null; renderPresent();
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
if (m.lost && isHost()) toast(`🎉 The old party was lost (server restart). New code ${m.code} — share the link again`, { duration: 8000 });
render();
@@ -108,6 +109,12 @@
st.allowControl = !!m.allowControl;
render();
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
} else if (m.type === 'present') {
const was = st.present && st.present.url;
st.present = m.present || null;
if (!st.present || st.present.url !== was) st.presentHidden = false;
if (st.present && st.present.url !== was && st.present.by !== myName()) toast(`🖥 ${st.present.by} is presenting`);
renderPresent(); render();
} else if (m.type === 'cmd' && isHost()) {
runCommand(m);
} else if (m.type === 'rtc') {
@@ -330,6 +337,7 @@
clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
try { if (ws) ws.close(); } catch { /* gone */ }
ls.set(HOST_KEY, null);
st.present = null; renderPresent();
$('partyPanel').classList.add('hidden');
render();
}
@@ -382,6 +390,9 @@
${!isHost() && s ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''}
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
<div class="pt-present">${st.present
? `${st.presentHidden ? '<button class="np-btn" type="button" data-act="present-show">🖥 Show the presentation</button>' : ''}${canPresent() ? '<button class="np-btn" type="button" data-act="present">Change link</button><button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : ''}`
: canPresent() ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : ''}</div>
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
<div class="pt-voice">
@@ -441,6 +452,9 @@
else if (act === 'mute') toggleMute();
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
else if (act === 'leave') leave(true);
else if (act === 'present') presentPrompt();
else if (act === 'present-stop') send({ type: 'present', present: null });
else if (act === 'present-show') { st.presentHidden = false; renderPresent(); render(); }
});
panel.addEventListener('change', (e) => {
if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); }
@@ -461,6 +475,77 @@
});
}
// ---------- presenting: a shared document / website everyone sees ----------
const canPresent = () => inParty() && (isHost() || st.allowControl);
// Google Slides/Docs/Drive and PDFs get their embeddable form; Slides and PDFs
// also follow the presenter's page.
function embedOf(url, page) {
let u; try { u = new URL(url); } catch { return { src: url, paged: false }; }
const g = u.hostname === 'docs.google.com' && u.pathname.match(/^\/(presentation|document|spreadsheets)\/d\/([\w-]+)/);
if (g && g[1] === 'presentation') return { src: `https://docs.google.com/presentation/d/${g[2]}/embed?start=false&loop=false&rm=minimal&slide=${page}`, paged: true, label: 'Google Slides' };
if (g) return { src: `https://docs.google.com/${g[1]}/d/${g[2]}/preview`, paged: false, label: g[1] === 'document' ? 'Google Doc' : 'Google Sheet' };
const d = u.hostname === 'drive.google.com' && u.pathname.match(/^\/file\/d\/([\w-]+)/);
if (d) return { src: `https://drive.google.com/file/d/${d[1]}/preview`, paged: false, label: 'Google Drive file' };
if (/\.pdf$/i.test(u.pathname)) { u.hash = `page=${page}`; return { src: u.href, paged: true, label: 'PDF' }; }
return { src: u.href, paged: false, label: u.hostname.replace(/^www\./, '') };
}
function presentEl() {
let el = $('partyPresent');
if (el) return el;
el = document.createElement('section');
el.id = 'partyPresent'; el.className = 'party-present hidden'; el.setAttribute('aria-label', 'Shared presentation');
el.innerHTML = `<header class="pp-bar">
<span class="pp-what"></span>
<span class="pp-pages"><button type="button" class="np-btn" data-pp="prev" aria-label="Previous page">‹</button><span class="pp-page"></span><button type="button" class="np-btn" data-pp="next" aria-label="Next page">›</button></span>
<a class="np-btn pp-open" target="_blank" rel="noopener noreferrer">Open</a>
<button type="button" class="np-btn" data-pp="hide">Hide</button>
</header>
<iframe class="pp-frame" title="Shared presentation" referrerpolicy="no-referrer" allow="fullscreen"
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation"></iframe>
<p class="pp-hint">Blank? Some sites don't allow embedding — tap Open to view it in a new tab.</p>`;
el.addEventListener('click', (e) => {
const b = e.target.closest('[data-pp]'); if (!b || !st.present) return;
if (b.dataset.pp === 'hide') { st.presentHidden = true; renderPresent(); render(); return; }
if (!canPresent()) return;
const page = Math.max(1, (st.present.page || 1) + (b.dataset.pp === 'next' ? 1 : -1));
send({ type: 'present', present: { url: st.present.url, page } });
});
document.addEventListener('keydown', (e) => {
if (!st.present || st.presentHidden || !canPresent() || e.target.closest?.('input, textarea')) return;
if (e.key === 'ArrowRight' || e.key === 'PageDown') el.querySelector('[data-pp="next"]').click();
if (e.key === 'ArrowLeft' || e.key === 'PageUp') el.querySelector('[data-pp="prev"]').click();
});
document.body.append(el);
return el;
}
function renderPresent() {
const p = st.present;
if (!p) { const el = $('partyPresent'); if (el) { el.classList.add('hidden'); el.querySelector('.pp-frame').removeAttribute('src'); } return; }
const el = presentEl(), e = embedOf(p.url, p.page || 1);
el.classList.toggle('hidden', st.presentHidden);
el.querySelector('.pp-what').textContent = `${p.by || 'Host'} · ${e.label}`;
el.querySelector('.pp-pages').hidden = !e.paged;
el.querySelector('.pp-page').textContent = `${e.label === 'PDF' ? 'Page' : 'Slide'} ${p.page || 1}`;
for (const b of el.querySelectorAll('.pp-pages button')) b.hidden = !canPresent();
el.querySelector('.pp-open').href = p.url;
const frame = el.querySelector('.pp-frame');
if (frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src);
}
function presentPrompt() {
const body = document.createElement('div');
body.innerHTML = `<p class="modal-text">Everyone in the party sees it full screen. Google Slides and PDFs follow your page — use ‹ › or the arrow keys.</p>
<input id="ppUrl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://docs.google.com/presentation/d/…" value="${escapeHtml(st.present?.url || '')}" />
<p class="modal-text" style="font-size:12px">Google files must be shared “Anyone with the link”. Sites like Facebook or banks refuse embedding; guests can still tap Open.</p>`;
const go = () => {
let url = ($('ppUrl').value || '').trim();
if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
try { new URL(url); } catch { toast('Paste a full link, e.g. https://…'); return; }
closeModal(); send({ type: 'present', present: { url, page: 1 } });
};
showModal('🖥 Present to the party', body, [{ label: 'Cancel', onClick: closeModal }, { label: 'Present', primary: true, onClick: go }]);
setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); } }, 50);
}
function boot() {
bindUi();
render();