Watch party: guests stream instead of waiting on a device copy, one load per song, host seat and guest seats can't be taken over, host reload restores the song, real taps only detach, reconnect after background, keep invite code and chat draft
This commit is contained in:
@@ -5,7 +5,7 @@ const {createHash}=require('node:crypto');
|
|||||||
const {join}=require('node:path');
|
const {join}=require('node:path');
|
||||||
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||||
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
|
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 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 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");}
|
{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");}
|
||||||
|
|||||||
@@ -2462,7 +2462,9 @@ const Player = {
|
|||||||
_currentBlobUrl = null;
|
_currentBlobUrl = null;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
const received = await DirectMedia.obtain(videoObj.id);
|
||||||
if (seq !== this._loadSeq) return;
|
if (seq !== this._loadSeq) return;
|
||||||
if (!received) { showSpinner(false); 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
|
// 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
|
// 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").
|
// 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) {
|
if (data.settings.loopOne) {
|
||||||
Player.seek(abA);
|
Player.seek(abA);
|
||||||
} else if (!advanceQueue()) {
|
} else if (!advanceQueue()) {
|
||||||
@@ -8652,7 +8654,8 @@ function saveAbMarkers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function restoreAbMarkers() {
|
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
|
// Prefer a marker stored on the current playlist's copy of the video; fall
|
||||||
// back to the global per-video marker.
|
// back to the global per-video marker.
|
||||||
const entry = currentPlaylistEntry();
|
const entry = currentPlaylistEntry();
|
||||||
|
|||||||
@@ -2,19 +2,25 @@
|
|||||||
(window.YT ||= {}).Party = (() => {
|
(window.YT ||= {}).Party = (() => {
|
||||||
const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
|
const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
|
||||||
if (!WEB || typeof WebSocket === 'undefined') return noop;
|
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 = {
|
const ls = {
|
||||||
get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
|
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 */ } },
|
set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
|
||||||
};
|
};
|
||||||
let pid = ls.get(PID_KEY);
|
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); }
|
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 wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
|
||||||
|
|
||||||
const st = {
|
const st = {
|
||||||
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
|
||||||
dj: { enabled: false, entries: [] }, djPending: false,
|
dj: { enabled: false, entries: [] }, djPending: false,
|
||||||
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
|
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() },
|
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
|
||||||
minimized: false,
|
minimized: false,
|
||||||
};
|
};
|
||||||
@@ -28,7 +34,7 @@
|
|||||||
// ---------- connection ----------
|
// ---------- connection ----------
|
||||||
function connect() {
|
function connect() {
|
||||||
clearTimeout(st.retry);
|
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 (st.code) q.code = st.code;
|
||||||
if (isHost() && st.secret) q.secret = st.secret;
|
if (isHost() && st.secret) q.secret = st.secret;
|
||||||
const ws = new WebSocket(wsUrl(q));
|
const ws = new WebSocket(wsUrl(q));
|
||||||
@@ -38,8 +44,10 @@
|
|||||||
if (st.ws !== ws) return;
|
if (st.ws !== ws) return;
|
||||||
st.ws = null;
|
st.ws = null;
|
||||||
if (!inParty()) return;
|
if (!inParty()) return;
|
||||||
if (e.code === 4010 || e.code === 4004 || e.code === 4003) {
|
if (e.code === 4000) { setStatus('Opened on another tab or device — tap Re-sync to use this one'); st.detached = true; render(); return; }
|
||||||
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 === 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);
|
leave(false);
|
||||||
toast(`🎉 ${why}`, { duration: 5000 });
|
toast(`🎉 ${why}`, { duration: 5000 });
|
||||||
return;
|
return;
|
||||||
@@ -62,9 +70,21 @@
|
|||||||
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
|
||||||
st.allowControl = !!m.allowControl;
|
st.allowControl = !!m.allowControl;
|
||||||
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
|
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();
|
render();
|
||||||
if (isHost()) pushState(true);
|
if (isHost()) {
|
||||||
else if (m.state) applyState(m.state);
|
// 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(); }
|
if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
|
||||||
} else if (m.type === 'dj') {
|
} else if (m.type === 'dj') {
|
||||||
st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
|
st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
|
||||||
@@ -110,7 +130,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
function pushState(force = false) {
|
function pushState(force = false) {
|
||||||
if (!isHost() || !st.ws) return;
|
if (!isHost() || !st.ws || st.restoring) return;
|
||||||
const s = stateNow();
|
const s = stateNow();
|
||||||
const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
|
const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
|
||||||
if (!force && key === st.lastPush) return;
|
if (!force && key === st.lastPush) return;
|
||||||
@@ -140,10 +160,14 @@
|
|||||||
async function applyState(s) {
|
async function applyState(s) {
|
||||||
st.state = s;
|
st.state = s;
|
||||||
if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
|
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);
|
const target = targetTime(s);
|
||||||
if (!current || !current.meta || current.meta.id !== s.v.id) {
|
if (!current || !current.meta || current.meta.id !== s.v.id) {
|
||||||
quiet(6000);
|
st.loadingId = s.v.id;
|
||||||
await playVideoAt({ ...s.v }, target);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const M = Player.master;
|
const M = Player.master;
|
||||||
@@ -155,7 +179,10 @@
|
|||||||
}
|
}
|
||||||
// A guest's own actions: forwarded when allowed, else they go off sync.
|
// A guest's own actions: forwarded when allowed, else they go off sync.
|
||||||
function onLocalAction(kind) {
|
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) {
|
if (st.allowControl) {
|
||||||
const M = Player.master;
|
const M = Player.master;
|
||||||
if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
|
if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
|
||||||
@@ -175,6 +202,7 @@
|
|||||||
}
|
}
|
||||||
function resync() {
|
function resync() {
|
||||||
st.detached = false;
|
st.detached = false;
|
||||||
|
if (!st.ws) { setStatus('Reconnecting…'); connect(); render(); return; }
|
||||||
setStatus('Synced with the host');
|
setStatus('Synced with the host');
|
||||||
render();
|
render();
|
||||||
applyState(st.state);
|
applyState(st.state);
|
||||||
@@ -281,7 +309,7 @@
|
|||||||
code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
|
if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
|
||||||
if (name) { st.name = name; ls.set(NAME_KEY, name); }
|
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 = [];
|
st.code = code; st.detached = false; st.chat = []; st.members = [];
|
||||||
showPanel();
|
showPanel();
|
||||||
setStatus('Joining…');
|
setStatus('Joining…');
|
||||||
@@ -332,13 +360,17 @@
|
|||||||
}
|
}
|
||||||
if (!panel || panel.classList.contains('hidden')) return;
|
if (!panel || panel.classList.contains('hidden')) return;
|
||||||
const nameOk = !!myName();
|
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()) {
|
if (!inParty()) {
|
||||||
panel.querySelector('.pt-body').innerHTML = `
|
panel.querySelector('.pt-body').innerHTML = `
|
||||||
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
|
<p class="modal-text">Watch together: everyone's player follows the host, with chat and optional voice.</p>
|
||||||
${nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>'}
|
${nameOk ? '' : '<label class="pt-row">Your name <input class="pt-name" maxlength="40" placeholder="e.g. Rex" /></label>'}
|
||||||
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
||||||
<div class="pt-or">or join one</div>
|
<div class="pt-or">or join one</div>
|
||||||
<div class="pt-row"><input class="pt-code" maxlength="7" placeholder="CODE" autocapitalize="characters" /><button class="np-btn" type="button" data-act="join">Join</button></div>`;
|
<div class="pt-row"><input class="pt-code" maxlength="7" placeholder="CODE" autocapitalize="characters" value="${escapeHtml(draft.code)}" /><button class="np-btn" type="button" data-act="join">Join</button></div>`;
|
||||||
|
const nameIn = panel.querySelector('.pt-name'); if (nameIn) nameIn.value = draft.name;
|
||||||
$('ptTitle').textContent = '🎉 Watch party';
|
$('ptTitle').textContent = '🎉 Watch party';
|
||||||
setStatus('');
|
setStatus('');
|
||||||
return;
|
return;
|
||||||
@@ -362,6 +394,8 @@
|
|||||||
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
|
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
|
||||||
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
|
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 || []; } });
|
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();
|
renderChat();
|
||||||
}
|
}
|
||||||
function renderChat() {
|
function renderChat() {
|
||||||
@@ -430,6 +464,17 @@
|
|||||||
function boot() {
|
function boot() {
|
||||||
bindUi();
|
bindUi();
|
||||||
render();
|
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.
|
// Resume hosting after a reload.
|
||||||
try {
|
try {
|
||||||
const h = JSON.parse(ls.get(HOST_KEY) || 'null');
|
const h = JSON.parse(ls.get(HOST_KEY) || 'null');
|
||||||
@@ -440,7 +485,7 @@
|
|||||||
if (code) {
|
if (code) {
|
||||||
try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
|
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);
|
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'); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
|
|||||||
const PARTY_GRACE_MS = 5 * 60_000;
|
const PARTY_GRACE_MS = 5 * 60_000;
|
||||||
const CHAT_KEEP = 60;
|
const CHAT_KEEP = 60;
|
||||||
const MAX_MSG = 64 * 1024;
|
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']);
|
export const PARTY_COMMANDS = new Set(['toggle', 'play', 'pause', 'seek', 'next', 'prev']);
|
||||||
|
|
||||||
const clean = (v, max, fallback = '') =>
|
const clean = (v, max, fallback = '') =>
|
||||||
@@ -73,22 +74,24 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
if (!parties.has(code)) {
|
if (!parties.has(code)) {
|
||||||
fails.push(t); joinFails.set(ip, fails);
|
fails.push(t); joinFails.set(ip, fails);
|
||||||
if (joinFails.size > 5000) joinFails.clear();
|
if (joinFails.size > 5000) joinFails.clear();
|
||||||
return new Response('no such party', { status: 404 });
|
|
||||||
}
|
}
|
||||||
} else if (role !== 'host') {
|
} else if (role !== 'host') {
|
||||||
return new Response('unknown role', { status: 400 });
|
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 });
|
return ok ? undefined : new Response('upgrade failed', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(ws) {
|
function open(ws) {
|
||||||
const d = ws.data;
|
const d = ws.data;
|
||||||
let p;
|
let p, lost = false;
|
||||||
if (d.role === 'host') {
|
if (d.role === 'host') {
|
||||||
p = d.code && parties.get(d.code);
|
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 && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
|
||||||
if (!p) {
|
if (!p) {
|
||||||
|
lost = !!d.code; // resuming a room the server no longer has (restart / expiry)
|
||||||
const code = newCode();
|
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 };
|
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);
|
parties.set(code, p);
|
||||||
@@ -99,12 +102,16 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
} else {
|
} else {
|
||||||
p = parties.get(d.code);
|
p = parties.get(d.code);
|
||||||
if (!p) { ws.close(4004, 'no such party'); return; }
|
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);
|
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 */ } }
|
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, {
|
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,
|
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);
|
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 p = parties.get(d.code);
|
||||||
const me = p && p.members.get(d.pid);
|
const me = p && p.members.get(d.pid);
|
||||||
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
|
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') {
|
if (m.type === 'direct') {
|
||||||
p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send);
|
p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send);
|
||||||
} else if (m.type === 'dj') {
|
} else if (m.type === 'dj') {
|
||||||
@@ -141,6 +154,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
|||||||
if (p.chat.length > CHAT_KEEP) p.chat.shift();
|
if (p.chat.length > CHAT_KEEP) p.chat.shift();
|
||||||
broadcast(p, { type: 'chat', msg });
|
broadcast(p, { type: 'chat', msg });
|
||||||
} else if (m.type === 'voice') {
|
} else if (m.type === 'voice') {
|
||||||
|
if (me.voice === !!m.on) return;
|
||||||
me.voice = !!m.on;
|
me.voice = !!m.on;
|
||||||
broadcast(p, { type: 'members', members: members(p) });
|
broadcast(p, { type: 'members', members: members(p) });
|
||||||
} else if (m.type === 'rtc' && typeof m.to === 'string') {
|
} else if (m.type === 'rtc' && typeof m.to === 'string') {
|
||||||
|
|||||||
@@ -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.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();
|
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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user