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

849 lines
52 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, 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, tab: 'chat', retries: 0, lastSeekAt: 0, pingAt: 0, lastPong: 0,
gentle: ls.get('ytpPartyGentle') !== 'false', session: 0, replaced: false,
};
const inParty = () => !!st.role;
const isHost = () => st.role === 'host';
const guestActive = () => st.role === 'guest' && !st.detached;
const send = (m) => {
if (navigator.onLine === false || !st.ws || st.ws.readyState !== 1 || st.ws.bufferedAmount > 65536) return false;
try { st.ws.send(JSON.stringify(m)); return true; } catch { return false; }
};
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);
if (navigator.onLine === false) { setStatus('Offline · waiting for your connection'); return; }
const old = st.ws; st.ws = null;
try { old?.close(); } catch { /* gone */ }
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; st.lastPong = Date.now();
ws.onmessage = (e) => { if (st.ws !== ws) return; 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; st.replaced = 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;
}
const delay = Math.min(30000, 1000 * 2 ** Math.min(st.retries++, 5)) + Math.random() * 500;
setStatus(`Connection lost · retrying in ${Math.ceil(delay / 1000)}s`);
st.retry = setTimeout(connect, delay);
};
ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); };
}
function onMessage(m) {
if (m.type === 'direct') { DirectMedia.handle('party', m); return; }
if (m.type === 'pong') {
if (m.echo !== st.pingAt) return;
const rtt = Date.now() - st.pingAt;
st.lastPong = Date.now();
if (rtt < 5000) st.offset = m.now - (st.pingAt + rtt / 2);
const el = $('ptLatency'); if (el) el.textContent = `${Math.round(rtt)} ms round trip`;
return;
}
if (m.type === 'hello') {
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] : [] })) });
st.retries = 0; st.lastPong = Date.now(); st.lastPush = '';
st.state = m.state || null;
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' : st.detached ? 'Watching independently' : 'Following 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;
const want = targetTime(m.state);
// playVideoAt seeks only once the media can play; broadcast after that
// seek lands, or guests are briefly sent back to 0:00.
const landed = () => new Promise((resolve) => {
const t0 = Date.now();
const check = () => { const M = Player.master; if (!want || (M && Math.abs((M.currentTime || 0) - want) < 2) || Date.now() - t0 > 8000) resolve(); else setTimeout(check, 150); };
check();
});
Promise.resolve(playVideoAt({ ...saved }, want)).catch(() => {}).then(landed).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.hostAway) setStatus('Host disconnected · waiting for them to return');
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?.readyState !== 1 || st.restoring) return;
const s = stateNow();
const key = JSON.stringify({ ...s, cur: Math.round(s.cur) });
if (!force && key === st.lastPush) return;
if (send({ type: 'state', state: s })) st.lastPush = key;
updateNowPlaying(s.v);
}
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 Math.max(0, s.paused ? s.cur : s.cur + Math.max(0, (serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1));
}
async function applyState(s) {
st.state = s;
updateNowPlaying(s?.v);
if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
if (!st.members.some(m => m.host)) 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) {
const session = st.session;
st.loadingId = s.v.id;
quiet(30000);
try { await playVideoAt({ ...s.v }, target); } catch { /* the drift timer retries */ }
finally { if (session === st.session) { st.loadingId = ''; quiet(); } }
if (session !== st.session) return;
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(); }
// Do not keep discarding buffered data while a slow device/network catches up.
const drift = Math.abs((M.currentTime || 0) - target);
const threshold = st.gentle && !s.paused ? 4 : 1.5;
if (drift > threshold && !M.seeking && (s.paused || M.readyState >= 3) &&
Date.now() - st.lastSeekAt > (st.gentle ? 8000 : 2500)) {
st.lastSeekAt = Date.now(); quiet(); Player.seek(Math.min(target, Number.isFinite(M.duration) ? M.duration : target));
}
if (!st.ws || st.ws.readyState !== 1) return;
setStatus(M.readyState < 3 && !s.paused ? 'Buffering · keeping your place' : 'Following the host');
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; st.replaced = false; st.lastSeekAt = 0;
if (!st.ws || st.ws.readyState !== 1) { 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.tab = 'chat';
st.session++; st.replaced = false; st.detached = false; st.loadingId = ''; st.state = null;
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 (inParty()) leave(false);
if (name) { st.name = name; ls.set(NAME_KEY, name); }
st.session++; st.replaced = false; st.loadingId = ''; st.state = null; st.lastSeekAt = 0;
st.tab = 'chat';
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();
if (st.ws?.readyState === 1 && Date.now() - st.pingAt >= 15000) {
if (st.lastPong && Date.now() - st.lastPong > 45000) { setStatus('Reconnecting…'); connect(); return; }
st.pingAt = Date.now(); send({ type: 'ping', echo: st.pingAt });
}
}, 3000);
st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.ws?.readyState === 1 && 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.session++; st.loadingId = ''; st.state = null; st.retries = 0;
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');
$('partyPanel').querySelector('.pt-body').dataset.view = '';
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 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) el.textContent = t; }
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); if (st.chat.length > 100) st.chat.shift(); renderChat(); }
function link() { return `${location.origin}/?party=${st.code}`; }
function showPanel() { Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); }
function updateNowPlaying(video) {
const el = $('ptNow');
const title = video?.title || 'Choose a video to watch together';
if (el && el.textContent !== title) el.textContent = title;
const play = document.querySelector('#partyPanel [data-act="playback"]');
if (play) { const label = Player.master?.paused ? 'Play' : 'Pause'; if (play.textContent !== label) play.textContent = label; play.disabled = !current?.meta; }
}
function updateSection(selector, key, html) {
const el = document.querySelector(`#partyPanel ${selector}`);
if (!el || el._key === key) return;
el._key = key; el.innerHTML = html;
}
function selectTab(tab) {
st.tab = tab;
const panel = $('partyPanel');
panel.querySelectorAll('[data-tab]').forEach(b => {
b.setAttribute('aria-selected', String(b.dataset.tab === tab));
b.tabIndex = b.dataset.tab === tab ? 0 : -1;
});
panel.querySelectorAll('[data-pane]').forEach(p => { p.hidden = p.dataset.pane !== tab; });
if (tab === 'chat') renderChat();
if (tab === 'queue') Lazy.load('feature:party-dj').then(render).catch(error => toast(error.message));
}
function render() {
const panel = $('partyPanel');
const label = $('navPartyBtn')?.querySelector('.nav-label');
if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party';
if (!panel || panel.classList.contains('hidden') || st.minimized) return;
const body = panel.querySelector('.pt-body');
if (!inParty()) {
$('ptTitle').textContent = 'Watch party';
if (body.dataset.view === 'lobby') return;
body.dataset.view = 'lobby';
body.innerHTML = `<div class="pt-lobby"><h2>Good company.<br>One shared screen.</h2>
<p>Watch in sync, talk in chat, and choose what plays next.</p>
${myName() ? '' : '<label class="pt-field">Your name<input class="pt-name" maxlength="40" autocomplete="nickname" placeholder="Your name" /></label>'}
<button class="rv-primary" type="button" data-act="start">Start a party</button>
<div class="pt-or">Have an invitation?</div>
<label class="pt-field" for="ptCode">Party code</label><div class="pt-row"><input id="ptCode" class="pt-code" maxlength="6" placeholder="ABC123" autocapitalize="characters" autocomplete="off" value="${escapeHtml(st.inviteCode)}" /><button class="np-btn" type="button" data-act="join">Join</button></div>
<p class="pt-hint">Each person streams on their own device. Audio-only is available for slower connections.</p></div>`;
return;
}
$('ptTitle').textContent = isHost() ? 'Your watch party' : 'Watch party';
if (body.dataset.view !== 'room') {
body.dataset.view = 'room';
body.innerHTML = `<div class="pt-session"><div><span class="pt-caption">Invitation code</span><strong id="ptRoomCode"></strong></div><button class="np-btn" data-act="copy" type="button">Copy link</button>${navigator.share ? '<button class="np-btn" data-act="share" type="button">Share</button>' : ''}</div>
<div class="pt-playing"><span class="pt-caption">Watching together</span><strong id="ptNow"></strong><button class="pt-resync np-btn" type="button" data-act="resync">Re-sync</button></div>
${isHost() ? `<div class="pt-transport" aria-label="Host playback controls"><button class="np-btn" type="button" data-act="back10" aria-label="Back 10 seconds">−10 s</button><button class="np-btn" type="button" data-act="playback">Play</button><button class="np-btn" type="button" data-act="forward10" aria-label="Forward 10 seconds">+10 s</button><button class="np-btn" type="button" data-act="next">Next</button></div>` : ''}
<div class="pt-tabs" role="tablist" aria-label="Party sections">${['chat','queue','room'].map(t => `<button type="button" id="ptTab-${t}" role="tab" data-tab="${t}" aria-controls="ptPane-${t}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}</div>
<section id="ptPane-chat" class="pt-pane" data-pane="chat" role="tabpanel" aria-labelledby="ptTab-chat">
<div class="pt-chat" id="ptChat" role="log" aria-label="Party chat" aria-live="polite" aria-relevant="additions" tabindex="0"></div>
<button type="button" class="pt-new np-btn" data-act="latest" hidden>New messages · jump to latest</button>
<form class="pt-send"><input class="pt-msg" aria-label="Message the party" maxlength="500" placeholder="Say something…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
</section>
<section id="ptPane-queue" class="pt-pane" data-pane="queue" role="tabpanel" aria-labelledby="ptTab-queue" hidden><p class="pt-hint">Suggest a video and vote for what’s next. The host approves the queue.</p><div class="pt-dj"></div></section>
<section id="ptPane-room" class="pt-pane" data-pane="room" role="tabpanel" aria-labelledby="ptTab-room" hidden>
<h3>In the room <span id="ptMemberCount"></span></h3><div class="pt-members"></div>
<div class="pt-options"><h3>On this device</h3>
<label class="pt-toggle"><input type="checkbox" data-act="gentle" />Gentle sync</label><p class="pt-hint">Allows a small delay and fewer seeks when your connection struggles.</p>
<label class="pt-toggle"><input type="checkbox" data-act="audio" />Audio only</label><p class="pt-hint">Use the audio stream to reduce video data and decoding.</p><span id="ptLatency" class="pt-hint">Measuring connection…</span>
</div><div class="pt-voice"></div><div class="pt-present"></div><div class="pt-host"></div>
${isHost() ? `<div class="pt-pq"></div>
<div class="pt-invite-box"><h3>Invite profiles</h3><form class="pt-invite"><input class="pt-invite-name" maxlength="40" placeholder="Profile name" aria-label="Invite a profile by name" autocapitalize="none" autocomplete="off" spellcheck="false" /><button class="np-btn" type="submit">Invite</button></form><div class="pt-members pt-invited"></div></div>` : ''}
<div class="pt-lyrics-controls"><button class="np-btn pt-lyrics" type="button" data-act="lyrics">Lyrics instead of video</button><button class="np-btn pt-land" type="button" data-act="land" hidden>Landscape lyrics</button></div>
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>
</section>`;
selectTab(st.tab);
}
$('ptRoomCode').textContent = st.code || 'Connecting…';
panel.querySelector('[data-act="copy"]').disabled = !st.code;
panel.querySelector('.pt-resync').hidden = isHost();
updateNowPlaying(isHost() ? current?.meta : st.state?.v);
$('ptMemberCount').textContent = `(${st.members.length})`;
updateSection('.pt-members', JSON.stringify(st.members), st.members.map(m => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${escapeHtml(m.name)}${m.host ? ' · Host' : ''}${m.voice ? ' · Voice' : ''}</span>`).join(''));
panel.querySelector('[data-act="gentle"]').checked = st.gentle;
panel.querySelector('[data-act="audio"]').checked = !!data.settings.audioOnly;
updateSection('.pt-voice', `${st.voice.on}/${st.voice.muted}/${st.voice.pcs.size}`, `<button class="np-btn" 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.pcs.size} connected</span>` : '<span class="pt-hint">Microphone is off</span>'}`);
updateSection('.pt-present', `${!!st.present}/${st.presentHidden}/${canPresent()}`, `${st.present && st.presentHidden ? '<button class="np-btn" type="button" data-act="present-show">Show presentation</button>' : ''}${canPresent() ? `<button class="np-btn" type="button" data-act="present">${st.present ? 'Change presentation' : 'Present a link'}</button>${st.present ? '<button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : ''}` : ''}`);
const host = panel.querySelector('.pt-host');
if (isHost() && !host.firstChild) host.innerHTML = `<h3>Host controls</h3><label class="pt-toggle"><input type="checkbox" data-act="allow" />Guests can control playback</label>`;
if (isHost()) {
host.querySelector('[data-act="allow"]').checked = st.allowControl;
}
if (isHost()) {
updateSection('.pt-pq', JSON.stringify([st.pq, st.present && st.present.url]), `<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>'}`);
updateSection('.pt-invited', JSON.stringify(st.invites), 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(''));
}
const dj = panel.querySelector('.pt-dj');
const djKey = JSON.stringify(st.dj);
if (Lazy.loaded('feature:party-dj') && dj._key !== djKey) {
dj._key = djKey;
PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
if (!st.dj.enabled) dj.insertAdjacentHTML('beforeend', `<p class="pt-hint">${isHost() ? 'Enable Party DJ to let everyone suggest and vote.' : 'The host hasn’t enabled suggestions yet.'}</p>`);
}
renderLyricsToggle(); renderChat();
}
function renderChat() {
const box = $('ptChat');
if (!box || st.minimized || st.tab !== 'chat' || $('partyPanel').classList.contains('hidden')) return;
const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 48;
const previous = box._messages || [];
if (box._messages && previous.length === st.chat.length && previous.every((c, i) => c === st.chat[i])) return;
const appendOnly = previous.length <= st.chat.length && previous.every((c, i) => c === st.chat[i]);
const scroll = box.scrollTop;
if (!appendOnly || !previous.length) box.replaceChildren();
const fragment = document.createDocumentFragment();
for (const c of st.chat.slice(appendOnly ? previous.length : 0)) {
const row = document.createElement('div');
row.className = c.sys ? 'pt-sys' : `pt-line${c.pid === pid ? ' me' : ''}`;
if (c.sys) row.textContent = c.text;
else { const name = document.createElement('b'); name.textContent = c.from; const message = document.createElement('span'); message.textContent = c.text; row.append(name, message); }
fragment.append(row);
}
box.append(fragment); box._messages = st.chat.slice();
if (!st.chat.length) box.innerHTML = '<p class="pt-empty">You’re in good company.<br>Start the conversation.</p>';
const latest = document.querySelector('#partyPanel .pt-new');
if (nearBottom || !previous.length) { box.scrollTop = box.scrollHeight; latest.hidden = true; }
else { box.scrollTop = scroll; latest.hidden = false; }
}
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'); render(); }
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); if (!st.minimized) render();
});
panel.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.target.matches('[data-tab]') && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
e.preventDefault(); const tabs = ['chat', 'queue', 'room'];
const i = e.key === 'Home' ? 0 : e.key === 'End' ? 2 : (tabs.indexOf(st.tab) + (e.key === 'ArrowRight' ? 1 : 2)) % 3;
selectTab(tabs[i]); panel.querySelector(`[data-tab="${tabs[i]}"]`).focus();
}
if (e.key === 'Enter' && e.target.matches('.pt-code')) { e.preventDefault(); panel.querySelector('[data-act="join"]').click(); }
});
panel.addEventListener('click', async (e) => {
const tab = e.target.closest('[data-tab]'); if (tab) { selectTab(tab.dataset.tab); return; }
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 === 'latest') { const box = $('ptChat'); box.scrollTop = box.scrollHeight; a.hidden = true; }
else if (isHost() && ['back10', 'forward10', 'playback', 'next'].includes(act)) {
if (act === 'playback') Player.toggle();
else if (act === 'next') playNext();
else Player.seek(Math.max(0, (Player.master?.currentTime || 0) + (act === 'back10' ? -10 : 10)));
pushState(true);
}
else 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 === 'gentle') { st.gentle = e.target.checked; ls.set('ytpPartyGentle', String(st.gentle)); }
if (e.target.dataset.act === 'audio') {
quiet(10000); els.audioOnlyToggle.checked = e.target.checked;
els.audioOnlyToggle.dispatchEvent(new Event('change', { bubbles: true }));
}
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) { if (send({ type: 'chat', text })) input.value = ''; else toast('Not connected yet. Your message is still here.'); }
});
// 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();
addEventListener('online', () => { if (inParty() && !st.replaced) connect(); });
addEventListener('offline', () => { if (inParty()) setStatus('Offline · waiting for your connection'); });
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.replaced) 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,
};
})();