diff --git a/frontend/app.js b/frontend/app.js
index cc0eb8b..c47775a 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -6225,6 +6225,19 @@ const Party = {
let resume = false;
try { const host = JSON.parse(localStorage.getItem('ytpPartyHost') || 'null'); resume = !!(host?.code && host?.secret); } catch {}
if (resume || new URLSearchParams(location.search).has('party')) Lazy.load('feature:watch-party').catch(error => toast(error.message));
+ // Invites for this device's online profile: a cheap check that only loads
+ // the party chunk (which shows the invite list) when there is one.
+ const peek = async () => {
+ const name = data.profile && data.profile.name;
+ if (!name || Lazy.loaded('feature:watch-party') || document.visibilityState === 'hidden') return;
+ try {
+ const j = await (await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: profileHeaders(name) })).json();
+ if (j && j.ok && j.invites && j.invites.length) { await Lazy.load('feature:watch-party'); window.YT.Party.checkInvites(); }
+ } catch { /* offline */ }
+ };
+ setTimeout(peek, 3000);
+ setInterval(peek, 60_000);
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') peek(); });
},
};
Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot());
diff --git a/frontend/styles.css b/frontend/styles.css
index d222b49..5d30294 100755
--- a/frontend/styles.css
+++ b/frontend/styles.css
@@ -4066,6 +4066,14 @@ 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-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; }
+.pt-invites { display: grid; gap: 8px; margin-top: 4px; }
+.pt-invite-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--bg-2); }
+.pt-invite-row > div { flex: 1; min-width: 0; display: grid; gap: 2px; }
+.pt-invite-row small { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pt-invite-row .rv-primary { flex: none; min-height: 44px; }
/* 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;
diff --git a/frontend/watch-party-client.js b/frontend/watch-party-client.js
index e9d4ee1..365a17e 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,
+ 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 @@
${st.present
? `${st.presentHidden ? '' : ''}${canPresent() ? '' : ''}`
: canPresent() ? '' : ''}
+ ${isHost() ? `
+ ${st.invites.length ? `${st.invites.map((n) => `β ${escapeHtml(n)} `).join('')}
` : ''}` : ''}
${isHost() ? `` : ''}
@@ -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 = `${list.length === 1 ? 'Youβve been invited to a watch party.' : `Youβve been invited to ${list.length} watch parties.`}
+ ${list.map((i) => `
+
${escapeHtml(i.host)}βs party${i.song ? `Playing ${escapeHtml(i.song)} Β· ` : ''}${i.members} ${i.members === 1 ? 'person' : 'people'}${i.hostOnline ? '' : ' Β· host away'}
+
`).join('')}
`;
+ 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),
diff --git a/server/party.js b/server/party.js
index c8c52f2..93924ed 100644
--- a/server/party.js
+++ b/server/party.js
@@ -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 β 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 β 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}
@@ -35,7 +37,10 @@ const clean = (v, max, fallback = '') =>
String(v || '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').trim().slice(0, max) || fallback;
const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
-export function createPartyHub({ now = () => Date.now() } = {}) {
+const PROFILE_RE = /^[a-z0-9_-]{3,40}$/;
+const MAX_INVITES = 30;
+
+export function createPartyHub({ now = () => Date.now(), profileExists = async () => true } = {}) {
const parties = new Map(); // code β party
const joinFails = new Map();
@@ -43,6 +48,20 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
const members = (p) => [...p.members.values()].map((m) => ({ pid: m.pid, name: m.name, host: m.pid === p.hostPid, voice: !!m.voice }));
const broadcast = (p, m, except) => { for (const x of p.members.values()) if (x.ws !== except) send(x.ws, m); };
+ const inviteList = (p) => [...p.invites.keys()];
+ // Parties that invited this profile and are still running.
+ function invitesFor(profile) {
+ const name = String(profile || '').toLowerCase(), out = [];
+ for (const p of parties.values()) {
+ const inv = p.invites.get(name);
+ if (!inv) continue;
+ const host = p.members.get(p.hostPid);
+ out.push({ code: p.code, host: host ? host.name : inv.by, hostOnline: p.hostOnline, members: p.members.size,
+ song: p.state && p.state.v ? p.state.v.title || '' : '', at: inv.at });
+ }
+ return out.sort((a, b) => b.at - a.at);
+ }
+
function newCode() {
let c;
do { c = Array.from({ length: 6 }, () => ALPHABET[randomInt(ALPHABET.length)]).join(''); } while (parties.has(c));
@@ -95,7 +114,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 = { 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(), 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;
@@ -114,7 +133,8 @@ 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, present: p.present, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
+ 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,
});
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
}
@@ -151,6 +171,16 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
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 === '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; }
+ if (m.type === 'uninvite') { p.invites.delete(name); send(ws, { type: 'invites', invites: inviteList(p) }); return; }
+ if (p.invites.size >= MAX_INVITES) { send(ws, { type: 'invites', invites: inviteList(p), error: `At most ${MAX_INVITES} invites per party` }); return; }
+ Promise.resolve(profileExists(name)).catch(() => false).then((ok) => {
+ if (!parties.has(p.code)) return;
+ if (ok) p.invites.set(name, { at: now(), by: me.name });
+ send(ws, { type: 'invites', invites: inviteList(p), error: ok ? undefined : `No profile called β${name}β` });
+ });
} else if (m.type === 'settings' && isHost) {
p.allowControl = !!m.allowControl;
broadcast(p, { type: 'settings', allowControl: p.allowControl });
@@ -195,6 +225,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
return {
upgrade,
+ invitesFor,
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : null), close },
_parties: parties,
stop() { clearInterval(sweeper); },
diff --git a/server/party.test.js b/server/party.test.js
index a204390..f2ddc7b 100644
--- a/server/party.test.js
+++ b/server/party.test.js
@@ -146,6 +146,28 @@ describe('watch party presenting', () => {
});
});
+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' });
+ const { code } = await host.next('hello');
+ const p = hub._parties.get(code);
+ host.send({ type: 'invite', profile: 'Psalm-Team' });
+ expect((await host.next('invites')).invites).toEqual(['psalm-team']);
+ expect(hub.invitesFor('PSALM-TEAM')).toMatchObject([{ code, host: 'Josh', members: 1 }]);
+ host.send({ type: 'invite', profile: 'x' });
+ expect((await host.next('invites')).error).toMatch(/3β40/);
+ const g = client({ role: 'guest', pid: 'guestpid0021', key: 'g'.repeat(36), name: 'G', code });
+ await g.next('hello');
+ g.send({ type: 'invite', profile: 'someone' });
+ await Bun.sleep(150);
+ expect([...p.invites.keys()]).toEqual(['psalm-team']);
+ host.send({ type: 'uninvite', profile: 'psalm-team' });
+ expect((await host.next('invites')).invites).toEqual([]);
+ expect(hub.invitesFor('psalm-team')).toEqual([]);
+ 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' });
diff --git a/server/server.js b/server/server.js
index 2014e34..bcecea5 100644
--- a/server/server.js
+++ b/server/server.js
@@ -2304,7 +2304,16 @@ app.get('/api/user/data', async (c) => {
// Phone remote for a desktop instance β see remote.js
// ============================================================================
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
-const party = createPartyHub();
+const party = createPartyHub({ profileExists: async (name) => !!(await getProfile(name)) });
+// Running parties that invited this online profile (protected profiles need their secret).
+app.get('/api/party/invites', async (c) => {
+ const name = (c.req.query('name') || '').trim().toLowerCase();
+ const row = name ? await getProfile(name) : null;
+ if (!row) return c.json({ ok: true, invites: [] }, 200, { 'Cache-Control': 'no-store' });
+ const denied = await profileGate(c, name, row, c.req.header('x-profile-secret'));
+ if (denied) return denied;
+ return c.json({ ok: true, invites: party.invitesFor(name) }, 200, { 'Cache-Control': 'no-store' });
+});
// Bun allows ONE websocket handler per server: party sockets are tagged
// (ws.data.hub === 'party'), everything else belongs to the remote relay.