Watch party
Integration preview · deterministic playback fixture
diff --git a/.impeccable/surfaces/frontend-watch-party-client-js.md b/.impeccable/surfaces/frontend-watch-party-client-js.md new file mode 100644 index 0000000..e0472ee --- /dev/null +++ b/.impeccable/surfaces/frontend-watch-party-client-js.md @@ -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. diff --git a/docs/watch-party-refresh.md b/docs/watch-party-refresh.md new file mode 100644 index 0000000..b0a782a --- /dev/null +++ b/docs/watch-party-refresh.md @@ -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. diff --git a/frontend/index.html b/frontend/index.html index 47bf877..42e3dcc 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -507,7 +507,7 @@ diff --git a/frontend/party-dj.js b/frontend/party-dj.js index b93390c..a7d7c52 100644 --- a/frontend/party-dj.js +++ b/frontend/party-dj.js @@ -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; diff --git a/frontend/styles.css b/frontend/styles.css index f772af1..b83798e 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -4551,3 +4551,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; } +} diff --git a/frontend/watch-party-client.js b/frontend/watch-party-client.js index eb13f2a..ec68f59 100644 --- a/frontend/watch-party-client.js +++ b/frontend/watch-party-client.js @@ -22,29 +22,36 @@ allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '', loadingId: '', restoring: false, inviteCode: '', hiddenAt: 0, present: null, presentHidden: false, invites: [], inviteError: '', pq: [], viewMode: {}, reader: { url: '', html: '', error: '', loading: false }, voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() }, - minimized: false, + minimized: false, tab: 'chat', retries: 0, lastSeekAt: 0, pingAt: 0, lastPong: 0, + gentle: ls.get('ytpPartyGentle') !== 'false', session: 0, replaced: false, }; const inParty = () => !!st.role; const isHost = () => st.role === 'host'; const guestActive = () => st.role === 'guest' && !st.detached; - const send = (m) => { if (st.ws && st.ws.readyState === 1) st.ws.send(JSON.stringify(m)); }; + const send = (m) => { + if (navigator.onLine === false || !st.ws || st.ws.readyState !== 1 || st.ws.bufferedAmount > 65536) return false; + try { st.ws.send(JSON.stringify(m)); return true; } catch { return false; } + }; const serverNow = () => Date.now() + st.offset; const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || ''; // ---------- connection ---------- function connect() { clearTimeout(st.retry); + if (navigator.onLine === false) { setStatus('Offline · waiting for your connection'); return; } + const old = st.ws; st.ws = null; + try { old?.close(); } catch { /* gone */ } const q = { role: st.role, pid, key: seatKey, name: myName() || 'Guest' }; if (st.code) q.code = st.code; if (isHost() && st.secret) q.secret = st.secret; const ws = new WebSocket(wsUrl(q)); - st.ws = ws; - ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); }; + st.ws = ws; st.lastPong = Date.now(); + ws.onmessage = (e) => { if (st.ws !== ws) return; let m; try { m = JSON.parse(e.data); } catch { return; } onMessage(m); }; ws.onclose = (e) => { if (st.ws !== ws) return; st.ws = null; if (!inParty()) return; - if (e.code === 4000) { setStatus('Opened on another tab or device — tap Re-sync to use this one'); st.detached = true; render(); return; } + if (e.code === 4000) { setStatus('Opened on another tab or device — tap Re-sync to use this one'); st.detached = true; st.replaced = true; render(); return; } if (e.code === 4010 || e.code === 4004 || e.code === 4003 || e.code === 4005) { const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : e.code === 4005 ? 'Someone else is using this seat in the party.' : 'The party ended.'; @@ -52,16 +59,28 @@ toast(`🎉 ${why}`, { duration: 5000 }); return; } - setStatus('Reconnecting…'); - st.retry = setTimeout(connect, 3000); + const delay = Math.min(30000, 1000 * 2 ** Math.min(st.retries++, 5)) + Math.random() * 500; + setStatus(`Connection lost · retrying in ${Math.ceil(delay / 1000)}s`); + st.retry = setTimeout(connect, delay); }; ws.onerror = () => { if (!st.code && isHost()) setStatus('Could not reach the server'); }; } function onMessage(m) { - DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) }); if (m.type === 'direct') { DirectMedia.handle('party', m); return; } + if (m.type === 'pong') { + if (m.echo !== st.pingAt) return; + const rtt = Date.now() - st.pingAt; + st.lastPong = Date.now(); + if (rtt < 5000) st.offset = m.now - (st.pingAt + rtt / 2); + const el = $('ptLatency'); if (el) el.textContent = `${Math.round(rtt)} ms round trip`; + return; + } if (m.type === 'hello') { + DirectMedia.attach('party', { send, peers: () => st.members.filter(m => m.pid !== pid).map(m => ({ id: m.pid, name: m.name, files: m.host && st.state?.directAvailable && st.state?.v ? [st.state.v.id] : [] })) }); + + st.retries = 0; st.lastPong = Date.now(); st.lastPush = ''; + st.state = m.state || null; st.code = m.code; if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); } st.offset = (m.now || Date.now()) - Date.now(); @@ -71,7 +90,7 @@ st.allowControl = !!m.allowControl; st.present = m.present || null; renderPresent(); st.invites = m.invites || []; st.pq = m.pq || []; - setStatus(isHost() ? 'You’re hosting' : 'Synced with the host'); + setStatus(isHost() ? 'You’re hosting' : st.detached ? 'Watching independently' : 'Following the host'); if (m.lost && isHost()) toast(`🎉 The old party was lost (server restart). New code ${m.code} — share the link again`, { duration: 8000 }); render(); if (isHost()) { @@ -80,7 +99,15 @@ const saved = m.state && m.state.v; if (saved && (!current || !current.meta || current.meta.id !== saved.id)) { st.restoring = true; - Promise.resolve(playVideoAt({ ...saved }, targetTime(m.state))).catch(() => {}).finally(() => { + const want = targetTime(m.state); + // playVideoAt seeks only once the media can play; broadcast after that + // seek lands, or guests are briefly sent back to 0:00. + const landed = () => new Promise((resolve) => { + const t0 = Date.now(); + const check = () => { const M = Player.master; if (!want || (M && Math.abs((M.currentTime || 0) - want) < 2) || Date.now() - t0 > 8000) resolve(); else setTimeout(check, 150); }; + check(); + }); + Promise.resolve(playVideoAt({ ...saved }, want)).catch(() => {}).then(landed).finally(() => { st.restoring = false; if (m.state.paused) Player.pause(); pushState(true); @@ -97,6 +124,7 @@ if (st.role === 'guest') applyState(m.state); } else if (m.type === 'members') { st.members = m.members || []; + if (m.hostAway) setStatus('Host disconnected · waiting for them to return'); if (m.joined) sysMsg(`${m.joined} joined`); if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`); render(); @@ -144,12 +172,12 @@ }; } function pushState(force = false) { - if (!isHost() || !st.ws || st.restoring) return; + if (!isHost() || st.ws?.readyState !== 1 || st.restoring) return; const s = stateNow(); const key = JSON.stringify({ ...s, cur: Math.round(s.cur) }); if (!force && key === st.lastPush) return; - st.lastPush = key; - send({ type: 'state', state: s }); + if (send({ type: 'state', state: s })) st.lastPush = key; + updateNowPlaying(s.v); } function runCommand(m) { switch (m.cmd) { @@ -169,25 +197,37 @@ const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; }; function targetTime(s) { if (!s) return 0; - return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1); + return Math.max(0, s.paused ? s.cur : s.cur + Math.max(0, (serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1)); } async function applyState(s) { st.state = s; + updateNowPlaying(s?.v); if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; } + if (!st.members.some(m => m.host)) return; if (st.loadingId) return; // a load is running; its finish re-applies the newest state const target = targetTime(s); if (!current || !current.meta || current.meta.id !== s.v.id) { + const session = st.session; st.loadingId = s.v.id; quiet(30000); try { await playVideoAt({ ...s.v }, target); } catch { /* the drift timer retries */ } - finally { st.loadingId = ''; quiet(); } + finally { if (session === st.session) { st.loadingId = ''; quiet(); } } + if (session !== st.session) return; if (st.state && st.state.v && current && current.meta && current.meta.id === st.state.v.id) applyState(st.state); return; } const M = Player.master; if (!M) return; if ((M.playbackRate || 1) !== (s.rate || 1)) { els.speed.value = String(s.rate || 1); Player.applySpeed(); } - if (Math.abs((M.currentTime || 0) - target) > 1.5) { quiet(); Player.seek(target); } + // Do not keep discarding buffered data while a slow device/network catches up. + const drift = Math.abs((M.currentTime || 0) - target); + const threshold = st.gentle && !s.paused ? 4 : 1.5; + if (drift > threshold && !M.seeking && (s.paused || M.readyState >= 3) && + Date.now() - st.lastSeekAt > (st.gentle ? 8000 : 2500)) { + st.lastSeekAt = Date.now(); quiet(); Player.seek(Math.min(target, Number.isFinite(M.duration) ? M.duration : target)); + } + if (!st.ws || st.ws.readyState !== 1) return; + setStatus(M.readyState < 3 && !s.paused ? 'Buffering · keeping your place' : 'Following the host'); if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); } else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); } } @@ -215,8 +255,8 @@ return true; } function resync() { - st.detached = false; - if (!st.ws) { setStatus('Reconnecting…'); connect(); render(); return; } + st.detached = false; st.replaced = false; st.lastSeekAt = 0; + if (!st.ws || st.ws.readyState !== 1) { setStatus('Reconnecting…'); connect(); render(); return; } setStatus('Synced with the host'); render(); applyState(st.state); @@ -311,6 +351,8 @@ // ---------- lifecycle ---------- function start() { + st.tab = 'chat'; + st.session++; st.replaced = false; st.detached = false; st.loadingId = ''; st.state = null; st.role = 'host'; st.code = ''; st.secret = ''; st.chat = []; st.members = []; ls.set(HOST_KEY, null); @@ -322,7 +364,10 @@ function join(code, name) { code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; } + if (inParty()) leave(false); if (name) { st.name = name; ls.set(NAME_KEY, name); } + st.session++; st.replaced = false; st.loadingId = ''; st.state = null; st.lastSeekAt = 0; + st.tab = 'chat'; st.role = 'guest'; st.inviteCode = ''; st.code = code; st.detached = false; st.chat = []; st.members = []; showPanel(); @@ -332,20 +377,28 @@ } function beginTimers() { clearInterval(st.pushTimer); clearInterval(st.driftTimer); - st.pushTimer = setInterval(() => pushState(), 3000); - st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.state && !st.detached) applyState(st.state); }, 2500); + st.pushTimer = setInterval(() => { + pushState(); + if (st.ws?.readyState === 1 && Date.now() - st.pingAt >= 15000) { + if (st.lastPong && Date.now() - st.lastPong > 45000) { setStatus('Reconnecting…'); connect(); return; } + st.pingAt = Date.now(); send({ type: 'ping', echo: st.pingAt }); + } + }, 3000); + st.driftTimer = setInterval(() => { if (st.role === 'guest' && st.ws?.readyState === 1 && st.state && !st.detached) applyState(st.state); }, 2500); } function leave(sendEnd = true) { if (isHost() && sendEnd) send({ type: 'end' }); if (st.voice.on) toggleVoice(); const ws = st.ws; st.ws = null; + st.session++; st.loadingId = ''; st.state = null; st.retries = 0; st.role = null; st.code = ''; st.secret = ''; clearTimeout(st.retry); clearInterval(st.pushTimer); clearInterval(st.driftTimer); try { if (ws) ws.close(); } catch { /* gone */ } ls.set(HOST_KEY, null); st.present = null; renderPresent(); $('partyPanel').classList.add('hidden'); + $('partyPanel').querySelector('.pt-body').dataset.view = ''; render(); } @@ -357,92 +410,147 @@ function renderLyricsToggle() { const b = document.querySelector('#partyPanel .pt-lyrics'); if (!b) return; - b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video'; + b.textContent = lyricsOn() ? 'Show video' : 'Lyrics instead of video'; b.classList.toggle('on', lyricsOn()); const l = document.querySelector('#partyPanel .pt-land'); if (l) { l.hidden = !lyricsOn(); l.textContent = Notes.stageLyrics.landscapeOn?.() ? '↺ Exit landscape' : '↻ Landscape lyrics'; } } - function setStatus(t) { const el = $('ptStatus'); if (el) el.textContent = t; } - function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); renderChat(); } + function setStatus(t) { const el = $('ptStatus'); if (el && el.textContent !== t) el.textContent = t; } + function sysMsg(text) { st.chat.push({ sys: true, text, at: Date.now() }); if (st.chat.length > 100) st.chat.shift(); renderChat(); } function link() { return `${location.origin}/?party=${st.code}`; } - function showPanel() { Lazy.load('feature:party-dj').then(render).catch(error=>toast(error.message)); Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); } + function showPanel() { Lazy.load('feature:direct').catch(error=>toast(error.message)); $('partyPanel').classList.remove('hidden'); st.minimized = false; $('partyPanel').classList.remove('min'); render(); } + function updateNowPlaying(video) { + const el = $('ptNow'); + const title = video?.title || 'Choose a video to watch together'; + if (el && el.textContent !== title) el.textContent = title; + const play = document.querySelector('#partyPanel [data-act="playback"]'); + if (play) { const label = Player.master?.paused ? 'Play' : 'Pause'; if (play.textContent !== label) play.textContent = label; play.disabled = !current?.meta; } + } + function updateSection(selector, key, html) { + const el = document.querySelector(`#partyPanel ${selector}`); + if (!el || el._key === key) return; + el._key = key; el.innerHTML = html; + } + function selectTab(tab) { + st.tab = tab; + const panel = $('partyPanel'); + panel.querySelectorAll('[data-tab]').forEach(b => { + b.setAttribute('aria-selected', String(b.dataset.tab === tab)); + b.tabIndex = b.dataset.tab === tab ? 0 : -1; + }); + panel.querySelectorAll('[data-pane]').forEach(p => { p.hidden = p.dataset.pane !== tab; }); + if (tab === 'chat') renderChat(); + if (tab === 'queue') Lazy.load('feature:party-dj').then(render).catch(error => toast(error.message)); + } function render() { const panel = $('partyPanel'); - const chip = $('navPartyBtn'); - if (chip) { - const label = chip.querySelector('.nav-label'); - if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party'; - else chip.textContent = inParty() ? `🎉 Party · ${st.members.length}` : '🎉 Watch party'; - } - if (!panel || panel.classList.contains('hidden')) return; - const nameOk = !!myName(); - // Keep what the user is typing across re-renders (members/DJ updates rebuild the panel). - const draft = { msg: panel.querySelector('.pt-msg')?.value || '', focus: document.activeElement?.classList?.contains('pt-msg'), - name: panel.querySelector('.pt-name')?.value || '', code: panel.querySelector('.pt-code')?.value || st.inviteCode }; + const label = $('navPartyBtn')?.querySelector('.nav-label'); + if (label) label.textContent = inParty() ? `Party · ${st.members.length}` : 'Watch party'; + if (!panel || panel.classList.contains('hidden') || st.minimized) return; + const body = panel.querySelector('.pt-body'); if (!inParty()) { - panel.querySelector('.pt-body').innerHTML = ` -
Watch together: everyone's player follows the host, with chat and optional voice.
- ${nameOk ? '' : ''} + $('ptTitle').textContent = 'Watch party'; + if (body.dataset.view === 'lobby') return; + body.dataset.view = 'lobby'; + body.innerHTML = `Watch in sync, talk in chat, and choose what plays next.
+ ${myName() ? '' : ''} -Each person streams on their own device. Audio-only is available for slower connections.
${escapeHtml(link())}${navigator.share ? '' : ''}Suggest a video and vote for what’s next. The host approves the queue.
Line up slides, documents and pages now; present each one when its time comes.
'}Line up slides, documents and pages now; present each one when its time comes.
'}`); + updateSection('.pt-invited', JSON.stringify(st.invites), st.invites.map((n) => `✉ ${escapeHtml(n)} `).join('')); + } + const dj = panel.querySelector('.pt-dj'); + const djKey = JSON.stringify(st.dj); + if (Lazy.loaded('feature:party-dj') && dj._key !== djKey) { + dj._key = djKey; + PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } }); + if (!st.dj.enabled) dj.insertAdjacentHTML('beforeend', `${isHost() ? 'Enable Party DJ to let everyone suggest and vote.' : 'The host hasn’t enabled suggestions yet.'}
`); + } + renderLyricsToggle(); renderChat(); } function renderChat() { const box = $('ptChat'); - if (!box) return; - box.innerHTML = st.chat.map((c) => c.sys - ? `You’re in good company.
Start the conversation.
Integration preview · deterministic playback fixture