Watch party: Prepared presentations queue for the host (add, name, reorder, remove, present when it is time)

This commit is contained in:
Jonathan Sykes
2026-10-15 19:31:53 +08:00
parent 1352ed9f29
commit 1383e89a25
4 changed files with 80 additions and 11 deletions

View File

@@ -4066,6 +4066,15 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
.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 { display: flex; flex-wrap: wrap; gap: 6px; }
.pt-present:empty { display: none; } .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 { display: flex; gap: 6px; }
.pt-invite input { flex: 1; min-width: 0; } .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; } .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-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-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 { 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); 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; border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px;
} }

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, 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() }, voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
minimized: false, minimized: false,
}; };
@@ -70,7 +70,7 @@
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(); 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'); 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();
@@ -110,6 +110,8 @@
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 === 'pq') {
st.pq = m.items || []; render();
} else if (m.type === 'invites') { } else if (m.type === 'invites') {
st.invites = m.invites || []; st.inviteError = m.error || ''; st.invites = m.invites || []; st.inviteError = m.error || '';
if (m.error) toast('⚠ ' + m.error); if (m.error) toast('⚠ ' + m.error);
@@ -400,6 +402,14 @@
<div class="pt-present">${st.present <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>' : ''}` ? `${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> : canPresent() ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : ''}</div>
${isHost() ? `<div class="pt-pq">
<div class="pt-pq-head"><b>Prepared</b><button class="np-btn" type="button" data-act="pq-new">+ Prepare</button></div>
${st.pq.length ? st.pq.map((q, i) => `<div class="pt-pq-row${st.present && st.present.url === q.url ? ' live' : ''}">
<span class="pt-pq-name">${escapeHtml(q.title || embedOf(q.url, 1).label || q.url)}<small>${q.mode === 'reader' ? 'Reader' : escapeHtml(embedOf(q.url, 1).label || '')}</small></span>
<button class="np-btn" type="button" data-act="pq-go" data-id="${escapeHtml(q.id)}">${st.present && st.present.url === q.url ? 'Showing' : 'Present'}</button>
<button class="np-btn" type="button" data-act="pq-up" data-id="${escapeHtml(q.id)}" aria-label="Move up"${i ? '' : ' disabled'}>↑</button>
<button class="np-btn" type="button" data-act="pq-del" data-id="${escapeHtml(q.id)}" aria-label="Remove">×</button></div>`).join('')
: '<p class="pt-pq-empty">Line up slides, documents and pages now; present each one when its time comes.</p>'}</div>` : ''}
${isHost() ? `<form class="pt-invite"><input class="pt-invite-name" maxlength="40" placeholder="Invite a profile by name" autocapitalize="none" autocomplete="off" spellcheck="false" /><button class="np-btn" type="submit">Invite</button></form> ${isHost() ? `<form class="pt-invite"><input class="pt-invite-name" maxlength="40" placeholder="Invite a profile by name" autocapitalize="none" autocomplete="off" spellcheck="false" /><button class="np-btn" type="submit">Invite</button></form>
${st.invites.length ? `<div class="pt-members pt-invited">${st.invites.map((n) => `<span class="pt-member">✉ ${escapeHtml(n)} <button type="button" class="pt-uninvite" data-act="uninvite" data-profile="${escapeHtml(n)}" aria-label="Cancel the invite for ${escapeHtml(n)}">×</button></span>`).join('')}</div>` : ''}` : ''} ${st.invites.length ? `<div class="pt-members pt-invited">${st.invites.map((n) => `<span class="pt-member">✉ ${escapeHtml(n)} <button type="button" class="pt-uninvite" data-act="uninvite" data-profile="${escapeHtml(n)}" aria-label="Cancel the invite for ${escapeHtml(n)}">×</button></span>`).join('')}</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>` : ''}
@@ -470,6 +480,10 @@
else if (act === 'land') { Notes.stageLyrics.landscape?.(!Notes.stageLyrics.landscapeOn?.()); renderLyricsToggle(); } else if (act === 'land') { Notes.stageLyrics.landscape?.(!Notes.stageLyrics.landscapeOn?.()); renderLyricsToggle(); }
else if (act === 'leave') leave(true); else if (act === 'leave') leave(true);
else if (act === 'present') presentPrompt(); else if (act === 'present') presentPrompt();
else if (act === 'pq-new') presentPrompt({ queue: true });
else if (act === 'pq-go') { const q = st.pq.find((x) => x.id === a.dataset.id); if (q) send({ type: 'present', present: { url: q.url, mode: q.mode, page: 1 } }); }
else if (act === 'pq-up') send({ type: 'pq', action: 'move', id: a.dataset.id, by: -1 });
else if (act === 'pq-del') send({ type: 'pq', action: 'remove', id: a.dataset.id });
else if (act === 'uninvite') send({ type: 'uninvite', profile: a.dataset.profile }); else if (act === 'uninvite') send({ type: 'uninvite', profile: a.dataset.profile });
else if (act === 'present-stop') send({ type: 'present', present: null }); else if (act === 'present-stop') send({ type: 'present', present: null });
else if (act === 'present-show') { st.presentHidden = false; renderPresent(); render(); } else if (act === 'present-show') { st.presentHidden = false; renderPresent(); render(); }
@@ -633,22 +647,29 @@
const frame = el.querySelector('.pp-frame'); const frame = el.querySelector('.pp-frame');
if (!reading && frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src); if (!reading && frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src);
} }
function presentPrompt() { function presentPrompt({ queue = false } = {}) {
const body = document.createElement('div'); 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> 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://… a website, Google Slides/Docs or a PDF" value="${escapeHtml(st.present?.url || '')}" /> <input id="ppUrl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://… a website, Google Slides/Docs or a PDF" value="${queue ? '' : escapeHtml(st.present?.url || '')}" />
${isHost() ? '<input id="ppTitle" maxlength="120" autocomplete="off" placeholder="Name it (optional), e.g. Sermon slides" style="margin-top:8px" />' : ''}
<label class="pt-toggle" style="margin-top:8px"><input type="checkbox" id="ppReader" ${st.present && st.present.mode === 'embed' ? '' : 'checked'} /> Reader view for websites — clean text everyone can enlarge</label> <label class="pt-toggle" style="margin-top:8px"><input type="checkbox" id="ppReader" ${st.present && st.present.mode === 'embed' ? '' : 'checked'} /> Reader view for websites — clean text everyone can enlarge</label>
<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>`; <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 = () => { const go = (toQueue = false) => {
let url = ($('ppUrl').value || '').trim(); let url = ($('ppUrl').value || '').trim();
if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url; if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
try { new URL(url); } catch { toast('Paste a full link, e.g. https://…'); return; } 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 special = embedOf(url, 1); // Google files and PDFs always embed
const mode = $('ppReader').checked && !special.paged && !/Google/.test(special.label || '') ? 'reader' : '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 }]); showModal(queue ? '🖥 Prepare a presentation' : '🖥 Present to the party', body, [
setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); } }, 50); { 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() { function boot() {

View File

@@ -14,6 +14,8 @@
* guest → host {type:'cmd', cmd, …} (only when the host allows control) * guest → host {type:'cmd', cmd, …} (only when the host allows control)
* host → server {type:'invite'|'uninvite', profile} invite an online profile; it sees * 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} * 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 * host → all {type:'present', present: {url, page} | null} shared document/website
* (guests too while the host allows control; kept for late joiners) * (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}
@@ -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 PROFILE_RE = /^[a-z0-9_-]{3,40}$/;
const MAX_INVITES = 30; 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 } = {}) { export function createPartyHub({ now = () => Date.now(), profileExists = async () => true } = {}) {
const parties = new Map(); // code → party const parties = new Map(); // code → party
@@ -114,7 +118,7 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async (
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 = { 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); parties.set(code, p);
} }
p.hostPid = d.pid; p.hostPid = d.pid;
@@ -134,7 +138,7 @@ export function createPartyHub({ now = () => Date.now(), profileExists = async (
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, present: p.present, 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); 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() }; 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 }); 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) { } else if ((m.type === 'invite' || m.type === 'uninvite') && isHost) {
const name = String(m.profile || '').trim().toLowerCase(); 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; } if (!PROFILE_RE.test(name)) { send(ws, { type: 'invites', invites: inviteList(p), error: 'Profile names are 3–40 letters, digits, - or _' }); return; }

View File

@@ -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', () => { describe('watch party invites', () => {
test('host invites a profile; it is listed for that profile until uninvited or the party ends', async () => { 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' }); const host = client({ role: 'host', pid: 'hostpid0020', name: 'Josh' });