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.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.