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:
@@ -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 });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user