diff --git a/frontend/styles.css b/frontend/styles.css index d675be4..3f864a0 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -4066,6 +4066,15 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t .party-panel.min .pt-body { display: none; } .pt-present { display: flex; flex-wrap: wrap; gap: 6px; } .pt-present:empty { display: none; } +.pt-pq { display: grid; gap: 6px; padding: 8px; border: 1px solid var(--line); border-radius: 12px; } +.pt-pq-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.pt-pq-row { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 10px; background: var(--bg-1); } +.pt-pq-row.live { box-shadow: inset 0 0 0 1px var(--accent, #f06b53); } +.pt-pq-name { flex: 1; min-width: 0; display: grid; overflow: hidden; } +.pt-pq-name, .pt-pq-name small { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pt-pq-name small { color: var(--text-dim); font-size: 11px; } +.pt-pq-row .np-btn { flex: none; min-height: 36px; padding-inline: 10px; } +.pt-pq-empty { margin: 0; font-size: 12px; color: var(--text-2); } .pt-invite { display: flex; gap: 6px; } .pt-invite input { flex: 1; min-width: 0; } .pt-uninvite { margin-left: 4px; min-width: 28px; min-height: 28px; border: 0; background: none; color: var(--text-2); font-size: 16px; cursor: pointer; } @@ -4120,7 +4129,7 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t .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-body { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; } .pt-row { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); } -.pt-row input, .pt-msg, .pt-name { +.pt-row input, .pt-msg, .pt-name, .pt-invite-name { flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px; } diff --git a/frontend/watch-party-client.js b/frontend/watch-party-client.js index 803f1ee..eb13f2a 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, present: null, presentHidden: false, invites: [], inviteError: '', viewMode: {}, reader: { url: '', html: '', error: '', loading: false }, + loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '', pq: [], viewMode: {}, reader: { url: '', html: '', error: '', loading: false }, voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() }, minimized: false, }; @@ -70,7 +70,7 @@ st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false; st.allowControl = !!m.allowControl; st.present = m.present || null; renderPresent(); - st.invites = m.invites || []; + st.invites = m.invites || []; st.pq = m.pq || []; 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(); @@ -110,6 +110,8 @@ 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 === 'pq') { + st.pq = m.items || []; render(); } else if (m.type === 'invites') { st.invites = m.invites || []; st.inviteError = m.error || ''; if (m.error) toast('β ' + m.error); @@ -400,6 +402,14 @@
Line up slides, documents and pages now; present each one when its time comes.
'}Everyone in the party sees it full screen. Google Slides and PDFs follow your page β use βΉ βΊ or the arrow keys.
- + + ${isHost() ? '' : ''}Google files must be shared βAnyone with the linkβ. Sites like Facebook or banks refuse embedding; guests can still tap Open.
`; - const go = () => { + const go = (toQueue = false) => { 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; } const special = embedOf(url, 1); // Google files and PDFs always embed const mode = $('ppReader').checked && !special.paged && !/Google/.test(special.label || '') ? 'reader' : 'embed'; - closeModal(); send({ type: 'present', present: { url, page: 1, mode } }); + closeModal(); + if (toQueue) { send({ type: 'pq', action: 'add', url, mode, title: ($('ppTitle')?.value || '').trim() }); toast('Added to Prepared β'); } + else send({ type: 'present', present: { url, page: 1, mode } }); }; - 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); + showModal(queue ? 'π₯ Prepare a presentation' : 'π₯ Present to the party', body, [ + { label: 'Cancel', onClick: closeModal }, + ...(isHost() ? [{ label: 'Add to Prepared', primary: queue, onClick: () => go(true) }] : []), + ...(queue ? [] : [{ label: 'Present now', primary: true, onClick: () => go(false) }]), + ]); + setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(queue); } }); } }, 50); } function boot() { diff --git a/server/party.js b/server/party.js index 96a6ff7..dc7860c 100644 --- a/server/party.js +++ b/server/party.js @@ -14,6 +14,8 @@ * guest β host {type:'cmd', cmd, β¦} (only when the host allows control) * host β server {type:'invite'|'uninvite', profile} invite an online profile; it sees * the party at GET /api/party/invites (server.js) Β· server β host {type:'invites', invites} + * host β server {type:'pq', action:'add'|'remove'|'move', β¦} prepared presentations; + * server β host {type:'pq', items} (also in hello). Presenting one is a normal 'present'. * 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} @@ -39,6 +41,8 @@ const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(Strin const PROFILE_RE = /^[a-z0-9_-]{3,40}$/; const MAX_INVITES = 30; +const MAX_QUEUE = 50; +const URL_RE = /^https?:\/\/[^\s<>"]{3,2000}$/i; export function createPartyHub({ now = () => Date.now(), profileExists = async () => true } = {}) { const parties = new Map(); // code β party @@ -114,7 +118,7 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async ( if (!p) { lost = !!d.code; // resuming a room the server no longer has (restart / expiry) const code = newCode(); - p = { invites: new Map(), 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 }; + p = { invites: new Map(), pq: [], 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; @@ -134,7 +138,7 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async ( 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, present: p.present, - invites: d.role === 'host' ? inviteList(p) : undefined, dj: queueSnapshot(p.dj), allowControl: p.allowControl, + invites: d.role === 'host' ? inviteList(p) : undefined, pq: d.role === 'host' ? p.pq : undefined, dj: queueSnapshot(p.dj), allowControl: p.allowControl, }); broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws); } @@ -171,6 +175,18 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async ( p.present = { url, mode: v.mode === 'reader' ? 'reader' : 'embed', 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 === 'pq' && isHost) { + const i = p.pq.findIndex((x) => x.id === m.id); + if (m.action === 'add') { + const url = String(m.url || '').trim(); + if (!URL_RE.test(url) || p.pq.length >= MAX_QUEUE) return; + p.pq.push({ id: randomBytes(6).toString('base64url'), url, mode: m.mode === 'reader' ? 'reader' : 'embed', title: clean(m.title, 120) }); + } else if (m.action === 'remove' && i >= 0) p.pq.splice(i, 1); + else if (m.action === 'move' && i >= 0) { + const j = Math.max(0, Math.min(p.pq.length - 1, i + (Number(m.by) < 0 ? -1 : 1))); + p.pq.splice(j, 0, p.pq.splice(i, 1)[0]); + } else return; + send(ws, { type: 'pq', items: p.pq }); } else if ((m.type === 'invite' || m.type === 'uninvite') && isHost) { const name = String(m.profile || '').trim().toLowerCase(); if (!PROFILE_RE.test(name)) { send(ws, { type: 'invites', invites: inviteList(p), error: 'Profile names are 3β40 letters, digits, - or _' }); return; } diff --git a/server/party.test.js b/server/party.test.js index f2ddc7b..11987e5 100644 --- a/server/party.test.js +++ b/server/party.test.js @@ -146,6 +146,29 @@ describe('watch party presenting', () => { }); }); +describe('watch party presentation queue', () => { + test('host prepares, reorders and removes queued presentations; guests cannot; it survives a host reload', async () => { + const host = client({ role: 'host', pid: 'hostpid0030', name: 'H' }); + const { code, secret } = await host.next('hello'); + host.send({ type: 'pq', action: 'add', url: 'https://docs.google.com/presentation/d/a/edit', mode: 'embed', title: 'Sermon slides' }); + host.send({ type: 'pq', action: 'add', url: 'https://example.com/notes', mode: 'reader', title: '' }); + host.send({ type: 'pq', action: 'add', url: 'javascript:alert(1)' }); + let items = (await host.next('pq')).items; items = (await host.next('pq')).items; + expect(items.map((x) => x.title || x.url)).toEqual(['Sermon slides', 'https://example.com/notes']); + host.send({ type: 'pq', action: 'move', id: items[1].id, by: -1 }); + expect((await host.next('pq')).items[0].url).toBe('https://example.com/notes'); + const g = client({ role: 'guest', pid: 'guestpid0031', key: 'q'.repeat(36), name: 'G', code }); + expect((await g.next('hello')).pq).toBeUndefined(); + g.send({ type: 'pq', action: 'add', url: 'https://evil.example/' }); + host.ws.close(); await host.closed; + const again = client({ role: 'host', pid: 'hostpid0030', name: 'H', code, secret }); + expect((await again.next('hello')).pq.length).toBe(2); + again.send({ type: 'pq', action: 'remove', id: items[0].id }); + expect((await again.next('pq')).items.length).toBe(1); + again.send({ type: 'end' }); await again.closed; + }); +}); + describe('watch party invites', () => { test('host invites a profile; it is listed for that profile until uninvited or the party ends', async () => { const host = client({ role: 'host', pid: 'hostpid0020', name: 'Josh' });