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 @@
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 = `Everyone in the party sees it full screen. Google Slides and PDFs follow your page β use βΉ βΊ or the arrow keys.
+ +Google files must be shared βAnyone with the linkβ. Sites like Facebook or banks refuse embedding; guests can still tap Open.
`; + 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' });