/* 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, 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(); 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 === '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()); } 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 = ` ${nameOk ? '' : ''}
or join one
`; 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 ? `
${escapeHtml(link())}${navigator.share ? '' : ''}
` : ''} ${!isHost() && s ? `
${TransportIcons.svg('play')} ${escapeHtml(s.title)}
` : ''} ${!isHost() && st.detached ? '' : ''}
${st.members.map((m) => `${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}`).join('')}
${st.present ? `${st.presentHidden ? '' : ''}${canPresent() ? '' : ''}` : canPresent() ? '' : ''}
${isHost() ? `` : ''}
${st.voice.on ? `` : ''} ${st.voice.on ? `${st.voice.pcs.size} connected` : ''}
`; 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 ? `
${escapeHtml(c.text)}
` : `
${escapeHtml(c.from)} ${escapeHtml(c.text)}
`).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); else if (act === 'present') presentPrompt(); 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(); 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); })); }); } // ---------- 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 = `
Open

Blank? Some sites don't allow embedding — tap Open to view it in a new tab.

`; 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 (!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 } }); }); 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; } function renderPresent() { const p = st.present; if (!p) { const el = $('partyPresent'); if (el) { el.classList.add('hidden'); el.querySelector('.pp-frame').removeAttribute('src'); } return; } const el = presentEl(), e = embedOf(p.url, p.page || 1); 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 (frame.getAttribute('src') !== e.src) frame.setAttribute('src', e.src); } function presentPrompt() { const body = document.createElement('div'); body.innerHTML = ` `; const go = () => { 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; } closeModal(); send({ type: 'present', present: { url, page: 1 } }); }; showModal('🖥 Present to the party', body, [{ label: 'Cancel', onClick: closeModal }, { label: 'Present', primary: true, onClick: go }]); setTimeout(() => { const i = $('ppUrl'); if (i) { i.focus(); i.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); } }, 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. 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, }; })();