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 = ` - - ${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 = ` + + ${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, + }; +})();