Merge the watch party refresh from Codex (tabs, gentle sync, heartbeat) with presenting, invites, Prepared queue and reader view; host reload waits for the restore seek before broadcasting

This commit is contained in:
Jonathan Sykes
2026-10-15 20:28:48 +08:00
12 changed files with 566 additions and 90 deletions

View File

@@ -22,29 +22,36 @@
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,
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 (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); };
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;
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); };
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; render(); 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.';
@@ -52,16 +59,28 @@
toast(`🎉 ${why}`, { duration: 5000 });
return;
}
setStatus('Reconnecting…');
st.retry = setTimeout(connect, 3000);
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) {
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 === '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();
@@ -71,7 +90,7 @@
st.allowControl = !!m.allowControl;
st.present = m.present || null; renderPresent();
st.invites = m.invites || []; st.pq = m.pq || [];
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
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()) {
@@ -80,7 +99,15 @@
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(() => {
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);
@@ -97,6 +124,7 @@
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();
@@ -144,12 +172,12 @@
};
}
function pushState(force = false) {
if (!isHost() || !st.ws || st.restoring) return;
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;
st.lastPush = key;
send({ type: 'state', state: s });
if (send({ type: 'state', state: s })) st.lastPush = key;
updateNowPlaying(s.v);
}
function runCommand(m) {
switch (m.cmd) {
@@ -169,25 +197,37 @@
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);
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 { st.loadingId = ''; quiet(); }
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(); }
if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); }
// 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(); }
}
@@ -215,8 +255,8 @@
return true;
}
function resync() {
st.detached = false;
if (!st.ws) { setStatus('Reconnecting…'); connect(); render(); return; }
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);
@@ -311,6 +351,8 @@
// ---------- 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);
@@ -322,7 +364,10 @@
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();
@@ -332,20 +377,28 @@
}
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);
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();
}
@@ -357,92 +410,147 @@
function renderLyricsToggle() {
const b = document.querySelector('#partyPanel .pt-lyrics');
if (!b) return;
b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
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; }
function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); }
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: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 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 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 };
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()) {
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>'}
$('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">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('');
<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 = `🎉 Party ${st.code || '…'}`;
const s = st.state && st.state.v;
panel.querySelector('.pt-body').innerHTML = `
${st.code ? `<div class="pt-share"><code>${escapeHtml(link())}</code><button class="np-btn" type="button" data-act="copy">Copy</button>${navigator.share ? '<button class="np-btn" type="button" data-act="share">↗</button>' : ''}</div>` : ''}
${!isHost() && s ? `<div class="pt-now">${TransportIcons.svg('play')} ${escapeHtml(s.title)}</div>` : ''}
${!isHost() && st.detached ? '<button class="rv-primary" type="button" data-act="resync">↻ Re-sync with the host</button>' : ''}
<div class="pt-members">${st.members.map((m) => `<span class="pt-member${m.pid === pid ? ' me' : ''}">${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}</span>`).join('')}</div>
<div class="pt-present">${st.present
? `${st.presentHidden ? '<button class="np-btn" type="button" data-act="present-show">🖥 Show the presentation</button>' : ''}${canPresent() ? '<button class="np-btn" type="button" data-act="present">Change link</button><button class="np-btn" type="button" data-act="present-stop">Stop presenting</button>' : ''}`
: canPresent() ? '<button class="np-btn" type="button" data-act="present">🖥 Present a document or website</button>' : ''}</div>
${isHost() ? `<div class="pt-pq">
<div class="pt-pq-head"><b>Prepared</b><button class="np-btn" type="button" data-act="pq-new">+ Prepare</button></div>
$('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>'}</div>` : ''}
${isHost() ? `<form class="pt-invite"><input class="pt-invite-name" maxlength="40" placeholder="Invite a profile by name" autocapitalize="none" autocomplete="off" spellcheck="false" /><button class="np-btn" type="submit">Invite</button></form>
${st.invites.length ? `<div class="pt-members pt-invited">${st.invites.map((n) => `<span class="pt-member">✉ ${escapeHtml(n)} <button type="button" class="pt-uninvite" data-act="uninvite" data-profile="${escapeHtml(n)}" aria-label="Cancel the invite for ${escapeHtml(n)}">×</button></span>`).join('')}</div>` : ''}` : ''}
${isHost() ? `<label class="pt-toggle"><input type="checkbox" data-act="allow" ${st.allowControl ? 'checked' : ''} /> Guests can control playback</label>` : ''}
<div class="pt-present"><button class="np-btn pt-lyrics${lyricsOn() ? ' on' : ''}" type="button" data-act="lyrics">${lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'}</button>
<button class="np-btn pt-land" type="button" data-act="land"${lyricsOn() ? '' : ' hidden'}>${Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'}</button></div>
<div class="pt-voice">
<button class="np-btn${st.voice.on ? ' on' : ''}" type="button" data-act="voice">${st.voice.on ? '🎤 Leave voice' : '🎤 Join voice'}</button>
${st.voice.on ? `<button class="np-btn" type="button" data-act="mute">${st.voice.muted ? '🔇 Unmute' : '🔈 Mute'}</button>` : ''}
<span class="pt-voice-n">${st.voice.on ? `${st.voice.pcs.size} connected` : ''}</span>
</div>
<div class="pt-chat" id="ptChat"></div>
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
if (Lazy.loaded('feature:party-dj')) PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
const msgIn = panel.querySelector('.pt-msg');
if (msgIn && draft.msg) { msgIn.value = draft.msg; if (draft.focus) msgIn.focus(); }
renderChat();
: '<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) 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;
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'); }
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'); }
}
@@ -452,10 +560,19 @@
$('ptMin').addEventListener('click', () => {
if (!inParty()) { panel.classList.add('hidden'); return; }
st.minimized = !st.minimized;
panel.classList.toggle('min', 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('keydown', (e) => e.stopPropagation());
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;
@@ -463,7 +580,14 @@
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();
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 */ } }
@@ -489,6 +613,11 @@
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) => {
@@ -500,7 +629,7 @@
}
const input = panel.querySelector('.pt-msg');
const text = input.value.trim();
if (text) { send({ type: 'chat', text }); input.value = ''; }
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) => {
@@ -674,6 +803,8 @@
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.
@@ -682,7 +813,7 @@
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') { st.hiddenAt = Date.now(); return; }
checkInvites();
if (!inParty() || st.detached) 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 */ }