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 = `
Watch together: everyone's player follows the host, with chat and optional voice.
${nameOk ? '' : ''}