Watch party refresh: Chat/Queue/Room tabs, host transport, gentle sync, heartbeat and latency-aware reconnect, in-place section updates

This commit is contained in:
Jonathan Sykes
2026-10-15 20:19:44 +08:00
parent 23097918e9
commit 088937d4de
12 changed files with 550 additions and 84 deletions

View File

@@ -507,7 +507,7 @@
<!-- YouTube-style bottom navigation bar (portrait PWA mode only) -->
<!-- Watch party panel (Party in app.js) -->
<aside id="partyPanel" class="party-panel hidden" aria-label="Watch party">
<div class="pt-head"><span id="ptTitle">🎉 Watch party</span><span id="ptStatus" class="pt-status"></span><button id="ptMin" class="rv-icon" type="button" aria-label="Minimise">▁</button></div>
<div class="pt-head"><span id="ptTitle">Watch party</span><span id="ptStatus" class="pt-status" role="status"></span><button id="ptMin" class="rv-icon" type="button" aria-label="Minimise">▁</button></div>
<div class="pt-body"></div>
</aside>

View File

@@ -2,9 +2,13 @@
'use strict';
const ranked = entries => [...entries].sort((a, b) => Number(b.approved) - Number(a.approved) || b.votes.length - a.votes.length);
function render(container, { state, host, pid, send, search }) {
if (container._partyDJ && container._partyDJ.enabled === state.enabled) {
container._partyDJ.update(state); return;
}
container.replaceChildren();
const doc = container.ownerDocument;
if (host) { const label = doc.createElement('label'); label.className = 'pt-toggle'; const toggle = doc.createElement('input'); toggle.type = 'checkbox'; toggle.checked = state.enabled; toggle.onchange = () => send({ type: 'dj', action: 'enable', enabled: toggle.checked }); label.append(toggle, ' Party DJ mode'); container.append(label); }
if (!state.enabled) return;
if (!state.enabled) { container._partyDJ = { enabled: false, update() {} }; return; }
const heading = doc.createElement('h3'); heading.textContent = 'Party Queue'; container.append(heading);
const form = doc.createElement('div'); form.className = 'party-dj-search';
const input = doc.createElement('input'); input.type = 'search'; input.placeholder = 'Suggest a song'; input.setAttribute('aria-label', 'Search for a party suggestion');
@@ -12,11 +16,17 @@
let request = 0;
button.onclick = async () => { const query = input.value.trim(); if (!query) return; const token = ++request; button.disabled = true; results.textContent = 'Searching…'; try { const videos = await search(query); if (!container.isConnected || token !== request) return; results.replaceChildren(); for (const video of videos.slice(0, 8)) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = `Suggest: ${video.title || video.id}`; b.onclick = () => send({ type: 'dj', action: 'suggest', video }); results.append(b); } if (!videos.length) results.textContent = 'No matches.'; } catch { results.textContent = 'Search unavailable. Try again.'; } finally { button.disabled = false; } };
input.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); button.click(); } }; form.append(input, button); container.append(form, results);
for (const entry of ranked(state.entries)) {
const rows = doc.createElement('div'); container.append(rows);
const update = next => {
rows.replaceChildren();
for (const entry of ranked(next.entries)) {
const row = doc.createElement('div'); row.className = 'party-dj-row'; const title = doc.createElement('b'); title.textContent = entry.video.title; row.append(title);
const status = doc.createElement('small'); status.textContent = entry.approved ? 'Upcoming' : 'Awaiting host approval'; row.append(status);
for (const action of ['vote', ...(host ? [...(entry.approved ? [] : ['approve']), 'remove'] : [])]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = action === 'vote' ? `👍 ${entry.votes.length}` : action === 'approve' ? 'Approve' : 'Remove'; b.setAttribute('aria-label', `${action} ${entry.video.title}`); if (action === 'vote') { b.disabled = entry.votes.includes(pid); b.setAttribute('aria-pressed', String(b.disabled)); } b.onclick = () => send({ type: 'dj', action, id: entry.video.id }); row.append(b); } container.append(row);
for (const action of ['vote', ...(host ? [...(entry.approved ? [] : ['approve']), 'remove'] : [])]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = action === 'vote' ? `👍 ${entry.votes.length}` : action === 'approve' ? 'Approve' : 'Remove'; b.setAttribute('aria-label', `${action} ${entry.video.title}`); if (action === 'vote') { b.disabled = entry.votes.includes(pid); b.setAttribute('aria-pressed', String(b.disabled)); } b.onclick = () => send({ type: 'dj', action, id: entry.video.id }); row.append(b); } rows.append(row);
}
if (!next.entries.length) { const empty = doc.createElement('p'); empty.className = 'pt-hint'; empty.textContent = 'The queue is open. Suggest the first video.'; rows.append(empty); }
};
container._partyDJ = { enabled: true, update }; update(state);
}
function register(open) { root.SettingsSections.register({ id: 'party-dj', title: 'Watch party', cluster: 'Live & worship', summary: () => 'Shared suggestions · host approval', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = 'Party DJ mode is off until the host enables it. Guests suggest songs and vote once per song; approved songs play in vote order.'; const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open watch party'; b.onclick = open; container.append(p, b); } }); }
const api = { ranked, render, register }; if (typeof module !== 'undefined') module.exports = api; else root.PartyDJ = api;

View File

@@ -4503,3 +4503,78 @@ html[data-layout] .search-btn:active { transform: scale(.98); }
.search-btn::before { width: 15px; height: 15px; }
}
@media (prefers-reduced-motion: reduce) { .search-btn { transition: none; } }
/* Watch party: compact cinema companion, with transport always in reach. */
.party-panel { width: 360px; padding: 16px; gap: 12px; box-shadow: none; background: var(--bg-1); }
.party-panel .pt-head { flex-wrap: wrap; gap: 4px 8px; }
.party-panel #ptTitle { flex: 1; font-size: 16px; }
.party-panel .pt-head .rv-icon { min-width: 44px; height: 44px; order: 1; }
.party-panel .pt-status { order: 2; flex-basis: 100%; font: inherit; font-size: 12px; white-space: normal; }
.party-panel .pt-body { overflow: hidden; gap: 12px; }
.party-panel button, .party-panel input:not([type="checkbox"]) { min-height: 44px; }
.party-panel button:focus-visible, .party-panel input:focus-visible, .party-panel [tabindex]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.party-panel [hidden] { display: none !important; }
.pt-session { display: flex; align-items: center; gap: 8px; }
.pt-session > div { flex: 1; min-width: 0; }
.pt-caption { display: block; color: var(--text-dim); font-size: 12px; margin-bottom: 4px; }
#ptRoomCode { font: 600 18px var(--mono); letter-spacing: .08em; }
.pt-playing { position: relative; padding: 12px; background: var(--bg-2); border-radius: 8px; }
#ptNow { display: block; font-size: 14px; line-height: 1.45; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pt-playing:has(.pt-resync:not([hidden])) { padding-right: 88px; }
.pt-resync { position: absolute; right: 8px; top: 8px; }
.pt-transport { display: flex; gap: 8px; }
.pt-transport button { flex: 1; min-width: 0; }
.pt-transport [data-act="playback"] { background: var(--accent); color: var(--on-play, var(--bg-1)); border-color: var(--accent); }
.pt-tabs { display: flex; border-bottom: 1px solid var(--line); flex-shrink: 0; }
.pt-tabs button { flex: 1; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--text-dim); font: inherit; font-size: 14px; cursor: pointer; }
.pt-tabs button[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
.pt-pane { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; overscroll-behavior: contain; }
.pt-pane[data-pane="chat"] { overflow: hidden; }
.party-panel .pt-chat { flex: 1; min-height: 48px; max-height: none; background: transparent; border: 0; padding: 4px 0; gap: 12px; overflow-anchor: none; }
.pt-line { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.pt-line span { padding: 8px 12px; border-radius: 8px; background: var(--bg-2); max-width: 100%; }
.pt-line.me { align-items: flex-end; }
.pt-line b { font-size: 12px; }
.pt-sys { padding: 4px 0; }
.pt-send { flex-shrink: 0; }
.pt-new { align-self: center; font-size: 12px; }
.pt-hint, .pt-empty { font-size: 12px; color: var(--text-dim); line-height: 1.6; margin: 0; }
.pt-empty { margin: auto; padding: 24px 8px; text-align: center; }
.pt-pane h3 { font-size: 14px; margin: 4px 0; }
.pt-pane h3 span { color: var(--text-dim); font-weight: 400; }
.pt-options, .pt-host { display: flex; flex-direction: column; gap: 8px; }
.pt-options { border-block: 1px solid var(--line-soft); padding: 12px 0; }
.party-panel .pt-toggle { min-height: 44px; font-size: 14px; gap: 8px; }
.pt-toggle input { width: 18px; height: 18px; accent-color: var(--accent); }
.pt-lyrics-controls, .pt-invited { display: flex; flex-wrap: wrap; gap: 8px; }
.party-panel .pt-leave { margin-top: 8px; color: var(--text-2); }
.pt-lobby { overflow-y: auto; display: flex; flex-direction: column; gap: 16px; padding: 8px 0; }
.pt-lobby h2 { font-size: 26px; line-height: 1.2; letter-spacing: -.02em; margin: 0; }
.pt-lobby p { font-size: 14px; line-height: 1.6; color: var(--text-dim); margin: 0; }
.pt-field { display: flex; flex-direction: column; gap: 8px; color: var(--text-2); font-size: 12px; }
.pt-lobby .pt-or { padding-top: 8px; border-top: 1px solid var(--line-soft); }
.pt-dj { display: flex; flex-direction: column; gap: 12px; }
.pt-dj button, .pt-dj input { font: inherit; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; padding: 8px; }
.pt-dj .party-dj-search { display: flex; gap: 8px; }
.pt-dj .party-dj-search input { min-width: 0; width: 100%; }
.pt-dj .party-dj-row { display: flex; flex-wrap: wrap; gap: 8px; border-bottom: 1px solid var(--line-soft); padding: 12px 0; }
.pt-dj .party-dj-row b, .pt-dj .party-dj-row small { flex-basis: 100%; overflow-wrap: anywhere; }
.pt-dj .party-dj-row small { color: var(--text-dim); }
@media (min-width: 900px) { .party-present { right: 392px; } }
@media (max-width: 720px) {
.party-panel { width: auto; padding: 12px; height: 62dvh; max-height: min(62dvh, calc(100dvh - max(var(--bottom-chrome, 0px), 96px + env(safe-area-inset-bottom)) - 72px)); }
.party-panel.min { height: auto; }
.party-panel .pt-body { overflow: hidden; }
.party-panel .pt-session { gap: 4px; }
.party-panel .pt-session .np-btn { padding-inline: 8px; }
}
@media (prefers-reduced-motion: reduce) { .party-panel *, .party-panel *::before, .party-panel *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } }
@media (max-width: 720px) {
.party-panel.min { bottom: calc(max(var(--bottom-chrome, 0px), 96px + env(safe-area-inset-bottom)) + 8px); }
}
@media (max-width: 720px) and (max-height: 700px) {
.party-panel:not(.min) { height: calc(100dvh - max(var(--bottom-chrome, 0px), 96px + env(safe-area-inset-bottom)) - 24px); max-height: none; }
.party-panel, .party-panel .pt-body { gap: 8px; }
.pt-playing .pt-caption, .pt-session .pt-caption { display: none; }
.pt-playing { padding-block: 8px; }
}

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,
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();
@@ -70,7 +89,7 @@
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');
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()) {
@@ -96,6 +115,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();
@@ -137,12 +157,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) {
@@ -162,25 +182,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(); }
}
@@ -208,8 +240,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);
@@ -304,6 +336,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);
@@ -315,7 +349,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();
@@ -325,20 +362,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();
}
@@ -350,82 +395,135 @@
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() ? `<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();
$('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) 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'); }
}
@@ -435,10 +533,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;
@@ -446,7 +553,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 */ } }
@@ -467,13 +581,18 @@
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) { 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) => {
@@ -558,12 +677,14 @@
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.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 */ }