Files
ytplayer/frontend/watch-party-client.js

504 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Lazy classic chunk; its executing singleton stays pinned until reload. */
(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', 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,
};
const inParty = () => !!st.role;
const isHost = () => st.role === 'host';
const guestActive = () => st.role === 'guest' && !st.detached;
const send = (m) => { if (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); };
const serverNow = () => Date.now() + st.offset;
const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
// ---------- connection ----------
function connect() {
clearTimeout(st.retry);
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));
st.ws = ws;
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); };
ws.onclose = (e) => {
if (st.ws !== ws) return;
st.ws = null;
if (!inParty()) return;
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;
}
setStatus('Reconnecting…');
st.retry = setTimeout(connect, 3000);
};
ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); };
}
function onMessage(m) {
DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) });
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
if (m.type === 'hello') {
st.code = m.code;
if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
st.offset = (m.now || Date.now()) - Date.now();
st.members = m.members || [];
st.chat = m.chat || [];
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()) {
// 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();
} else if (m.type === 'dj-play' && isHost()) {
st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); }
} else if (m.type === 'state') {
st.state = m.state;
if (st.role === 'guest') applyState(m.state);
} else if (m.type === 'members') {
st.members = m.members || [];
if (m.joined) sysMsg(`${m.joined} joined`);
if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
render();
syncVoicePeers();
} else if (m.type === 'chat') {
st.chat.push(m.msg);
if (st.chat.length > 100) st.chat.shift();
renderChat();
if (m.msg.pid !== pid && (st.minimized || $('partyPanel').classList.contains('hidden'))) toast(`💬 ${m.msg.from}: ${m.msg.text}`);
} else if (m.type === 'settings') {
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 === 'cmd' && isHost()) {
runCommand(m);
} else if (m.type === 'rtc') {
onRtc(m.from, m.data);
} else if (m.type === 'ended') {
toast(`🎉 ${m.reason || 'The party ended'}`, { duration: 5000 });
}
}
// ---------- host: broadcast ----------
function stateNow() {
const meta = current && current.meta;
const M = Player.master;
return {
directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
cur: M ? M.currentTime || 0 : 0,
paused: !M || M.paused,
rate: (M && M.playbackRate) || 1,
};
}
function pushState(force = false) {
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;
st.lastPush = key;
send({ type: 'state', state: s });
}
function runCommand(m) {
switch (m.cmd) {
case 'toggle': Player.toggle(); break;
case 'play': Player.play(); break;
case 'pause': Player.pause(); break;
case 'seek': if (Number.isFinite(Number(m.t))) Player.seek(Math.max(0, Number(m.t))); break;
case 'next': playNext(); break;
case 'prev': playPrev(); break;
default: return;
}
toast(`🎉 ${m.from}: ${m.cmd === 'seek' ? `jumped to ${fmtTime(Number(m.t))}` : m.cmd}`);
setTimeout(() => pushState(true), 200);
}
// ---------- guest: follow ----------
const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
function targetTime(s) {
if (!s) return 0;
return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1);
}
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) {
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;
if (!M) return;
if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); }
if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); }
if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
}
// A guest's own actions: forwarded when allowed, else they go off sync.
function onLocalAction(kind) {
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 });
else send({ type: 'cmd', cmd: kind === 'pause' ? 'pause' : 'play' });
return;
}
st.detached = true;
setStatus('Off sync — you paused or skipped on your own');
render();
}
// playNext/playPrev guard: guests don't change songs themselves.
function intercept(cmd) {
if (st.role !== 'guest' || st.detached) return false;
if (st.allowControl) send({ type: 'cmd', cmd });
else toast('🎉 The host picks the songs in a watch party');
return true;
}
function resync() {
st.detached = false;
if (!st.ws) { setStatus('Reconnecting…'); connect(); render(); return; }
setStatus('Synced with the host');
render();
applyState(st.state);
}
// ---------- voice (WebRTC mesh) ----------
const ICE = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
function peer(other) {
let pc = st.voice.pcs.get(other);
if (pc) return pc;
pc = new RTCPeerConnection({ iceServers: ICE });
st.voice.pcs.set(other, pc);
if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => pc.addTrack(t, st.voice.stream));
pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'rtc', to: other, data: { ice: e.candidate } }); };
pc.ontrack = (e) => {
let a = st.voice.audios.get(other);
if (!a) { a = new Audio(); a.autoplay = true; a.setAttribute('playsinline', ''); st.voice.audios.set(other, a); }
a.srcObject = e.streams[0];
a.play().catch(() => {});
};
pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) dropPeer(other); render(); };
return pc;
}
function dropPeer(other) {
const pc = st.voice.pcs.get(other);
if (pc) { try { pc.close(); } catch { /* closed */ } st.voice.pcs.delete(other); }
const a = st.voice.audios.get(other);
if (a) { a.srcObject = null; st.voice.audios.delete(other); }
}
async function offerTo(other) {
const pc = peer(other);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
send({ type: 'rtc', to: other, data: { sdp: pc.localDescription } });
}
async function onRtc(from, d) {
if (!st.voice.on || !d) return;
try {
if (d.sdp) {
const pc = peer(from);
await pc.setRemoteDescription(d.sdp);
if (d.sdp.type === 'offer') {
const ans = await pc.createAnswer();
await pc.setLocalDescription(ans);
send({ type: 'rtc', to: from, data: { sdp: pc.localDescription } });
}
} else if (d.ice) {
await peer(from).addIceCandidate(d.ice);
}
} catch { /* a glare/ICE hiccup — the next members update retries */ }
}
// The member with the smaller pid makes the offer, so each pair connects once.
function connectVoicePeers() {
if (!st.voice.on) return;
for (const m of st.members) if (m.voice && m.pid !== pid && !st.voice.pcs.has(m.pid) && pid < m.pid) offerTo(m.pid).catch(() => {});
}
function syncVoicePeers() {
const voiced = new Set(st.members.filter((m) => m.voice).map((m) => m.pid));
for (const other of [...st.voice.pcs.keys()]) if (!voiced.has(other)) dropPeer(other);
connectVoicePeers();
}
async function toggleVoice() {
if (st.voice.on) {
st.voice.on = false;
for (const other of [...st.voice.pcs.keys()]) dropPeer(other);
if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => t.stop());
st.voice.stream = null;
send({ type: 'voice', on: false });
render();
return;
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof RTCPeerConnection === 'undefined') { toast('Voice chat isn’t supported in this browser'); return; }
try {
st.voice.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
} catch (e) {
toast('🎤 Microphone blocked: ' + (e.message || e.name));
return;
}
st.voice.on = true;
st.voice.muted = false;
send({ type: 'voice', on: true });
toast('🎤 Voice on — headphones stop the music echoing into your mic', { duration: 5000 });
render();
connectVoicePeers();
}
function toggleMute() {
if (!st.voice.stream) return;
st.voice.muted = !st.voice.muted;
st.voice.stream.getAudioTracks().forEach((t) => { t.enabled = !st.voice.muted; });
render();
}
// ---------- lifecycle ----------
function start() {
st.role = 'host';
st.code = ''; st.secret = ''; st.chat = []; st.members = [];
ls.set(HOST_KEY, null);
showPanel();
setStatus('Starting…');
connect();
beginTimers();
}
function join(code, name) {
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.inviteCode = '';
st.code = code; st.detached = false; st.chat = []; st.members = [];
showPanel();
setStatus('Joining…');
connect();
beginTimers();
}
function beginTimers() {
clearInterval(st.pushTimer); clearInterval(st.driftTimer);
st.pushTimer = setInterval(() => pushState(), 3000);
st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.state && !st.detached) applyState(st.state); }, 2500);
}
function leave(sendEnd = true) {
if (isHost() && sendEnd) send({ type: 'end' });
if (st.voice.on) toggleVoice();
const ws = st.ws;
st.ws = null;
st.role = null; st.code = ''; st.secret = '';
clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer);
try { if (ws) ws.close(); } catch { /* gone */ }
ls.set(HOST_KEY, null);
$('partyPanel').classList.add('hidden');
render();
}
// ---------- UI ----------
// Lyrics-instead-of-video is a per-device view: each guest chooses it for
// themselves, and the host's playback is untouched (the video keeps playing
// behind the lyrics, so sync and audio carry on).
const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on());
function renderLyricsToggle() {
const b = document.querySelector('#partyPanel .pt-lyrics');
if (!b) return;
b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
b.classList.toggle('on', lyricsOn());
}
function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; }
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
function link() { return `${location.origin}/?party=${st.code}`; }
function showPanel() { Lazy.load('feature:party-dj').then(render).catch(error=>toast(error.message)); Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
function render() {
const panel = $('partyPanel');
const chip = $('navPartyBtn');
if (chip) {
const label = chip.querySelector('.nav-label');
if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party';
else chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party';
}
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" 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;
}
$('ptTitle').textContent = `🎉 Party ${st.code || '…'}`;
const s = st.state && st.state.v;
panel.querySelector('.pt-body').innerHTML = `
${st.code ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
${!isHost() && s ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''}
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
<button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
<div class="pt-voice">
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
<span class="pt-voice-n">${st.voice.on ? `${st.voice.pcs.size} connected` : ''}</span>
</div>
<div class="pt-chat" id="ptChat"></div>
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
<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() {
const box = $('ptChat');
if (!box) return;
box.innerHTML = st.chat.map((c) => c.sys
? `<div class="pt-sys">${escapeHtml(c.text)}</div>`
: `<div class="pt-line${c.pid === pid ? ' me' : ''}"><b>${escapeHtml(c.from)}</b> ${escapeHtml(c.text)}</div>`).join('');
box.scrollTop = box.scrollHeight;
}
function open() {
const panel = $('partyPanel');
if (typeof closeSidebar === 'function') { try { closeSidebar(); } catch { /* not open */ } }
if (panel.classList.contains('hidden')) showPanel();
else if (st.minimized) { st.minimized = false; panel.classList.remove('min'); }
else if (!inParty()) panel.classList.add('hidden');
else { st.minimized = true; panel.classList.add('min'); }
}
function bindUi() {
const panel = $('partyPanel');
$('ptMin').addEventListener('click', () => {
if (!inParty()) { panel.classList.add('hidden'); return; }
st.minimized = !st.minimized;
panel.classList.toggle('min', st.minimized);
});
panel.addEventListener('keydown', (e) => e.stopPropagation());
panel.addEventListener('click', async (e) => {
const a = e.target.closest('[data-act]');
if (!a || a.tagName === 'INPUT') return;
const act = a.dataset.act;
const nameIn = panel.querySelector('.pt-name');
const name = nameIn ? nameIn.value.trim() : '';
if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
if (name) { st.name = name; ls.set(NAME_KEY, name); }
if (act === 'start') start();
else if (act === 'join') join(panel.querySelector('.pt-code').value);
else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
else if (act === 'resync') resync();
else if (act === 'voice') toggleVoice();
else if (act === 'mute') toggleMute();
else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
else if (act === 'leave') leave(true);
});
panel.addEventListener('change', (e) => {
if (e.target.dataset.act === 'allow') { st.allowControl = e.target.checked; send({ type: 'settings', allowControl: st.allowControl }); }
});
panel.addEventListener('submit', (e) => {
e.preventDefault();
const input = panel.querySelector('.pt-msg');
const text = input.value.trim();
if (text) { send({ type: 'chat', text }); input.value = ''; }
});
// Host: push on every player change; guests: detect their own actions.
['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
[els.video, els.audio].forEach((el) => el.addEventListener(ev, (e) => {
if (el !== Player.master) return;
if (isHost()) pushState(true);
else if (ev === 'play' || ev === 'pause' || ev === 'seeked') onLocalAction(ev);
}));
});
}
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');
if (h && h.code && h.secret) { st.role = 'host'; st.code = h.code; st.secret = h.secret; showPanel(); connect(); beginTimers(); }
} catch { /* none */ }
let code = '';
try { code = (new URLSearchParams(location.search).get('party') || '').toUpperCase(); } catch { /* no URL */ }
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 { st.inviteCode = code; showPanel(); toast('Enter your name, then tap Join'); }
}
}
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),
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),
remoteAudio: [...st.voice.audios.values()].filter((a) => a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live')).length,
chat: st.chat.length, allowControl: st.allowControl,
}),
_join: join, _start: start,
};
})();