diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js
index c2031ce..d7dfc14 100644
--- a/frontend/app-seams.test.js
+++ b/frontend/app-seams.test.js
@@ -48,3 +48,12 @@ test('remote facade preserves synchronous no-session and loaded changed hooks',(
let calls=0;context.window.YT={Remote:{changed:()=>{calls++;return 'sync';}}};
assert.equal(context.api.changed(),'sync');assert.equal(calls,1);
});
+
+test('watch-party facade keeps playback decisions synchronous before and after loading',()=>{
+ const source=app.match(/\/\* Party facade \*\/([\s\S]*?)\/\* End party facade \*\//)?.[1];assert.ok(source);
+ const context=require('node:vm').createContext({window:{},Lazy:{proxy:()=>({}),onLoad(){}}});
+ require('node:vm').runInContext(source+';globalThis.api=Party;',context);
+ for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),false);
+ context.window.YT={Party:{guestActive:()=>true,intercept:action=>action==='next',nextDJ:()=>true}};
+ for(const method of ['guestActive','intercept','nextDJ'])assert.equal(context.api[method]('next'),true);
+});
diff --git a/frontend/app.js b/frontend/app.js
index f2f0f87..3706557 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -6169,461 +6169,27 @@ const External = (() => {
// player either sends a command (when the host allows control) or goes
// "off sync" until they tap Re-sync. Voice chat is a WebRTC mesh between
// members who switch it on (STUN only — works on most home/mobile networks).
-const Party = (() => {
- const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
- if (!WEB || typeof WebSocket === 'undefined') return noop;
- const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName';
- const ls = {
- get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
- set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
- };
- let pid = ls.get(PID_KEY);
- if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); }
- const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
+/* Party facade */
+const Party = {
+ ...Lazy.proxy('feature:watch-party', ['open', '_join', '_start'], 'Party'),
+ guestActive: () => window.YT?.Party?.guestActive() || false,
+ intercept: action => window.YT?.Party?.intercept(action) || false,
+ nextDJ: () => window.YT?.Party?.nextDJ() || false,
+ afterLoad: () => window.YT?.Party?.afterLoad(),
+ renderLyricsToggle: () => window.YT?.Party?.renderLyricsToggle(),
+ state: () => window.YT?.Party?.state() || { role: null },
+ boot() {
+ if (!WEB) return;
+ $('navPartyBtn').addEventListener('click', () => Party.open().catch(error => toast(error.message)));
+ Lazy.intent($('navPartyBtn'), 'feature:watch-party');
+ let resume = false;
+ try { const host = JSON.parse(localStorage.getItem('ytpPartyHost') || 'null'); resume = !!(host?.code && host?.secret); } catch {}
+ if (resume || new URLSearchParams(location.search).has('party')) Lazy.load('feature:watch-party').catch(error => toast(error.message));
+ },
+};
+Lazy.onLoad('feature:watch-party', () => window.YT.Party.boot());
+/* End party facade */
- const st = {
- ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
- dj: { enabled: false, entries: [] }, djPending: false,
- allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
- voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
- minimized: 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 serverNow = () => Date.now() + st.offset;
- const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
-
- // ---------- connection ----------
- function connect() {
- clearTimeout(st.retry);
- const q = { role: st.role, pid, 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); };
- ws.onclose = (e) => {
- if (st.ws !== ws) return;
- st.ws = null;
- if (!inParty()) return;
- if (e.code === 4010 || e.code === 4004 || e.code === 4003) {
- const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.';
- leave(false);
- toast(`🎉 ${why}`, { duration: 5000 });
- return;
- }
- setStatus('Reconnecting…');
- st.retry = setTimeout(connect, 3000);
- };
- 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 === 'hello') {
- st.code = m.code;
- if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
- st.offset = (m.now || Date.now()) - Date.now();
- st.members = m.members || [];
- st.chat = m.chat || [];
- st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
- st.allowControl = !!m.allowControl;
- setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
- render();
- if (isHost()) pushState(true);
- else if (m.state) applyState(m.state);
- if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
- } else if (m.type === 'dj') {
- st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
- } else if (m.type === 'dj-play' && isHost()) {
- st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); }
- } else if (m.type === 'state') {
- st.state = m.state;
- if (st.role === 'guest') applyState(m.state);
- } else if (m.type === 'members') {
- st.members = m.members || [];
- if (m.joined) sysMsg(`${m.joined} joined`);
- if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
- render();
- syncVoicePeers();
- } else if (m.type === 'chat') {
- st.chat.push(m.msg);
- if (st.chat.length > 100) st.chat.shift();
- renderChat();
- if (m.msg.pid !== pid && (st.minimized || $('partyPanel').classList.contains('hidden'))) toast(`💬 ${m.msg.from}: ${m.msg.text}`);
- } else if (m.type === 'settings') {
- st.allowControl = !!m.allowControl;
- render();
- if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
- } else if (m.type === 'cmd' && isHost()) {
- runCommand(m);
- } else if (m.type === 'rtc') {
- onRtc(m.from, m.data);
- } else if (m.type === 'ended') {
- toast(`🎉 ${m.reason || 'The party ended'}`, { duration: 5000 });
- }
- }
-
- // ---------- host: broadcast ----------
- function stateNow() {
- const meta = current && current.meta;
- const M = Player.master;
- return {
- directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
- v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
- cur: M ? M.currentTime || 0 : 0,
- paused: !M || M.paused,
- rate: (M && M.playbackRate) || 1,
- };
- }
- function pushState(force = false) {
- if (!isHost() || !st.ws) 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 });
- }
- function runCommand(m) {
- switch (m.cmd) {
- case 'toggle': Player.toggle(); break;
- case 'play': Player.play(); break;
- case 'pause': Player.pause(); break;
- case 'seek': if (Number.isFinite(Number(m.t))) Player.seek(Math.max(0, Number(m.t))); break;
- case 'next': playNext(); break;
- case 'prev': playPrev(); break;
- default: return;
- }
- toast(`🎉 ${m.from}: ${m.cmd === 'seek' ? `jumped to ${fmtTime(Number(m.t))}` : m.cmd}`);
- setTimeout(() => pushState(true), 200);
- }
-
- // ---------- guest: follow ----------
- const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
- function targetTime(s) {
- if (!s) return 0;
- return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1);
- }
- async function applyState(s) {
- st.state = s;
- if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
- const target = targetTime(s);
- if (!current || !current.meta || current.meta.id !== s.v.id) {
- quiet(6000);
- await playVideoAt({ ...s.v }, target);
- 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); }
- if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
- else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
- }
- // A guest's own actions: forwarded when allowed, else they go off sync.
- function onLocalAction(kind) {
- if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return;
- if (st.allowControl) {
- const M = Player.master;
- if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
- else send({ type: 'cmd', cmd: kind === 'pause' ? 'pause' : 'play' });
- return;
- }
- st.detached = true;
- setStatus('Off sync — you paused or skipped on your own');
- render();
- }
- // playNext/playPrev guard: guests don't change songs themselves.
- function intercept(cmd) {
- if (st.role !== 'guest' || st.detached) return false;
- if (st.allowControl) send({ type: 'cmd', cmd });
- else toast('🎉 The host picks the songs in a watch party');
- return true;
- }
- function resync() {
- st.detached = false;
- setStatus('Synced with the host');
- render();
- applyState(st.state);
- }
-
- // ---------- voice (WebRTC mesh) ----------
- const ICE = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
- function peer(other) {
- let pc = st.voice.pcs.get(other);
- if (pc) return pc;
- pc = new RTCPeerConnection({ iceServers: ICE });
- st.voice.pcs.set(other, pc);
- if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => pc.addTrack(t, st.voice.stream));
- pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'rtc', to: other, data: { ice: e.candidate } }); };
- pc.ontrack = (e) => {
- let a = st.voice.audios.get(other);
- if (!a) { a = new Audio(); a.autoplay = true; a.setAttribute('playsinline', ''); st.voice.audios.set(other, a); }
- a.srcObject = e.streams[0];
- a.play().catch(() => {});
- };
- pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) dropPeer(other); render(); };
- return pc;
- }
- function dropPeer(other) {
- const pc = st.voice.pcs.get(other);
- if (pc) { try { pc.close(); } catch { /* closed */ } st.voice.pcs.delete(other); }
- const a = st.voice.audios.get(other);
- if (a) { a.srcObject = null; st.voice.audios.delete(other); }
- }
- async function offerTo(other) {
- const pc = peer(other);
- const offer = await pc.createOffer();
- await pc.setLocalDescription(offer);
- send({ type: 'rtc', to: other, data: { sdp: pc.localDescription } });
- }
- async function onRtc(from, d) {
- if (!st.voice.on || !d) return;
- try {
- if (d.sdp) {
- const pc = peer(from);
- await pc.setRemoteDescription(d.sdp);
- if (d.sdp.type === 'offer') {
- const ans = await pc.createAnswer();
- await pc.setLocalDescription(ans);
- send({ type: 'rtc', to: from, data: { sdp: pc.localDescription } });
- }
- } else if (d.ice) {
- await peer(from).addIceCandidate(d.ice);
- }
- } catch { /* a glare/ICE hiccup — the next members update retries */ }
- }
- // The member with the smaller pid makes the offer, so each pair connects once.
- function connectVoicePeers() {
- if (!st.voice.on) return;
- for (const m of st.members) if (m.voice && m.pid !== pid && !st.voice.pcs.has(m.pid) && pid < m.pid) offerTo(m.pid).catch(() => {});
- }
- function syncVoicePeers() {
- const voiced = new Set(st.members.filter((m) => m.voice).map((m) => m.pid));
- for (const other of [...st.voice.pcs.keys()]) if (!voiced.has(other)) dropPeer(other);
- connectVoicePeers();
- }
- async function toggleVoice() {
- if (st.voice.on) {
- st.voice.on = false;
- for (const other of [...st.voice.pcs.keys()]) dropPeer(other);
- if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => t.stop());
- st.voice.stream = null;
- send({ type: 'voice', on: false });
- render();
- return;
- }
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof RTCPeerConnection === 'undefined') { toast('Voice chat isn’t supported in this browser'); return; }
- try {
- st.voice.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
- } catch (e) {
- toast('🎤 Microphone blocked: ' + (e.message || e.name));
- return;
- }
- st.voice.on = true;
- st.voice.muted = false;
- send({ type: 'voice', on: true });
- toast('🎤 Voice on — headphones stop the music echoing into your mic', { duration: 5000 });
- render();
- connectVoicePeers();
- }
- function toggleMute() {
- if (!st.voice.stream) return;
- st.voice.muted = !st.voice.muted;
- st.voice.stream.getAudioTracks().forEach((t) => { t.enabled = !st.voice.muted; });
- render();
- }
-
- // ---------- lifecycle ----------
- function start() {
- st.role = 'host';
- st.code = ''; st.secret = ''; st.chat = []; st.members = [];
- ls.set(HOST_KEY, null);
- showPanel();
- setStatus('Starting…');
- connect();
- beginTimers();
- }
- function join(code, name) {
- code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
- if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
- if (name) { st.name = name; ls.set(NAME_KEY, name); }
- st.role = 'guest';
- st.code = code; st.detached = false; st.chat = []; st.members = [];
- showPanel();
- setStatus('Joining…');
- connect();
- beginTimers();
- }
- function beginTimers() {
- clearInterval(st.pushTimer); clearInterval(st.driftTimer);
- st.pushTimer = setInterval(() => pushState(), 3000);
- st.driftTimer = setInterval(() => { if (st.role === 'guest' && 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.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);
- $('partyPanel').classList.add('hidden');
- render();
- }
-
- // ---------- UI ----------
- // Lyrics-instead-of-video is a per-device view: each guest chooses it for
- // themselves, and the host's playback is untouched (the video keeps playing
- // behind the lyrics, so sync and audio carry on).
- const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on());
- function renderLyricsToggle() {
- const b = document.querySelector('#partyPanel .pt-lyrics');
- if (!b) return;
- b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
- b.classList.toggle('on', lyricsOn());
- }
- 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 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 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();
- if (!inParty()) {
- panel.querySelector('.pt-body').innerHTML = `
-
Watch together: everyone's player follows the host, with chat and optional voice.
- ${nameOk ? '' : ''}
-
- or join one
- `;
- $('ptTitle').textContent = '🎉 Watch party';
- setStatus('');
- return;
- }
- $('ptTitle').textContent = `🎉 Party ${st.code || '…'}`;
- const s = st.state && st.state.v;
- panel.querySelector('.pt-body').innerHTML = `
- ${st.code ? `${escapeHtml(link())}${navigator.share ? '' : ''}
` : ''}
- ${!isHost() && s ? `▶ ${escapeHtml(s.title)}
` : ''}
- ${!isHost() && st.detached ? '' : ''}
- ${st.members.map((m) => `${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}`).join('')}
- ${isHost() ? `` : ''}
-
-
-
- ${st.voice.on ? `` : ''}
- ${st.voice.on ? `${st.voice.pcs.size} connected` : ''}
-
-
-
- `;
- 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 || []; } });
- renderChat();
- }
- function renderChat() {
- const box = $('ptChat');
- if (!box) return;
- box.innerHTML = st.chat.map((c) => c.sys
- ? `${escapeHtml(c.text)}
`
- : `${escapeHtml(c.from)} ${escapeHtml(c.text)}
`).join('');
- box.scrollTop = box.scrollHeight;
- }
-
- function bindUi() {
- const panel = $('partyPanel');
- $('navPartyBtn').addEventListener('click', () => {
- 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 (!inParty()) panel.classList.add('hidden');
- else { st.minimized = true; panel.classList.add('min'); }
- });
- $('ptMin').addEventListener('click', () => {
- if (!inParty()) { panel.classList.add('hidden'); return; }
- st.minimized = !st.minimized;
- panel.classList.toggle('min', st.minimized);
- });
- panel.addEventListener('keydown', (e) => e.stopPropagation());
- panel.addEventListener('click', async (e) => {
- const a = e.target.closest('[data-act]');
- if (!a || a.tagName === 'INPUT') return;
- const act = a.dataset.act;
- const nameIn = panel.querySelector('.pt-name');
- const name = nameIn ? nameIn.value.trim() : '';
- if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
- if (name) { st.name = name; ls.set(NAME_KEY, name); }
- if (act === 'start') start();
- else if (act === 'join') join(panel.querySelector('.pt-code').value);
- else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
- else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
- else if (act === 'resync') resync();
- else if (act === 'voice') toggleVoice();
- else if (act === 'mute') toggleMute();
- else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
- else if (act === 'leave') leave(true);
- });
- panel.addEventListener('change', (e) => {
- 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 = ''; }
- });
- // Host: push on every player change; guests: detect their own actions.
- ['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
- [els.video, els.audio].forEach((el) => el.addEventListener(ev, (e) => {
- if (el !== Player.master) return;
- if (isHost()) pushState(true);
- else if (ev === 'play' || ev === 'pause' || ev === 'seeked') onLocalAction(ev);
- }));
- });
- }
-
- function boot() {
- bindUi();
- render();
- // Resume hosting after a reload.
- try {
- const h = JSON.parse(ls.get(HOST_KEY) || 'null');
- if (h && h.code && h.secret) { st.role = 'host'; st.code = h.code; st.secret = h.secret; showPanel(); connect(); beginTimers(); }
- } catch { /* none */ }
- let code = '';
- try { code = (new URLSearchParams(location.search).get('party') || '').toUpperCase(); } catch { /* no URL */ }
- if (code) {
- try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
- if (myName()) join(code);
- else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); }
- }
- }
-
- return {
- nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
- boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
- state: () => ({
- role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
- peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
- remoteAudio: [...st.voice.audios.values()].filter((a) => a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live')).length,
- chat: st.chat.length, allowControl: st.allowControl,
- }),
- _join: join, _start: start,
- };
-})();
function updateProgress() {
const cur = Player.master.currentTime || 0;
diff --git a/frontend/assets.json b/frontend/assets.json
index af81548..ef30f6f 100644
--- a/frontend/assets.json
+++ b/frontend/assets.json
@@ -332,6 +332,14 @@
"files": [
"/remote-client.js"
]
+ },
+ "feature:watch-party": {
+ "contract": 1,
+ "eager": false,
+ "background": true,
+ "files": [
+ "/watch-party-client.js"
+ ]
}
}
}
diff --git a/frontend/index.html b/frontend/index.html
index 9ceac1d..e288275 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -19,7 +19,7 @@
-
+
diff --git a/frontend/sw.js b/frontend/sw.js
index f7dab0e..74c2329 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -142,6 +142,7 @@ const SHELL = [
'/section-rail.js',
'/presenter.js',
'/remote-client.js',
+ '/watch-party-client.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',
diff --git a/frontend/watch-party-client.js b/frontend/watch-party-client.js
new file mode 100644
index 0000000..c88b8c2
--- /dev/null
+++ b/frontend/watch-party-client.js
@@ -0,0 +1,458 @@
+/* Lazy classic chunk; its executing singleton stays pinned until reload. */
+(window.YT ||= {}).Party = (() => {
+ const noop = { boot() {}, guestActive: () => false, intercept: () => false, afterLoad() {} };
+ if (!WEB || typeof WebSocket === 'undefined') return noop;
+ const PID_KEY = 'ytpPartyPid', HOST_KEY = 'ytpPartyHost', NAME_KEY = 'ytpPartyName';
+ const ls = {
+ get: (k) => { try { return localStorage.getItem(k); } catch { return null; } },
+ set: (k, v) => { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* blocked */ } },
+ };
+ let pid = ls.get(PID_KEY);
+ if (!pid) { pid = 'p' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36); ls.set(PID_KEY, pid); }
+ const wsUrl = (q) => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/party?${new URLSearchParams(q)}`;
+
+ const st = {
+ ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
+ dj: { enabled: false, entries: [] }, djPending: false,
+ allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
+ voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
+ minimized: 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 serverNow = () => Date.now() + st.offset;
+ const myName = () => st.name || (data.profile && data.profile.name) || ls.get(NAME_KEY) || '';
+
+ // ---------- connection ----------
+ function connect() {
+ clearTimeout(st.retry);
+ const q = { role: st.role, pid, 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); };
+ ws.onclose = (e) => {
+ if (st.ws !== ws) return;
+ st.ws = null;
+ if (!inParty()) return;
+ if (e.code === 4010 || e.code === 4004 || e.code === 4003) {
+ const why = e.code === 4004 ? 'That party code doesn’t exist (or has ended).' : e.code === 4003 ? 'This party belongs to another host.' : 'The party ended.';
+ leave(false);
+ toast(`🎉 ${why}`, { duration: 5000 });
+ return;
+ }
+ setStatus('Reconnecting…');
+ st.retry = setTimeout(connect, 3000);
+ };
+ 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 === 'hello') {
+ st.code = m.code;
+ if (m.secret) { st.secret = m.secret; ls.set(HOST_KEY, JSON.stringify({ code: m.code, secret: m.secret })); }
+ st.offset = (m.now || Date.now()) - Date.now();
+ st.members = m.members || [];
+ st.chat = m.chat || [];
+ st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
+ st.allowControl = !!m.allowControl;
+ setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
+ render();
+ if (isHost()) pushState(true);
+ else if (m.state) applyState(m.state);
+ if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
+ } else if (m.type === 'dj') {
+ st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
+ } else if (m.type === 'dj-play' && isHost()) {
+ st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); }
+ } else if (m.type === 'state') {
+ st.state = m.state;
+ if (st.role === 'guest') applyState(m.state);
+ } else if (m.type === 'members') {
+ st.members = m.members || [];
+ if (m.joined) sysMsg(`${m.joined} joined`);
+ if (m.left) sysMsg(`${m.left} left${m.hostAway ? ' — waiting for the host to come back' : ''}`);
+ render();
+ syncVoicePeers();
+ } else if (m.type === 'chat') {
+ st.chat.push(m.msg);
+ if (st.chat.length > 100) st.chat.shift();
+ renderChat();
+ if (m.msg.pid !== pid && (st.minimized || $('partyPanel').classList.contains('hidden'))) toast(`💬 ${m.msg.from}: ${m.msg.text}`);
+ } else if (m.type === 'settings') {
+ st.allowControl = !!m.allowControl;
+ render();
+ if (!isHost()) sysMsg(st.allowControl ? 'The host lets everyone control playback' : 'Only the host controls playback now');
+ } else if (m.type === 'cmd' && isHost()) {
+ runCommand(m);
+ } else if (m.type === 'rtc') {
+ onRtc(m.from, m.data);
+ } else if (m.type === 'ended') {
+ toast(`🎉 ${m.reason || 'The party ended'}`, { duration: 5000 });
+ }
+ }
+
+ // ---------- host: broadcast ----------
+ function stateNow() {
+ const meta = current && current.meta;
+ const M = Player.master;
+ return {
+ directAvailable: !!meta && cachedIds.has(meta.id) && DirectMedia.enabled() && data.settings.p2pShare !== false,
+ v: meta ? { id: meta.id, title: meta.title || '', channel: meta.channel || '', thumbnail: meta.thumbnail || '', duration: meta.duration || 0 } : null,
+ cur: M ? M.currentTime || 0 : 0,
+ paused: !M || M.paused,
+ rate: (M && M.playbackRate) || 1,
+ };
+ }
+ function pushState(force = false) {
+ if (!isHost() || !st.ws) 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 });
+ }
+ function runCommand(m) {
+ switch (m.cmd) {
+ case 'toggle': Player.toggle(); break;
+ case 'play': Player.play(); break;
+ case 'pause': Player.pause(); break;
+ case 'seek': if (Number.isFinite(Number(m.t))) Player.seek(Math.max(0, Number(m.t))); break;
+ case 'next': playNext(); break;
+ case 'prev': playPrev(); break;
+ default: return;
+ }
+ toast(`🎉 ${m.from}: ${m.cmd === 'seek' ? `jumped to ${fmtTime(Number(m.t))}` : m.cmd}`);
+ setTimeout(() => pushState(true), 200);
+ }
+
+ // ---------- guest: follow ----------
+ const quiet = (ms = 1500) => { st.quietUntil = Date.now() + ms; };
+ function targetTime(s) {
+ if (!s) return 0;
+ return s.paused ? s.cur : s.cur + ((serverNow() - (s.ts || serverNow())) / 1000) * (s.rate || 1);
+ }
+ async function applyState(s) {
+ st.state = s;
+ if (st.role !== 'guest' || st.detached || !s || !s.v) { render(); return; }
+ const target = targetTime(s);
+ if (!current || !current.meta || current.meta.id !== s.v.id) {
+ quiet(6000);
+ await playVideoAt({ ...s.v }, target);
+ 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); }
+ if (s.paused && !M.paused) { quiet(); Player.pause(); updatePlayBtn(); }
+ else if (!s.paused && M.paused) { quiet(); Player.play(); updatePlayBtn(); }
+ }
+ // A guest's own actions: forwarded when allowed, else they go off sync.
+ function onLocalAction(kind) {
+ if (st.role !== 'guest' || st.detached || Date.now() < st.quietUntil) return;
+ if (st.allowControl) {
+ const M = Player.master;
+ if (kind === 'seeked') send({ type: 'cmd', cmd: 'seek', t: M.currentTime });
+ else send({ type: 'cmd', cmd: kind === 'pause' ? 'pause' : 'play' });
+ return;
+ }
+ st.detached = true;
+ setStatus('Off sync — you paused or skipped on your own');
+ render();
+ }
+ // playNext/playPrev guard: guests don't change songs themselves.
+ function intercept(cmd) {
+ if (st.role !== 'guest' || st.detached) return false;
+ if (st.allowControl) send({ type: 'cmd', cmd });
+ else toast('🎉 The host picks the songs in a watch party');
+ return true;
+ }
+ function resync() {
+ st.detached = false;
+ setStatus('Synced with the host');
+ render();
+ applyState(st.state);
+ }
+
+ // ---------- voice (WebRTC mesh) ----------
+ const ICE = [{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }];
+ function peer(other) {
+ let pc = st.voice.pcs.get(other);
+ if (pc) return pc;
+ pc = new RTCPeerConnection({ iceServers: ICE });
+ st.voice.pcs.set(other, pc);
+ if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => pc.addTrack(t, st.voice.stream));
+ pc.onicecandidate = (e) => { if (e.candidate) send({ type: 'rtc', to: other, data: { ice: e.candidate } }); };
+ pc.ontrack = (e) => {
+ let a = st.voice.audios.get(other);
+ if (!a) { a = new Audio(); a.autoplay = true; a.setAttribute('playsinline', ''); st.voice.audios.set(other, a); }
+ a.srcObject = e.streams[0];
+ a.play().catch(() => {});
+ };
+ pc.onconnectionstatechange = () => { if (['failed', 'closed'].includes(pc.connectionState)) dropPeer(other); render(); };
+ return pc;
+ }
+ function dropPeer(other) {
+ const pc = st.voice.pcs.get(other);
+ if (pc) { try { pc.close(); } catch { /* closed */ } st.voice.pcs.delete(other); }
+ const a = st.voice.audios.get(other);
+ if (a) { a.srcObject = null; st.voice.audios.delete(other); }
+ }
+ async function offerTo(other) {
+ const pc = peer(other);
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ send({ type: 'rtc', to: other, data: { sdp: pc.localDescription } });
+ }
+ async function onRtc(from, d) {
+ if (!st.voice.on || !d) return;
+ try {
+ if (d.sdp) {
+ const pc = peer(from);
+ await pc.setRemoteDescription(d.sdp);
+ if (d.sdp.type === 'offer') {
+ const ans = await pc.createAnswer();
+ await pc.setLocalDescription(ans);
+ send({ type: 'rtc', to: from, data: { sdp: pc.localDescription } });
+ }
+ } else if (d.ice) {
+ await peer(from).addIceCandidate(d.ice);
+ }
+ } catch { /* a glare/ICE hiccup — the next members update retries */ }
+ }
+ // The member with the smaller pid makes the offer, so each pair connects once.
+ function connectVoicePeers() {
+ if (!st.voice.on) return;
+ for (const m of st.members) if (m.voice && m.pid !== pid && !st.voice.pcs.has(m.pid) && pid < m.pid) offerTo(m.pid).catch(() => {});
+ }
+ function syncVoicePeers() {
+ const voiced = new Set(st.members.filter((m) => m.voice).map((m) => m.pid));
+ for (const other of [...st.voice.pcs.keys()]) if (!voiced.has(other)) dropPeer(other);
+ connectVoicePeers();
+ }
+ async function toggleVoice() {
+ if (st.voice.on) {
+ st.voice.on = false;
+ for (const other of [...st.voice.pcs.keys()]) dropPeer(other);
+ if (st.voice.stream) st.voice.stream.getTracks().forEach((t) => t.stop());
+ st.voice.stream = null;
+ send({ type: 'voice', on: false });
+ render();
+ return;
+ }
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || typeof RTCPeerConnection === 'undefined') { toast('Voice chat isn’t supported in this browser'); return; }
+ try {
+ st.voice.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
+ } catch (e) {
+ toast('🎤 Microphone blocked: ' + (e.message || e.name));
+ return;
+ }
+ st.voice.on = true;
+ st.voice.muted = false;
+ send({ type: 'voice', on: true });
+ toast('🎤 Voice on — headphones stop the music echoing into your mic', { duration: 5000 });
+ render();
+ connectVoicePeers();
+ }
+ function toggleMute() {
+ if (!st.voice.stream) return;
+ st.voice.muted = !st.voice.muted;
+ st.voice.stream.getAudioTracks().forEach((t) => { t.enabled = !st.voice.muted; });
+ render();
+ }
+
+ // ---------- lifecycle ----------
+ function start() {
+ st.role = 'host';
+ st.code = ''; st.secret = ''; st.chat = []; st.members = [];
+ ls.set(HOST_KEY, null);
+ showPanel();
+ setStatus('Starting…');
+ connect();
+ beginTimers();
+ }
+ function join(code, name) {
+ code = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+ if (code.length !== 6) { toast('Party codes have 6 letters/numbers'); return; }
+ if (name) { st.name = name; ls.set(NAME_KEY, name); }
+ st.role = 'guest';
+ st.code = code; st.detached = false; st.chat = []; st.members = [];
+ showPanel();
+ setStatus('Joining…');
+ connect();
+ beginTimers();
+ }
+ function beginTimers() {
+ clearInterval(st.pushTimer); clearInterval(st.driftTimer);
+ st.pushTimer = setInterval(() => pushState(), 3000);
+ st.driftTimer = setInterval(() => { if (st.role === 'guest' && 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.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);
+ $('partyPanel').classList.add('hidden');
+ render();
+ }
+
+ // ---------- UI ----------
+ // Lyrics-instead-of-video is a per-device view: each guest chooses it for
+ // themselves, and the host's playback is untouched (the video keeps playing
+ // behind the lyrics, so sync and audio carry on).
+ const lyricsOn = () => !!(Notes.stageLyrics && Notes.stageLyrics.on());
+ function renderLyricsToggle() {
+ const b = document.querySelector('#partyPanel .pt-lyrics');
+ if (!b) return;
+ b.textContent = lyricsOn() ? '📺 Show the video' : '🎤 Lyrics instead of video';
+ b.classList.toggle('on', lyricsOn());
+ }
+ 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 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 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();
+ if (!inParty()) {
+ panel.querySelector('.pt-body').innerHTML = `
+ Watch together: everyone's player follows the host, with chat and optional voice.
+ ${nameOk ? '' : ''}
+
+ or join one
+ `;
+ $('ptTitle').textContent = '🎉 Watch party';
+ setStatus('');
+ return;
+ }
+ $('ptTitle').textContent = `🎉 Party ${st.code || '…'}`;
+ const s = st.state && st.state.v;
+ panel.querySelector('.pt-body').innerHTML = `
+ ${st.code ? `${escapeHtml(link())}${navigator.share ? '' : ''}
` : ''}
+ ${!isHost() && s ? `▶ ${escapeHtml(s.title)}
` : ''}
+ ${!isHost() && st.detached ? '' : ''}
+ ${st.members.map((m) => `${m.host ? '👑 ' : ''}${escapeHtml(m.name)}${m.voice ? ' 🎤' : ''}`).join('')}
+ ${isHost() ? `` : ''}
+
+
+
+ ${st.voice.on ? `` : ''}
+ ${st.voice.on ? `${st.voice.pcs.size} connected` : ''}
+
+
+
+ `;
+ 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 || []; } });
+ renderChat();
+ }
+ function renderChat() {
+ const box = $('ptChat');
+ if (!box) return;
+ box.innerHTML = st.chat.map((c) => c.sys
+ ? `${escapeHtml(c.text)}
`
+ : `${escapeHtml(c.from)} ${escapeHtml(c.text)}
`).join('');
+ box.scrollTop = box.scrollHeight;
+ }
+
+ 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 (!inParty()) panel.classList.add('hidden');
+ else { st.minimized = true; panel.classList.add('min'); }
+ }
+
+ function bindUi() {
+ const panel = $('partyPanel');
+ $('ptMin').addEventListener('click', () => {
+ if (!inParty()) { panel.classList.add('hidden'); return; }
+ st.minimized = !st.minimized;
+ panel.classList.toggle('min', st.minimized);
+ });
+ panel.addEventListener('keydown', (e) => e.stopPropagation());
+ panel.addEventListener('click', async (e) => {
+ const a = e.target.closest('[data-act]');
+ if (!a || a.tagName === 'INPUT') return;
+ const act = a.dataset.act;
+ const nameIn = panel.querySelector('.pt-name');
+ const name = nameIn ? nameIn.value.trim() : '';
+ if ((act === 'start' || act === 'join') && !myName() && !name) { toast('Enter your name first'); if (nameIn) nameIn.focus(); return; }
+ if (name) { st.name = name; ls.set(NAME_KEY, name); }
+ if (act === 'start') start();
+ else if (act === 'join') join(panel.querySelector('.pt-code').value);
+ else if (act === 'copy') { try { await navigator.clipboard.writeText(link()); toast('Party link copied ✓'); } catch { toast('Copy blocked'); } }
+ else if (act === 'share') { try { await navigator.share({ title: 'Join my watch party', url: link() }); } catch { /* dismissed */ } }
+ else if (act === 'resync') resync();
+ else if (act === 'voice') toggleVoice();
+ else if (act === 'mute') toggleMute();
+ else if (act === 'lyrics') { Notes.stageLyrics.set(!lyricsOn()); renderLyricsToggle(); }
+ else if (act === 'leave') leave(true);
+ });
+ panel.addEventListener('change', (e) => {
+ 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 = ''; }
+ });
+ // Host: push on every player change; guests: detect their own actions.
+ ['play', 'pause', 'seeked', 'ratechange', 'loadedmetadata'].forEach((ev) => {
+ [els.video, els.audio].forEach((el) => el.addEventListener(ev, (e) => {
+ if (el !== Player.master) return;
+ if (isHost()) pushState(true);
+ else if (ev === 'play' || ev === 'pause' || ev === 'seeked') onLocalAction(ev);
+ }));
+ });
+ }
+
+ function boot() {
+ bindUi();
+ render();
+ // Resume hosting after a reload.
+ try {
+ const h = JSON.parse(ls.get(HOST_KEY) || 'null');
+ if (h && h.code && h.secret) { st.role = 'host'; st.code = h.code; st.secret = h.secret; showPanel(); connect(); beginTimers(); }
+ } catch { /* none */ }
+ let code = '';
+ try { code = (new URLSearchParams(location.search).get('party') || '').toUpperCase(); } catch { /* no URL */ }
+ if (code) {
+ try { const u = new URL(location.href); u.searchParams.delete('party'); history.replaceState(null, '', u.pathname + u.search + u.hash); } catch { /* keep */ }
+ if (myName()) join(code);
+ else { showPanel(); const c = $('partyPanel').querySelector('.pt-code'); if (c) c.value = code; toast('Enter your name, then tap Join'); }
+ }
+ }
+
+ return {
+ nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
+ open, boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
+ state: () => ({
+ role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
+ peers: st.voice.pcs.size, peerStates: [...st.voice.pcs.values()].map((pc) => pc.connectionState),
+ remoteAudio: [...st.voice.audios.values()].filter((a) => a.srcObject && a.srcObject.getAudioTracks().some((t) => t.readyState === 'live')).length,
+ chat: st.chat.length, allowControl: st.allowControl,
+ }),
+ _join: join, _start: start,
+ };
+})();