From ca042ccbe361cf6f936dd9a6786f9fcae30fd3ae Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 15 Oct 2026 19:15:04 +0800 Subject: [PATCH] Watch party presentation mode: share a website, Google Slides/Docs or PDF; everyone sees it, Slides and PDFs follow the presenter's page --- frontend/index.html | 2 +- frontend/styles.css | 19 ++++++++ frontend/watch-party-client.js | 87 +++++++++++++++++++++++++++++++++- server/party.js | 15 +++++- server/party.test.js | 19 ++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index f0f9bec..d25e756 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,7 +13,7 @@ YT Player diff --git a/frontend/styles.css b/frontend/styles.css index 00c9dc7..10a9071 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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; } diff --git a/frontend/watch-party-client.js b/frontend/watch-party-client.js index f6d92be..ac7ca7a 100644 --- a/frontend/watch-party-client.js +++ b/frontend/watch-party-client.js @@ -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 ? `
${TransportIcons.svg('play')} ${escapeHtml(s.title)}
` : ''} ${!isHost() && st.detached ? '' : ''}
${st.members.map((m) => `${m.host ? 'πŸ‘‘ ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎀' : ''}`).join('')}
+
${st.present + ? `${st.presentHidden ? '' : ''}${canPresent() ? '' : ''}` + : canPresent() ? '' : ''}
${isHost() ? `` : ''}
@@ -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 = `
+ + + Open + +
+ +

Blank? Some sites don't allow embedding β€” tap Open to view it in a new tab.

`; + 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 = ` + + `; + 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(); diff --git a/server/party.js b/server/party.js index 3a9e589..c8c52f2 100644 --- a/server/party.js +++ b/server/party.js @@ -12,6 +12,8 @@ * any β†’ all {type:'voice', on} (voice-chat presence) * any β†’ one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed) * 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 β†’ all {type:'members', members} Β· {type:'ended'} * 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) { lost = !!d.code; // resuming a room the server no longer has (restart / expiry) 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); } 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() }); send(ws, { 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); } @@ -140,6 +142,15 @@ export function createPartyHub({ now = () => Date.now() } = {}) { } else if (m.type === 'state' && isHost) { p.state = { ...(m.state || {}), ts: now() }; 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) { p.allowControl = !!m.allowControl; broadcast(p, { type: 'settings', allowControl: p.allowControl }); diff --git a/server/party.test.js b/server/party.test.js index d7d6bce..a204390 100644 --- a/server/party.test.js +++ b/server/party.test.js @@ -127,6 +127,25 @@ test('DJ suggestions and votes are broadcast, host consumes approved songs', asy 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', () => { test('unknown code closes with 4004 (no reconnect loop)', async () => { const bad = client({ role: 'guest', pid: 'guestpid7777', name: 'X', code: 'YYYYYY' });