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:
@@ -13,7 +13,7 @@
|
||||
<meta name="apple-mobile-web-app-title" content="YT Player" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'sha256-__APP_SCRIPT_HASH__' 'wasm-unsafe-eval' https://esm.sh; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
|
||||
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'sha256-__APP_SCRIPT_HASH__' 'wasm-unsafe-eval' https://esm.sh; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self'; frame-src https:;"
|
||||
/>
|
||||
<title>YT Player</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''}
|
||||
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
|
||||
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
|
||||
<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>' : ''}`
|
||||
: canPresent() ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : ''}</div>
|
||||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||||
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||||
<div class="pt-voice">
|
||||
@@ -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 = `<header class="pp-bar">
|
||||
<span class="pp-what"></span>
|
||||
<span class="pp-pages"><button type="button" class="np-btn" data-pp="prev" aria-label="Previous page">‹</button><span class="pp-page"></span><button type="button" class="np-btn" data-pp="next" aria-label="Next page">›</button></span>
|
||||
<a class="np-btn pp-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||
<button type="button" class="np-btn" data-pp="hide">Hide</button>
|
||||
</header>
|
||||
<iframe class="pp-frame" title="Shared presentation" referrerpolicy="no-referrer" allow="fullscreen"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation"></iframe>
|
||||
<p class="pp-hint">Blank? Some sites don't allow embedding — tap Open to view it in a new tab.</p>`;
|
||||
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 = `<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://docs.google.com/presentation/d/…" value="${escapeHtml(st.present?.url || '')}" />
|
||||
<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 = () => {
|
||||
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();
|
||||
|
||||
@@ -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