718 lines
42 KiB
JavaScript
718 lines
42 KiB
JavaScript
/* 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, present: null, presentHidden: false, invites: [], inviteError: '', pq: [], viewMode: {}, reader: { url: '', html: '', error: '', loading: false },
|
||
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;
|
||
st.present = m.present || null; renderPresent();
|
||
st.invites = m.invites || []; st.pq = m.pq || [];
|
||
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 === 'pq') {
|
||
st.pq = m.items || []; render();
|
||
} else if (m.type === 'invites') {
|
||
st.invites = m.invites || []; st.inviteError = m.error || '';
|
||
if (m.error) toast('⚠ ' + m.error);
|
||
render();
|
||
} else if (m.type === 'present') {
|
||
const was = st.present && st.present.url;
|
||
st.present = m.present || null;
|
||
if (!st.present || st.present.url !== was) st.presentHidden = false;
|
||
if (st.present && st.present.url !== was && st.present.by !== myName()) toast(`🖥 ${st.present.by} is presenting`);
|
||
renderPresent(); render();
|
||
} 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);
|
||
st.present = null; renderPresent();
|
||
$('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());
|
||
const l = document.querySelector('#partyPanel .pt-land');
|
||
if (l) { l.hidden = !lyricsOn(); l.textContent = Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'; }
|
||
}
|
||
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>
|
||
<div class="pt-present">${st.present
|
||
? `${st.presentHidden ? '<button class="np-btn" type="button" data-act="present-show">🖥 Show the presentation</button>' : ''}${canPresent() ? '<button class="np-btn" type="button" data-act="present">Change link</button><button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : ''}`
|
||
: canPresent() ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : ''}</div>
|
||
${isHost() ? `<div class="pt-pq">
|
||
<div class="pt-pq-head"><b>Prepared</b><button class="np-btn" type="button" data-act="pq-new">+ Prepare</button></div>
|
||
${st.pq.length ? st.pq.map((q, i) => `<div class="pt-pq-row${st.present && st.present.url === q.url ? ' live' : ''}">
|
||
<span class="pt-pq-name">${escapeHtml(q.title || embedOf(q.url, 1).label || q.url)}<small>${q.mode === 'reader' ? 'Reader' : escapeHtml(embedOf(q.url, 1).label || '')}</small></span>
|
||
<button class="np-btn" type="button" data-act="pq-go" data-id="${escapeHtml(q.id)}">${st.present && st.present.url === q.url ? 'Showing' : 'Present'}</button>
|
||
<button class="np-btn" type="button" data-act="pq-up" data-id="${escapeHtml(q.id)}" aria-label="Move up"${i ? '' : ' disabled'}>↑</button>
|
||
<button class="np-btn" type="button" data-act="pq-del" data-id="${escapeHtml(q.id)}" aria-label="Remove">×</button></div>`).join('')
|
||
: '<p class="pt-pq-empty">Line up slides, documents and pages now; present each one when its time comes.</p>'}</div>` : ''}
|
||
${isHost() ? `<form class="pt-invite"><input class="pt-invite-name" maxlength="40" placeholder="Invite a profile by name" autocapitalize="none" autocomplete="off" spellcheck="false" /><button class="np-btn" type="submit">Invite</button></form>
|
||
${st.invites.length ? `<div class="pt-members pt-invited">${st.invites.map((n) => `<span class="pt-member">✉ ${escapeHtml(n)} <button type="button" class="pt-uninvite" data-act="uninvite" data-profile="${escapeHtml(n)}" aria-label="Cancel the invite for ${escapeHtml(n)}">×</button></span>`).join('')}</div>` : ''}` : ''}
|
||
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
|
||
<div class="pt-present"><button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
|
||
<button class="np-btn pt-land" type="button" data-act="land"${lyricsOn() ? '' : ' hidden'}>${Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'}</button></div>
|
||
<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') {
|
||
const on = !lyricsOn();
|
||
Notes.stageLyrics.set(on);
|
||
// Phones held upright go straight to landscape lyrics for the sing-along.
|
||
if (on && matchMedia('(pointer: coarse) and (orientation: portrait)').matches) Notes.stageLyrics.landscape?.(true);
|
||
renderLyricsToggle();
|
||
}
|
||
else if (act === 'land') { Notes.stageLyrics.landscape?.(!Notes.stageLyrics.landscapeOn?.()); renderLyricsToggle(); }
|
||
else if (act === 'leave') leave(true);
|
||
else if (act === 'present') presentPrompt();
|
||
else if (act === 'pq-new') presentPrompt({ queue: true });
|
||
else if (act === 'pq-go') { const q = st.pq.find((x) => x.id === a.dataset.id); if (q) send({ type: 'present', present: { url: q.url, mode: q.mode, page: 1 } }); }
|
||
else if (act === 'pq-up') send({ type: 'pq', action: 'move', id: a.dataset.id, by: -1 });
|
||
else if (act === 'pq-del') send({ type: 'pq', action: 'remove', id: a.dataset.id });
|
||
else if (act === 'uninvite') send({ type: 'uninvite', profile: a.dataset.profile });
|
||
else if (act === 'present-stop') send({ type: 'present', present: null });
|
||
else if (act === 'present-show') { st.presentHidden = false; renderPresent(); render(); }
|
||
});
|
||
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();
|
||
if (e.target.closest('.pt-invite')) {
|
||
const inp = panel.querySelector('.pt-invite-name'), name = inp.value.trim().toLowerCase();
|
||
if (name) { send({ type: 'invite', profile: name }); inp.value = ''; }
|
||
return;
|
||
}
|
||
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);
|
||
}));
|
||
});
|
||
}
|
||
|
||
// ---------- invites: parties that invited this device's online profile ----------
|
||
const SEEN_KEY = 'ytpPartyInvitesSeen';
|
||
let invitesOpen = false;
|
||
async function checkInvites({ force = false } = {}) {
|
||
const name = data.profile && data.profile.name;
|
||
if (!name || inParty() || invitesOpen || document.visibilityState === 'hidden') return;
|
||
let list = [];
|
||
try {
|
||
const res = await fetch(`/api/party/invites?name=${encodeURIComponent(name)}`, { headers: typeof profileHeaders === 'function' ? profileHeaders(name) : {} });
|
||
const j = await res.json(); list = (j && j.ok && j.invites) || [];
|
||
} catch { return; }
|
||
let seen = []; try { seen = JSON.parse(ls.get(SEEN_KEY) || '[]'); } catch { /* none */ }
|
||
const fresh = list.filter((i) => force || !seen.includes(`${i.code}:${i.at}`));
|
||
if (!fresh.length || inParty()) return;
|
||
invitesOpen = true;
|
||
const markSeen = () => { ls.set(SEEN_KEY, JSON.stringify([...new Set([...seen, ...list.map((i) => `${i.code}:${i.at}`)])].slice(-50))); };
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `<p class="modal-text">${list.length === 1 ? 'You’ve been invited to a watch party.' : `You’ve been invited to ${list.length} watch parties.`}</p>
|
||
<div class="pt-invites">${list.map((i) => `<div class="pt-invite-row">
|
||
<div><b>${escapeHtml(i.host)}’s party</b><small>${i.song ? `Playing ${escapeHtml(i.song)} · ` : ''}${i.members} ${i.members === 1 ? 'person' : 'people'}${i.hostOnline ? '' : ' · host away'}</small></div>
|
||
<button class="rv-primary" type="button" data-join="${escapeHtml(i.code)}">Join</button></div>`).join('')}</div>`;
|
||
body.addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-join]'); if (!b) return;
|
||
markSeen(); invitesOpen = false; closeModal(); join(b.dataset.join, myName() || name);
|
||
});
|
||
showModal('🎉 Watch party invites', body, [{ label: 'Dismiss', onClick: () => { markSeen(); invitesOpen = false; closeModal(); } }]);
|
||
}
|
||
|
||
// ---------- presenting: a shared document / website everyone sees ----------
|
||
const canPresent = () => inParty() && (isHost() || st.allowControl);
|
||
// Google Slides/Docs/Drive and PDFs get their embeddable form; Slides and PDFs
|
||
// also follow the presenter's page.
|
||
function embedOf(url, page) {
|
||
let u; try { u = new URL(url); } catch { return { src: url, paged: false }; }
|
||
const g = u.hostname === 'docs.google.com' && u.pathname.match(/^\/(presentation|document|spreadsheets)\/d\/([\w-]+)/);
|
||
if (g && g[1] === 'presentation') return { src: `https://docs.google.com/presentation/d/${g[2]}/embed?start=false&loop=false&rm=minimal&slide=${page}`, paged: true, label: 'Google Slides' };
|
||
if (g) return { src: `https://docs.google.com/${g[1]}/d/${g[2]}/preview`, paged: false, label: g[1] === 'document' ? 'Google Doc' : 'Google Sheet' };
|
||
const d = u.hostname === 'drive.google.com' && u.pathname.match(/^\/file\/d\/([\w-]+)/);
|
||
if (d) return { src: `https://drive.google.com/file/d/${d[1]}/preview`, paged: false, label: 'Google Drive file' };
|
||
if (/\.pdf$/i.test(u.pathname)) { u.hash = `page=${page}`; return { src: u.href, paged: true, label: 'PDF' }; }
|
||
return { src: u.href, paged: false, label: u.hostname.replace(/^www\./, '') };
|
||
}
|
||
function presentEl() {
|
||
let el = $('partyPresent');
|
||
if (el) return el;
|
||
el = document.createElement('section');
|
||
el.id = 'partyPresent'; el.className = 'party-present hidden'; el.setAttribute('aria-label', 'Shared presentation');
|
||
el.innerHTML = `<header class="pp-bar">
|
||
<span class="pp-what"></span>
|
||
<span class="pp-pages"><button type="button" class="np-btn" data-pp="prev" aria-label="Previous page">‹</button><span class="pp-page"></span><button type="button" class="np-btn" data-pp="next" aria-label="Next page">›</button></span>
|
||
<span class="pp-rtools"><button type="button" class="np-btn" data-pp="smaller" aria-label="Smaller text">A−</button><button type="button" class="np-btn" data-pp="bigger" aria-label="Larger text">A+</button><button type="button" class="np-btn" data-pp="rotate" aria-pressed="false">⟳ Landscape</button></span>
|
||
<button type="button" class="np-btn" data-pp="mode"></button>
|
||
<a class="np-btn pp-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||
<button type="button" class="np-btn" data-pp="hide">Hide</button>
|
||
</header>
|
||
<iframe class="pp-frame" title="Shared presentation" referrerpolicy="no-referrer" allow="fullscreen"
|
||
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation"></iframe>
|
||
<div class="pp-reader" hidden></div>
|
||
<p class="pp-hint">Blank? Some sites don't allow embedding — tap Open to view it in a new tab.</p>`;
|
||
el.addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-pp]'); if (!b || !st.present) return;
|
||
if (b.dataset.pp === 'hide') { st.presentHidden = true; renderPresent(); render(); return; }
|
||
if (b.dataset.pp === 'smaller' || b.dataset.pp === 'bigger') { readerSize(readerSize() + (b.dataset.pp === 'bigger' ? 2 : -2)); renderPresent(); return; }
|
||
if (b.dataset.pp === 'rotate') { setPresentLandscape(!el.classList.contains('landscape')); return; }
|
||
if (b.dataset.pp === 'mode') { st.viewMode[st.present.url] = viewOf(st.present) === 'reader' ? 'embed' : 'reader'; renderPresent(); return; }
|
||
if (!canPresent()) return;
|
||
const page = Math.max(1, (st.present.page || 1) + (b.dataset.pp === 'next' ? 1 : -1));
|
||
send({ type: 'present', present: { url: st.present.url, page, mode: st.present.mode } });
|
||
});
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!st.present || st.presentHidden || !canPresent() || e.target.closest?.('input, textarea')) return;
|
||
if (e.key === 'ArrowRight' || e.key === 'PageDown') el.querySelector('[data-pp="next"]').click();
|
||
if (e.key === 'ArrowLeft' || e.key === 'PageUp') el.querySelector('[data-pp="prev"]').click();
|
||
});
|
||
document.body.append(el);
|
||
return el;
|
||
}
|
||
// Reader view: the server turns the page into clean text; each device picks
|
||
// its own size and orientation, and can switch to the original page.
|
||
const viewOf = (p) => st.viewMode[p.url] || p.mode || 'embed';
|
||
const readerSize = (v) => {
|
||
if (v !== undefined) { ls.set('ytpReaderSize', String(Math.max(14, Math.min(44, v)))); }
|
||
return Number(ls.get('ytpReaderSize')) || 20;
|
||
};
|
||
async function setPresentLandscape(on) {
|
||
const el = $('partyPresent'); if (!el) return;
|
||
el.classList.toggle('landscape', !!on);
|
||
const b = el.querySelector('[data-pp="rotate"]'); b.setAttribute('aria-pressed', String(!!on)); b.textContent = on ? '⟲ Portrait' : '⟳ Landscape';
|
||
try {
|
||
if (on && document.documentElement.requestFullscreen && screen.orientation?.lock) { if (!document.fullscreenElement) await document.documentElement.requestFullscreen(); await screen.orientation.lock('landscape'); }
|
||
else if (!on) { try { screen.orientation?.unlock?.(); } catch { /* not locked */ } if (document.fullscreenElement) await document.exitFullscreen(); }
|
||
} catch { /* iOS: the CSS rotation covers it */ }
|
||
}
|
||
async function loadReader(url) {
|
||
if (st.reader.url === url && (st.reader.html || st.reader.loading)) return;
|
||
st.reader = { url, html: '', error: '', loading: true }; renderPresent();
|
||
try {
|
||
const j = await (await fetch(`/api/party/${encodeURIComponent(st.code)}/reader`)).json();
|
||
if (st.reader.url !== url) return;
|
||
if (!j.ok) throw new Error(j.error || 'Could not read that page');
|
||
st.reader = { url, loading: false, error: '', html: `<article class="pp-article"><h1>${escapeHtml(j.title || '')}</h1>
|
||
<p class="pp-by">${escapeHtml([j.byline, j.site].filter(Boolean).join(' · '))}</p>${j.content}</article>` };
|
||
} catch (err) { if (st.reader.url === url) st.reader = { url, loading: false, html: '', error: err.message }; }
|
||
renderPresent();
|
||
}
|
||
function renderPresent() {
|
||
const p = st.present;
|
||
if (!p) { const el = $('partyPresent'); if (el) { el.classList.add('hidden'); el.classList.remove('landscape'); el.querySelector('.pp-frame').removeAttribute('src'); } return; }
|
||
const el = presentEl(), e = embedOf(p.url, p.page || 1);
|
||
const reading = viewOf(p) === 'reader';
|
||
el.classList.toggle('reading', reading);
|
||
el.querySelector('.pp-rtools').hidden = !reading;
|
||
el.querySelector('[data-pp="mode"]').textContent = reading ? 'Original' : 'Reader';
|
||
const box = el.querySelector('.pp-reader');
|
||
box.hidden = !reading;
|
||
el.querySelector('.pp-frame').hidden = reading;
|
||
el.querySelector('.pp-hint').hidden = reading;
|
||
if (reading) {
|
||
el.style.setProperty('--pp-size', readerSize() + 'px');
|
||
if (st.reader.url !== p.url) loadReader(p.url);
|
||
const r = st.reader;
|
||
const html = r.loading ? '<p class="pp-msg">Making the page easy to read…</p>'
|
||
: r.error ? `<p class="pp-msg">${escapeHtml(r.error)}</p><p class="pp-msg"><button type="button" class="np-btn" data-pp="mode">Show the original page</button></p>` : r.html;
|
||
if (box.dataset.for !== p.url + '|' + html.length) { box.innerHTML = html; box.dataset.for = p.url + '|' + html.length; }
|
||
el.querySelector('.pp-frame').removeAttribute('src');
|
||
}
|
||
el.classList.toggle('hidden', st.presentHidden);
|
||
el.querySelector('.pp-what').textContent = `${p.by || 'Host'} · ${e.label}`;
|
||
el.querySelector('.pp-pages').hidden = !e.paged;
|
||
el.querySelector('.pp-page').textContent = `${e.label === 'PDF' ? 'Page' : 'Slide'} ${p.page || 1}`;
|
||
for (const b of el.querySelectorAll('.pp-pages button')) b.hidden = !canPresent();
|
||
el.querySelector('.pp-open').href = p.url;
|
||
const frame = el.querySelector('.pp-frame');
|
||
if (!reading && frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src);
|
||
}
|
||
function presentPrompt({ queue = false } = {}) {
|
||
const body = document.createElement('div');
|
||
body.innerHTML = `<p class="modal-text">Everyone in the party sees it full screen. Google Slides and PDFs follow your page — use ‹ › or the arrow keys.</p>
|
||
<input id="ppUrl" type="url" inputmode="url" autocomplete="off" spellcheck="false" placeholder="https://… a website, Google Slides/Docs or a PDF" value="${queue ? '' : escapeHtml(st.present?.url || '')}" />
|
||
${isHost() ? '<input id="ppTitle" maxlength="120" autocomplete="off" placeholder="Name it (optional), e.g. Sermon slides" style="margin-top:8px" />' : ''}
|
||
<label class="pt-toggle" style="margin-top:8px"><input type="checkbox" id="ppReader" ${st.present && st.present.mode === 'embed' ? '' : 'checked'} /> Reader view for websites — clean text everyone can enlarge</label>
|
||
<p class="modal-text" style="font-size:12px">Google files must be shared “Anyone with the link”. Sites like Facebook or banks refuse embedding; guests can still tap Open.</p>`;
|
||
const go = (toQueue = false) => {
|
||
let url = ($('ppUrl').value || '').trim();
|
||
if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||
try { new URL(url); } catch { toast('Paste a full link, e.g. https://…'); return; }
|
||
const special = embedOf(url, 1); // Google files and PDFs always embed
|
||
const mode = $('ppReader').checked && !special.paged && !/Google/.test(special.label || '') ? 'reader' : 'embed';
|
||
closeModal();
|
||
if (toQueue) { send({ type: 'pq', action: 'add', url, mode, title: ($('ppTitle')?.value || '').trim() }); toast('Added to Prepared ✓'); }
|
||
else send({ type: 'present', present: { url, page: 1, mode } });
|
||
};
|
||
showModal(queue ? '🖥 Prepare a presentation' : '🖥 Present to the party', body, [
|
||
{ label: 'Cancel', onClick: closeModal },
|
||
...(isHost() ? [{ label: 'Add to Prepared', primary: queue, onClick: () => go(true) }] : []),
|
||
...(queue ? [] : [{ label: 'Present now', primary: true, onClick: () => go(false) }]),
|
||
]);
|
||
setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(queue); } }); } }, 50);
|
||
}
|
||
|
||
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.
|
||
setTimeout(() => checkInvites(), 2500);
|
||
setInterval(() => checkInvites(), 60_000);
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; }
|
||
checkInvites();
|
||
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, checkInvites, 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,
|
||
};
|
||
})();
|