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

599 lines
33 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,
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());
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() ? `<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 === '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 = `<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>
<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>
<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 (!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 = `<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://docs.google.com/presentation/d/…" value="${escapeHtml(st.present?.url || '')}" />
<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 = () => {
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,
};
})();