Watch party invites by profile: host invites online profiles, invited users see running parties on open and can join one or dismiss

This commit is contained in:
Jonathan Sykes
2026-10-15 19:26:37 +08:00
parent 23097918e9
commit 3f913de0c3
6 changed files with 133 additions and 6 deletions

View File

@@ -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,
loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '',
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
minimized: false,
};
@@ -70,6 +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 || [];
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();
@@ -109,6 +110,10 @@
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 === 'invites') {
st.invites = m.invites || []; st.inviteError = m.error || '';
if (m.error) toast('⚠ ' + m.error);
render();
} else if (m.type === 'present') {
const was = st.present && st.present.url;
st.present = m.present || null;
@@ -395,6 +400,8 @@
<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() ? `<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>` : ''}` : ''}
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
<div class="pt-present"><button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
<button class="np-btn pt-land" type="button" data-act="land"${lyricsOn() ? '' : ' hidden'}>${Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'}</button></div>
@@ -463,6 +470,7 @@
else if (act === 'land') { Notes.stageLyrics.landscape?.(!Notes.stageLyrics.landscapeOn?.()); renderLyricsToggle(); }
else if (act === 'leave') leave(true);
else if (act === 'present') presentPrompt();
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-show') { st.presentHidden = false; renderPresent(); render(); }
});
@@ -471,6 +479,11 @@
});
panel.addEventListener('submit', (e) => {
e.preventDefault();
if (e.target.closest('.pt-invite')) {
const inp = panel.querySelector('.pt-invite-name'), name = inp.value.trim().toLowerCase();
if (name) { send({ type: 'invite', profile: name }); inp.value = ''; }
return;
}
const input = panel.querySelector('.pt-msg');
const text = input.value.trim();
if (text) { send({ type: 'chat', text }); input.value = ''; }
@@ -485,6 +498,34 @@
});
}
// ---------- invites: parties that invited this device's online profile ----------
const SEEN_KEY = 'ytpPartyInvitesSeen';
let invitesOpen = false;
async function checkInvites({ force = false } = {}) {
const name = data.profile && data.profile.name;
if (!name || inParty() || invitesOpen || document.visibilityState === 'hidden') return;
let list = [];
try {
const res = await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: typeof profileHeaders === 'function' ? profileHeaders(name) : {} });
const j = await res.json(); list = (j && j.ok && j.invites) || [];
} catch { return; }
let seen = []; try { seen = JSON.parse(ls.get(SEEN_KEY) || '[]'); } catch { /* none */ }
const fresh = list.filter((i) => force || !seen.includes(`${i.code}:${i.at}`));
if (!fresh.length || inParty()) return;
invitesOpen = true;
const markSeen = () => { ls.set(SEEN_KEY, JSON.stringify([...new Set([...seen, ...list.map((i) => `${i.code}:${i.at}`)])].slice(-50))); };
const body = document.createElement('div');
body.innerHTML = `<p class="modal-text">${list.length === 1 ? 'You’ve been invited to a watch party.' : `You’ve been invited to ${list.length} watch parties.`}</p>
<div class="pt-invites">${list.map((i) => `<div class="pt-invite-row">
<div><b>${escapeHtml(i.host)}’s party</b><small>${i.song ? `Playing ${escapeHtml(i.song)} · ` : ''}${i.members} ${i.members === 1 ? 'person' : 'people'}${i.hostOnline ? '' : ' · host away'}</small></div>
<button class="rv-primary" type="button" data-join="${escapeHtml(i.code)}">Join</button></div>`).join('')}</div>`;
body.addEventListener('click', (e) => {
const b = e.target.closest('[data-join]'); if (!b) return;
markSeen(); invitesOpen = false; closeModal(); join(b.dataset.join, myName() || name);
});
showModal('🎉 Watch party invites', body, [{ label: 'Dismiss', onClick: () => { markSeen(); invitesOpen = false; closeModal(); } }]);
}
// ---------- 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
@@ -561,8 +602,11 @@
render();
// iOS keeps a dead socket "open" after the PWA was in the background:
// after a long absence reconnect straight away instead of waiting for onclose.
setTimeout(() => checkInvites(), 2500);
setInterval(() => checkInvites(), 60_000);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; }
checkInvites();
if (!inParty() || st.detached) return;
const ws = st.ws;
if (!ws || ws.readyState !== 1 || Date.now() - st.hiddenAt > 20000) {
@@ -586,7 +630,7 @@
return {
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
open, boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
open, boot, guestActive, intercept, renderLyricsToggle, checkInvites, afterLoad: () => pushState(true),
state: () => ({
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),