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:
11
.impeccable/surfaces/frontend-watch-party-client-js.md
Normal file
11
.impeccable/surfaces/frontend-watch-party-client-js.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Watch party companion
|
||||
|
||||
Mode: Operate. Primary target: frontend/watch-party-client.js. Related targets: frontend/styles.css, frontend/party-dj.js, frontend/index.html.
|
||||
|
||||
User direction: compact cinema companion; the person who starts the party must retain playback controls. Inherit the application's theme variables, typography, light/high-contrast preferences, and safe-area clearance. This is a component redesign inside the existing product, not a replacement for Classic's visual system.
|
||||
|
||||
Keep invitation code, current title, and host transport above Chat / Queue / Room tabs. Chat is the default; Room holds participant names, local playback preferences, voice, presentation and guest permissions. Give Queue an explicit empty state and keep searches stable when votes change. No new font, image, framework, backdrop filter, or animation dependency.
|
||||
|
||||
Keyboard: labeled inputs, roving tab focus and arrow navigation, visible focus, live connection status, chat additions announced without rebuilding the log. Controls have 44px minimum height. Small screens retain navigation clearance and scroll Room/Queue independently.
|
||||
|
||||
Evidence: tests/watch-party-browser.cjs uses the actual frontend chunk/CSS and real Bun party relay with deterministic playback objects. Screenshots in .impeccable/review are fixture captures; they do not prove live video playback, voice quality or production network throughput.
|
||||
37
docs/watch-party-refresh.md
Normal file
37
docs/watch-party-refresh.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Watch party refresh
|
||||
|
||||
Branch: `feat/watch-party-refresh`, based on local `main` at `2309791`.
|
||||
Worktree: `/home/josh/development/personal/ytplayer-watch-party`.
|
||||
|
||||
## Implemented
|
||||
|
||||
- Compact Chat / Queue / Room interface, with the host's Play/Pause, ±10 seconds and Next controls always above the tabs.
|
||||
- Invitation code and Copy link stay visible. Guest playback remains subject to the existing host permission rules.
|
||||
- Room includes audio-only playback (uses the existing player setting), a persistent Gentle sync preference, round-trip connection timing, voice, presentation and lyrics controls.
|
||||
- Gentle sync permits four seconds of drift during playback and limits correction seeks to once every eight seconds; strict mode retains the 1.5-second tolerance. Neither mode corrects playing media while it is already seeking or buffering. Paused state still mirrors the host.
|
||||
- Bounded exponential reconnect with jitter, online recovery, socket identity checks, periodic heartbeat and latency-aware clock offset. Guests wait when the host is absent instead of following a stale timeline.
|
||||
- DOM sections update only when their data changes. Chat appends new messages, preserves history scroll position, and offers Jump to latest. Drafts remain intact through membership/reconnect updates and failed offline sends.
|
||||
- Queue updates preserve the search input and search results. The DJ feature loads when its tab is opened.
|
||||
- Small-screen controls remain above navigation; short viewports use a more compact arrangement. No new runtime dependencies.
|
||||
|
||||
## Validation
|
||||
|
||||
From this worktree, with npm development dependencies installed and Chromium available:
|
||||
|
||||
```sh
|
||||
node --test tests/watch-party-browser.cjs
|
||||
node --test frontend/party-dj.test.js frontend/app-seams.test.js
|
||||
cd server
|
||||
bun test ./party.test.js ./party-dj.test.js
|
||||
```
|
||||
|
||||
The browser suite starts an isolated loopback Bun relay, opens host and guest browser contexts, and checks controls, draft preservation, buffering/correction cooldown, queue search persistence, chat scroll, offline reconnect, timing and narrow-screen layout. Its playback engine is a deterministic fixture: these checks do not measure actual video throughput, codecs, battery use or live voice quality. Existing server tests cover host authority, seat ownership, chat, DJ, presentations and room lifecycle.
|
||||
|
||||
Audio-only savings depend on whether a separate audio rendition is available; this change does not create lower-bitrate server renditions. Gentle sync deliberately allows more playback drift in exchange for fewer disruptive seeks. Voice still uses the existing STUN-only mesh.
|
||||
|
||||
## Further improvements worth a separate change
|
||||
|
||||
1. Participant readiness/buffering indicators, with an opt-in host “wait for everyone” policy. Needs a bounded readiness protocol and a timeout so one offline guest cannot stall the room.
|
||||
2. Low-bitrate video renditions for server-cached uploads. Requires storage/transcoding budgets and device-compatible codec choices; lowering the quality selector alone cannot reduce a single cached file's bitrate.
|
||||
3. Acknowledged chat delivery and retry IDs. Avoid silent server flood-limit drops and duplicates after reconnect.
|
||||
4. TURN relay and voice connection diagnostics for networks where the existing peer-to-peer voice mesh cannot connect.
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -134,7 +134,9 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||
if (me.tokens < 1) return; // flood: drop
|
||||
me.tokens -= 1;
|
||||
}
|
||||
if (m.type === 'direct') {
|
||||
if (m.type === 'ping') {
|
||||
send(ws, { type: 'pong', echo: Number(m.echo) || 0, now: now() });
|
||||
} else if (m.type === 'direct') {
|
||||
p.direct.handle(me.pid, m, new Map([...p.members].map(([pid, member]) => [pid, member.ws])), send);
|
||||
} else if (m.type === 'dj') {
|
||||
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
|
||||
|
||||
@@ -187,3 +187,18 @@ describe('watch party seat rules', () => {
|
||||
host.send({ type: 'end' }); await host.closed;
|
||||
});
|
||||
});
|
||||
|
||||
test('heartbeat replies only to the sender with the server clock', async () => {
|
||||
const h = client({ role: 'host', pid: 'heartbeat-host', name: 'Host' });
|
||||
const hello = await h.next('hello');
|
||||
const g = client({ role: 'guest', pid: 'heartbeat-guest', code: hello.code, name: 'Guest' });
|
||||
await g.next('hello');
|
||||
const before = Date.now();
|
||||
g.send({ type: 'ping', echo: 12345 });
|
||||
const pong = await g.next('pong');
|
||||
expect(pong.echo).toBe(12345);
|
||||
expect(pong.now).toBeGreaterThanOrEqual(before);
|
||||
expect(pong.now).toBeLessThanOrEqual(Date.now());
|
||||
await expect(h.next('pong', 50)).rejects.toThrow('timeout pong');
|
||||
g.ws.close(); h.send({ type: 'end' }); await h.closed;
|
||||
});
|
||||
|
||||
28
tests/fixtures/party-fixture.js
vendored
Normal file
28
tests/fixtures/party-fixture.js
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
if (document.currentScript.src.includes('?boot')) {
|
||||
window.YT.Party.boot();
|
||||
document.getElementById('navPartyBtn').onclick = () => window.YT.Party.open();
|
||||
} else {
|
||||
Object.assign(window, {
|
||||
WEB: true, $: id => document.getElementById(id),
|
||||
data: { settings: {}, profile: null }, current: null, cachedIds: new Set(),
|
||||
DirectMedia: { attach() {}, enabled: () => false, handle() {} },
|
||||
Lazy: { load: () => Promise.resolve(), loaded: () => true },
|
||||
Notes: { stageLyrics: { on: () => false, set() {} } },
|
||||
API: { search: async () => ({ results: [{ id: 'demo-video1', title: 'Evening session' }] }) },
|
||||
toast: text => { window.lastToast = text; }, escapeHtml: s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])),
|
||||
fmtTime: String, updatePlayBtn() {}, advanceQueue() {},
|
||||
TransportIcons: { svg: () => '' }, seekCount: 0, nextCount: 0,
|
||||
});
|
||||
window.els = { video: document.createElement('video'), audio: document.createElement('audio'), speed: { value: '1' }, audioOnlyToggle: document.createElement('input') };
|
||||
window.Player = {
|
||||
master: Object.assign(new EventTarget(), { currentTime: 0, paused: true, playbackRate: 1, readyState: 4, seeking: false, duration: 3600 }),
|
||||
play() { this.master.paused = false; }, pause() { this.master.paused = true; },
|
||||
toggle() { this.master.paused = !this.master.paused; },
|
||||
seek(t) { this.master.currentTime = t; window.seekCount++; }, applySpeed() { this.master.playbackRate = Number(els.speed.value); },
|
||||
};
|
||||
els.video = Player.master;
|
||||
window.playVideoAt = async (v, t = 0) => { window.current = { meta: v }; Player.master.currentTime = t; Player.master.paused = false; };
|
||||
window.playNext = () => { window.nextCount++; };
|
||||
window.playPrev = () => {};
|
||||
els.audioOnlyToggle.addEventListener('change', () => { data.settings.audioOnly = els.audioOnlyToggle.checked; });
|
||||
}
|
||||
16
tests/fixtures/party-server.mjs
vendored
Normal file
16
tests/fixtures/party-server.mjs
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
// Isolated integration harness: real party relay and frontend chunk; deterministic player.
|
||||
import { createPartyHub } from '../../server/party.js';
|
||||
import { join } from 'node:path';
|
||||
const root = new URL('../../frontend/', import.meta.url).pathname;
|
||||
const hub = createPartyHub();
|
||||
const server = Bun.serve({ hostname: '127.0.0.1', port: Number(process.env.PARTY_TEST_PORT || 0),
|
||||
async fetch(req, srv) {
|
||||
const path = new URL(req.url).pathname;
|
||||
if (path === '/ws/party') return hub.upgrade(req, srv, '127.0.0.1');
|
||||
if (path === '/') return new Response(await Bun.file(new URL('party.html', import.meta.url)).text(), { headers: { 'Content-Type': 'text/html' } });
|
||||
if (path === '/party-fixture.js') return new Response(Bun.file(new URL('party-fixture.js', import.meta.url)));
|
||||
if (path.includes('..')) return new Response('', {status: 400});
|
||||
return new Response(Bun.file(join(root, path)));
|
||||
}, websocket: hub.websocket,
|
||||
});
|
||||
console.log(`http://127.0.0.1:${server.port}`);
|
||||
4
tests/fixtures/party.html
vendored
Normal file
4
tests/fixtures/party.html
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html lang="en" data-layout="classic"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Watch party integration fixture</title><link rel="stylesheet" href="/styles.css"><link rel="stylesheet" href="/theme-classic.css"></head><body>
|
||||
<main style="padding:32px;max-width:600px"><h1>Watch party</h1><p>Integration preview · deterministic playback fixture</p><button id="navPartyBtn"><span class="nav-label">Watch party</span></button></main>
|
||||
<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" role="status"></span><button id="ptMin" class="rv-icon" type="button" aria-label="Minimise">▁</button></div><div class="pt-body"></div></aside>
|
||||
<script src="/party-fixture.js"></script><script src="/party-dj.js"></script><script src="/watch-party-client.js"></script><script src="/party-fixture.js?boot"></script></body></html>
|
||||
147
tests/watch-party-browser.cjs
Normal file
147
tests/watch-party-browser.cjs
Normal file
@@ -0,0 +1,147 @@
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { chromium } = require('playwright');
|
||||
let server, browser, base;
|
||||
before(async () => {
|
||||
server = spawn('bun', ['tests/fixtures/party-server.mjs'], { cwd: require('node:path').resolve(__dirname, '..'), stdio: ['ignore','pipe','pipe'] });
|
||||
base = await new Promise((resolve, reject) => { server.stdout.once('data', x => resolve(String(x).trim())); server.once('error', reject); server.once('exit', code => reject(new Error(`server exit ${code}`))); });
|
||||
browser = await chromium.launch({ headless: true });
|
||||
});
|
||||
after(async () => { await browser?.close(); server?.kill(); });
|
||||
async function host(viewport = { width: 1280, height: 900 }) {
|
||||
const context = await browser.newContext({ viewport }); const page = await context.newPage();
|
||||
const errors = []; page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(base); await page.click('#navPartyBtn');
|
||||
await page.fill('.pt-name', 'Alex'); await page.click('[data-act=start]');
|
||||
await page.waitForFunction(() => /^[A-Z0-9]{6}$/.test(document.querySelector('#ptRoomCode')?.textContent));
|
||||
return { context, page, errors, code: await page.locator('#ptRoomCode').textContent() };
|
||||
}
|
||||
async function guest(code) {
|
||||
const context = await browser.newContext(); const page = await context.newPage(); await page.goto(base);
|
||||
await page.evaluate(code => YT.Party._join(code, 'Sam'), code);
|
||||
await page.waitForFunction(() => YT.Party.state().members === 2);
|
||||
return { context, page };
|
||||
}
|
||||
test('host transport stays available, drafts survive membership updates, chat and room controls work', async () => {
|
||||
const h = await host();
|
||||
await h.page.evaluate(() => { current = { meta: { id: 'demo', title: 'Evening session' } }; YT.Party.afterLoad(); });
|
||||
await h.page.fill('.pt-msg', 'Draft that must survive');
|
||||
const g = await guest(h.code);
|
||||
await h.page.waitForFunction(() => YT.Party.state().members === 2);
|
||||
assert.equal(await h.page.inputValue('.pt-msg'), 'Draft that must survive');
|
||||
await h.page.click('[data-act=playback]');
|
||||
assert.equal(await h.page.evaluate(() => Player.master.paused), false);
|
||||
await h.page.click('[data-act=forward10]'); assert.equal(await h.page.evaluate(() => Player.master.currentTime), 10);
|
||||
await h.page.click('[data-act=next]'); assert.equal(await h.page.evaluate(() => nextCount), 1);
|
||||
await h.page.locator('.pt-send button').click();
|
||||
await g.page.waitForFunction(() => document.querySelector('#ptChat').textContent.includes('Draft that must survive'));
|
||||
await h.page.click('[data-tab=room]');
|
||||
assert.equal(await h.page.locator('[data-act=playback]').isVisible(), true);
|
||||
await h.page.check('[data-act=audio]'); assert.equal(await h.page.evaluate(() => data.settings.audioOnly), true);
|
||||
await h.page.uncheck('[data-act=gentle]'); assert.equal(await h.page.evaluate(() => localStorage.getItem('ytpPartyGentle')), 'false');
|
||||
assert.deepEqual(h.errors, []);
|
||||
await g.context.close(); await h.context.close();
|
||||
});
|
||||
test('buffering guest does not repeatedly seek; ready playback recovers and cooldown prevents thrashing', async () => {
|
||||
const h = await host();
|
||||
await h.page.evaluate(() => { current = { meta: { id: 'demo', title: 'Evening session' } }; Player.master.paused = false; YT.Party.afterLoad(); });
|
||||
const g = await guest(h.code);
|
||||
await g.page.waitForFunction(() => current?.meta?.id === 'demo');
|
||||
await g.page.evaluate(() => { Player.master.readyState = 2; Player.master.currentTime = 0; seekCount = 0; });
|
||||
await h.page.evaluate(() => { Player.master.currentTime = 60; YT.Party.afterLoad(); });
|
||||
await g.page.waitForFunction(() => document.querySelector('#ptStatus').textContent.includes('Buffering'));
|
||||
assert.equal(await g.page.evaluate(() => seekCount), 0);
|
||||
await g.page.evaluate(() => { Player.master.readyState = 4; });
|
||||
await h.page.evaluate(() => YT.Party.afterLoad());
|
||||
await g.page.waitForFunction(() => seekCount === 1);
|
||||
await h.page.evaluate(() => { Player.master.currentTime = 90; YT.Party.afterLoad(); });
|
||||
await g.page.waitForTimeout(150);
|
||||
assert.equal(await g.page.evaluate(() => seekCount), 1);
|
||||
await g.context.close(); await h.context.close();
|
||||
});
|
||||
test('membership preserves input node; mobile panel and host controls fit above navigation', async () => {
|
||||
const h = await host({ width: 390, height: 844 });
|
||||
await h.page.fill('.pt-msg', 'Still typing');
|
||||
await h.page.evaluate(() => { window.draftNode = document.querySelector('.pt-msg'); });
|
||||
const g = await guest(h.code);
|
||||
assert.equal(await h.page.evaluate(() => draftNode === document.querySelector('.pt-msg')), true);
|
||||
await h.page.click('[data-tab=queue]'); await h.page.click('[data-tab=room]');
|
||||
const fit = await h.page.evaluate(() => {
|
||||
const panel = document.querySelector('#partyPanel').getBoundingClientRect();
|
||||
const controls = document.querySelector('.pt-transport').getBoundingClientRect();
|
||||
return panel.left >= 0 && panel.right <= innerWidth && panel.bottom <= innerHeight - 96 && controls.bottom <= panel.bottom;
|
||||
});
|
||||
assert.equal(fit, true);
|
||||
await h.page.setViewportSize({width:320,height:568}); await h.page.click('[data-tab=chat]');
|
||||
assert.equal(await h.page.locator('.pt-send').evaluate(e => e.getBoundingClientRect().bottom <= document.querySelector('#partyPanel').getBoundingClientRect().bottom), true);
|
||||
await h.page.click('#ptMin');
|
||||
assert.equal(await h.page.locator('#ptMin').isVisible(), true);
|
||||
assert.equal(await h.page.locator('#partyPanel').evaluate(e => e.getBoundingClientRect().bottom <= innerHeight - 96), true);
|
||||
await h.page.click('#ptMin');
|
||||
assert.deepEqual(h.errors, []);
|
||||
await g.context.close(); await h.context.close();
|
||||
});
|
||||
test('queue votes preserve search, chat preserves history position, reconnect keeps drafts', async () => {
|
||||
const h = await host(); const g = await guest(h.code);
|
||||
await h.page.click('[data-tab=queue]');
|
||||
await h.page.locator('.pt-dj input[type=checkbox]').check();
|
||||
await h.page.locator('.party-dj-search input').fill('Evening');
|
||||
await h.page.locator('.party-dj-search button').click();
|
||||
await h.page.getByRole('button', {name:'Suggest: Evening session', exact:true}).click();
|
||||
await h.page.waitForFunction(() => document.querySelector('.party-dj-row'));
|
||||
assert.equal(await h.page.inputValue('.party-dj-search input'), 'Evening');
|
||||
await h.page.click('[data-tab=chat]');
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await g.page.fill('.pt-msg', `Message ${i}: This is a conversation to test reading earlier messages.`);
|
||||
await g.page.locator('.pt-send button').click(); await g.page.waitForTimeout(410);
|
||||
}
|
||||
await h.page.evaluate(() => { document.querySelector('#ptChat').scrollTop = 0; });
|
||||
await g.page.fill('.pt-msg', 'A new message while you read'); await g.page.locator('.pt-send button').click();
|
||||
await h.page.waitForFunction(() => !document.querySelector('.pt-new').hidden);
|
||||
assert.equal(await h.page.locator('#ptChat').evaluate(e => e.scrollTop), 0);
|
||||
await h.page.click('[data-act=latest]');
|
||||
assert.equal(await h.page.locator('.pt-new').isVisible(), false);
|
||||
await h.page.fill('.pt-msg', 'Keep this offline draft'); await h.context.setOffline(true);
|
||||
await h.page.locator('.pt-send button').click();
|
||||
assert.equal(await h.page.inputValue('.pt-msg'), 'Keep this offline draft');
|
||||
await h.context.setOffline(false);
|
||||
await h.page.waitForFunction(() => document.querySelector('#ptStatus').textContent.includes('hosting'));
|
||||
assert.equal(await h.page.inputValue('.pt-msg'), 'Keep this offline draft');
|
||||
assert.deepEqual(h.errors, []);
|
||||
await g.context.close(); await h.context.close();
|
||||
});
|
||||
test('heartbeat measures round trip and screenshots cover desktop and mobile host controls', async () => {
|
||||
const h = await host();
|
||||
await h.page.evaluate(() => { current = { meta: { id:'demo', title:'Evening session · acoustic favorites' } }; Player.master.paused = false; YT.Party.afterLoad(); });
|
||||
const g = await guest(h.code);
|
||||
await g.page.fill('.pt-msg', 'Ready when you are!'); await g.page.locator('.pt-send button').click();
|
||||
await h.page.waitForFunction(() => document.querySelector('#ptChat').textContent.includes('Ready when you are'));
|
||||
await h.page.click('[data-tab=room]');
|
||||
await h.page.waitForFunction(() => document.querySelector('#ptLatency').textContent.includes('ms round trip'));
|
||||
await h.page.click('[data-tab=chat]');
|
||||
if (process.env.PARTY_SCREENSHOTS) {
|
||||
const fs = require('node:fs'); fs.mkdirSync(process.env.PARTY_SCREENSHOTS, {recursive:true});
|
||||
await h.page.screenshot({path: `${process.env.PARTY_SCREENSHOTS}/desktop.png`});
|
||||
await h.page.setViewportSize({width:390,height:844});
|
||||
await h.page.screenshot({path: `${process.env.PARTY_SCREENSHOTS}/mobile.png`});
|
||||
await h.page.click('[data-tab=room]');
|
||||
await h.page.screenshot({path: `${process.env.PARTY_SCREENSHOTS}/mobile-room.png`});
|
||||
}
|
||||
assert.deepEqual(h.errors, []);
|
||||
await g.context.close(); await h.context.close();
|
||||
});
|
||||
|
||||
test('independent guest reconnects chat without resuming host playback', async () => {
|
||||
const h = await host(); const g = await guest(h.code);
|
||||
await g.page.waitForTimeout(1600);
|
||||
await g.page.click('.pt-msg');
|
||||
await g.page.evaluate(() => { Player.master.paused = true; Player.master.dispatchEvent(new Event('pause')); });
|
||||
assert.equal(await g.page.evaluate(() => YT.Party.state().detached), true);
|
||||
await g.context.setOffline(true); await g.context.setOffline(false);
|
||||
await g.page.waitForFunction(() => document.querySelector('#ptStatus').textContent === 'Watching independently');
|
||||
assert.equal(await g.page.evaluate(() => YT.Party.state().detached), true);
|
||||
await g.page.fill('.pt-msg', 'Still here, watching independently'); await g.page.locator('.pt-send button').click();
|
||||
await h.page.waitForFunction(() => document.querySelector('#ptChat').textContent.includes('Still here, watching independently'));
|
||||
await g.context.close(); await h.context.close();
|
||||
});
|
||||
Reference in New Issue
Block a user