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 name="apple-mobile-web-app-title" content="YT Player" />
<meta <meta
http-equiv="Content-Security-Policy" 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> <title>YT Player</title>
<link rel="manifest" href="/manifest.webmanifest" /> <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 { bottom: auto; }
.party-panel.min .pt-body { display: none; } .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 { 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-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; } .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, ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
dj: { enabled: false, entries: [] }, djPending: false, dj: { enabled: false, entries: [] }, djPending: false,
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '', 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() }, voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
minimized: false, minimized: false,
}; };
@@ -69,6 +69,7 @@
st.chat = m.chat || []; st.chat = m.chat || [];
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false; st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
st.allowControl = !!m.allowControl; st.allowControl = !!m.allowControl;
st.present = m.present || null; renderPresent();
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host'); 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 }); if (m.lost && isHost()) toast(`🎉 The old party was lost (server restart). New code ${m.code} — share the link again`, { duration: 8000 });
render(); render();
@@ -108,6 +109,12 @@
st.allowControl = !!m.allowControl; st.allowControl = !!m.allowControl;
render(); render();
if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now'); 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()) { } else if (m.type === 'cmd' && isHost()) {
runCommand(m); runCommand(m);
} else if (m.type === 'rtc') { } else if (m.type === 'rtc') {
@@ -330,6 +337,7 @@
clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer); clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
try { if (ws) ws.close(); } catch { /* gone */ } try { if (ws) ws.close(); } catch { /* gone */ }
ls.set(HOST_KEY, null); ls.set(HOST_KEY, null);
st.present = null; renderPresent();
$('partyPanel').classList.add('hidden'); $('partyPanel').classList.add('hidden');
render(); render();
} }
@@ -382,6 +390,9 @@
${!isHost() && s ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''} ${!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>' : ''} ${!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-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>` : ''} ${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> <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"> <div class="pt-voice">
@@ -441,6 +452,9 @@
else if (act === 'mute') toggleMute(); else if (act === 'mute') toggleMute();
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); } else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
else if (act === 'leave') leave(true); 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) => { panel.addEventListener('change', (e) => {
if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); } 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() { function boot() {
bindUi(); bindUi();
render(); render();

View File

@@ -12,6 +12,8 @@
* any → all {type:'voice', on} (voice-chat presence) * any → all {type:'voice', on} (voice-chat presence)
* any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed) * any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed)
* guest → host {type:'cmd', cmd, …} (only when the host allows control) * guest → host {type:'cmd', cmd, …} (only when the host allows control)
* host → all {type:'present', present: {url, page} | null} shared document/website
* (guests too while the host allows control; kept for late joiners)
* server → you {type:'hello', code, secret?, you, now, members, state, chat, allowControl} * server → you {type:'hello', code, secret?, you, now, members, state, chat, allowControl}
* server → all {type:'members', members} · {type:'ended'} * server → all {type:'members', members} · {type:'ended'}
* A party survives its host dropping for PARTY_GRACE_MS (a reload, a flaky * A party survives its host dropping for PARTY_GRACE_MS (a reload, a flaky
@@ -93,7 +95,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
if (!p) { if (!p) {
lost = !!d.code; // resuming a room the server no longer has (restart / expiry) lost = !!d.code; // resuming a room the server no longer has (restart / expiry)
const code = newCode(); const code = newCode();
p = { direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 }; p = { present: null, direct: createDirectRelay(), code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
parties.set(code, p); parties.set(code, p);
} }
p.hostPid = d.pid; p.hostPid = d.pid;
@@ -112,7 +114,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0, key: d.key || '', host: d.role === 'host', tokens: MSG_BURST, refill: now() }); p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0, key: d.key || '', host: d.role === 'host', tokens: MSG_BURST, refill: now() });
send(ws, { send(ws, {
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid, lost, type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid, lost,
now: now(), members: members(p), state: p.state, chat: p.chat, dj: queueSnapshot(p.dj), allowControl: p.allowControl, now: now(), members: members(p), state: p.state, chat: p.chat, present: p.present, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
}); });
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws); broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
} }
@@ -140,6 +142,15 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
} else if (m.type === 'state' && isHost) { } else if (m.type === 'state' && isHost) {
p.state = { ...(m.state || {}), ts: now() }; p.state = { ...(m.state || {}), ts: now() };
broadcast(p, { type: 'state', state: p.state }, ws); broadcast(p, { type: 'state', state: p.state }, ws);
} else if (m.type === 'present' && (isHost || p.allowControl)) {
const v = m.present;
if (v === null) p.present = null;
else {
const url = String(v?.url || '').trim();
if (!/^https?:\/\/[^\s<>"]{3,2000}$/i.test(url)) return;
p.present = { url, page: Math.max(1, Math.min(9999, Math.floor(Number(v.page) || 1))), by: me.name, at: now() };
}
broadcast(p, { type: 'present', present: p.present });
} else if (m.type === 'settings' && isHost) { } else if (m.type === 'settings' && isHost) {
p.allowControl = !!m.allowControl; p.allowControl = !!m.allowControl;
broadcast(p, { type: 'settings', allowControl: p.allowControl }); broadcast(p, { type: 'settings', allowControl: p.allowControl });

View File

@@ -127,6 +127,25 @@ test('DJ suggestions and votes are broadcast, host consumes approved songs', asy
h.ws.close(); g.ws.close(); h.ws.close(); g.ws.close();
}); });
describe('watch party presenting', () => {
test('host shares a link that guests and late joiners see; junk and guest attempts are ignored', async () => {
const host = client({ role: 'host', pid: 'hostpid0010', name: 'H' });
const { code } = await host.next('hello');
const g = client({ role: 'guest', pid: 'guestpid0011', key: 'k'.repeat(36), name: 'G', code });
await g.next('hello');
g.send({ type: 'present', present: { url: 'https://evil.example/' } });
host.send({ type: 'present', present: { url: 'javascript:alert(1)' } });
host.send({ type: 'present', present: { url: 'https://docs.google.com/presentation/d/abc/edit', page: 3 } });
const shown = await g.next('present');
expect(shown.present).toMatchObject({ url: 'https://docs.google.com/presentation/d/abc/edit', page: 3, by: 'H' });
const late = client({ role: 'guest', pid: 'guestpid0012', key: 'l'.repeat(36), name: 'L', code });
expect((await late.next('hello')).present.page).toBe(3);
host.send({ type: 'present', present: null });
expect((await g.next('present')).present).toBe(null);
host.send({ type: 'end' }); await host.closed;
});
});
describe('watch party seat rules', () => { describe('watch party seat rules', () => {
test('unknown code closes with 4004 (no reconnect loop)', async () => { test('unknown code closes with 4004 (no reconnect loop)', async () => {
const bad = client({ role: 'guest', pid: 'guestpid7777', name: 'X', code: 'YYYYYY' }); const bad = client({ role: 'guest', pid: 'guestpid7777', name: 'X', code: 'YYYYYY' });