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:
@@ -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 = `
|
||||
<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>'}
|
||||
<button class="rv-primary" type="button" data-act="start">Start a party</button>
|
||||
<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';
|
||||
setStatus('');
|
||||
return;
|
||||
@@ -362,6 +394,8 @@
|
||||
<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);
|
||||
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'); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user