diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js index 4b9d3bb..a73effc 100644 --- a/frontend/app-seams.test.js +++ b/frontend/app-seams.test.js @@ -5,7 +5,7 @@ const {createHash}=require('node:crypto'); const {join}=require('node:path'); const app=readFileSync(join(__dirname,'app.js'),'utf8'); test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{ - {const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"2a0edc8b4a556589b1fa5c8ba49ba2e9978e90d452882a30594f172f13c93cc0","Player body unchanged");} + {const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"190834ae6f33a168917416e55799f8a467e0d28f966f636a182745a59ffcac41","Player body unchanged");} {const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");} {const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");} {const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");} diff --git a/frontend/app.js b/frontend/app.js index 54e2da6..89929d0 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -2462,7 +2462,9 @@ const Player = { _currentBlobUrl = null; } try { - if (WEB && !cachedIds.has(videoObj.id) && window.DirectMedia?.hasSource(videoObj.id)) { + // Party guests follow the host in real time: never hold playback for a + // full device-to-device copy (and its accept prompt) — stream instead. + if (WEB && !cachedIds.has(videoObj.id) && !Party.guestActive() && window.DirectMedia?.hasSource(videoObj.id)) { const received = await DirectMedia.obtain(videoObj.id); if (seq !== this._loadSeq) return; if (!received) { showSpinner(false); return; } @@ -6214,7 +6216,7 @@ function updateProgress() { // A-B loop: passing B behaves like the track ending. Loop the A-B segment only // when "loop current" is on; otherwise B is the effective end of the track and // we advance per the repeat settings (next track, or wrap when "repeat list"). - if (!playFullMode && abA !== null && abB !== null && abB > abA && cur >= abB) { + if (!playFullMode && abA !== null && abB !== null && abB > abA && cur >= abB && !Party.guestActive()) { if (data.settings.loopOne) { Player.seek(abA); } else if (!advanceQueue()) { @@ -8652,7 +8654,8 @@ function saveAbMarkers() { } } function restoreAbMarkers() { - if (!current || !current.meta) { abA = null; abB = null; updateAbUI(); return; } + // A party guest follows the host's timeline; their own practice loop would fight it. + if (!current || !current.meta || Party.guestActive()) { abA = null; abB = null; updateAbUI(); return; } // Prefer a marker stored on the current playlist's copy of the video; fall // back to the global per-video marker. const entry = currentPlaylistEntry(); diff --git a/frontend/watch-party-client.js b/frontend/watch-party-client.js index f1e81a9..f6d92be 100644 --- a/frontend/watch-party-client.js +++ b/frontend/watch-party-client.js @@ -2,19 +2,25 @@ (window.YT ||= {}).Party = (() => { const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} }; if (!WEB || typeof WebSocket === 'undefined') return noop; - const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName'; + const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName', KEY_KEY = 'ytpPartyKey'; const ls = { get: (k) => { try { return localStorage.getItem(k); } catch { return null; } }, set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } }, }; let pid = ls.get(PID_KEY); if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); } + // Private per-device key: the participant id is shown to everyone, this proves the seat is ours. + let seatKey = ls.get(KEY_KEY); + if (!seatKey) { const b = new Uint8Array(18); crypto.getRandomValues(b); seatKey = [...b].map((x) => x.toString(16).padStart(2, '0')).join(''); ls.set(KEY_KEY, seatKey); } + let lastGestureAt = 0; + for (const n of ['pointerdown', 'keydown', 'touchstart']) addEventListener(n, () => { lastGestureAt = Date.now(); }, { capture: true, passive: true }); const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`; const st = { 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, voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() }, minimized: false, }; @@ -28,7 +34,7 @@ // ---------- connection ---------- function connect() { clearTimeout(st.retry); - const q = { role: st.role, pid, name: myName() || 'Guest' }; + const q = { role: st.role, pid, key: seatKey, name: myName() || 'Guest' }; if (st.code) q.code = st.code; if (isHost() && st.secret) q.secret = st.secret; const ws = new WebSocket(wsUrl(q)); @@ -38,8 +44,10 @@ if (st.ws !== ws) return; st.ws = null; if (!inParty()) return; - if (e.code === 4010 || e.code === 4004 || e.code === 4003) { - const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.'; + if (e.code === 4000) { setStatus('Opened on another tab or device — tap Re-sync to use this one'); st.detached = true; render(); return; } + if (e.code === 4010 || e.code === 4004 || e.code === 4003 || e.code === 4005) { + const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' + : e.code === 4005 ? 'Someone else is using this seat in the party.' : 'The party ended.'; leave(false); toast(`🎉 ${why}`, { duration: 5000 }); return; @@ -62,9 +70,21 @@ st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false; st.allowControl = !!m.allowControl; 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(); - if (isHost()) pushState(true); - else if (m.state) applyState(m.state); + if (isHost()) { + // A host reload: put the party's song back before broadcasting, or + // everyone would be sent an empty player. + const saved = m.state && m.state.v; + if (saved && (!current || !current.meta || current.meta.id !== saved.id)) { + st.restoring = true; + Promise.resolve(playVideoAt({ ...saved }, targetTime(m.state))).catch(() => {}).finally(() => { + st.restoring = false; + if (m.state.paused) Player.pause(); + pushState(true); + }); + } else pushState(true); + } else if (m.state) applyState(m.state); if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); } } else if (m.type === 'dj') { st.dj = { enabled: m.enabled, entries: m.entries || [] }; render(); @@ -110,7 +130,7 @@ }; } function pushState(force = false) { - if (!isHost() || !st.ws) return; + if (!isHost() || !st.ws || st.restoring) return; const s = stateNow(); const key = JSON.stringify({ ...s, cur: Math.round(s.cur) }); if (!force && key === st.lastPush) return; @@ -140,10 +160,14 @@ async function applyState(s) { st.state = s; if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; } + if (st.loadingId) return; // a load is running; its finish re-applies the newest state const target = targetTime(s); if (!current || !current.meta || current.meta.id !== s.v.id) { - quiet(6000); - await playVideoAt({ ...s.v }, target); + st.loadingId = s.v.id; + quiet(30000); + try { await playVideoAt({ ...s.v }, target); } catch { /* the drift timer retries */ } + finally { st.loadingId = ''; quiet(); } + if (st.state && st.state.v && current && current.meta && current.meta.id === st.state.v.id) applyState(st.state); return; } const M = Player.master; @@ -155,7 +179,10 @@ } // A guest's own actions: forwarded when allowed, else they go off sync. function onLocalAction(kind) { - if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return; + if (st.role !== 'guest' || st.detached || st.loadingId || Date.now() < st.quietUntil) return; + // Buffering, drift correction and media reattachment fire play/pause/seeked + // too; only a real tap or key press is the guest's own decision. + if (Date.now() - lastGestureAt > 1500) return; if (st.allowControl) { const M = Player.master; if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime }); @@ -175,6 +202,7 @@ } function resync() { st.detached = false; + if (!st.ws) { setStatus('Reconnecting…'); connect(); render(); return; } setStatus('Synced with the host'); render(); applyState(st.state); @@ -281,7 +309,7 @@ code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; } if (name) { st.name = name; ls.set(NAME_KEY, name); } - st.role = 'guest'; + st.role = 'guest'; st.inviteCode = ''; st.code = code; st.detached = false; st.chat = []; st.members = []; showPanel(); setStatus('Joining…'); @@ -332,13 +360,17 @@ } if (!panel || panel.classList.contains('hidden')) return; const nameOk = !!myName(); + // Keep what the user is typing across re-renders (members/DJ updates rebuild the panel). + const draft = { msg: panel.querySelector('.pt-msg')?.value || '', focus: document.activeElement?.classList?.contains('pt-msg'), + name: panel.querySelector('.pt-name')?.value || '', code: panel.querySelector('.pt-code')?.value || st.inviteCode }; if (!inParty()) { panel.querySelector('.pt-body').innerHTML = ` ${nameOk ? '' : ''}
or join one
-
`; +
`; + const nameIn = panel.querySelector('.pt-name'); if (nameIn) nameIn.value = draft.name; $('ptTitle').textContent = '🎉 Watch party'; setStatus(''); return; @@ -362,6 +394,8 @@ `; const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj); if (Lazy.loaded('feature:party-dj')) PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } }); + const msgIn = panel.querySelector('.pt-msg'); + if (msgIn && draft.msg) { msgIn.value = draft.msg; if (draft.focus) msgIn.focus(); } renderChat(); } function renderChat() { @@ -430,6 +464,17 @@ function boot() { bindUi(); 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. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; } + if (!inParty() || st.detached) return; + const ws = st.ws; + if (!ws || ws.readyState !== 1 || Date.now() - st.hiddenAt > 20000) { + st.ws = null; try { if (ws) ws.close(); } catch { /* gone */ } + setStatus('Reconnecting…'); connect(); + } + }); // Resume hosting after a reload. try { const h = JSON.parse(ls.get(HOST_KEY) || 'null'); @@ -440,7 +485,7 @@ if (code) { try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ } if (myName()) join(code); - else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); } + else { st.inviteCode = code; showPanel(); toast('Enter your name, then tap Join'); } } } diff --git a/server/party.js b/server/party.js index 01e6d17..3a9e589 100644 --- a/server/party.js +++ b/server/party.js @@ -26,6 +26,7 @@ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I const PARTY_GRACE_MS = 5 * 60_000; const CHAT_KEEP = 60; const MAX_MSG = 64 * 1024; +const MSG_BURST = 40, MSG_PER_SEC = 20; // per member: everything except chat, which has its own guard export const PARTY_COMMANDS = new Set(['toggle', 'play', 'pause', 'seek', 'next', 'prev']); const clean = (v, max, fallback = '') => @@ -73,22 +74,24 @@ export function createPartyHub({ now = () => Date.now() } = {}) { if (!parties.has(code)) { fails.push(t); joinFails.set(ip, fails); if (joinFails.size > 5000) joinFails.clear(); - return new Response('no such party', { status: 404 }); } } else if (role !== 'host') { return new Response('unknown role', { status: 400 }); } - const ok = server.upgrade(req, { data: { hub: 'party', role, pid, name, code, secret: q('secret') } }); + // Unknown codes still upgrade so open() can answer with close code 4004 — + // a plain HTTP 404 made the client retry forever. + const ok = server.upgrade(req, { data: { hub: 'party', role, pid, name, code, secret: q('secret'), key: q('key').slice(0, 64), ip } }); return ok ? undefined : new Response('upgrade failed', { status: 400 }); } function open(ws) { const d = ws.data; - let p; + let p, lost = false; if (d.role === 'host') { p = d.code && parties.get(d.code); if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; } 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 }; parties.set(code, p); @@ -99,12 +102,16 @@ export function createPartyHub({ now = () => Date.now() } = {}) { } else { p = parties.get(d.code); if (!p) { ws.close(4004, 'no such party'); return; } + // Participant ids are public (members list); a guest may not claim the host's. + if (d.pid === p.hostPid) { ws.close(4003, 'not the host of this party'); return; } } const prev = p.members.get(d.pid); + // Only the same device (same private key) may take over a participant's seat. + if (prev && prev.ws !== ws && prev.key && d.role !== 'host' && !same(prev.key, d.key || '')) { ws.close(4005, 'participant id in use'); return; } if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } } - p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 }); + 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, + 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, }); broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws); @@ -118,7 +125,13 @@ export function createPartyHub({ now = () => Date.now() } = {}) { const p = parties.get(d.code); const me = p && p.members.get(d.pid); if (!me || me.ws !== ws || !m || typeof m !== 'object') return; - const isHost = d.pid === p.hostPid; + const isHost = me.host && d.pid === p.hostPid; + if (m.type !== 'chat') { + const t = now(); + me.tokens = Math.min(MSG_BURST, me.tokens + ((t - me.refill) / 1000) * MSG_PER_SEC); me.refill = t; + if (me.tokens < 1) return; // flood: drop + me.tokens -= 1; + } if (m.type === 'direct') { p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send); } else if (m.type === 'dj') { @@ -141,6 +154,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) { if (p.chat.length > CHAT_KEEP) p.chat.shift(); broadcast(p, { type: 'chat', msg }); } else if (m.type === 'voice') { + if (me.voice === !!m.on) return; me.voice = !!m.on; broadcast(p, { type: 'members', members: members(p) }); } else if (m.type === 'rtc' && typeof m.to === 'string') { diff --git a/server/party.test.js b/server/party.test.js index 5a9dd23..d7d6bce 100644 --- a/server/party.test.js +++ b/server/party.test.js @@ -126,3 +126,45 @@ test('DJ suggestions and votes are broadcast, host consumes approved songs', asy h.send({ type: 'dj', action: 'take' }); expect((await h.next('dj-play')).video.id).toBe('0gfX0dFLaBc'); expect((await g.next('dj')).entries).toEqual([]); h.ws.close(); g.ws.close(); }); + +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' }); + expect(await bad.closed).toBe(4004); + }); + + test('a guest cannot take the host seat or another guest seat; the same device can', async () => { + const host = client({ role: 'host', pid: 'hostpid0002', name: 'H' }); + const { code } = await host.next('hello'); + const fake = client({ role: 'guest', pid: 'hostpid0002', name: 'Evil', code }); + expect(await fake.closed).toBe(4003); + const g = client({ role: 'guest', pid: 'guestpid0003', key: 'k'.repeat(36), name: 'G', code }); + await g.next('hello'); + const thief = client({ role: 'guest', pid: 'guestpid0003', key: 'x'.repeat(36), name: 'T', code }); + expect(await thief.closed).toBe(4005); + const again = client({ role: 'guest', pid: 'guestpid0003', key: 'k'.repeat(36), name: 'G', code }); + await again.next('hello'); + expect(await g.closed).toBe(4000); + host.send({ type: 'end' }); await host.closed; + }); + + test('a host resuming a room the server lost is told so', async () => { + const host = client({ role: 'host', pid: 'hostpid0004', name: 'H', code: 'QQQQQQ', secret: 'whatever-secret' }); + const hello = await host.next('hello'); + expect(hello.lost).toBe(true); + expect(hello.code).not.toBe('QQQQQQ'); + host.send({ type: 'end' }); await host.closed; + }); + + test('voice presence floods are dropped', async () => { + const host = client({ role: 'host', pid: 'hostpid0005', name: 'H' }); + const { code } = await host.next('hello'); + const g = client({ role: 'guest', pid: 'guestpid0006', key: 'k'.repeat(36), name: 'G', code }); + await g.next('hello'); host.drain('members'); + for (let i = 0; i < 400; i++) g.send({ type: 'voice', on: i % 2 === 0 }); + await Bun.sleep(300); + let n = 0; try { for (;;) { await host.next('members', 50); n++; } } catch { /* drained */ } + expect(n).toBeLessThanOrEqual(41); + host.send({ type: 'end' }); await host.closed; + }); +});