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

720 lines
42 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, 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();
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;
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.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 === '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>
<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;
}
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 === '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();
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);
}));
});
}
// ---------- 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();
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.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; }
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, 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,
};
})();